Compare commits
14 Commits
c2334b2cdd
...
simplified
| Author | SHA1 | Date | |
|---|---|---|---|
|
27022a9390
|
|||
|
33a465eca0
|
|||
|
2442b17a4c
|
|||
|
a79213436c
|
|||
|
d45f1ddeb3
|
|||
| bcc3277cb9 | |||
|
b2ccff5b19
|
|||
| 12b7bde74f | |||
| 42d23fa35e | |||
| b6ee25d1dd | |||
| b4d82b8b1f | |||
| a0d9775015 | |||
| 6c01ac1c1b | |||
| ebe1d8acda |
@@ -1,904 +0,0 @@
|
||||
{
|
||||
"$schema": "https://libauth.org/schemas/wallet-template-v0.schema.json",
|
||||
"name": "Wallet (P2PKH)",
|
||||
"description": "A standard single-factor wallet template that uses Pay-to-Public-Key-Hash (P2PKH) locking scripts.",
|
||||
"icon": "wallet",
|
||||
"version": 0,
|
||||
"supported": ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"],
|
||||
"roles": {
|
||||
"owner": {
|
||||
"name": "Wallet Owner",
|
||||
"description": "The party who can spend from this wallet.",
|
||||
"icon": "owner"
|
||||
},
|
||||
"receiver": {
|
||||
"name": "Receiver",
|
||||
"description": "A party that is receiving value.",
|
||||
"icon": "receiver"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sender",
|
||||
"description": "A party that is sending value.",
|
||||
"icon": "sender"
|
||||
}
|
||||
},
|
||||
"start": [
|
||||
{ "action": "receive", "role": "receiver" },
|
||||
{ "action": "requestSatoshis", "role": "receiver" },
|
||||
{ "action": "requestFungibleTokens", "role": "receiver" },
|
||||
{ "action": "requestNonfungibleTokens", "role": "receiver" }
|
||||
],
|
||||
"actions": {
|
||||
"receive": {
|
||||
"name": "Receive",
|
||||
"description": "Receive an unspecified amount of cash and/or tokens from one or more senders.",
|
||||
"icon": "receive",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Receive",
|
||||
"description": "Receive an unspecified amount of cash and/or tokens from one or more senders.",
|
||||
"icon": "receive",
|
||||
"requirements": { "generate": ["ownerKey"] }
|
||||
},
|
||||
"sender": {
|
||||
"name": "Send",
|
||||
"description": "Send an unspecified amount of cash and/or tokens to the provided receiver.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [
|
||||
{ "role": "receiver", "slots": { "min": 1, "max": 1 } },
|
||||
{ "role": "sender", "slots": { "min": 1 } }
|
||||
],
|
||||
"variables": ["requestedSatoshis"]
|
||||
},
|
||||
"transaction": "receiveTransaction"
|
||||
},
|
||||
"requestSatoshis": {
|
||||
"name": "Request Satoshis",
|
||||
"description": "Requests a specific amount of Bitcoin Cash from one or more senders.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Request Satoshis",
|
||||
"description": "Requests a specific amount of Bitcoin Cash from one or more senders.",
|
||||
"icon": "request",
|
||||
"requirements": {
|
||||
"generate": ["ownerKey"],
|
||||
"variables": ["requestedSatoshis"]
|
||||
}
|
||||
},
|
||||
"sender": {
|
||||
"name": "Send",
|
||||
"description": "Send a specific amount of Bitcoin Cash to the provided receiver.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [
|
||||
{ "role": "receiver", "slots": { "min": 1, "max": 1 } },
|
||||
{ "role": "sender", "slots": { "min": 1 } }
|
||||
]
|
||||
},
|
||||
"transaction": "requestSatoshisTransaction"
|
||||
},
|
||||
"requestFungibleTokens": {
|
||||
"name": "Request Fungible Tokens",
|
||||
"description": "Requests a specific amount of a fungible tokens from one or more senders.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Request Fungible Tokens",
|
||||
"description": "Requests a specific amount of a fungible tokens from one or more senders.",
|
||||
"icon": "request",
|
||||
"requirements": {
|
||||
"generate": ["ownerKey"],
|
||||
"variables": ["requestedTokenCategory", "requestedTokenAmount"]
|
||||
}
|
||||
},
|
||||
"sender": {
|
||||
"name": "Send",
|
||||
"description": "Send a specific amount of fungible tokens to the provided receiver.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [
|
||||
{ "role": "receiver", "slots": { "min": 1, "max": 1 } },
|
||||
{ "role": "sender", "slots": { "min": 1 } }
|
||||
]
|
||||
},
|
||||
"transaction": "requestFungibleTokensTransaction"
|
||||
},
|
||||
"requestNonfungibleTokens": {
|
||||
"name": "Request a Non-fungible Token",
|
||||
"description": "Requests a non-fungible token from one or more senders.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Request a Non-fungible Token",
|
||||
"description": "Requests a non-fungible token from one or more senders.",
|
||||
"icon": "request",
|
||||
"requirements": {
|
||||
"generate": ["ownerKey"],
|
||||
"variables": [
|
||||
"requestedTokenCategory",
|
||||
"requestedTokenCapability",
|
||||
"requestedTokenCommitment"
|
||||
]
|
||||
}
|
||||
},
|
||||
"sender": {
|
||||
"name": "Send",
|
||||
"description": "Send a non-fungible token to the provided receiver.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [
|
||||
{ "role": "receiver", "slots": { "min": 1, "max": 1 } },
|
||||
{ "role": "sender", "slots": { "min": 1 } }
|
||||
]
|
||||
},
|
||||
"transaction": "requestNonfungibleTokensTransaction"
|
||||
},
|
||||
"sendSatoshis": {
|
||||
"name": "Send Satoshis",
|
||||
"description": "Sends a specific amount of Bitcoin Cash to a given recipient.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"sender": {
|
||||
"requirements": {
|
||||
"variables": ["transferredSatoshis", "recipientLockingscript"],
|
||||
"secrets": ["ownerKey"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [{ "role": "sender", "slots": { "min": 1, "max": 1 } }]
|
||||
},
|
||||
"condition": "$(OP_INPUTINDEX OP_UTXOVALUE <dustLimit> OP_GREATERTHAN)",
|
||||
"transaction": "transferSatoshisTransaction"
|
||||
},
|
||||
"sendFungibleTokens": {
|
||||
"name": "Send Fungible Tokens",
|
||||
"description": "Send a specific amount of a fungible token to a given recipient.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"sender": {
|
||||
"requirements": {
|
||||
"variables": [
|
||||
"transferredTokenCategory",
|
||||
"transferredTokenAmount",
|
||||
"recipientLockingscript"
|
||||
],
|
||||
"secrets": ["ownerKey"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [{ "role": "sender", "slots": { "min": 1, "max": 1 } }]
|
||||
},
|
||||
"condition": "$(OP_INPUTINDEX OP_UTXOTOKENAMOUNT <0> OP_GREATERTHAN)",
|
||||
"transaction": "transferFungibleTokensTransaction"
|
||||
},
|
||||
"sendNonfungibleTokens": {
|
||||
"name": "Send a Non-fungible Token",
|
||||
"description": "Send a non-fungible token to a given recipient.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"sender": {
|
||||
"requirements": {
|
||||
"variables": [
|
||||
"transferredTokenCategory",
|
||||
"transferredTokenCapability",
|
||||
"transferredTokenCommitment",
|
||||
"recipientLockingscript"
|
||||
],
|
||||
"secrets": ["ownerKey"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [{ "role": "sender", "slots": { "min": 1, "max": 1 } }]
|
||||
},
|
||||
"condition": "$(OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_SIZE OP_NIP <32> OP_GREATERTHAN)",
|
||||
"transaction": "transferNonfungibleTokensTransaction"
|
||||
},
|
||||
"burnFungibleTokens": {
|
||||
"name": "Delete Fungible Tokens",
|
||||
"description": "Permanently and irreversibly deletes one or more fungible tokens.",
|
||||
"icon": "burn",
|
||||
"roles": {
|
||||
"owner": {
|
||||
"requirements": {
|
||||
"variables": ["burnedTokenCategory", "burnedTokenAmount"],
|
||||
"secrets": ["ownerKey"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [{ "role": "owner", "slots": { "min": 1, "max": 1 } }]
|
||||
},
|
||||
"condition": "$(OP_INPUTINDEX OP_UTXOTOKENAMOUNT <0> OP_GREATERTHAN)",
|
||||
"transaction": "burnFungibleTokensTransaction"
|
||||
},
|
||||
"burnNonfungibleTokens": {
|
||||
"name": "Delete a Non-fungible Token",
|
||||
"description": "Permanently and irreversibly deletes one non-fungible token.",
|
||||
"icon": "burn",
|
||||
"roles": {
|
||||
"owner": {
|
||||
"requirements": {
|
||||
"variables": [
|
||||
"burnedTokenCategory",
|
||||
"burnedTokenCapability",
|
||||
"burnedTokenCommitment"
|
||||
],
|
||||
"secrets": ["ownerKey"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [{ "role": "owner", "slots": { "min": 1, "max": 1 } }]
|
||||
},
|
||||
"condition": "$(OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_SIZE OP_NIP <32> OP_GREATERTHAN)",
|
||||
"transaction": "burnNonfungibleTokenTransaction"
|
||||
},
|
||||
"sign": {
|
||||
"name": "Sign Message",
|
||||
"description": "Signs a provided message using the Bitcoin message signing protocol.",
|
||||
"icon": "sign",
|
||||
"roles": {
|
||||
"owner": {
|
||||
"requirements": {
|
||||
"variables": ["messageToSign"],
|
||||
"secrets": ["ownerKey"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [{ "role": "owner", "slots": { "min": 1, "max": 1 } }]
|
||||
},
|
||||
"data": ["messageSignature"]
|
||||
},
|
||||
"verify": {
|
||||
"name": "Verify Message Signature",
|
||||
"description": "Verifies a provided message signature according to the Bitcoin message signing protocol.",
|
||||
"icon": "verify",
|
||||
"roles": {
|
||||
"owner": {
|
||||
"requirements": {
|
||||
"variables": ["messageSignature", "messageToVerify"],
|
||||
"secrets": ["ownerKey"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [{ "role": "owner", "slots": { "min": 1, "max": 1 } }]
|
||||
},
|
||||
"data": ["messageSignatureValidity"]
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"messageSignature": {
|
||||
"value": "$(<messagePrefix> <message> OP_CAT <ownerKey.data_signature.top_stack_element>)",
|
||||
"type": "bytes",
|
||||
"hint": "signature"
|
||||
},
|
||||
"messageSignatureValidity": {
|
||||
"value": "$(<messageSignature> <messagePrefix> <message> OP_CAT <ownerKey.public_key> OP_CHECKDATASIG)",
|
||||
"type": "integer",
|
||||
"hint": "script_boolean"
|
||||
}
|
||||
},
|
||||
"transactions": {
|
||||
"receiveTransaction": {
|
||||
"name": "Transfer request",
|
||||
"description": "Transfer request for an unspecified amount of cash and/or tokens.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received an unspecified amount of cash and/or tokens.",
|
||||
"icon": "receive"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent an unspecified amount of cash and/or tokens.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [{ "output": "receiveOutput" }],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
},
|
||||
"requestSatoshisTransaction": {
|
||||
"name": "Transfer request",
|
||||
"description": "Transfer request for $(<requestedSatoshis>) satoshis.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received $(<requestedSatoshis>) satoshis.",
|
||||
"icon": "receive"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent $(<requestedSatoshis>) satoshis.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [{ "output": "requestSatoshisOutput" }],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
},
|
||||
"requestFungibleTokensTransaction": {
|
||||
"name": "Transfer request",
|
||||
"description": "Transfer request for $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens.",
|
||||
"icon": "receive"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [{ "output": "requestFungibleTokensOutput" }],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
},
|
||||
"requestNonfungibleTokensTransaction": {
|
||||
"name": "Transfer request",
|
||||
"description": "Transfer request for one non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<requestedTokenCategory.symbol>) token, with $(<requestedTokenCommitment>) commitment.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received one non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<requestedTokenCategory.symbol>) token, with $(<requestedTokenCommitment>) commitment.",
|
||||
"icon": "receive"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent the requested non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<requestedTokenCategory.symbol>) token, with $(<requestedTokenCommitment>) commitment.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [{ "output": "requestNonfungibleTokensOutput" }],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
},
|
||||
"transferSatoshisTransaction": {
|
||||
"name": "Satoshis Transferred",
|
||||
"description": "$(<transferredSatoshis>) satoshis were transferred to a recipient.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received $(<transferredSatoshis>) satoshis.",
|
||||
"icon": "receive"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent $(<transferredSatoshis>) satoshis.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [{ "output": "transferSatoshisOutput" }],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
},
|
||||
"transferFungibleTokensTransaction": {
|
||||
"name": "Fungible Tokens Transferred",
|
||||
"description": "$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens were transferred to a recipient.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received $(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens.",
|
||||
"icon": "receive"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent $(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [{ "output": "transferFungibleTokensOutput" }],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
},
|
||||
"transferNonfungibleTokensTransaction": {
|
||||
"name": "Non-fungible Token Transferred",
|
||||
"description": "One non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<transferredTokenCategory.symbol>) token was transferred to a recipient, with $(<transferredTokenCommitment>) commitment.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received one non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<transferredTokenCategory.symbol>) token, with $(<transferredTokenCommitment>) commitment.",
|
||||
"icon": "receive"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent one non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<transferredTokenCategory.symbol>) token, with $(<transferredTokenCommitment>) commitment.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [{ "output": "transferNonfungibleTokenOutput" }],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
},
|
||||
"burnFungibleTokensTransaction": {
|
||||
"name": "Deleted fungible tokens",
|
||||
"description": "Permanently and irreversibly deleted $(<burnedTokenAmount> <burnedTokenCategory.decimalsFactor> OP_DIV).$(<burnedTokenAmount> <burnedTokenCategory.decimalsFactor> OP_MOD) $(<burnedTokenCategory.symbol>) tokens.",
|
||||
"icon": "burn",
|
||||
"inputs": [{ "input": "burnFungibleTokensInput" }],
|
||||
"outputs": [],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
},
|
||||
"burnNonfungibleTokenTransaction": {
|
||||
"name": "Deleted non fungible token",
|
||||
"description": "Permanently and irreversibly deleted a non-fungible $(<burnedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <burnedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<burnedTokenCategory.symbol>) token, with $(<burnedTokenCommitment>) commitment.",
|
||||
"icon": "burn",
|
||||
"inputs": [{ "input": "burnNonfungibleTokenInput" }],
|
||||
"outputs": [],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"receiveOutput": {
|
||||
"name": "Recipient output",
|
||||
"description": "Transferred an unspecified amount of cash and/or tokens to a recipient.",
|
||||
"icon": "receive",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received an unspecified amount of cash and/or tokens."
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent an unspecified amount of cash and/or tokens."
|
||||
}
|
||||
},
|
||||
"lockscript": "receivingLockingScript"
|
||||
},
|
||||
"requestSatoshisOutput": {
|
||||
"name": "Recipient output",
|
||||
"description": "Transferred $(<requestedSatoshis>) satoshis to a recipient.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received $(<requestedSatoshis>) satoshis."
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent $(<requestedSatoshis>) satoshis."
|
||||
}
|
||||
},
|
||||
"lockscript": "receivingLockingScript",
|
||||
"valueSatoshis": "$(<requestedSatoshis>)",
|
||||
"token": null
|
||||
},
|
||||
"requestFungibleTokensOutput": {
|
||||
"name": "Recipient output",
|
||||
"description": "Transferred $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens to a recipient.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens."
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens."
|
||||
}
|
||||
},
|
||||
"lockscript": "receivingLockingScript",
|
||||
"valueSatoshis": "1000",
|
||||
"token": {
|
||||
"category": "$(<requestedTokenCategory>)",
|
||||
"amount": "$(<requestedTokenAmount>)",
|
||||
"nft": null
|
||||
}
|
||||
},
|
||||
"requestNonfungibleTokensOutput": {
|
||||
"name": "Recipient output",
|
||||
"description": "Transferred one non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<requestedTokenCategory.symbol>) token to a recipient, with $(<requestedTokenCommitment>) commitment.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received one non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<requestedTokenCategory.symbol>) token, with $(<requestedTokenCommitment>) commitment."
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent the requested non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<requestedTokenCategory.symbol>) token, with $(<requestedTokenCommitment>) commitment."
|
||||
}
|
||||
},
|
||||
"lockscript": "receivingLockingScript",
|
||||
"valueSatoshis": "1000",
|
||||
"token": {
|
||||
"category": "$(<requestedTokenCategory>)",
|
||||
"amount": null,
|
||||
"nft": {
|
||||
"capability": "$(<requestedTokenCapability>)",
|
||||
"commitment": "$(<requestedTokenCommitment>)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transferSatoshisOutput": {
|
||||
"name": "Recipient output",
|
||||
"description": "Transferred $(<transferredSatoshis>) satoshis to a recipient.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received $(<transferredSatoshis>) satoshis."
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent $(<transferredSatoshis>) satoshis."
|
||||
}
|
||||
},
|
||||
"lockscript": "sendingLockingscript",
|
||||
"valueSatoshis": "$(<transferredSatoshis>)"
|
||||
},
|
||||
"transferFungibleTokensOutput": {
|
||||
"name": "Recipient output",
|
||||
"description": "Transferred $(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens to a recipient.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received $(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens."
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent $(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens."
|
||||
}
|
||||
},
|
||||
"lockscript": "sendingLockingscript",
|
||||
"token": {
|
||||
"category": "$(<transferredTokenCategory>)",
|
||||
"amount": "$(<transferredTokenAmount>)"
|
||||
}
|
||||
},
|
||||
"transferNonfungibleTokenOutput": {
|
||||
"name": "Recipient output",
|
||||
"description": "Transferred one non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<transferredTokenCategory.symbol>) token to a recipient, with $(<transferredTokenCommitment>) commitment.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received one non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<transferredTokenCategory.symbol>) token, with $(<transferredTokenCommitment>) commitment."
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent one non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<transferredTokenCategory.symbol>) token, with $(<transferredTokenCommitment>) commitment."
|
||||
}
|
||||
},
|
||||
"lockscript": "sendingLockingscript",
|
||||
"token": {
|
||||
"category": "$(<transferredTokenCategory>)",
|
||||
"nft": {
|
||||
"capability": "$(<transferredTokenCapability>)",
|
||||
"commitment": "$(<transferredTokenCommitment>)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"burnFungibleTokensInput": {
|
||||
"name": "Deleted fungible tokens",
|
||||
"description": "Permanently and irreversibly deleted $(<burnedTokenAmount>) $(<burnedTokenCategory>).",
|
||||
"icon": "burn",
|
||||
"unlockingScript": "unlockP2PKH",
|
||||
"token": {
|
||||
"category": "$(<burnedTokenCategory>)",
|
||||
"amount": "$(<this.fungibleTokenAmount> <burnedTokenAmount> OP_GREATERTHANOREQUAL OP_IF <this.fungibleTokens> OP_ENDIF)"
|
||||
},
|
||||
"omitChangeAmounts": { "fungibleTokens": "${<burnedTokenAmount>}" }
|
||||
},
|
||||
"burnNonfungibleTokenInput": {
|
||||
"name": "Deleted non-fungible token",
|
||||
"description": "Permanently and irreversibly burned one non-fungible $(<burnedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <burnedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) token of category $(<burnedTokenCategory>), with a $(<burnedTokenCommitment>) commitment.",
|
||||
"icon": "burn",
|
||||
"unlockingScript": "unlockP2PKH",
|
||||
"token": {
|
||||
"category": "$(<burnedTokenCategory>)",
|
||||
"nft": {
|
||||
"capability": "$(<burnedTokenCapability>)",
|
||||
"commitment": "$(<burnedTokenCommitment>)"
|
||||
}
|
||||
},
|
||||
"omitChangeAmounts": { "nonfungibleTokens": 1 }
|
||||
}
|
||||
},
|
||||
"lockingScripts": {
|
||||
"sendingLockingscript": {
|
||||
"name": "Sent",
|
||||
"description": "Funds sent to an external recipient",
|
||||
"icon": "address",
|
||||
"lockingType": "standard",
|
||||
"lockingScript": "lockToRecipient",
|
||||
"actions": [],
|
||||
"state": [],
|
||||
"secrets": [],
|
||||
"balance": false,
|
||||
"selectable": false,
|
||||
"privacy": false
|
||||
},
|
||||
"receivingLockingScript": {
|
||||
"name": "Received",
|
||||
"description": "Funds received without wallet coordination.",
|
||||
"icon": "address",
|
||||
"lockingType": "standard",
|
||||
"lockingScript": "lockP2PKH",
|
||||
"unlockingScript": "unlockP2PKH",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"state": { "variables": [], "secrets": ["ownerKey"] },
|
||||
"actions": [
|
||||
{ "action": "sign", "role": "owner", "secrets": ["ownerKey"] },
|
||||
{ "action": "verify", "role": "owner", "secrets": ["ownerKey"] },
|
||||
{
|
||||
"action": "sendSatoshis",
|
||||
"role": "sender",
|
||||
"secrets": ["ownerKey"]
|
||||
},
|
||||
{
|
||||
"action": "sendFungibleTokens",
|
||||
"role": "sender",
|
||||
"secrets": ["ownerKey"]
|
||||
},
|
||||
{
|
||||
"action": "sendNonfungibleTokens",
|
||||
"role": "sender",
|
||||
"secrets": ["ownerKey"]
|
||||
},
|
||||
{
|
||||
"action": "burnFungibleTokens",
|
||||
"role": "sender",
|
||||
"secrets": ["ownerKey"]
|
||||
},
|
||||
{
|
||||
"action": "burnNonfungibleTokens",
|
||||
"role": "sender",
|
||||
"secrets": ["ownerKey"]
|
||||
}
|
||||
],
|
||||
"balance": { "satoshis": true, "fungibleTokens": true },
|
||||
"selectable": true,
|
||||
"privacy": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lockP2PKH": "OP_DUP OP_HASH160 <$(<ownerKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG",
|
||||
"unlockP2PKH": "<ownerKey.schnorr_signature.all_outputs> <ownerKey.public_key>",
|
||||
"lockToRecipient": "<recipientLockingscript>"
|
||||
},
|
||||
"constants": {
|
||||
"dustLimit": {
|
||||
"name": "Dust Limit",
|
||||
"description": "Standard required minimum satoshis for Pay to Public Key Hash outputs.",
|
||||
"type": "integer",
|
||||
"value": 546
|
||||
},
|
||||
"messagePrefix": {
|
||||
"name": "Message Prefix",
|
||||
"description": "Standard message prefix used in the bitcoin signed message protocol.",
|
||||
"type": "bytes",
|
||||
"value": "0x18426974636f696e205369676e6564204d6573736167653a0a"
|
||||
}
|
||||
},
|
||||
"variables": {
|
||||
"ownerKey": {
|
||||
"name": "Owners Private Key",
|
||||
"description": "The private key used to authorize spending of received funds.",
|
||||
"type": "bytes",
|
||||
"hint": "private_key"
|
||||
},
|
||||
"messageToSign": {
|
||||
"name": "Message",
|
||||
"description": "The text message to sign.",
|
||||
"type": "string"
|
||||
},
|
||||
"messageToVerify": {
|
||||
"name": "Message",
|
||||
"description": "The text message to verify.",
|
||||
"type": "string"
|
||||
},
|
||||
"messageSignature": {
|
||||
"name": "Message Signature",
|
||||
"description": "The signature for the message.",
|
||||
"type": "bytes",
|
||||
"hint": "signature"
|
||||
},
|
||||
"requestedSatoshis": {
|
||||
"name": "Requested Amount",
|
||||
"description": "The Bitcoin Cash amount requested",
|
||||
"type": "integer",
|
||||
"hint": "satoshis"
|
||||
},
|
||||
"requestedTokenCategory": {
|
||||
"name": "Requested Token Category",
|
||||
"description": "The token category requested",
|
||||
"type": "bytes",
|
||||
"hint": "token_category"
|
||||
},
|
||||
"requestedTokenAmount": {
|
||||
"name": "Requested Token Amount",
|
||||
"description": "The fungible token amount requested",
|
||||
"type": "integer",
|
||||
"hint": "token_amount"
|
||||
},
|
||||
"requestedTokenCapability": {
|
||||
"name": "Requested Token Capability",
|
||||
"description": "The non-fungible token capability requested",
|
||||
"type": "bytes",
|
||||
"hint": "token_capability"
|
||||
},
|
||||
"requestedTokenCommitment": {
|
||||
"name": "Requested Token Commitment",
|
||||
"description": "The non-fungible token commitment requested",
|
||||
"type": "bytes",
|
||||
"hint": "token_commitment"
|
||||
},
|
||||
"transferredTokenCategory": {
|
||||
"name": "Sending Token Category",
|
||||
"description": "The token category of the token(s) to send",
|
||||
"type": "bytes",
|
||||
"hint": "token_category"
|
||||
},
|
||||
"transferredTokenAmount": {
|
||||
"name": "Sending Token Amount",
|
||||
"description": "The fungible token amount to send",
|
||||
"type": "integer",
|
||||
"hint": "token_amount"
|
||||
},
|
||||
"transferredTokenCapability": {
|
||||
"name": "Sending Token Capability",
|
||||
"description": "The token capability for the non-fungible token to send",
|
||||
"type": "bytes",
|
||||
"hint": "token_capability"
|
||||
},
|
||||
"transferredTokenCommitment": {
|
||||
"name": "Sending Token Commitment",
|
||||
"description": "The token commitment for the non-fungible token to send",
|
||||
"type": "bytes",
|
||||
"hint": "token_commitment"
|
||||
},
|
||||
"burnedTokenCategory": {
|
||||
"name": "Deleted Token Category",
|
||||
"description": "The token category of the token(s) to delete",
|
||||
"type": "bytes",
|
||||
"hint": "token_category"
|
||||
},
|
||||
"burnedTokenAmount": {
|
||||
"name": "Deleted Token Amount",
|
||||
"description": "The fungible token amount to delete",
|
||||
"type": "integer",
|
||||
"hint": "token_amount"
|
||||
},
|
||||
"burnedTokenCapability": {
|
||||
"name": "Deleted Token Capability",
|
||||
"description": "The token capability for the non-fungible token to delete",
|
||||
"type": "bytes",
|
||||
"hint": "token_capability"
|
||||
},
|
||||
"burnedTokenCommitment": {
|
||||
"name": "Deleted Token Commitment",
|
||||
"description": "The token commitment for the non-fungible token to delete",
|
||||
"type": "bytes",
|
||||
"hint": "token_commitment"
|
||||
}
|
||||
},
|
||||
"icons": [
|
||||
{ "name": "wallet", "hash": "0000000000000000000000" },
|
||||
{ "name": "owner", "hash": "0000000000000000000000" },
|
||||
{ "name": "sender", "hash": "0000000000000000000000" },
|
||||
{ "name": "address", "hash": "0000000000000000000000" },
|
||||
{ "name": "receive", "hash": "0000000000000000000000" },
|
||||
{ "name": "request", "hash": "0000000000000000000000" },
|
||||
{ "name": "send", "hash": "0000000000000000000000" },
|
||||
{ "name": "burn", "hash": "0000000000000000000000" },
|
||||
{ "name": "sign", "hash": "0000000000000000000000" },
|
||||
{ "name": "verify", "hash": "0000000000000000000000" }
|
||||
],
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "requesting satoshis",
|
||||
"description": "happy-path evaluation for requesting satoshis.",
|
||||
"action": "requestSatoshis",
|
||||
"roles": [
|
||||
{
|
||||
"role": "receiver",
|
||||
"values": {
|
||||
"generated": {
|
||||
"ownerKey": "KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8"
|
||||
},
|
||||
"variables": { "requestedSatoshis": 2000 },
|
||||
"secrets": {},
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"lockingBytecode": "76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac",
|
||||
"valueSatoshis": 2000
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"role": "sender",
|
||||
"values": {
|
||||
"generated": {},
|
||||
"variables": {},
|
||||
"secrets": {},
|
||||
"inputs": [
|
||||
{
|
||||
"outpointTransactionHash": "4ef28553a31a266719e66ba97fee3aeecd6d1788f7ff6ab12f8ebceda49660c0",
|
||||
"outpointIndex": 0,
|
||||
"sequenceNumber": 0,
|
||||
"unlockingBytecode": "41226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db04a422c9ee49f5eefd26fee24e91910edbbb032b90cc54c34da80a61e69b0ee3d22412103e7ab26c36a7c7f45b2c26f33c08b0fa43a633268700f47216646d4cb37ae5696"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"lockingBytecode": "76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac",
|
||||
"valueSatoshis": 2000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"values": { "generated": {}, "variables": {}, "secrets": {} },
|
||||
"outcome": {
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Request",
|
||||
"description": "Requested a specific amount of satoshis from one or more senders.",
|
||||
"icon": "request"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Send",
|
||||
"description": "Sent a specific amount of satoshis to the provided receiver.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"transactions": [
|
||||
{
|
||||
"transaction": "0200000001c06096a4edbc8e2fb16afff788176dcdee3aee7fa96be61967261aa35385f24e000000006441226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db04a422c9ee49f5eefd26fee24e91910edbbb032b90cc54c34da80a61e69b0ee3d22412103e7ab26c36a7c7f45b2c26f33c08b0fa43a633268700f47216646d4cb37ae5696000000000267530300000000001976a91475c715ecb74178fe87933e57e947e5e92d904b8188acd0070000000000001976a91475c715ecb74178fe87933e57e947e5e92d904b8188ac00000000",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4298
package-lock.json
generated
4298
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
|
||||
"build": "tsc && npm run build:copy-scripts",
|
||||
"build": "tsc --nocheck --noEmitOnError false || true && npm run build:copy-scripts",
|
||||
"build:safe": "tsc && npm run build:copy-scripts",
|
||||
"build:copy-scripts": "cp -r src/cli/autocomplete/scripts dist/cli/autocomplete/",
|
||||
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
||||
"test": "vitest --run --passWithNoTests",
|
||||
@@ -38,10 +39,9 @@
|
||||
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
|
||||
"@xo-cash/crypto": "^0.0.1",
|
||||
"@xo-cash/engine": "file:../engine",
|
||||
"@xo-cash/state": "file:../state",
|
||||
"@xo-cash/templates": "^0.0.1",
|
||||
"@xo-cash/state": "0.0.1",
|
||||
"@xo-cash/templates": "^0.0.3",
|
||||
"@xo-cash/types": "^0.0.1",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"clipboardy": "^5.1.0",
|
||||
"ink": "^6.6.0",
|
||||
"prettier": "^3.8.1",
|
||||
@@ -50,7 +50,6 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
|
||||
23
readme.md
23
readme.md
@@ -9,7 +9,7 @@ mkdir xo-terminal && cd xo-terminal
|
||||
|
||||
# ----- Start Engine Setup -----
|
||||
# Clone the Engine Repo (Note, this uses harvey's fork of the engine repo to access the cli-test branch)
|
||||
git clone git@gitlab.com:Harvmaster/engine.git
|
||||
git clone https://gitlab.com/Harvmaster/engine.git
|
||||
|
||||
# Move into teh engine directory
|
||||
cd engine
|
||||
@@ -27,28 +27,9 @@ npm run build
|
||||
# Move back to the top level directory
|
||||
cd ..
|
||||
|
||||
# ----- Start State Setup -----
|
||||
# Clone the State Repo
|
||||
git clone git@gitlab.com:Harvmaster/state.git
|
||||
|
||||
# Move into the state directory
|
||||
cd state
|
||||
|
||||
git checkout in-memory-adapter
|
||||
|
||||
# Install the dependencies
|
||||
npm ci
|
||||
|
||||
# Build the state
|
||||
npm run build
|
||||
# ----- End State Setup -----
|
||||
|
||||
# Move back to the top level directory
|
||||
cd ..
|
||||
|
||||
# ----- Start CLI Setup -----
|
||||
# Clone the CLI Repo
|
||||
git clone git@git.harvmaster.com:Harvmaster/xo-cli.git
|
||||
git clone https://git.harvmaster.com/Harvmaster/xo-cli.git
|
||||
|
||||
# Move into the cli directory
|
||||
cd xo-cli
|
||||
|
||||
@@ -15,7 +15,7 @@ Wallet state lives under **`~/.config/xo-cli/`** (XDG-style), so you can run com
|
||||
| ----------------------------- | ----------------------------------------------------------------------- |
|
||||
| `~/.config/xo-cli/mnemonics/` | Mnemonic files (`mnemonic-*`) |
|
||||
| `~/.config/xo-cli/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) |
|
||||
| `~/.config/xo-cli/.wallet` | Last-used mnemonic reference (so `-m` can be omitted) |
|
||||
| `~/.config/xo-cli/.wallet` | JSON settings (`default-mnemonic`, `currency`) |
|
||||
|
||||
**Local to your shell’s current directory:** template JSON paths, invitation JSON you create/import, and any path you pass explicitly (e.g. `-m /abs/path/to/file`).
|
||||
|
||||
@@ -57,7 +57,7 @@ npx tsx src/index.ts # TUI
|
||||
xo-cli mnemonic create
|
||||
|
||||
# Import an existing mnemonic seed phrase
|
||||
xo-cli mnemonic import page pencil stock planet limb cluster assault speak off joke private pioneer
|
||||
xo-cli mnemonic import oven crop same above under tower promote decrease vocal pretty require slow
|
||||
|
||||
# List mnemonic basenames (use with -m)
|
||||
xo-cli mnemonic list
|
||||
@@ -67,7 +67,10 @@ xo-cli mnemonic list
|
||||
|
||||
### Wallet Persistence
|
||||
|
||||
The first time you pass `-m <name>`, that reference is saved to `~/.config/xo-cli/.wallet`. Later runs can omit `-m`.
|
||||
The first time you pass `-m <name>`, that reference is saved as
|
||||
`default-mnemonic` in `~/.config/xo-cli/.wallet`. Later runs can omit `-m`.
|
||||
|
||||
`currency` controls the fiat unit used when showing BCH/sats conversions in the TUI.
|
||||
|
||||
Mnemonic resolution order:
|
||||
|
||||
@@ -85,6 +88,7 @@ xo-cli resource list
|
||||
| Flag | Description |
|
||||
| ------------------------------ | --------------------------------------------------- |
|
||||
| `-m`, `--mnemonic-file <file>` | Mnemonic file (basename, cwd-relative, or absolute) |
|
||||
| `--currency <code>` | Fiat display currency (e.g. `USD`, `AUD`) |
|
||||
| `-o`, `--output <filename>` | Output filename (used by `mnemonic create`/`import`) |
|
||||
| `-v`, `--verbose` | Verbose output |
|
||||
| `-h`, `--help` | Help |
|
||||
@@ -126,6 +130,16 @@ xo-cli resource unreserve <txhash:vout>
|
||||
xo-cli resource unreserve-all
|
||||
```
|
||||
|
||||
### `settings` — Manage Persisted Settings
|
||||
|
||||
```bash
|
||||
xo-cli settings show
|
||||
xo-cli settings get currency
|
||||
xo-cli settings get default-mnemonic
|
||||
xo-cli settings set currency AUD
|
||||
xo-cli settings set default-mnemonic mnemonic-nuclear
|
||||
```
|
||||
|
||||
### `receive` — Generate a Receiving Address
|
||||
|
||||
```bash
|
||||
|
||||
@@ -7,9 +7,9 @@ import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Converts the CLI args to a key-value object and return the options object along with the other arguments still in the array.\
|
||||
* eg: `xo-cli mnemonic create page pencil stock planet limb cluster assault speak off joke private pioneer -v -o mnemonic.txt` will return:
|
||||
* eg: `xo-cli mnemonic create oven crop same above under tower promote decrease vocal pretty require slow -v -o mnemonic.txt` will return:
|
||||
* {
|
||||
* args: ["mnemonic", "create", "page", "pencil", "stock", "planet", "limb", "cluster", "assault", "speak", "off", "joke", "private", "pioneer"],
|
||||
* args: ["mnemonic", "create", "oven", "crop", "same", "above", "under", "tower", "promote", "decrease", "vocal", "pretty", "require", "slow"],
|
||||
* options: {
|
||||
* output: "mnemonic.txt",
|
||||
* verbose: "true",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
* 1 - Error (no output, fails silently for shell integration)
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "../../utils/paths.js";
|
||||
import { loadMnemonic } from "../mnemonic.js";
|
||||
import { Storage } from "../../services/storage.js";
|
||||
import { SettingsService } from "../../services/settings.js";
|
||||
import { COMMAND_TREE } from "./completions.js";
|
||||
|
||||
// Lazy-loaded modules (only loaded when needed for dynamic completions)
|
||||
@@ -103,12 +104,8 @@ function listSubcommands(command: string, prefix?: string): void {
|
||||
*/
|
||||
function getCurrentMnemonic(): string | null {
|
||||
try {
|
||||
const walletConfigPath = getWalletConfigPath();
|
||||
if (!existsSync(walletConfigPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mnemonicFile = readFileSync(walletConfigPath, "utf8").trim();
|
||||
const settings = new SettingsService(getWalletConfigPath());
|
||||
const mnemonicFile = settings.getDefaultMnemonic();
|
||||
if (!mnemonicFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import { homedir } from "node:os";
|
||||
* - template.ts: import, list, inspect, set-default
|
||||
* - invitation.ts: create, append, sign, broadcast, requirements, import, inspect, list
|
||||
* - resource.ts: list, unreserve, unreserve-all
|
||||
* - settings.ts: show, get, set
|
||||
*/
|
||||
|
||||
/** Subcommands for the mnemonic command */
|
||||
@@ -56,6 +57,8 @@ const INVITATION_SUBS = [
|
||||
];
|
||||
/** Subcommands for the resource command */
|
||||
const RESOURCE_SUBS = ["list", "unreserve", "unreserve-all"];
|
||||
/** Subcommands for the settings command */
|
||||
const SETTINGS_SUBS = ["show", "get", "set"];
|
||||
/** Subcommands for the completions command */
|
||||
const COMPLETIONS_SUBS = ["bash", "zsh", "fish"];
|
||||
|
||||
@@ -65,6 +68,7 @@ export const COMMAND_TREE = {
|
||||
invitation: INVITATION_SUBS,
|
||||
receive: [],
|
||||
resource: RESOURCE_SUBS,
|
||||
settings: SETTINGS_SUBS,
|
||||
help: [],
|
||||
completions: COMPLETIONS_SUBS,
|
||||
} as const;
|
||||
@@ -77,6 +81,7 @@ const GLOBAL_OPTIONS = [
|
||||
"--verbose",
|
||||
"-m",
|
||||
"--mnemonic-file",
|
||||
"--currency",
|
||||
"-o",
|
||||
"--output",
|
||||
];
|
||||
|
||||
@@ -201,6 +201,17 @@ _{{FUNC_NAME}}_completions() {
|
||||
fi
|
||||
;;
|
||||
|
||||
settings)
|
||||
if [[ -z "${subcmd}" ]]; then
|
||||
COMPREPLY=($(compgen -W "show get set" -- "${cur}"))
|
||||
elif [[ "${subcmd}" == "get" || "${subcmd}" == "set" ]]; then
|
||||
local pos=$((cword - subcmd_idx))
|
||||
if [[ $pos -eq 1 ]]; then
|
||||
COMPREPLY=($(compgen -W "currency default-mnemonic" -- "${cur}"))
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
receive)
|
||||
# receive <template> [output]
|
||||
# Template is the first positional argument after `receive`.
|
||||
|
||||
@@ -35,6 +35,7 @@ complete -c {{BIN_NAME}} -s v -d "Verbose output"
|
||||
complete -c {{BIN_NAME}} -l verbose -d "Verbose output"
|
||||
complete -c {{BIN_NAME}} -s o -d "Output file"
|
||||
complete -c {{BIN_NAME}} -l output -d "Output file"
|
||||
complete -c {{BIN_NAME}} -l currency -d "Set fiat display currency"
|
||||
|
||||
# Dynamic completion for `-m/--mnemonic-file`.
|
||||
complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_dynamic mnemonics)'
|
||||
|
||||
@@ -180,6 +180,17 @@ _{{FUNC_NAME}}_completions() {
|
||||
fi
|
||||
;;
|
||||
|
||||
settings)
|
||||
if [[ -z "${subcmd}" ]]; then
|
||||
compadd -- show get set
|
||||
elif [[ "${subcmd}" == "get" || "${subcmd}" == "set" ]]; then
|
||||
local pos=$((CURRENT - subcmd_idx))
|
||||
if [[ $pos -eq 1 ]]; then
|
||||
compadd -- currency default-mnemonic
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
receive)
|
||||
# receive <template> [output]
|
||||
local pos=$((CURRENT - cmd_idx))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { handleMnemonicCommand, printMnemonicHelp } from "./mnemonic.js";
|
||||
export { handleSettingsCommand, printSettingsHelp } from "./settings.js";
|
||||
export { handleTemplateCommand, printTemplateHelp } from "./template.js";
|
||||
export { handleInvitationCommand, printInvitationHelp } from "./invitation.js";
|
||||
export { handleReceiveCommand, printReceiveHelp } from "./receive.js";
|
||||
|
||||
131
src/cli/commands/settings.ts
Normal file
131
src/cli/commands/settings.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { SettingsService } from "../../services/settings.js";
|
||||
import { formatObject } from "../utils.js";
|
||||
import type { BaseCommandDependencies, CommandIO } from "./types.js";
|
||||
import { CommandError } from "./types.js";
|
||||
|
||||
/**
|
||||
* Prints help text for the settings command.
|
||||
*/
|
||||
export const printSettingsHelp = (io: CommandIO): void => {
|
||||
io.out(`Settings Command Help:
|
||||
Commands:
|
||||
settings show
|
||||
settings get <currency|default-mnemonic>
|
||||
settings set <currency|default-mnemonic> <value>
|
||||
|
||||
Examples:
|
||||
xo-cli settings show
|
||||
xo-cli settings get currency
|
||||
xo-cli settings set currency AUD
|
||||
xo-cli settings set default-mnemonic mnemonic-main`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Supported settings keys exposed on the CLI.
|
||||
*/
|
||||
type SettingsKey = "currency" | "default-mnemonic";
|
||||
|
||||
/**
|
||||
* Normalizes user input to one of the supported settings keys.
|
||||
*/
|
||||
function parseSettingsKey(input: string | undefined): SettingsKey | null {
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = input.trim().toLowerCase();
|
||||
if (normalized === "currency") {
|
||||
return "currency";
|
||||
}
|
||||
if (normalized === "default-mnemonic" || normalized === "defaultMnemonic") {
|
||||
return "default-mnemonic";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `xo-cli settings` commands.
|
||||
*
|
||||
* This command intentionally does not require wallet initialization so users can
|
||||
* configure currency/default mnemonic without passing `-m`.
|
||||
*/
|
||||
export const handleSettingsCommand = async (
|
||||
deps: BaseCommandDependencies,
|
||||
args: string[],
|
||||
options: Record<string, string>,
|
||||
): Promise<Record<string, unknown>> => {
|
||||
const settings = new SettingsService(deps.paths.walletConfigPath);
|
||||
|
||||
// settings show (default if no subcommand)
|
||||
const subCommand = args[0] ?? "show";
|
||||
if (subCommand === "help" || options["help"] === "true") {
|
||||
printSettingsHelp(deps.io);
|
||||
return {};
|
||||
}
|
||||
|
||||
switch (subCommand) {
|
||||
case "show": {
|
||||
const snapshot = settings.getSettings();
|
||||
deps.io.out(formatObject(snapshot));
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
case "get": {
|
||||
const key = parseSettingsKey(args[1]);
|
||||
if (!key) {
|
||||
printSettingsHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"settings.get.key_missing",
|
||||
'Missing or invalid key. Expected "currency" or "default-mnemonic".',
|
||||
);
|
||||
}
|
||||
|
||||
const value =
|
||||
key === "currency"
|
||||
? settings.getCurrency()
|
||||
: settings.getDefaultMnemonic() ?? "";
|
||||
deps.io.out(value);
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
case "set": {
|
||||
const key = parseSettingsKey(args[1]);
|
||||
if (!key) {
|
||||
printSettingsHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"settings.set.key_missing",
|
||||
'Missing or invalid key. Expected "currency" or "default-mnemonic".',
|
||||
);
|
||||
}
|
||||
|
||||
const rawValue = args.slice(2).join(" ").trim();
|
||||
if (!rawValue) {
|
||||
printSettingsHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"settings.set.value_missing",
|
||||
"Missing value for settings set command.",
|
||||
);
|
||||
}
|
||||
|
||||
if (key === "currency") {
|
||||
settings.setCurrency(rawValue);
|
||||
const currency = settings.getCurrency();
|
||||
deps.io.out(`Updated currency: ${currency}`);
|
||||
return { key, value: currency };
|
||||
}
|
||||
|
||||
settings.setDefaultMnemonic(rawValue);
|
||||
const defaultMnemonic = settings.getDefaultMnemonic() ?? "";
|
||||
deps.io.out(`Updated default-mnemonic: ${defaultMnemonic}`);
|
||||
return { key, value: defaultMnemonic };
|
||||
}
|
||||
|
||||
default: {
|
||||
printSettingsHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"settings.subcommand.unknown",
|
||||
`Unknown settings command: ${subCommand}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -35,10 +35,10 @@
|
||||
* -m --mnemonic-file <mnemonic-file>
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
import { AppService } from "../services/app.js";
|
||||
import { SettingsService } from "../services/settings.js";
|
||||
import { convertArgsToObject } from "./arguments.js";
|
||||
import { bold, dim, formatObject } from "./utils.js";
|
||||
import { listGlobalMnemonicFiles, loadMnemonic } from "./mnemonic.js";
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
type CommandPaths,
|
||||
CommandError,
|
||||
handleMnemonicCommand,
|
||||
handleSettingsCommand,
|
||||
handleTemplateCommand,
|
||||
handleInvitationCommand,
|
||||
handleReceiveCommand,
|
||||
@@ -115,6 +116,7 @@ async function main(): Promise<void> {
|
||||
walletConfigPath: getWalletConfigPath(),
|
||||
workingDir: process.cwd(),
|
||||
};
|
||||
const settings = new SettingsService(paths.walletConfigPath);
|
||||
|
||||
// Early handling for completions command
|
||||
if (command === "completions") {
|
||||
@@ -134,10 +136,26 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve mnemonic file: explicit flag > persisted config > error.
|
||||
if (command === "settings") {
|
||||
try {
|
||||
await handleSettingsCommand({ io, paths }, subArgs, options);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
if (error instanceof CommandError) {
|
||||
process.exit(error.code);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve mnemonic file: explicit flag > persisted settings > error.
|
||||
let mnemonicFile = options["mnemonicFile"];
|
||||
if (!mnemonicFile && existsSync(paths.walletConfigPath)) {
|
||||
mnemonicFile = readFileSync(paths.walletConfigPath, "utf8").trim();
|
||||
let didUsePersistedMnemonic = false;
|
||||
if (!mnemonicFile) {
|
||||
mnemonicFile = settings.getDefaultMnemonic();
|
||||
didUsePersistedMnemonic = Boolean(mnemonicFile);
|
||||
}
|
||||
if (didUsePersistedMnemonic && mnemonicFile) {
|
||||
io.verbose(`Using persisted wallet: ${mnemonicFile}`);
|
||||
}
|
||||
if (!mnemonicFile) {
|
||||
@@ -152,7 +170,11 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
// Persist the choice so subsequent commands can omit -m.
|
||||
writeFileSync(paths.walletConfigPath, mnemonicFile);
|
||||
settings.setDefaultMnemonic(mnemonicFile);
|
||||
if (options["currency"]) {
|
||||
settings.setCurrency(options["currency"]);
|
||||
io.verbose(`Using configured currency: ${settings.getCurrency()}`);
|
||||
}
|
||||
|
||||
const mnemonic = loadMnemonic(paths.mnemonicsDir, mnemonicFile);
|
||||
io.verbose(`Loaded mnemonic from file: ${mnemonicFile} ${mnemonic}`);
|
||||
@@ -168,7 +190,7 @@ async function main(): Promise<void> {
|
||||
invitationStoragePath:
|
||||
options["invitationStoragePath"] ??
|
||||
join(paths.dataDir, "xo-invitations.db"),
|
||||
});
|
||||
}, settings);
|
||||
io.verbose("App instance created");
|
||||
|
||||
// Start the app
|
||||
@@ -255,12 +277,14 @@ Commands:
|
||||
invitation ${dim("Manage invitations")}
|
||||
receive ${dim("Generate a single-use receiving address")}
|
||||
resource ${dim("Manage resources")}
|
||||
settings ${dim("Manage persisted wallet settings")}
|
||||
completions ${dim("Generate shell completion scripts (bash, zsh, fish)")}
|
||||
help ${dim("Show this help message")}
|
||||
|
||||
Options:
|
||||
-h, --help ${dim("Show this help message")}
|
||||
-m, --mnemonic-file <mnemonic-file> ${dim("Use a specific mnemonic file")}
|
||||
--currency <currency-code> ${dim("Set fiat display currency (e.g. USD, AUD)")}
|
||||
-v, --verbose ${dim("Show verbose output")}`,
|
||||
);
|
||||
return {};
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SyncServer } from "../utils/sync-server.js";
|
||||
import { HistoryService } from "./history.js";
|
||||
import { type BlockchainService, ElectrumService } from "./electrum.js";
|
||||
import { RatesService } from "./rates.js";
|
||||
import { SettingsService } from "./settings.js";
|
||||
|
||||
import { EventEmitter } from "../utils/event-emitter.js";
|
||||
|
||||
@@ -49,6 +50,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
public history: HistoryService;
|
||||
public electrum: BlockchainService;
|
||||
public rates: RatesService;
|
||||
public settings: SettingsService;
|
||||
|
||||
public invitations: Invitation[] = [];
|
||||
private invitationEventCleanup = new Map<
|
||||
@@ -59,7 +61,11 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
}
|
||||
>();
|
||||
|
||||
static async create(seed: string, config: AppConfig): Promise<AppService> {
|
||||
static async create(
|
||||
seed: string,
|
||||
config: AppConfig,
|
||||
settings: SettingsService = new SettingsService(),
|
||||
): Promise<AppService> {
|
||||
// Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app.
|
||||
// We are going to do this by computing a hash of the seed and prefixing the storage paths with it.
|
||||
const seedHash = createHash("sha256").update(seed).digest("hex");
|
||||
@@ -77,20 +83,33 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
// Import the default P2PKH template
|
||||
const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate);
|
||||
|
||||
engine
|
||||
.subscribeToLockingBytecodesForTemplate(templateIdentifier)
|
||||
.catch((err) =>
|
||||
console.error(
|
||||
`Error subscribing to locking bytecodes for template ${templateIdentifier}: ${err}`,
|
||||
),
|
||||
);
|
||||
engine
|
||||
.updateUnspentOutputsForTemplate(templateIdentifier)
|
||||
.catch((err) =>
|
||||
console.error(
|
||||
`Error updating unspent outputs for template ${templateIdentifier}: ${err}`,
|
||||
),
|
||||
);
|
||||
// engine
|
||||
// .subscribeToLockingBytecodesForTemplate(templateIdentifier)
|
||||
// .catch((err) =>
|
||||
// console.error(
|
||||
// `Error subscribing to locking bytecodes for template ${templateIdentifier}: ${err}`,
|
||||
// ),
|
||||
// );
|
||||
// engine
|
||||
// .updateUnspentOutputsForTemplate(templateIdentifier)
|
||||
// .catch((err) =>
|
||||
// console.error(
|
||||
// `Error updating unspent outputs for template ${templateIdentifier}: ${err}`,
|
||||
// ),
|
||||
// );
|
||||
|
||||
// Update all the unspents for every template, and subscribe to the locking bytecodes for changes
|
||||
// TODO: Remove the above lines that do the same thing. Minimising changes for BLISS.
|
||||
const updateTemplates = async () => {
|
||||
const templates = await engine.listImportedTemplates();
|
||||
|
||||
templates.forEach(async (template) => {
|
||||
// engine.updateUnspentOutputsForTemplate(generateTemplateIdentifier(template));
|
||||
engine.subscribeToLockingBytecodesForTemplate(generateTemplateIdentifier(template));
|
||||
});
|
||||
};
|
||||
|
||||
updateTemplates();
|
||||
|
||||
// Set default locking parameters for P2PKH
|
||||
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
|
||||
@@ -110,9 +129,9 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
host: config.electrumHost,
|
||||
applicationIdentifier: config.electrumApplicationIdentifier,
|
||||
});
|
||||
const rates = await RatesService.create();
|
||||
const rates = await RatesService.create(settings);
|
||||
|
||||
return new AppService(engine, walletStorage, config, electrum, rates);
|
||||
return new AppService(engine, walletStorage, config, electrum, rates, settings);
|
||||
}
|
||||
|
||||
constructor(
|
||||
@@ -121,6 +140,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
config: AppConfig,
|
||||
electrum: BlockchainService,
|
||||
rates: RatesService,
|
||||
settings: SettingsService,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -129,6 +149,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
this.config = config;
|
||||
this.electrum = electrum;
|
||||
this.rates = rates;
|
||||
this.settings = settings;
|
||||
this.history = new HistoryService(engine, this.invitations);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,12 @@ export class HistoryService {
|
||||
private invitations: Invitation[],
|
||||
) {}
|
||||
|
||||
|
||||
/**
|
||||
* I Might swap this over to invitation based history before the event to make it a bit more evident... Really not happy with the UTXO for demo purposes
|
||||
* But for the actual usage, UTXO is easier to follow - just not good for demo
|
||||
* Long term, this is intended to be in the Engine, so we will just be a consumer of history state.
|
||||
*/
|
||||
async getHistory(): Promise<WalletHistoryItem[]> {
|
||||
const allUtxos = await this.engine.listUnspentOutputsData();
|
||||
const metadataIndex = await this.buildWalletMetadataIndex(allUtxos);
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
Engine,
|
||||
GetSpendableResourcesParameters,
|
||||
} from "@xo-cash/engine";
|
||||
import { hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
|
||||
import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
|
||||
import type {
|
||||
XOInvitation,
|
||||
XOInvitationCommit,
|
||||
@@ -498,16 +498,38 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedOptions: GetSpendableResourcesParameters = {
|
||||
templateIdentifier,
|
||||
outputIdentifier: options.outputIdentifier ?? fallbackOutputIdentifier ?? "",
|
||||
};
|
||||
// const resolvedOptions: GetSpendableResourcesParameters = {
|
||||
// templateIdentifier,
|
||||
// outputIdentifier: options.outputIdentifier ?? fallbackOutputIdentifier ?? "",
|
||||
// };
|
||||
|
||||
// Find the suitable resources
|
||||
const { unspentOutputs } = await this.engine.getSpendableResources(
|
||||
this.data,
|
||||
resolvedOptions,
|
||||
);
|
||||
// There are disagreements around whether all spendables should be returned from getSpendableResources.
|
||||
// I had a fix merged in, but it got overwritten. So, im just going to get all of them manually and go around
|
||||
// The engine's expectations.
|
||||
// To do this, we are going to grab all out templates
|
||||
const templates = await this.engine.listImportedTemplates();
|
||||
|
||||
// For each template, we need to create a 2d array of all the outputs
|
||||
const outputs = templates.map(template => {
|
||||
return Object.keys(template.outputs).map(output => {
|
||||
const templateIdentifier = generateTemplateIdentifier(template);
|
||||
|
||||
return {
|
||||
templateIdentifier,
|
||||
outputIdentifier: output,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// then, for each output, we need to get the spendable resources
|
||||
const spendableResources = await Promise.all(outputs.flat().map(output => {
|
||||
return this.engine.getSpendableResources(this.data, {
|
||||
templateIdentifier: output.templateIdentifier,
|
||||
outputIdentifier: output.outputIdentifier,
|
||||
});
|
||||
}));
|
||||
|
||||
const unspentOutputs = spendableResources.flatMap(resource => resource.unspentOutputs);
|
||||
|
||||
// Update the status of the invitation
|
||||
await this.updateStatus();
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type RatesEventMap,
|
||||
} from '../utils/rates/base-rates.js';
|
||||
import { RatesOracle } from '../utils/rates/rates-oracles.js';
|
||||
import { SettingsService } from './settings.js';
|
||||
|
||||
/**
|
||||
* Event map emitted by {@link RatesService}.
|
||||
@@ -52,13 +53,15 @@ export interface RatesAdapter {
|
||||
*/
|
||||
export class RatesService extends EventEmitter<RatesServiceEventMap> {
|
||||
private readonly adapter: RatesAdapter;
|
||||
private readonly settings: SettingsService;
|
||||
private readonly ratesByPair = new Map<string, CachedRate>();
|
||||
private unsubscribeFromAdapter: (() => void) | null = null;
|
||||
private started = false;
|
||||
|
||||
constructor(adapter: RatesAdapter) {
|
||||
constructor(adapter: RatesAdapter, settings: SettingsService) {
|
||||
super();
|
||||
this.adapter = adapter;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,9 +69,12 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
|
||||
*
|
||||
* If no adapter is passed, this defaults to the Oracle-backed adapter.
|
||||
*/
|
||||
public static async create(adapter?: RatesAdapter): Promise<RatesService> {
|
||||
const resolvedAdapter = adapter ?? (await RatesOracle.from());
|
||||
return new RatesService(resolvedAdapter);
|
||||
public static async create(
|
||||
settings: SettingsService,
|
||||
adapter?: RatesAdapter,
|
||||
): Promise<RatesService> {
|
||||
const resolvedAdapter = adapter ?? (await RatesOracle.from(undefined, settings));
|
||||
return new RatesService(resolvedAdapter, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,6 +168,20 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
|
||||
return this.adapter.formatCurrency(amount, currencyCode.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists available market pairs in NUMERATOR/DENOMINATOR format.
|
||||
*/
|
||||
public async listPairs(): Promise<Set<string>> {
|
||||
return this.adapter.listPairs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the fiat currency currently configured in settings.
|
||||
*/
|
||||
public getConfiguredCurrency(): string {
|
||||
return this.settings.getCurrency();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles normalized updates from the underlying adapter.
|
||||
*/
|
||||
|
||||
194
src/services/settings.ts
Normal file
194
src/services/settings.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
import { EventEmitter } from "../utils/event-emitter.js";
|
||||
import { getSettingsPath } from "../utils/paths.js";
|
||||
|
||||
/**
|
||||
* Supported persisted settings keys.
|
||||
*/
|
||||
export type SettingsData = {
|
||||
"default-mnemonic"?: string;
|
||||
currency: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Event payloads emitted by {@link SettingsService}.
|
||||
*/
|
||||
export type SettingsServiceEventMap = {
|
||||
"settings-updated": {
|
||||
key: keyof SettingsData;
|
||||
value: string | undefined;
|
||||
settings: SettingsData;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Runtime defaults for settings that should always exist in memory.
|
||||
*/
|
||||
const DEFAULT_SETTINGS: SettingsData = {
|
||||
currency: "USD",
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles loading, migrating, and persisting wallet settings.
|
||||
*
|
||||
* The backing file is `~/.config/xo-cli/.wallet`. Historically it stored a raw
|
||||
* mnemonic reference string. This service migrates that legacy format to JSON:
|
||||
* `{ "default-mnemonic": "<value>", "currency": "USD" }`.
|
||||
*/
|
||||
export class SettingsService extends EventEmitter<SettingsServiceEventMap> {
|
||||
private readonly settingsPath: string;
|
||||
private settings: SettingsData;
|
||||
|
||||
/**
|
||||
* Creates a new settings service instance.
|
||||
*
|
||||
* @param settingsPath - Optional custom settings file path (useful for tests)
|
||||
*/
|
||||
constructor(settingsPath: string = getSettingsPath()) {
|
||||
super();
|
||||
this.settingsPath = settingsPath;
|
||||
this.settings = this.loadSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current settings snapshot.
|
||||
*/
|
||||
public getSettings(): SettingsData {
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently selected default mnemonic reference.
|
||||
*/
|
||||
public getDefaultMnemonic(): string | undefined {
|
||||
return this.settings["default-mnemonic"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the default mnemonic reference and persists it to disk.
|
||||
*/
|
||||
public setDefaultMnemonic(mnemonicRef: string): void {
|
||||
const normalizedMnemonicRef = mnemonicRef.trim();
|
||||
if (normalizedMnemonicRef.length === 0) {
|
||||
throw new Error("default-mnemonic cannot be empty");
|
||||
}
|
||||
|
||||
this.settings["default-mnemonic"] = normalizedMnemonicRef;
|
||||
this.persistSettings();
|
||||
|
||||
this.emit("settings-updated", {
|
||||
key: "default-mnemonic",
|
||||
value: normalizedMnemonicRef,
|
||||
settings: this.getSettings(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the selected fiat currency code (ISO-like uppercase).
|
||||
*/
|
||||
public getCurrency(): string {
|
||||
return this.settings.currency;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the selected fiat currency and persists it to disk.
|
||||
*/
|
||||
public setCurrency(currencyCode: string): void {
|
||||
const normalizedCurrency = this.normalizeCurrency(currencyCode);
|
||||
if (this.settings.currency === normalizedCurrency) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.settings.currency = normalizedCurrency;
|
||||
this.persistSettings();
|
||||
|
||||
this.emit("settings-updated", {
|
||||
key: "currency",
|
||||
value: normalizedCurrency,
|
||||
settings: this.getSettings(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and normalizes the settings file from disk.
|
||||
*
|
||||
* If the file contains the old legacy format (raw mnemonic string), the
|
||||
* migrated JSON shape is written back immediately.
|
||||
*/
|
||||
private loadSettings(): SettingsData {
|
||||
if (!existsSync(this.settingsPath)) {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
const rawContents = readFileSync(this.settingsPath, "utf8").trim();
|
||||
if (rawContents.length === 0) {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawContents);
|
||||
const normalized = this.normalizeSettings(parsed);
|
||||
return normalized;
|
||||
} catch {
|
||||
const migrated = this.normalizeSettings({
|
||||
"default-mnemonic": rawContents,
|
||||
});
|
||||
this.persistSettings(migrated);
|
||||
return migrated;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the given settings object to disk as pretty JSON.
|
||||
*
|
||||
* @param nextSettings - Optional explicit value, defaults to in-memory state
|
||||
*/
|
||||
private persistSettings(nextSettings?: SettingsData): void {
|
||||
if (nextSettings) {
|
||||
this.settings = nextSettings;
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
this.settingsPath,
|
||||
`${JSON.stringify(this.settings, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerces unknown input into a safe settings object.
|
||||
*/
|
||||
private normalizeSettings(input: unknown): SettingsData {
|
||||
const normalized: SettingsData = {
|
||||
...DEFAULT_SETTINGS,
|
||||
};
|
||||
|
||||
if (!input || typeof input !== "object") {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const maybeMnemonic = (input as Record<string, unknown>)["default-mnemonic"];
|
||||
if (typeof maybeMnemonic === "string" && maybeMnemonic.trim().length > 0) {
|
||||
normalized["default-mnemonic"] = maybeMnemonic.trim();
|
||||
}
|
||||
|
||||
const maybeCurrency = (input as Record<string, unknown>).currency;
|
||||
if (typeof maybeCurrency === "string" && maybeCurrency.trim().length > 0) {
|
||||
normalized.currency = this.normalizeCurrency(maybeCurrency);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures currency values stay uppercase and non-empty.
|
||||
*/
|
||||
private normalizeCurrency(currencyCode: string): string {
|
||||
const normalizedCurrency = currencyCode.trim().toUpperCase();
|
||||
if (normalizedCurrency.length === 0) {
|
||||
throw new Error("currency cannot be empty");
|
||||
}
|
||||
return normalizedCurrency;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import Database from "better-sqlite3";
|
||||
// import Database from "better-sqlite3";
|
||||
import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js";
|
||||
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
/**
|
||||
* This is not an actual storage adapter that we want to make use of. This storage adapter is a stop-gap while the engine is under development.
|
||||
* At the time of writing the storage adapter, the engine provided no way to read data about your currenty invitations, so that is where this is coming in.
|
||||
@@ -21,7 +23,7 @@ export abstract class BaseStorage {
|
||||
export class Storage extends BaseStorage {
|
||||
static async create(dbPath: string): Promise<Storage> {
|
||||
// Create the database
|
||||
const database = new Database(dbPath);
|
||||
const database = new DatabaseSync(dbPath);
|
||||
|
||||
// Create the storage table if it doesn't exist
|
||||
database
|
||||
@@ -34,7 +36,7 @@ export class Storage extends BaseStorage {
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly database: Database.Database,
|
||||
private readonly database: DatabaseSync,
|
||||
private readonly basePath: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
1435
src/templates/p2pkh.ts
Normal file
1435
src/templates/p2pkh.ts
Normal file
File diff suppressed because it is too large
Load Diff
188
src/tui/components/CurrencySelectionDialog.tsx
Normal file
188
src/tui/components/CurrencySelectionDialog.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React, { useEffect, useId, useMemo, useState } from "react";
|
||||
import { Box, Text } from "ink";
|
||||
|
||||
import { ScrollableList, type ListItemData } from "./List.js";
|
||||
import TextInput from "./TextInput.js";
|
||||
import { DialogWrapper } from "./Dialog.js";
|
||||
import { useInputLayer, useLayeredInput } from "../hooks/useInputLayer.js";
|
||||
import { colors } from "../theme.js";
|
||||
|
||||
/**
|
||||
* Props for the currency selection dialog.
|
||||
*/
|
||||
interface CurrencySelectionDialogProps {
|
||||
/** Current wallet currency from persisted settings. */
|
||||
currentCurrency: string;
|
||||
/** Available fiat numerator symbols that can be paired with BCH. */
|
||||
currencies: string[];
|
||||
/** True while the dialog is loading available pairs. */
|
||||
isLoading: boolean;
|
||||
/** Optional loading/error message for pair discovery. */
|
||||
errorMessage: string | null;
|
||||
/** Called when the user chooses a currency and confirms. */
|
||||
onSelectCurrency: (currencyCode: string) => void;
|
||||
/** Called when the dialog should close without applying changes. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Currency picker dialog.
|
||||
*
|
||||
* UX requirements:
|
||||
* - Arrow keys move the highlighted item.
|
||||
* - Typing immediately filters results.
|
||||
* - Enter applies current selection.
|
||||
* - Escape closes without saving.
|
||||
*/
|
||||
export function CurrencySelectionDialog({
|
||||
currentCurrency,
|
||||
currencies,
|
||||
isLoading,
|
||||
errorMessage,
|
||||
onSelectCurrency,
|
||||
onCancel,
|
||||
}: CurrencySelectionDialogProps): React.ReactElement {
|
||||
const layerId = useId();
|
||||
const [filterText, setFilterText] = useState("");
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// Mount this as a capturing input layer so background screens stop handling keys.
|
||||
useInputLayer(layerId);
|
||||
|
||||
/**
|
||||
* Applies the currently selected filtered result.
|
||||
*/
|
||||
const applySelection = (): void => {
|
||||
const selectedCurrency = filteredCurrencies[selectedIndex];
|
||||
if (!selectedCurrency) {
|
||||
return;
|
||||
}
|
||||
onSelectCurrency(selectedCurrency);
|
||||
};
|
||||
|
||||
useLayeredInput(layerId, (_input, key) => {
|
||||
if (key.escape) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) =>
|
||||
prev <= 0 ? Math.max(filteredCurrencies.length - 1, 0) : prev - 1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex((prev) =>
|
||||
filteredCurrencies.length === 0
|
||||
? 0
|
||||
: prev >= filteredCurrencies.length - 1
|
||||
? 0
|
||||
: prev + 1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
* Filter currencies as the user types.
|
||||
*/
|
||||
const filteredCurrencies = useMemo(() => {
|
||||
const normalizedFilter = filterText.trim().toUpperCase();
|
||||
if (!normalizedFilter) {
|
||||
return currencies;
|
||||
}
|
||||
return currencies.filter((currencyCode) =>
|
||||
currencyCode.toUpperCase().includes(normalizedFilter),
|
||||
);
|
||||
}, [currencies, filterText]);
|
||||
|
||||
/**
|
||||
* Keep selected index valid whenever filtering shrinks the result set.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (filteredCurrencies.length === 0) {
|
||||
setSelectedIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedIndex >= filteredCurrencies.length) {
|
||||
setSelectedIndex(filteredCurrencies.length - 1);
|
||||
}
|
||||
}, [filteredCurrencies, selectedIndex]);
|
||||
|
||||
/**
|
||||
* When the dialog opens or the list updates, default to current currency.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (filterText.trim().length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = filteredCurrencies.findIndex(
|
||||
(currencyCode) => currencyCode.toUpperCase() === currentCurrency.toUpperCase(),
|
||||
);
|
||||
if (currentIndex >= 0) {
|
||||
setSelectedIndex(currentIndex);
|
||||
}
|
||||
}, [filteredCurrencies, currentCurrency, filterText]);
|
||||
|
||||
const listItems: ListItemData<string>[] = filteredCurrencies.map(
|
||||
(currencyCode) => ({
|
||||
key: currencyCode,
|
||||
label: currencyCode,
|
||||
description:
|
||||
currencyCode.toUpperCase() === currentCurrency.toUpperCase()
|
||||
? "(current)"
|
||||
: undefined,
|
||||
value: currencyCode,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogWrapper title="Select Fiat Currency" borderColor={colors.info} width={64}>
|
||||
<Text color={colors.textMuted}>
|
||||
Available BCH quote pairs are loaded from the live rates adapter.
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.primary}>Filter:</Text>
|
||||
</Box>
|
||||
<Box borderStyle="single" borderColor={colors.focus} paddingX={1}>
|
||||
<TextInput
|
||||
value={filterText}
|
||||
onChange={setFilterText}
|
||||
onSubmit={() => applySelection()}
|
||||
placeholder="Type currency code (e.g. USD, AUD)..."
|
||||
focus
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{isLoading ? (
|
||||
<Text color={colors.textMuted}>Loading available pairs...</Text>
|
||||
) : errorMessage ? (
|
||||
<Text color={colors.error}>{errorMessage}</Text>
|
||||
) : (
|
||||
<ScrollableList
|
||||
items={listItems}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={setSelectedIndex}
|
||||
onActivate={() => applySelection()}
|
||||
focus={false}
|
||||
maxVisible={8}
|
||||
emptyMessage="No BCH quote pairs match this filter."
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>
|
||||
Type to filter • ↑↓ navigate • Enter apply • Esc cancel
|
||||
</Text>
|
||||
</Box>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,8 @@ interface QRCodeProps {
|
||||
dialogTitle?: string;
|
||||
/** Whether to display the raw encoded value as copyable text above the QR code. */
|
||||
showValue?: boolean;
|
||||
/** Optional subtitle to display below the QR code. */
|
||||
subtitle?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,6 +157,7 @@ export function QRCode({
|
||||
dialog = false,
|
||||
dialogTitle = 'QR Code',
|
||||
showValue = false,
|
||||
subtitle = null,
|
||||
}: QRCodeProps): React.ReactElement {
|
||||
const { rows, moduleCount } = useMemo(() => {
|
||||
const matrix = generateMatrix(value);
|
||||
@@ -190,6 +193,7 @@ export function QRCode({
|
||||
return (
|
||||
<DialogWrapper title={dialogTitle} borderColor={colors.primary} width={dialogWidth}>
|
||||
{qrContent}
|
||||
{subtitle}
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export function VariableInputField({
|
||||
focusColor,
|
||||
}: VariableInputFieldProps): React.ReactElement {
|
||||
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
|
||||
useSatoshisConversion("USD");
|
||||
useSatoshisConversion();
|
||||
const satoshisValue = useMemo(
|
||||
() => parseSatoshis(variable.value),
|
||||
[variable.value],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
||||
import { useAppContext } from './useAppContext.js';
|
||||
import { useBchToFiatRate } from './useRates.js';
|
||||
|
||||
@@ -9,9 +9,40 @@ import { useBchToFiatRate } from './useRates.js';
|
||||
* component using it will re-render automatically when the selected pair
|
||||
* receives a new quote.
|
||||
*/
|
||||
export function useSatoshisConversion(targetCurrency: string = 'USD') {
|
||||
export function useSatoshisConversion(targetCurrency?: string) {
|
||||
const { appService } = useAppContext();
|
||||
const currencyCode = useMemo(() => targetCurrency.toUpperCase(), [targetCurrency]);
|
||||
const subscribeToCurrency = useCallback(
|
||||
(callback: () => void) => {
|
||||
if (!appService || targetCurrency) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
return appService.settings.on('settings-updated', (event) => {
|
||||
if (event.key === 'currency') {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
},
|
||||
[appService, targetCurrency],
|
||||
);
|
||||
|
||||
const getCurrencySnapshot = useCallback(() => {
|
||||
if (targetCurrency) {
|
||||
return targetCurrency.toUpperCase();
|
||||
}
|
||||
|
||||
if (!appService) {
|
||||
return 'USD';
|
||||
}
|
||||
|
||||
return appService.settings.getCurrency();
|
||||
}, [appService, targetCurrency]);
|
||||
|
||||
const currencyCode = useSyncExternalStore(
|
||||
subscribeToCurrency,
|
||||
getCurrencySnapshot,
|
||||
getCurrencySnapshot,
|
||||
);
|
||||
const fiatPerBchRate = useBchToFiatRate(currencyCode);
|
||||
|
||||
const formattedFiatPerBchRate = useMemo(() => {
|
||||
|
||||
@@ -19,7 +19,7 @@ import path from 'path';
|
||||
import { createMnemonicFile } from '../../cli/mnemonic.js';
|
||||
import { getMnemonicsDir } from '../../utils/paths.js';
|
||||
import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js';
|
||||
import { encodeBip39Mnemonic } from '@bitauth/libauth';
|
||||
import { encodeBip39Mnemonic, generateBip39Mnemonic } from '@bitauth/libauth';
|
||||
|
||||
/**
|
||||
* Status message type.
|
||||
@@ -41,7 +41,7 @@ interface MnemonicFileEntry {
|
||||
* Focus sections the user can tab between.
|
||||
* When saved wallets exist the file list is shown first.
|
||||
*/
|
||||
type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'button';
|
||||
type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'generateRandomSeed' | 'button';
|
||||
|
||||
/**
|
||||
* Reads mnemonic-* files from ~/.config/xo-cli/mnemonics/ (same as xo-cli),
|
||||
@@ -117,8 +117,8 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
* The ordered list of focusable sections (files section only when entries exist).
|
||||
*/
|
||||
const focusSections: FocusSection[] = mnemonicFiles.length > 0
|
||||
? ['files', 'input', 'saveCheckbox', 'button']
|
||||
: ['input', 'saveCheckbox', 'button'];
|
||||
? ['files', 'input', 'generateRandomSeed', 'saveCheckbox', 'button']
|
||||
: ['input', 'generateRandomSeed', 'saveCheckbox', 'button'];
|
||||
|
||||
/**
|
||||
* Shows a status message with the given type.
|
||||
@@ -202,7 +202,7 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
}, [mnemonicFiles, doInitialize]);
|
||||
|
||||
// Keyboard navigation
|
||||
useBlockableInput((_input, key) => {
|
||||
useBlockableInput((input, key) => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
// Tab / Shift-Tab to cycle focus sections
|
||||
@@ -219,7 +219,7 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
|
||||
// Space or Enter toggles "save mnemonic" when that row is focused
|
||||
if (focusedSection === 'saveCheckbox') {
|
||||
if (_input === ' ' || key.return) {
|
||||
if (input === ' ' || key.return) {
|
||||
setSaveMnemonicChecked((v) => !v);
|
||||
return;
|
||||
}
|
||||
@@ -241,6 +241,18 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl-R generates a random seed phrase and fills it in the input
|
||||
if (key.ctrl && input === 'r') {
|
||||
setSeedPhrase(generateBip39Mnemonic());
|
||||
return;
|
||||
}
|
||||
|
||||
// If pressing enter while the generate random seed section is focused, generate a random seed and fill it in the input
|
||||
if (key.return && focusedSection === 'generateRandomSeed') {
|
||||
setSeedPhrase(generateBip39Mnemonic());
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter on button submits manual seed
|
||||
if (key.return && focusedSection === 'button') {
|
||||
handleSubmit();
|
||||
@@ -358,6 +370,19 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Generate random seed phrase and fill in the input */}
|
||||
<Box marginTop={1}>
|
||||
<Box
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
backgroundColor={focusedSection === 'generateRandomSeed' ? colors.focus : colors.bgSelected}
|
||||
>
|
||||
<Text color={focusedSection === 'generateRandomSeed' ? colors.bg : colors.text} bold>Generate Random Seed</Text>
|
||||
</Box>
|
||||
|
||||
<Text color={colors.textMuted}> (Ctrl-R)</Text>
|
||||
</Box>
|
||||
|
||||
{/* Save mnemonic checkbox (manual entry only; applies on Continue) */}
|
||||
<Box
|
||||
marginTop={1}
|
||||
|
||||
@@ -11,6 +11,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { ScrollableList, type ListItemData } from '../components/List.js';
|
||||
import { QRCode } from '../components/QRCode.js';
|
||||
import { CurrencySelectionDialog } from '../components/CurrencySelectionDialog.js';
|
||||
import { useNavigation } from '../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||
import { useSatoshisConversion } from '../hooks/useSatoshisConversion.js';
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
type HistoryDisplayRow,
|
||||
type HistoryColorName,
|
||||
} from '../../utils/history-utils.js';
|
||||
import { copyToClipboard } from '../utils/clipboard.js';
|
||||
|
||||
/**
|
||||
* Map history color name to theme color.
|
||||
@@ -59,6 +61,7 @@ const menuItems: ListItemData<string>[] = [
|
||||
{ key: 'import', label: 'Import Invitation', value: 'import' },
|
||||
{ key: 'invitations', label: 'View Invitations', value: 'invitations' },
|
||||
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' },
|
||||
{ key: 'set-currency', label: 'Set Fiat Currency', value: 'set-currency' },
|
||||
{ key: 'unreserve-all', label: 'Unreserve All Resources', value: 'unreserve-all' },
|
||||
{ key: 'refresh', label: 'Refresh', value: 'refresh' },
|
||||
];
|
||||
@@ -75,7 +78,10 @@ type HistoryListItem = ListItemData<HistoryDisplayRow>;
|
||||
function QRDialogOverlay({ address, onClose }: { address: string; onClose: () => void }): React.ReactElement {
|
||||
useInputLayer('qr-dialog');
|
||||
|
||||
useLayeredInput('qr-dialog', (_input, key) => {
|
||||
useLayeredInput('qr-dialog', (input, key) => {
|
||||
if (input === 'c' || input === 'C') {
|
||||
copyToClipboard(address);
|
||||
}
|
||||
if (key.escape || key.return) {
|
||||
onClose();
|
||||
}
|
||||
@@ -93,10 +99,13 @@ function QRDialogOverlay({ address, onClose }: { address: string; onClose: () =>
|
||||
dialog
|
||||
dialogTitle="Receive Address"
|
||||
showValue
|
||||
/>
|
||||
<Box justifyContent="center" marginTop={1}>
|
||||
subtitle={
|
||||
<Box flexDirection="column" justifyContent="center" marginTop={1}>
|
||||
<Text color={colors.textMuted}>Press C to copy to clipboard</Text>
|
||||
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -114,7 +123,7 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
fiatPerBchRate,
|
||||
formattedFiatPerBchRate,
|
||||
formatSatoshisToFiat,
|
||||
} = useSatoshisConversion('USD');
|
||||
} = useSatoshisConversion();
|
||||
|
||||
// State
|
||||
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
||||
@@ -126,6 +135,14 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
|
||||
/** Cash address to display in the QR code dialog (null when dialog is hidden). */
|
||||
const [qrAddress, setQrAddress] = useState<string | null>(null);
|
||||
/** Whether the fiat currency selection dialog is open. */
|
||||
const [isCurrencyDialogOpen, setCurrencyDialogOpen] = useState(false);
|
||||
/** Loading state for rates pair discovery. */
|
||||
const [isLoadingCurrencyPairs, setLoadingCurrencyPairs] = useState(false);
|
||||
/** Optional error message shown in the currency dialog. */
|
||||
const [currencyPairsError, setCurrencyPairsError] = useState<string | null>(null);
|
||||
/** Available fiat currencies derived from rates pairs in X/BCH format. */
|
||||
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([]);
|
||||
|
||||
/**
|
||||
* Refreshes wallet state.
|
||||
@@ -253,6 +270,89 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
}
|
||||
}, [appService, setStatus, showError, showInfo, refresh]);
|
||||
|
||||
/**
|
||||
* Loads all available rates pairs, then extracts fiat numerator symbols from
|
||||
* pairs shaped like X/BCH.
|
||||
*
|
||||
* We retry briefly because rates startup is asynchronous and metadata can take
|
||||
* a moment to hydrate right after wallet initialization.
|
||||
*/
|
||||
const loadAvailableCurrencies = useCallback(async (): Promise<void> => {
|
||||
if (!appService) {
|
||||
setCurrencyPairsError("AppService not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingCurrencyPairs(true);
|
||||
setCurrencyPairsError(null);
|
||||
|
||||
try {
|
||||
let pairs = new Set<string>();
|
||||
|
||||
// Retry a few times so we can catch late metadata initialization.
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
pairs = await appService.rates.listPairs();
|
||||
if (pairs.size > 0) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
const currencies = Array.from(pairs)
|
||||
.map((pair) => pair.toUpperCase())
|
||||
.filter((pair) => pair.endsWith("/BCH"))
|
||||
.map((pair) => pair.split("/")[0] ?? "")
|
||||
.filter((currency) => currency.length > 0)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const uniqueCurrencies = Array.from(new Set(currencies));
|
||||
setAvailableCurrencies(uniqueCurrencies);
|
||||
|
||||
if (uniqueCurrencies.length === 0) {
|
||||
setCurrencyPairsError(
|
||||
"No X/BCH rates are currently available. Try again in a moment.",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setCurrencyPairsError(
|
||||
`Failed to load currency pairs: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
setLoadingCurrencyPairs(false);
|
||||
}
|
||||
}, [appService]);
|
||||
|
||||
/**
|
||||
* Opens the fiat currency dialog and triggers pair discovery.
|
||||
*/
|
||||
const openCurrencyDialog = useCallback(() => {
|
||||
setCurrencyDialogOpen(true);
|
||||
void loadAvailableCurrencies();
|
||||
}, [loadAvailableCurrencies]);
|
||||
|
||||
/**
|
||||
* Applies the selected fiat currency to persisted settings.
|
||||
*/
|
||||
const applyCurrencySelection = useCallback(
|
||||
(currencyCode: string) => {
|
||||
if (!appService) {
|
||||
showError("AppService not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
appService.settings.setCurrency(currencyCode);
|
||||
setStatus(`Fiat currency updated to ${currencyCode}`);
|
||||
setCurrencyDialogOpen(false);
|
||||
} catch (error) {
|
||||
showError(
|
||||
`Failed to update currency: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[appService, setStatus, showError],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles menu action.
|
||||
*/
|
||||
@@ -270,6 +370,9 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
case 'new-address':
|
||||
generateNewAddress();
|
||||
break;
|
||||
case 'set-currency':
|
||||
openCurrencyDialog();
|
||||
break;
|
||||
case 'unreserve-all':
|
||||
unreserveAll();
|
||||
break;
|
||||
@@ -277,7 +380,7 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
refresh();
|
||||
break;
|
||||
}
|
||||
}, [navigate, generateNewAddress, unreserveAll, refresh]);
|
||||
}, [navigate, generateNewAddress, openCurrencyDialog, unreserveAll, refresh]);
|
||||
|
||||
/**
|
||||
* Handle menu item activation.
|
||||
@@ -536,6 +639,27 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
onClose={() => setQrAddress(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fiat currency selection dialog overlay */}
|
||||
{isCurrencyDialogOpen && (
|
||||
<Box
|
||||
position="absolute"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<CurrencySelectionDialog
|
||||
currentCurrency={currencyCode}
|
||||
currencies={availableCurrencies}
|
||||
isLoading={isLoadingCurrencyPairs}
|
||||
errorMessage={currencyPairsError}
|
||||
onSelectCurrency={applyCurrencySelection}
|
||||
onCancel={() => setCurrencyDialogOpen(false)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function InputsStep({
|
||||
changeAmount,
|
||||
focusArea,
|
||||
}: Props): React.ReactElement {
|
||||
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
||||
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||
|
||||
const getFiatSuffix = (satoshis: bigint): string => {
|
||||
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||
|
||||
@@ -23,7 +23,7 @@ export function ReviewStep({
|
||||
changeAmount,
|
||||
}: ReviewStepProps): React.ReactElement {
|
||||
const selectedUtxos = availableUtxos.filter((u) => u.selected);
|
||||
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
||||
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||
|
||||
const getFiatSuffix = (satoshis: bigint): string => {
|
||||
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||
|
||||
@@ -113,7 +113,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
|
||||
const invitations = useInvitations();
|
||||
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
|
||||
useSatoshisConversion('USD');
|
||||
useSatoshisConversion();
|
||||
|
||||
// ── UI state ─────────────────────────────────────────────────────────────
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
@@ -17,15 +17,15 @@ import { StepIndicator, type Step } from '../../../components/ProgressBar.js';
|
||||
import { FetchInvitationStep } from './steps/FetchInvitationStep.js';
|
||||
import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js';
|
||||
import { RoleSelectStep } from './steps/RoleSelectStep.js';
|
||||
import { VariablesStep } from './steps/VariablesStep.js';
|
||||
import { InputsSelectStep } from './steps/InputsSelectStep.js';
|
||||
import { ReviewStep } from './steps/ReviewStep.js';
|
||||
|
||||
import { IMPORT_STEPS, type ImportFlowProps, type SelectableUTXO } from './types.js';
|
||||
import { IMPORT_STEPS, type ImportFlowProps, type ImportStepType, type ImportVariableInput, type SelectableUTXO } from './types.js';
|
||||
import type { Invitation } from '../../../../services/invitation.js';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
import { DialogWrapper } from '../../../components/Dialog.js';
|
||||
import { useInputLayer, useLayeredInput } from '../../../hooks/useInputLayer.js';
|
||||
import { InvitationBuilder } from '@xo-cash/engine';
|
||||
import { hexToBin } from '@bitauth/libauth';
|
||||
|
||||
/** Default fee estimate in satoshis. */
|
||||
@@ -34,6 +34,24 @@ const DEFAULT_FEE = 500n;
|
||||
/** Dust threshold — outputs below this are unspendable. */
|
||||
const DUST_THRESHOLD = 546n;
|
||||
|
||||
/**
|
||||
* Resolve the fixed index of a flow step from `IMPORT_STEPS`.
|
||||
* We centralize this so step transitions do not rely on magic numbers.
|
||||
*/
|
||||
function getStepIndex(type: ImportStepType): number {
|
||||
const index = IMPORT_STEPS.findIndex((step) => step.type === type);
|
||||
if (index === -1) {
|
||||
throw new Error(`Import step not found: ${type}`);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
const PREVIEW_STEP_INDEX = getStepIndex('preview');
|
||||
const ROLE_SELECT_STEP_INDEX = getStepIndex('role-select');
|
||||
const VARIABLES_STEP_INDEX = getStepIndex('variables');
|
||||
const INPUTS_SELECT_STEP_INDEX = getStepIndex('inputs-select');
|
||||
const REVIEW_STEP_INDEX = getStepIndex('review');
|
||||
|
||||
export function InvitationImportFlow({
|
||||
invitationId,
|
||||
mode,
|
||||
@@ -46,10 +64,10 @@ export function InvitationImportFlow({
|
||||
// ── Accumulated state ────────────────────────────────────────────────────
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [invitation, setInvitation] = useState<Invitation | null>(null);
|
||||
const [buildableInvitation, setBuildableInvitation] = useState<InvitationBuilder | null>(null);
|
||||
const [template, setTemplate] = useState<XOTemplate | null>(null);
|
||||
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
||||
const [selectedRole, setSelectedRole] = useState<string | null>(null);
|
||||
const [variableInputs, setVariableInputs] = useState<ImportVariableInput[]>([]);
|
||||
const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]);
|
||||
const [changeAmount, setChangeAmount] = useState(0n);
|
||||
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||
@@ -79,9 +97,6 @@ export function InvitationImportFlow({
|
||||
setInvitation(inv);
|
||||
setTemplate(tmpl);
|
||||
|
||||
const builder = InvitationBuilder.fromInvitation(inv.data);
|
||||
setBuildableInvitation(builder);
|
||||
|
||||
try {
|
||||
const roles = await inv.getAvailableRoles();
|
||||
setAvailableRoles(roles);
|
||||
@@ -89,20 +104,98 @@ export function InvitationImportFlow({
|
||||
showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
setCurrentStep(1); // → Preview
|
||||
setCurrentStep(PREVIEW_STEP_INDEX); // → Preview
|
||||
}, [showError]);
|
||||
|
||||
/** PreviewStep completed — user reviewed the invitation state and wants to proceed. */
|
||||
const handlePreviewComplete = useCallback(() => {
|
||||
setCurrentStep(2); // → Role Select
|
||||
setCurrentStep(ROLE_SELECT_STEP_INDEX); // → Role Select
|
||||
}, []);
|
||||
|
||||
/** RoleSelectStep completed — user picked a role. */
|
||||
const handleRoleComplete = useCallback((role: string) => {
|
||||
setSelectedRole(role);
|
||||
setCurrentStep(3); // → Inputs Select
|
||||
|
||||
const action = template?.actions?.[invitation?.data.actionIdentifier ?? ""];
|
||||
const roleRequirements = action?.roles?.[role]?.requirements?.variables ?? [];
|
||||
const hasRequiredVariables = roleRequirements.length > 0;
|
||||
|
||||
if (!hasRequiredVariables) {
|
||||
setVariableInputs([]);
|
||||
setCurrentStep(INPUTS_SELECT_STEP_INDEX); // → Inputs Select
|
||||
return;
|
||||
}
|
||||
|
||||
const initializedVariables: ImportVariableInput[] = roleRequirements.map((variableId) => {
|
||||
const variableDefinition = template?.variables?.[variableId];
|
||||
return {
|
||||
id: variableId,
|
||||
name: variableDefinition?.name ?? variableId,
|
||||
type: variableDefinition?.type ?? 'string',
|
||||
hint: variableDefinition?.hint,
|
||||
value: '',
|
||||
};
|
||||
});
|
||||
|
||||
setVariableInputs(initializedVariables);
|
||||
setCurrentStep(VARIABLES_STEP_INDEX); // → Variables
|
||||
}, [template, invitation]);
|
||||
|
||||
/** VariablesStep edited a field value. */
|
||||
const handleVariableUpdate = useCallback((index: number, value: string) => {
|
||||
setVariableInputs((previous) => {
|
||||
const updated = [...previous];
|
||||
const current = updated[index];
|
||||
if (current) {
|
||||
updated[index] = { ...current, value };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Convert variable input value to its invitation payload representation.
|
||||
* Numeric variables are persisted as bigint so they match action wizard behavior.
|
||||
*/
|
||||
const parseVariableValue = useCallback((variable: ImportVariableInput) => {
|
||||
const variableHint = variable.hint?.toLowerCase();
|
||||
const isNumeric =
|
||||
['integer', 'number', 'satoshis'].includes(variable.type) ||
|
||||
(variableHint !== undefined && ['satoshis', 'amount'].includes(variableHint));
|
||||
|
||||
if (!isNumeric) {
|
||||
return variable.value;
|
||||
}
|
||||
|
||||
return BigInt(variable.value || '0');
|
||||
}, []);
|
||||
|
||||
/** VariablesStep completed — persist variables then continue to input selection. */
|
||||
const handleVariablesComplete = useCallback(async () => {
|
||||
if (!invitation || !selectedRole) return;
|
||||
|
||||
const emptyVariables = variableInputs.filter((variable) => variable.value.trim() === '');
|
||||
if (emptyVariables.length > 0) {
|
||||
showError(`Please enter values for: ${emptyVariables.map((variable) => variable.name).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await invitation.addVariables(
|
||||
variableInputs.map((variable) => ({
|
||||
variableIdentifier: variable.id,
|
||||
roleIdentifier: selectedRole,
|
||||
value: parseVariableValue(variable),
|
||||
})),
|
||||
);
|
||||
setCurrentStep(INPUTS_SELECT_STEP_INDEX); // → Inputs Select
|
||||
} catch (error) {
|
||||
showError(
|
||||
`Failed to add variables: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}, [invitation, selectedRole, variableInputs, parseVariableValue, showError]);
|
||||
|
||||
/** InputsSelectStep completed — user selected UTXOs. */
|
||||
const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => {
|
||||
setSelectedInputs(inputs);
|
||||
@@ -130,8 +223,8 @@ export function InvitationImportFlow({
|
||||
}]);
|
||||
}
|
||||
|
||||
setCurrentStep(4); // → Review
|
||||
}, [invitation, buildableInvitation, selectedInputs]);
|
||||
setCurrentStep(REVIEW_STEP_INDEX); // → Review
|
||||
}, [invitation]);
|
||||
|
||||
/** ReviewStep completed — invitation import is done. */
|
||||
const handleReviewComplete = useCallback(() => {
|
||||
@@ -205,6 +298,17 @@ export function InvitationImportFlow({
|
||||
/>
|
||||
);
|
||||
|
||||
case 'variables':
|
||||
return (
|
||||
<VariablesStep
|
||||
variables={variableInputs}
|
||||
onUpdateVariable={handleVariableUpdate}
|
||||
onComplete={handleVariablesComplete}
|
||||
onCancel={handleCancel}
|
||||
isActive={true}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'inputs-select':
|
||||
if (!invitation || !selectedRole) return null;
|
||||
return (
|
||||
|
||||
@@ -33,7 +33,7 @@ export function InputsSelectStep({
|
||||
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
||||
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||
|
||||
const fee = DEFAULT_FEE;
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export function PreviewInvitationStep({
|
||||
onCancel,
|
||||
isActive,
|
||||
}: PreviewStepProps): React.ReactElement {
|
||||
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
||||
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||
|
||||
useLayeredInput('import-flow', (_input, key) => {
|
||||
if (key.return) onComplete();
|
||||
|
||||
@@ -33,7 +33,7 @@ export function ReviewStep({
|
||||
}: ReviewStepProps): React.ReactElement {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
||||
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||
|
||||
const fee = DEFAULT_FEE;
|
||||
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* VariablesStep — collects all required variable values for invitation import.
|
||||
*
|
||||
* This runs after role selection and before input selection so cashasm
|
||||
* expressions can resolve required variables during `getSatsOut()`.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState, useCallback } from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { colors } from "../../../../theme.js";
|
||||
import { useLayeredInput } from "../../../../hooks/useInputLayer.js";
|
||||
import { VariableInputField } from "../../../../components/VariableInputField.js";
|
||||
import type { VariablesStepProps } from "../types.js";
|
||||
|
||||
/**
|
||||
* Build a user-facing validation error for empty required fields.
|
||||
*/
|
||||
function validateVariables(
|
||||
variables: VariablesStepProps["variables"],
|
||||
): string | null {
|
||||
const empty = variables.filter((v) => v.value.trim() === "");
|
||||
if (empty.length === 0) return null;
|
||||
return `Please enter values for: ${empty.map((v) => v.name).join(", ")}`;
|
||||
}
|
||||
|
||||
export function VariablesStep({
|
||||
variables,
|
||||
onUpdateVariable,
|
||||
onComplete,
|
||||
onCancel,
|
||||
isActive,
|
||||
}: VariablesStepProps): React.ReactElement {
|
||||
const [focusedInput, setFocusedInput] = useState(0);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const helpText = useMemo(() => {
|
||||
if (variables.length === 0) {
|
||||
return "No variables required for this role.";
|
||||
}
|
||||
return "Enter a value for each variable, then press Enter on the last field to continue.";
|
||||
}, [variables.length]);
|
||||
|
||||
/**
|
||||
* Move focus to next input, or finish the step if this is the last one.
|
||||
*/
|
||||
const handleInputSubmit = useCallback(() => {
|
||||
if (variables.length === 0) {
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusedInput < variables.length - 1) {
|
||||
setFocusedInput((prev) => prev + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateVariables(variables);
|
||||
setValidationError(validation);
|
||||
if (!validation) {
|
||||
onComplete();
|
||||
}
|
||||
}, [variables, focusedInput, onComplete]);
|
||||
|
||||
// Keyboard navigation for non-text actions.
|
||||
useLayeredInput(
|
||||
"import-flow",
|
||||
(input, key) => {
|
||||
if (key.upArrow || input === "k") {
|
||||
setFocusedInput((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow || input === "j") {
|
||||
setFocusedInput((prev) => Math.min(variables.length - 1, prev + 1));
|
||||
} else if (key.escape) {
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
{ isActive },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.primary} bold>
|
||||
Required Variables
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{variables.map((variable, index) => (
|
||||
<VariableInputField
|
||||
key={variable.id}
|
||||
variable={variable}
|
||||
index={index}
|
||||
isFocused={focusedInput === index}
|
||||
onChange={onUpdateVariable}
|
||||
onSubmit={handleInputSubmit}
|
||||
borderColor={colors.border as string}
|
||||
focusColor={colors.primary as string}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{validationError && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.error}>{validationError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>
|
||||
{helpText} ↑↓: Change field • Esc: Cancel
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export type ImportStepType =
|
||||
| "fetch"
|
||||
| "preview"
|
||||
| "role-select"
|
||||
| "variables"
|
||||
| "inputs-select"
|
||||
| "review";
|
||||
|
||||
@@ -30,6 +31,7 @@ export const IMPORT_STEPS: ImportStep[] = [
|
||||
{ name: "Fetch", type: "fetch" },
|
||||
{ name: "Preview", type: "preview" },
|
||||
{ name: "Select Role", type: "role-select" },
|
||||
{ name: "Variables", type: "variables" },
|
||||
{ name: "Select Inputs", type: "inputs-select" },
|
||||
{ name: "Review", type: "review" },
|
||||
];
|
||||
@@ -81,6 +83,24 @@ export interface RoleSelectStepProps {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/** A single variable input required by the selected action role. */
|
||||
export interface ImportVariableInput {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
hint?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Props for VariablesStep — collects required role/action variable values. */
|
||||
export interface VariablesStepProps {
|
||||
variables: ImportVariableInput[];
|
||||
onUpdateVariable: (index: number, value: string) => void;
|
||||
onComplete: () => void;
|
||||
onCancel: () => void;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/** Props for InputsSelectStep — lets user pick UTXOs to fund the invitation. */
|
||||
export interface InputsSelectStepProps {
|
||||
invitation: Invitation;
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface SelectableUtxoLike {
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
// TODO: Move to engine
|
||||
export const hasMissingRequirements = (missingRequirements: {
|
||||
variables?: string[];
|
||||
inputs?: string[];
|
||||
@@ -32,6 +33,7 @@ export const isInvitationRequirementsComplete = async (
|
||||
return !hasMissingRequirements(missingRequirements);
|
||||
};
|
||||
|
||||
// TODO: Move to engine in templates.ts
|
||||
export const resolveActionRoles = (
|
||||
template: XOTemplate | undefined,
|
||||
actionIdentifier: string | undefined,
|
||||
@@ -51,6 +53,7 @@ export const resolveActionRoles = (
|
||||
return [...new Set(roleIds)];
|
||||
};
|
||||
|
||||
// TODO: Move to engine
|
||||
export const roleRequiresInputs = (
|
||||
template: XOTemplate | undefined,
|
||||
actionIdentifier: string | undefined,
|
||||
@@ -75,6 +78,7 @@ export const roleRequiresInputs = (
|
||||
return (roleInputs?.length ?? 0) > 0;
|
||||
};
|
||||
|
||||
|
||||
export const getTransactionOutputIdentifier = (
|
||||
output: XOTemplateTransactionOutput,
|
||||
): string | undefined => {
|
||||
@@ -121,6 +125,7 @@ export const tryCashAddressToLockingBytecodeHex = (
|
||||
return binToHex(result.bytecode);
|
||||
};
|
||||
|
||||
// Replace with libauth compiler in the engine
|
||||
export const resolveProvidedLockingBytecodeHex = (
|
||||
template: XOTemplate,
|
||||
outputIdentifier: string,
|
||||
|
||||
@@ -35,10 +35,17 @@ export function getDataDir(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* File storing the last-used mnemonic reference for `-m` omission.
|
||||
* File storing CLI settings (JSON), including the last-used mnemonic reference.
|
||||
*/
|
||||
export function getSettingsPath(): string {
|
||||
return join(getConfigDir(), ".wallet");
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Prefer {@link getSettingsPath}.
|
||||
*/
|
||||
export function getWalletConfigPath(): string {
|
||||
return join(getConfigDir(), ".wallet");
|
||||
return getSettingsPath();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,19 +38,34 @@ export abstract class BaseRates<
|
||||
* @returns The formatted amount.
|
||||
*/
|
||||
public formatCurrency(amount: number, targetCurrency: string): string {
|
||||
const normalizedCurrency = targetCurrency.toUpperCase();
|
||||
const minimumFractionDigitsMap: { [currency: string]: number } = {
|
||||
AUD: 2,
|
||||
BCH: 8,
|
||||
USD: 2,
|
||||
};
|
||||
const minimumFractionDigits = minimumFractionDigitsMap[normalizedCurrency] ?? 2;
|
||||
const maximumFractionDigits = Math.max(minimumFractionDigits, 8);
|
||||
|
||||
try {
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: targetCurrency,
|
||||
currency: normalizedCurrency,
|
||||
currencyDisplay: 'narrowSymbol',
|
||||
minimumFractionDigits: minimumFractionDigitsMap[targetCurrency] || 0,
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
});
|
||||
|
||||
return formatter.format(amount);
|
||||
} catch {
|
||||
// Some numerator symbols from oracle pairs (e.g. DOGE/BCH) are not ISO-4217
|
||||
// fiat currency codes, so Intl currency formatting will throw a RangeError.
|
||||
// In that case we still return a human-readable formatted value.
|
||||
const numericFormatter = new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
});
|
||||
return `${numericFormatter.format(amount)} ${normalizedCurrency}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
} from '@generalprotocols/oracle-client';
|
||||
|
||||
import { type RatesEventMap, BaseRates } from './base-rates.js';
|
||||
import { type OffCallback } from '../event-emitter.js';
|
||||
import { SettingsService } from '../../services/settings.js';
|
||||
|
||||
// Add the Oracle Price Message to our Events for this Adapter.
|
||||
export type RatesOracleEventMap = RatesEventMap & {
|
||||
@@ -22,22 +24,34 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
||||
* @param client The underlying oracle client. If not provided, a new client will be created.
|
||||
* @returns The rates oracle.
|
||||
*/
|
||||
static async from(client?: OracleClient) {
|
||||
const ratesOracle = new RatesOracle(client ?? (await OracleClient.from()));
|
||||
static async from(
|
||||
client?: OracleClient,
|
||||
settings: SettingsService = new SettingsService(),
|
||||
) {
|
||||
const ratesOracle = new RatesOracle(
|
||||
client ?? (await OracleClient.from()),
|
||||
settings,
|
||||
);
|
||||
|
||||
return ratesOracle;
|
||||
}
|
||||
|
||||
private client: OracleClient;
|
||||
private settings: SettingsService;
|
||||
private oracles: OracleMetadataMap;
|
||||
|
||||
private started: boolean = false;
|
||||
private targetNumeratorUnitCode: string;
|
||||
private targetDenominatorUnitCode: string = 'BCH';
|
||||
private unsubscribeFromSettings: OffCallback | null = null;
|
||||
|
||||
private constructor(client: OracleClient) {
|
||||
private constructor(client: OracleClient, settings: SettingsService) {
|
||||
super();
|
||||
|
||||
this.client = client;
|
||||
this.settings = settings;
|
||||
this.oracles = {};
|
||||
this.targetNumeratorUnitCode = settings.getCurrency().toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,6 +62,10 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
this.unsubscribeFromSettings = this.settings.on(
|
||||
'settings-updated',
|
||||
this.handleSettingsUpdated.bind(this),
|
||||
);
|
||||
|
||||
// Create event listeners for the client.
|
||||
this.client.setOnMetadataMessage(this.handleMetadataMessage.bind(this));
|
||||
@@ -71,6 +89,8 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
||||
return;
|
||||
}
|
||||
this.started = false;
|
||||
this.unsubscribeFromSettings?.();
|
||||
this.unsubscribeFromSettings = null;
|
||||
|
||||
// Remove event listeners by setting them to empty functions.
|
||||
this.client.setOnMetadataMessage(() => {});
|
||||
@@ -85,6 +105,12 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
||||
* @returns A set of pairs.
|
||||
*/
|
||||
async listPairs() {
|
||||
// If metadata has not arrived yet but the client is running, query once so
|
||||
// callers (like the currency picker) can still discover available pairs.
|
||||
if (Object.keys(this.oracles).length === 0 && this.started) {
|
||||
this.oracles = await this.client.getMetadataMap();
|
||||
}
|
||||
|
||||
return new Set(
|
||||
Object.values(this.oracles).map((oracle) => {
|
||||
return `${oracle.SOURCE_NUMERATOR_UNIT_CODE}/${oracle.SOURCE_DENOMINATOR_UNIT_CODE}`;
|
||||
@@ -157,14 +183,48 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceNumeratorUnitCode = oracle.SOURCE_NUMERATOR_UNIT_CODE.toUpperCase();
|
||||
const sourceDenominatorUnitCode = oracle.SOURCE_DENOMINATOR_UNIT_CODE.toUpperCase();
|
||||
|
||||
// Only emit the pair currently selected in settings.
|
||||
if (
|
||||
sourceNumeratorUnitCode !== this.targetNumeratorUnitCode ||
|
||||
sourceDenominatorUnitCode !== this.targetDenominatorUnitCode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Scale the price
|
||||
const priceValue = message.priceValue / oracle.ATTESTATION_SCALING;
|
||||
|
||||
this.emit('rateUpdated', {
|
||||
numeratorUnitCode: oracle.SOURCE_NUMERATOR_UNIT_CODE,
|
||||
denominatorUnitCode: oracle.SOURCE_DENOMINATOR_UNIT_CODE,
|
||||
numeratorUnitCode: sourceNumeratorUnitCode,
|
||||
denominatorUnitCode: sourceDenominatorUnitCode,
|
||||
price: priceValue,
|
||||
oraclePriceMessage: message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks updates to settings and switches the actively emitted fiat pair.
|
||||
*/
|
||||
private handleSettingsUpdated(
|
||||
event: {
|
||||
key: 'currency' | 'default-mnemonic';
|
||||
value: string | undefined;
|
||||
},
|
||||
) {
|
||||
if (event.key !== 'currency' || !event.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.targetNumeratorUnitCode = event.value.toUpperCase();
|
||||
|
||||
// Refresh so listeners get the latest value for the new currency quickly.
|
||||
if (this.started) {
|
||||
this.refreshPrices().catch((error) => {
|
||||
console.error('Error refreshing prices after currency update:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
83
tests/cli/commands/settings.test.ts
Normal file
83
tests/cli/commands/settings.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { handleSettingsCommand } from "../../../src/cli/commands/settings";
|
||||
import { CommandError } from "../../../src/cli/commands/types";
|
||||
import { createMockIO, createMockPaths } from "../mocks/command";
|
||||
|
||||
describe("settings command", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-settings-command-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("shows default settings when .wallet does not exist", async () => {
|
||||
const { io, capture } = createMockIO();
|
||||
const paths = createMockPaths(tempDir);
|
||||
|
||||
const result = await handleSettingsCommand({ io, paths }, ["show"], {});
|
||||
|
||||
expect(result).toEqual({ currency: "USD" });
|
||||
expect(capture.out.join("\n")).toContain("currency");
|
||||
expect(capture.out.join("\n")).toContain("USD");
|
||||
});
|
||||
|
||||
test("sets and gets currency", async () => {
|
||||
const { io, capture } = createMockIO();
|
||||
const paths = createMockPaths(tempDir);
|
||||
|
||||
await handleSettingsCommand({ io, paths }, ["set", "currency", "aud"], {});
|
||||
const getResult = await handleSettingsCommand(
|
||||
{ io, paths },
|
||||
["get", "currency"],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(getResult).toEqual({ key: "currency", value: "AUD" });
|
||||
expect(capture.out).toContain("Updated currency: AUD");
|
||||
expect(capture.out).toContain("AUD");
|
||||
});
|
||||
|
||||
test("sets default-mnemonic and persists JSON .wallet", async () => {
|
||||
const { io } = createMockIO();
|
||||
const paths = createMockPaths(tempDir);
|
||||
|
||||
await handleSettingsCommand(
|
||||
{ io, paths },
|
||||
["set", "default-mnemonic", "mnemonic-primary"],
|
||||
{},
|
||||
);
|
||||
|
||||
const persisted = JSON.parse(readFileSync(paths.walletConfigPath, "utf8")) as {
|
||||
currency: string;
|
||||
"default-mnemonic"?: string;
|
||||
};
|
||||
|
||||
expect(persisted).toEqual({
|
||||
currency: "USD",
|
||||
"default-mnemonic": "mnemonic-primary",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws command error for unknown subcommand", async () => {
|
||||
const { io } = createMockIO();
|
||||
const paths = createMockPaths(tempDir);
|
||||
|
||||
try {
|
||||
await handleSettingsCommand({ io, paths }, ["unknown"], {});
|
||||
expect.fail("Expected settings command to throw");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CommandError);
|
||||
expect((error as CommandError).event).toBe("settings.subcommand.unknown");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -19,8 +19,7 @@ import {
|
||||
import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url";
|
||||
|
||||
const TEST_SEED =
|
||||
"page pencil stock planet limb cluster assault speak off joke private pioneer";
|
||||
|
||||
"oven crop same above under tower promote decrease vocal pretty require slow";
|
||||
describe("mnemonic utilities", () => {
|
||||
let tempDir: string;
|
||||
|
||||
@@ -54,7 +53,7 @@ describe("mnemonic utilities", () => {
|
||||
test("creates a mnemonic file with auto-generated name", () => {
|
||||
const filename = createMnemonicFile(tempDir, TEST_SEED);
|
||||
|
||||
expect(filename).toMatch(/^mnemonic-page$/);
|
||||
expect(filename).toMatch(/^mnemonic-oven$/);
|
||||
expect(existsSync(path.join(tempDir, filename))).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Node js tool for temp dir
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { BlockchainMonitor, Engine } from "@xo-cash/engine";
|
||||
|
||||
import {
|
||||
createStorageAdapter,
|
||||
State,
|
||||
StorageType,
|
||||
UnspentOutputStatus,
|
||||
type UnspentOutputData,
|
||||
} from "@xo-cash/state";
|
||||
@@ -12,13 +13,15 @@ import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
|
||||
|
||||
import { binToHex, sha256 } from "@bitauth/libauth";
|
||||
import { AppService } from "../../../src/services/app";
|
||||
import { InMemoryStorage as XOStorageInMemory } from './xo-storage-in-memory'
|
||||
import { InMemoryStorage } from "../../../src/services/storage";
|
||||
import { MockElectrumService } from "./electrum-service";
|
||||
import { MockRatesService } from "./rates-service";
|
||||
import { RatesService } from "../../../src/services/rates";
|
||||
import { SettingsService } from "../../../src/services/settings";
|
||||
|
||||
export const DEFAULT_SEED =
|
||||
"page pencil stock planet limb cluster assault speak off joke private pioneer";
|
||||
"oven crop same above under tower promote decrease vocal pretty require slow";
|
||||
|
||||
/**
|
||||
* Options for creating a fake resource (UTXO) in tests.
|
||||
@@ -67,8 +70,6 @@ export const addFakeResource = async (
|
||||
status: UnspentOutputStatus.CONFIRMED,
|
||||
selectable: true,
|
||||
privacy: false,
|
||||
templateIdentifier: options.templateIdentifier ?? "test-template",
|
||||
outputIdentifier: options.outputIdentifier ?? "receiveOutput",
|
||||
outpointIndex: options.outpointIndex ?? 0,
|
||||
outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(),
|
||||
minedAtHeight: options.minedAtHeight ?? 800000,
|
||||
@@ -130,10 +131,7 @@ export const unreserveResource = async (
|
||||
*/
|
||||
export const createMockEngine = async (seed: string) => {
|
||||
// Create the in-memory storage adapter.
|
||||
const storage = await createStorageAdapter({
|
||||
storageType: "inmemory",
|
||||
accountHash: binToHex(sha256.hash(convertMnemonicToSeedBytes(seed))),
|
||||
});
|
||||
const storage = new XOStorageInMemory();
|
||||
|
||||
// Initialize the storage adapter.
|
||||
await storage.initialize();
|
||||
@@ -143,10 +141,7 @@ export const createMockEngine = async (seed: string) => {
|
||||
|
||||
// Create the in-memory blockchain provider.
|
||||
const blockchainProvider = new InMemoryBlockchainProvider();
|
||||
await blockchainProvider.initialize({
|
||||
applicationIdentifier: "xo-cli-tests",
|
||||
electrumOptions: {},
|
||||
});
|
||||
await blockchainProvider.initialize();
|
||||
|
||||
// Create the blockchain monitor instance.
|
||||
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
|
||||
@@ -160,10 +155,15 @@ export const createMockEngine = async (seed: string) => {
|
||||
};
|
||||
|
||||
export const createMockAppService = async (engine: Engine) => {
|
||||
const settings = new SettingsService(
|
||||
`${tmpdir()}/xo-cli-tests-settings.json`,
|
||||
);
|
||||
settings.setCurrency("USD");
|
||||
|
||||
const storage = await InMemoryStorage.create();
|
||||
|
||||
const mockRates = new MockRatesService();
|
||||
const rates = new RatesService(mockRates);
|
||||
const rates = new RatesService(mockRates, settings);
|
||||
|
||||
const mockElectrum = new MockElectrumService();
|
||||
|
||||
@@ -176,5 +176,5 @@ export const createMockAppService = async (engine: Engine) => {
|
||||
invitationStoragePath: "test-invitations.db",
|
||||
};
|
||||
|
||||
return new AppService(engine, storage, config, mockElectrum, rates);
|
||||
return new AppService(engine, storage, config, mockElectrum, rates, settings);
|
||||
};
|
||||
|
||||
536
tests/cli/mocks/xo-storage-in-memory.ts
Normal file
536
tests/cli/mocks/xo-storage-in-memory.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
import type { XOInvitation, XOTemplate } from '@xo-cash/types';
|
||||
|
||||
import {
|
||||
DatabaseClosedError,
|
||||
DatabaseNotInitializedError,
|
||||
QueryFieldsLimitExceededError,
|
||||
QueryInvalidError,
|
||||
UnspentOutputNotFoundInStoreError,
|
||||
UnspentOutputReservationConflictError,
|
||||
DatabaseState,
|
||||
EventData,
|
||||
EventDataQuery,
|
||||
ScriptHashData,
|
||||
ScriptHashDataQuery,
|
||||
UnspentOutputData,
|
||||
UnspentOutputDataFilter,
|
||||
UnspentOutputDataQuery,
|
||||
UnspentOutputStatus,
|
||||
XOStorage,
|
||||
} from '@xo-cash/state';
|
||||
|
||||
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
||||
|
||||
/**
|
||||
* Returns the names of all fields in a query object that have a defined value.
|
||||
*
|
||||
* @param query - The query object to inspect
|
||||
* @returns Array of field names whose values are not undefined
|
||||
*/
|
||||
export const getDefinedQueryFields = (query: Record<string, unknown>): string[] => {
|
||||
const definedFields: string[] = [];
|
||||
|
||||
for (const [ field, value ] of Object.entries(query)) {
|
||||
if (value !== undefined) {
|
||||
definedFields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
return definedFields;
|
||||
};
|
||||
|
||||
/**
|
||||
* In-memory storage adapter implementation.
|
||||
*
|
||||
* This adapter mirrors the behavior of the IndexedDB implementation while keeping
|
||||
* all data in process memory. It is useful for tests and ephemeral sessions where
|
||||
* persistence across process restarts is not required.
|
||||
*/
|
||||
export class InMemoryStorage implements XOStorage {
|
||||
/**
|
||||
* Lifecycle state for this in-memory adapter instance.
|
||||
*/
|
||||
private databaseState: DatabaseState = DatabaseState.Uninitialized;
|
||||
|
||||
/**
|
||||
* Pending initialization promise used to deduplicate concurrent initialize calls.
|
||||
*/
|
||||
private databaseInitializationPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Template records keyed by template identifier.
|
||||
*/
|
||||
private templates = new Map<string, XOTemplate>();
|
||||
|
||||
/**
|
||||
* Invitation records keyed by invitation identifier.
|
||||
*/
|
||||
private invitations = new Map<string, XOInvitation>();
|
||||
|
||||
/**
|
||||
* Script hash records keyed by script hash.
|
||||
*/
|
||||
private scriptHashes = new Map<string, ScriptHashData>();
|
||||
|
||||
/**
|
||||
* Unspent output records keyed by outpoint hash:index.
|
||||
*/
|
||||
private unspentOutputs = new Map<string, UnspentOutputData>();
|
||||
|
||||
/**
|
||||
* Event records keyed by event identifier.
|
||||
*/
|
||||
private events = new Map<string, EventData>();
|
||||
|
||||
/**
|
||||
* Initializes the storage adapter.
|
||||
*
|
||||
* @returns Promise that resolves when initialization is complete
|
||||
* @throws {DatabaseClosedError} if the adapter has already been closed
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.databaseState === DatabaseState.Ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.databaseState === DatabaseState.Closed) {
|
||||
throw new DatabaseClosedError();
|
||||
}
|
||||
|
||||
if (this.databaseInitializationPromise !== null) {
|
||||
return this.databaseInitializationPromise;
|
||||
}
|
||||
|
||||
this.databaseInitializationPromise = Promise.resolve()
|
||||
.then(() => {
|
||||
this.databaseState = DatabaseState.Ready;
|
||||
})
|
||||
.finally(() => {
|
||||
this.databaseInitializationPromise = null;
|
||||
});
|
||||
|
||||
return this.databaseInitializationPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a template from storage by its identifier.
|
||||
*
|
||||
* @param templateIdentifier - Identifier for the template
|
||||
* @returns Promise resolving to the template data, or undefined if not found
|
||||
*/
|
||||
async getTemplate(templateIdentifier: string): Promise<XOTemplate | undefined> {
|
||||
this.ensureReady();
|
||||
const template = this.templates.get(templateIdentifier);
|
||||
|
||||
return template === undefined ? undefined : this.clone(template);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a template in storage.
|
||||
*
|
||||
* @param template - The template data to store
|
||||
* @returns Promise that resolves when the template is stored
|
||||
*/
|
||||
async storeTemplate(template: XOTemplate): Promise<void> {
|
||||
this.ensureReady();
|
||||
const templateIdentifier = generateTemplateIdentifier(template);
|
||||
|
||||
this.templates.set(templateIdentifier, this.clone(template));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all templates stored in storage.
|
||||
*
|
||||
* @returns Promise resolving to all stored templates
|
||||
*/
|
||||
async listTemplates(): Promise<XOTemplate[]> {
|
||||
this.ensureReady();
|
||||
|
||||
return Array.from(this.templates.values(), (template) => this.clone(template));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an invitation from storage by its identifier.
|
||||
*
|
||||
* @param invitationIdentifier - Identifier for the invitation
|
||||
* @returns Promise resolving to the invitation data, or undefined if not found
|
||||
*/
|
||||
async getInvitation(invitationIdentifier: string): Promise<XOInvitation | undefined> {
|
||||
this.ensureReady();
|
||||
const invitation = this.invitations.get(invitationIdentifier);
|
||||
|
||||
return invitation === undefined ? undefined : this.clone(invitation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores an invitation in storage.
|
||||
*
|
||||
* @param invitationIdentifier - Identifier for the invitation
|
||||
* @param invitation - The invitation data to store
|
||||
* @returns Promise that resolves when the invitation is stored
|
||||
*/
|
||||
async storeInvitation(invitationIdentifier: string, invitation: XOInvitation): Promise<void> {
|
||||
this.ensureReady();
|
||||
this.invitations.set(invitationIdentifier, this.clone(invitation));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves script hash data from storage by script hash.
|
||||
*
|
||||
* @param scriptHash - Script hash to retrieve
|
||||
* @returns Promise resolving to script hash data, or undefined if not found
|
||||
*/
|
||||
async getScriptHashData(scriptHash: string): Promise<ScriptHashData | undefined> {
|
||||
this.ensureReady();
|
||||
const scriptHashData = this.scriptHashes.get(scriptHash);
|
||||
|
||||
return scriptHashData === undefined ? undefined : this.clone(scriptHashData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores script hash data in storage.
|
||||
*
|
||||
* @param data - Script hash data to store
|
||||
* @returns Promise that resolves when data is stored
|
||||
*/
|
||||
async storeScriptHashData(data: ScriptHashData): Promise<void> {
|
||||
this.ensureReady();
|
||||
this.scriptHashes.set(data.scriptHash, this.clone(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the last derivative index used for standard locking bytecodes.
|
||||
* Filters by templateIdentifier and outputIdentifier, and optionally by invitationIdentifier.
|
||||
*
|
||||
* @param templateIdentifier - The template identifier to filter by
|
||||
* @param outputIdentifier - The output identifier to filter by
|
||||
* @param invitationIdentifier - Optional invitation identifier to filter by
|
||||
* @returns Promise resolving to the last derivative index as a string, or null if none found
|
||||
*/
|
||||
async getLastDerivativeIndexForP2PKH(
|
||||
templateIdentifier: string,
|
||||
outputIdentifier: string,
|
||||
invitationIdentifier?: string,
|
||||
): Promise<string | null> {
|
||||
this.ensureReady();
|
||||
const allLockingBytecodes = await this.listScriptHashData({ templateIdentifier });
|
||||
|
||||
const filtered = allLockingBytecodes.filter((lockingBytecodeData) =>
|
||||
lockingBytecodeData.derivationSource?.lockingType === 'standard'
|
||||
&& lockingBytecodeData.outputIdentifier === outputIdentifier
|
||||
&& lockingBytecodeData.derivationSource?.derivativeIndex !== undefined
|
||||
&& (invitationIdentifier === undefined || lockingBytecodeData.invitationIdentifier === invitationIdentifier));
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return filtered.reduce((maximumDerivativeIndex, lockingBytecodeData) => {
|
||||
const currentDerivativeIndex = lockingBytecodeData.derivationSource?.derivativeIndex ?? '0';
|
||||
const currentNum = parseInt(currentDerivativeIndex, 10);
|
||||
const maxNum = parseInt(maximumDerivativeIndex, 10);
|
||||
|
||||
return currentNum > maxNum ? currentDerivativeIndex : maximumDerivativeIndex;
|
||||
}, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves unspent output data from storage.
|
||||
*
|
||||
* @param outpointTransactionHash - Outpoint transaction hash
|
||||
* @param outpointIndex - Outpoint index
|
||||
* @returns Promise resolving to unspent output data, or undefined if not found
|
||||
*/
|
||||
async getUnspentOutputData(outpointTransactionHash: string, outpointIndex: number): Promise<UnspentOutputData | undefined> {
|
||||
this.ensureReady();
|
||||
const key = this.getUnspentOutputKey(outpointTransactionHash, outpointIndex);
|
||||
const output = this.unspentOutputs.get(key);
|
||||
|
||||
return output === undefined ? undefined : this.clone(output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores unspent output data in storage.
|
||||
*
|
||||
* @param data - Unspent output data to store
|
||||
* @returns Promise that resolves when data is stored
|
||||
*/
|
||||
async storeUnspentOutputData(data: UnspentOutputData): Promise<void> {
|
||||
this.ensureReady();
|
||||
const key = this.getUnspentOutputKey(data.outpointTransactionHash, data.outpointIndex);
|
||||
|
||||
this.unspentOutputs.set(key, this.clone(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status of an existing unspent output.
|
||||
*
|
||||
* @param outpointTransactionHash - Outpoint transaction hash
|
||||
* @param outpointIndex - Outpoint index
|
||||
* @param status - New status to set
|
||||
* @returns Promise that resolves when the update is complete
|
||||
* @throws {UnspentOutputNotFoundInStoreError} if the output doesn't exist
|
||||
*/
|
||||
async updateUnspentOutputDataStatus(outpointTransactionHash: string, outpointIndex: number, status: UnspentOutputStatus): Promise<void> {
|
||||
this.ensureReady();
|
||||
const key = this.getUnspentOutputKey(outpointTransactionHash, outpointIndex);
|
||||
const existing = this.unspentOutputs.get(key);
|
||||
|
||||
if (existing === undefined) {
|
||||
throw new UnspentOutputNotFoundInStoreError(key);
|
||||
}
|
||||
|
||||
this.unspentOutputs.set(key, {
|
||||
...existing,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets reservation status of unspent outputs in bulk.
|
||||
*
|
||||
* @param outpoints - The outpoints to reserve or unreserve
|
||||
* @param shouldBeReserved - Whether outputs should be reserved
|
||||
* @param invitationIdentifier - Invitation identifier owning the reservation
|
||||
* @returns Promise that resolves when reservation updates complete
|
||||
* @throws {UnspentOutputNotFoundInStoreError} if any output doesn't exist
|
||||
* @throws {UnspentOutputReservationConflictError} if any output is held by another invitation
|
||||
*/
|
||||
async executeBulkUnspentOutputReservation(
|
||||
outpoints: Array<{ outpointTransactionHash: string; outpointIndex: number }>,
|
||||
shouldBeReserved: boolean,
|
||||
invitationIdentifier: string,
|
||||
): Promise<void> {
|
||||
this.ensureReady();
|
||||
|
||||
const outputsToUpdate: Array<{ storageKey: string; updatedOutput: UnspentOutputData }> = [];
|
||||
|
||||
for (const { outpointTransactionHash, outpointIndex } of outpoints) {
|
||||
const storageKey = this.getUnspentOutputKey(outpointTransactionHash, outpointIndex);
|
||||
const existingOutput = this.unspentOutputs.get(storageKey);
|
||||
|
||||
if (existingOutput === undefined) {
|
||||
throw new UnspentOutputNotFoundInStoreError(storageKey);
|
||||
}
|
||||
|
||||
if (shouldBeReserved) {
|
||||
if (existingOutput.reservedBy === invitationIdentifier) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingOutput.reservedBy !== undefined) {
|
||||
throw new UnspentOutputReservationConflictError(storageKey, existingOutput.reservedBy);
|
||||
}
|
||||
} else {
|
||||
if (existingOutput.reservedBy === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingOutput.reservedBy !== invitationIdentifier) {
|
||||
throw new UnspentOutputReservationConflictError(storageKey, existingOutput.reservedBy);
|
||||
}
|
||||
}
|
||||
|
||||
outputsToUpdate.push({
|
||||
storageKey,
|
||||
updatedOutput: {
|
||||
...existingOutput,
|
||||
reservedBy: shouldBeReserved ? invitationIdentifier : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const { storageKey, updatedOutput } of outputsToUpdate) {
|
||||
this.unspentOutputs.set(storageKey, updatedOutput);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes unspent output data from storage.
|
||||
*
|
||||
* @param outpointTransactionHash - Outpoint transaction hash
|
||||
* @param outpointIndex - Outpoint index
|
||||
* @returns Promise that resolves when deletion is complete
|
||||
*/
|
||||
async deleteUnspentOutputData(outpointTransactionHash: string, outpointIndex: number): Promise<void> {
|
||||
this.ensureReady();
|
||||
const key = this.getUnspentOutputKey(outpointTransactionHash, outpointIndex);
|
||||
|
||||
this.unspentOutputs.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists script hash data matching the provided query.
|
||||
*
|
||||
* @param query - Query to apply
|
||||
* @returns Promise resolving to matching script hash records
|
||||
* @throws {QueryFieldsLimitExceededError} if more than one query field is provided
|
||||
* @throws {QueryInvalidError} if the query contains no recognized field
|
||||
*/
|
||||
async listScriptHashData(query: ScriptHashDataQuery): Promise<ScriptHashData[]> {
|
||||
this.ensureReady();
|
||||
const definedFields = getDefinedQueryFields(query);
|
||||
|
||||
if (definedFields.length > 1) {
|
||||
throw new QueryFieldsLimitExceededError('scriptHashes', definedFields);
|
||||
}
|
||||
|
||||
if (query.templateIdentifier !== undefined) {
|
||||
return this.filterScriptHashes((item) => item.templateIdentifier === query.templateIdentifier);
|
||||
}
|
||||
|
||||
if (query.invitationIdentifier !== undefined) {
|
||||
return this.filterScriptHashes((item) => item.invitationIdentifier === query.invitationIdentifier);
|
||||
}
|
||||
|
||||
if (query.scriptHash !== undefined) {
|
||||
return this.filterScriptHashes((item) => item.scriptHash === query.scriptHash);
|
||||
}
|
||||
|
||||
throw new QueryInvalidError('scriptHashes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists unspent output data matching query and optional in-memory filter.
|
||||
*
|
||||
* @param query - Query for selecting unspent outputs
|
||||
* @param filter - Optional filter for reserved status
|
||||
* @returns Promise resolving to matching unspent outputs
|
||||
* @throws {QueryFieldsLimitExceededError} if more than one query field is provided
|
||||
*/
|
||||
async listUnspentOutputs(query: UnspentOutputDataQuery = {}, filter?: UnspentOutputDataFilter): Promise<UnspentOutputData[]> {
|
||||
this.ensureReady();
|
||||
const definedFields = getDefinedQueryFields(query);
|
||||
|
||||
if (definedFields.length > 1) {
|
||||
throw new QueryFieldsLimitExceededError('unspentOutputs', definedFields);
|
||||
}
|
||||
|
||||
let unspentOutputs = Array.from(this.unspentOutputs.values(), (output) => this.clone(output));
|
||||
|
||||
if (query.templateIdentifier !== undefined) {
|
||||
unspentOutputs = unspentOutputs.filter((output) => output.templateIdentifier === query.templateIdentifier);
|
||||
} else if (query.reservedBy !== undefined) {
|
||||
unspentOutputs = unspentOutputs.filter((output) => output.reservedBy === query.reservedBy);
|
||||
} else if (query.scriptHash !== undefined) {
|
||||
unspentOutputs = unspentOutputs.filter((output) => output.scriptHash === query.scriptHash);
|
||||
} else if (query.status !== undefined) {
|
||||
unspentOutputs = unspentOutputs.filter((output) => output.status === query.status);
|
||||
}
|
||||
|
||||
if (filter?.reserved !== undefined) {
|
||||
unspentOutputs = unspentOutputs.filter((unspentOutput) => (unspentOutput.reservedBy !== undefined) === filter.reserved);
|
||||
}
|
||||
|
||||
return unspentOutputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores an event in storage.
|
||||
*
|
||||
* @param eventId - Event identifier
|
||||
* @param eventType - Event type
|
||||
* @param data - Event payload
|
||||
* @param timestamp - Optional timestamp, defaults to Date.now()
|
||||
* @returns Promise that resolves when event is stored
|
||||
*/
|
||||
async storeEvent(eventId: string, eventType: string, data: unknown, timestamp?: number): Promise<void> {
|
||||
this.ensureReady();
|
||||
this.events.set(eventId, {
|
||||
eventId,
|
||||
timestamp: timestamp ?? Date.now(),
|
||||
eventType,
|
||||
data: this.clone(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists event data matching the provided query.
|
||||
*
|
||||
* @param query - Query to apply
|
||||
* @returns Promise resolving to matching events
|
||||
* @throws {QueryFieldsLimitExceededError} if more than one query field is provided
|
||||
* @throws {QueryInvalidError} if the query contains no recognized field
|
||||
*/
|
||||
async listEvents(query: EventDataQuery): Promise<EventData[]> {
|
||||
this.ensureReady();
|
||||
const definedFields = getDefinedQueryFields(query);
|
||||
|
||||
if (definedFields.length > 1) {
|
||||
throw new QueryFieldsLimitExceededError('events', definedFields);
|
||||
}
|
||||
|
||||
if (query.eventType !== undefined) {
|
||||
return Array.from(this.events.values())
|
||||
.filter((event) => event.eventType === query.eventType)
|
||||
.map((event) => this.clone(event));
|
||||
}
|
||||
|
||||
throw new QueryInvalidError('events');
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the storage adapter and releases in-memory resources.
|
||||
*
|
||||
* @returns Promise that resolves when close operation finishes
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
this.templates.clear();
|
||||
this.invitations.clear();
|
||||
this.scriptHashes.clear();
|
||||
this.unspentOutputs.clear();
|
||||
this.events.clear();
|
||||
|
||||
this.databaseState = DatabaseState.Closed;
|
||||
this.databaseInitializationPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the adapter is in a ready state before handling any operation.
|
||||
* Mirrors the behavior of the IndexedDB implementation where calls after its been closed throw an error.
|
||||
*
|
||||
* @throws {DatabaseClosedError} if the adapter has already been closed
|
||||
* @throws {DatabaseNotInitializedError} if initialize has not been called
|
||||
*/
|
||||
private ensureReady(): void {
|
||||
if (this.databaseState === DatabaseState.Closed) {
|
||||
throw new DatabaseClosedError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the canonical storage key for unspent outputs.
|
||||
*
|
||||
* @param outpointTransactionHash - Outpoint transaction hash
|
||||
* @param outpointIndex - Outpoint index
|
||||
* @returns Canonical key in hash:index format
|
||||
*/
|
||||
private getUnspentOutputKey(outpointTransactionHash: string, outpointIndex: number): string {
|
||||
return `${outpointTransactionHash}:${outpointIndex}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns script hash records matching the given predicate.
|
||||
*
|
||||
* @param predicate - Predicate used to filter script hash records
|
||||
* @returns Matching script hash records
|
||||
*/
|
||||
private filterScriptHashes(predicate: (item: ScriptHashData) => boolean): ScriptHashData[] {
|
||||
return Array.from(this.scriptHashes.values())
|
||||
.filter(predicate)
|
||||
.map((item) => this.clone(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a detached clone to preserve storage value semantics.
|
||||
*
|
||||
* IndexedDB deserializes values when reading, so callers cannot mutate data that is
|
||||
* still stored in the database. We clone in-memory values to keep that same behavior.
|
||||
*
|
||||
* @param value - Value to clone
|
||||
* @returns Detached clone of the provided value
|
||||
*/
|
||||
private clone<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
}
|
||||
}
|
||||
40
tests/cli/rates-format.test.ts
Normal file
40
tests/cli/rates-format.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { BaseRates } from "../../src/utils/rates/base-rates";
|
||||
|
||||
/**
|
||||
* Minimal concrete adapter used only for testing BaseRates helpers.
|
||||
*/
|
||||
class TestRatesAdapter extends BaseRates {
|
||||
public async start(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
public async listPairs(): Promise<Set<string>> {
|
||||
return new Set(["USD/BCH", "DOGE/BCH"]);
|
||||
}
|
||||
}
|
||||
|
||||
describe("BaseRates.formatCurrency", () => {
|
||||
test("formats ISO currency codes with Intl currency style", () => {
|
||||
const rates = new TestRatesAdapter();
|
||||
const formatted = rates.formatCurrency(12.5, "USD");
|
||||
|
||||
expect(formatted).toContain("$");
|
||||
expect(formatted).toContain("12.50");
|
||||
});
|
||||
|
||||
test("formats non-ISO symbols without throwing", () => {
|
||||
const rates = new TestRatesAdapter();
|
||||
const formatted = rates.formatCurrency(12.3456789, "DOGE");
|
||||
|
||||
expect(formatted).toContain("DOGE");
|
||||
expect(formatted).toContain("12.3456789");
|
||||
});
|
||||
});
|
||||
96
tests/cli/settings.test.ts
Normal file
96
tests/cli/settings.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { beforeEach, afterEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { SettingsService } from "../../src/services/settings";
|
||||
|
||||
/**
|
||||
* Tests for SettingsService persistence and migration behavior.
|
||||
*/
|
||||
describe("SettingsService", () => {
|
||||
let testDir: string;
|
||||
let settingsPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = mkdtempSync(join(tmpdir(), "xo-cli-settings-test-"));
|
||||
settingsPath = join(testDir, ".wallet");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("returns defaults when settings file does not exist", () => {
|
||||
const settings = new SettingsService(settingsPath);
|
||||
|
||||
expect(settings.getDefaultMnemonic()).toBeUndefined();
|
||||
expect(settings.getCurrency()).toBe("USD");
|
||||
expect(settings.getSettings()).toEqual({ currency: "USD" });
|
||||
expect(existsSync(settingsPath)).toBe(false);
|
||||
});
|
||||
|
||||
test("migrates legacy .wallet plaintext mnemonic to JSON", () => {
|
||||
writeFileSync(settingsPath, "mnemonic-legacy", "utf8");
|
||||
|
||||
const settings = new SettingsService(settingsPath);
|
||||
|
||||
expect(settings.getDefaultMnemonic()).toBe("mnemonic-legacy");
|
||||
expect(settings.getCurrency()).toBe("USD");
|
||||
|
||||
const persisted = JSON.parse(readFileSync(settingsPath, "utf8")) as {
|
||||
"default-mnemonic"?: string;
|
||||
currency: string;
|
||||
};
|
||||
expect(persisted).toEqual({
|
||||
"default-mnemonic": "mnemonic-legacy",
|
||||
currency: "USD",
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes and persists currency and default-mnemonic", () => {
|
||||
const settings = new SettingsService(settingsPath);
|
||||
|
||||
settings.setDefaultMnemonic(" mnemonic-primary ");
|
||||
settings.setCurrency("aud");
|
||||
|
||||
expect(settings.getSettings()).toEqual({
|
||||
"default-mnemonic": "mnemonic-primary",
|
||||
currency: "AUD",
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(readFileSync(settingsPath, "utf8")) as {
|
||||
"default-mnemonic"?: string;
|
||||
currency: string;
|
||||
};
|
||||
expect(persisted).toEqual({
|
||||
"default-mnemonic": "mnemonic-primary",
|
||||
currency: "AUD",
|
||||
});
|
||||
});
|
||||
|
||||
test("emits settings-updated events on setting changes", () => {
|
||||
const settings = new SettingsService(settingsPath);
|
||||
const events: Array<{ key: string; value: string | undefined }> = [];
|
||||
|
||||
settings.on("settings-updated", (event) => {
|
||||
events.push({ key: event.key, value: event.value });
|
||||
});
|
||||
|
||||
settings.setCurrency("cad");
|
||||
settings.setDefaultMnemonic("mnemonic-blue");
|
||||
|
||||
expect(events).toEqual([
|
||||
{ key: "currency", value: "CAD" },
|
||||
{ key: "default-mnemonic", value: "mnemonic-blue" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user