diff --git a/.gitignore b/.gitignore index dfdc5d4..5be6aec 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ dist/ *.sqlite *.sqlite-journal resolvedTemplate.json -mnemonic-* \ No newline at end of file +mnemonic-* +.xo-cli-wallet +inv-*.json \ No newline at end of file diff --git a/p2pkh-template.json b/p2pkh-template.json new file mode 100644 index 0000000..bb80ce1 --- /dev/null +++ b/p2pkh-template.json @@ -0,0 +1 @@ +{"$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 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":"$( OP_CAT )","type":"bytes","hint":"signature"},"messageSignatureValidity":{"value":"$( OP_CAT 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 $() satoshis.","icon":"request","roles":{"receiver":{"name":"Received","description":"Received $() satoshis.","icon":"receive"},"sender":{"name":"Sent","description":"Sent $() satoshis.","icon":"send"}},"inputs":[],"outputs":[{"output":"requestSatoshisOutput"}],"version":2,"locktime":0,"composable":true},"requestFungibleTokensTransaction":{"name":"Transfer request","description":"Transfer request for $( OP_DIV).$( OP_MOD) $() tokens.","icon":"request","roles":{"receiver":{"name":"Received","description":"Received $( OP_DIV).$( OP_MOD) $() tokens.","icon":"receive"},"sender":{"name":"Sent","description":"Sent $( OP_DIV).$( OP_MOD) $() tokens.","icon":"send"}},"inputs":[],"outputs":[{"output":"requestFungibleTokensOutput"}],"version":2,"locktime":0,"composable":true},"requestNonfungibleTokensTransaction":{"name":"Transfer request","description":"Transfer request for one non-fungible $( <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) () token, with $() commitment.","icon":"request","roles":{"receiver":{"name":"Received","description":"Received one non-fungible $( <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) () token, with $() commitment.","icon":"receive"},"sender":{"name":"Sent","description":"Sent the requested non-fungible $( <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) () token, with $() commitment.","icon":"send"}},"inputs":[],"outputs":[{"output":"requestNonfungibleTokensOutput"}],"version":2,"locktime":0,"composable":true},"transferSatoshisTransaction":{"name":"Satoshis Transferred","description":"$() satoshis were transferred to a recipient.","icon":"send","roles":{"receiver":{"name":"Received","description":"Received $() satoshis.","icon":"receive"},"sender":{"name":"Sent","description":"Sent $() satoshis.","icon":"send"}},"inputs":[],"outputs":[{"output":"transferSatoshisOutput"}],"version":2,"locktime":0,"composable":true},"transferFungibleTokensTransaction":{"name":"Fungible Tokens Transferred","description":"$( OP_DIV).$( OP_MOD) $() tokens were transferred to a recipient.","icon":"send","roles":{"receiver":{"name":"Received","description":"Received $( OP_DIV).$( OP_MOD) $() tokens.","icon":"receive"},"sender":{"name":"Sent","description":"Sent $( OP_DIV).$( OP_MOD) $() tokens.","icon":"send"}},"inputs":[],"outputs":[{"output":"transferFungibleTokensOutput"}],"version":2,"locktime":0,"composable":true},"transferNonfungibleTokensTransaction":{"name":"Non-fungible Token Transferred","description":"One non-fungible $( <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $() token was transferred to a recipient, with $() commitment.","icon":"send","roles":{"receiver":{"name":"Received","description":"Received one non-fungible $( <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) () token, with $() commitment.","icon":"receive"},"sender":{"name":"Sent","description":"Sent one non-fungible $( <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) () token, with $() commitment.","icon":"send"}},"inputs":[],"outputs":[{"output":"transferNonfungibleTokenOutput"}],"version":2,"locktime":0,"composable":true},"burnFungibleTokensTransaction":{"name":"Deleted fungible tokens","description":"Permanently and irreversibly deleted $( OP_DIV).$( OP_MOD) $() 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 $( <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) () token, with $() 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 $() satoshis to a recipient.","icon":"request","roles":{"receiver":{"name":"Received","description":"Received $() satoshis."},"sender":{"name":"Sent","description":"Sent $() satoshis."}},"lockscript":"receivingLockingScript","valueSatoshis":"$()","token":null},"requestFungibleTokensOutput":{"name":"Recipient output","description":"Transferred $( OP_DIV).$( OP_MOD) $() tokens to a recipient.","icon":"request","roles":{"receiver":{"name":"Received","description":"Received $( OP_DIV).$( OP_MOD) $() tokens."},"sender":{"name":"Sent","description":"Sent $( OP_DIV).$( OP_MOD) $() tokens."}},"lockscript":"receivingLockingScript","valueSatoshis":"1000","token":{"category":"$()","amount":"$()","nft":null}},"requestNonfungibleTokensOutput":{"name":"Recipient output","description":"Transferred one non-fungible $( <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $() token to a recipient, with $() commitment.","icon":"request","roles":{"receiver":{"name":"Received","description":"Received one non-fungible $( <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $() token, with $() commitment."},"sender":{"name":"Sent","description":"Sent the requested non-fungible $( <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $() token, with $() commitment."}},"lockscript":"receivingLockingScript","valueSatoshis":"1000","token":{"category":"$()","amount":null,"nft":{"capability":"$()","commitment":"$()"}}},"transferSatoshisOutput":{"name":"Recipient output","description":"Transferred $() satoshis to a recipient.","icon":"send","roles":{"receiver":{"name":"Received","description":"Received $() satoshis."},"sender":{"name":"Sent","description":"Sent $() satoshis."}},"lockscript":"sendingLockingscript","valueSatoshis":"$()"},"transferFungibleTokensOutput":{"name":"Recipient output","description":"Transferred $( OP_DIV).$( OP_MOD) $() tokens to a recipient.","icon":"send","roles":{"receiver":{"name":"Received","description":"Received $( OP_DIV).$( OP_MOD) $() tokens."},"sender":{"name":"Sent","description":"Sent $( OP_DIV).$( OP_MOD) $() tokens."}},"lockscript":"sendingLockingscript","token":{"category":"$()","amount":"$()"}},"transferNonfungibleTokenOutput":{"name":"Recipient output","description":"Transferred one non-fungible $( <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $() token to a recipient, with $() commitment.","icon":"send","roles":{"receiver":{"name":"Received","description":"Received one non-fungible $( <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $() token, with $() commitment."},"sender":{"name":"Sent","description":"Sent one non-fungible $( <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $() token, with $() commitment."}},"lockscript":"sendingLockingscript","token":{"category":"$()","nft":{"capability":"$()","commitment":"$()"}}}},"inputs":{"burnFungibleTokensInput":{"name":"Deleted fungible tokens","description":"Permanently and irreversibly deleted $() $().","icon":"burn","unlockingScript":"unlockP2PKH","token":{"category":"$()","amount":"$( OP_GREATERTHANOREQUAL OP_IF OP_ENDIF)"},"omitChangeAmounts":{"fungibleTokens":"${}"}},"burnNonfungibleTokenInput":{"name":"Deleted non-fungible token","description":"Permanently and irreversibly burned one non-fungible $( <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) token of category $(), with a $() commitment.","icon":"burn","unlockingScript":"unlockP2PKH","token":{"category":"$()","nft":{"capability":"$()","commitment":"$()"}},"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 <$( OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG","unlockP2PKH":" ","lockToRecipient":""},"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":""}]}}]} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2d5eb75..bf29c54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,8 @@ "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.2" } }, "../engine": { @@ -233,6 +234,43 @@ "ws": "^8.13.0" } }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -675,6 +713,13 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@monsterbitar/isomorphic-ws": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@monsterbitar/isomorphic-ws/-/isomorphic-ws-5.3.1.tgz", @@ -684,6 +729,297 @@ "ws": "*" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -702,6 +1038,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -712,6 +1066,31 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.0.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", @@ -750,6 +1129,119 @@ "@types/node": "*" } }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xo-cash/engine": { "resolved": "../engine", "link": true @@ -805,6 +1297,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-mutex": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", @@ -913,6 +1415,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -1129,6 +1641,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-to-spaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", @@ -1251,6 +1770,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-toolkit": { "version": "1.44.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", @@ -1312,6 +1838,16 @@ "node": ">=8" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -1353,6 +1889,34 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -1733,6 +2297,267 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -1751,6 +2576,16 @@ "integrity": "sha512-ToxOC+SsduRmdSuoLZLYAr5zy1Qu7l5XhmPWM3zefCZ5IcrzW/h108qbJUKfOlDlhvhjUK84+8PSVX0kxnit0g==", "license": "MIT" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -1793,6 +2628,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -1839,6 +2693,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1938,6 +2803,33 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -1947,6 +2839,35 @@ "node": ">=10.13.0" } }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/powershell-utils": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz", @@ -2142,6 +3063,40 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2207,6 +3162,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -2280,6 +3242,16 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -2292,6 +3264,20 @@ "node": ">=10" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2394,6 +3380,50 @@ "node": ">=6" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2482,6 +3512,166 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2503,6 +3693,23 @@ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "license": "ISC" }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/widest-line": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", diff --git a/package.json b/package.json index d38a571..9b53085 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts", "build": "tsc", "start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js", - "test": "echo \"Error: no test specified\" && exit 1", + "test": "vitest --run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "nuke": "tsx scripts/rm-dbs.ts", "nuke:dry": "tsx scripts/rm-dbs.ts --dry", "format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore", @@ -43,6 +45,7 @@ "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.2" } } diff --git a/src/cli/arguments.ts b/src/cli/arguments.ts new file mode 100644 index 0000000..93b6ceb --- /dev/null +++ b/src/cli/arguments.ts @@ -0,0 +1,80 @@ +/** + * CLI Argument extraction and validation. + * + * Converts `-${key}` or `--${key}` to `key` in the args object. + */ +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: + * { + * args: ["mnemonic", "create", "page", "pencil", "stock", "planet", "limb", "cluster", "assault", "speak", "off", "joke", "private", "pioneer"], + * options: { + * output: "mnemonic.txt", + * verbose: "true", + * }, + * } + * + * @param args - The CLI args to convert. + * @returns The key-value object. + */ +export function convertArgsToObject(args: string[]): { args: string[], options: Record } { + // Map of single-character short flags to their canonical long names + const shortToFull: Record = { + 'm': 'mnemonicFile', + 'o': 'output', + 'v': 'verbose', + 'h': 'help', + }; + + // Flags that are always boolean and never consume the next argument as a value. + // Uses the canonical (expanded) names so the check works after short-form resolution. + const booleanFlags = new Set([ + 'verbose', + 'help', + 'autoInputs', + 'sign', + 'broadcast', + ]); + + const positionalArgs: string[] = []; + const optionsObject: Record = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + // Collect non-option arguments as positional args + if (!arg || !arg.startsWith("-")) { + if (arg) positionalArgs.push(arg); + continue; + } + + // Format the option key: + // - Remove the leading `-`s + // - Convert kebab-case to camelCase + // - Expand known short forms to their full names + let key = arg.replace(/^-+/, "").replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + key = shortToFull[key] ?? key; + + // Known boolean flags never take a value + if (booleanFlags.has(key)) { + optionsObject[key] = "true"; + continue; + } + + const nextArg = args[i + 1]; + + // If there's no next arg or it starts with `-`, treat this as a boolean flag + if (!nextArg || nextArg.startsWith("-")) { + optionsObject[key] = "true"; + continue; + } + + // Consume the next arg as the value and skip it in the next iteration + optionsObject[key] = nextArg; + i++; + } + + return { args: positionalArgs, options: optionsObject }; +} \ No newline at end of file diff --git a/src/cli/cli-utils.ts b/src/cli/cli-utils.ts new file mode 100644 index 0000000..486f974 --- /dev/null +++ b/src/cli/cli-utils.ts @@ -0,0 +1,48 @@ +import util from "node:util"; + +/** + * Text formatting utilities for the CLI. + * + * Uses ANSI escape codes to format text. + * + * AI Generated links: + * @see https://en.wikipedia.org/wiki/ANSI_escape_code + * @see https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters + * @see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors + * @see https://en.wikipedia.org/wiki/ANSI_escape_code#Formatting + * @see https://en.wikipedia.org/wiki/ANSI_escape_code#Cursor_movement + * @see https://en.wikipedia.org/wiki/ANSI_escape_code#Screen_manipulation + */ + +const BOLD = "\x1b[1m"; +export const bold = (text: string) => `${BOLD}${text}${RESET}`; + +const DIM = "\x1b[2m"; +export const dim = (text: string) => `${DIM}${text}${RESET}`; + +const UNDERLINE = "\x1b[4m"; +export const underline = (text: string) => `${UNDERLINE}${text}${RESET}`; + +const INVERSE = "\x1b[7m"; +export const inverse = (text: string) => `${INVERSE}${text}${RESET}`; + +const HIDDEN = "\x1b[8m"; +export const hidden = (text: string) => `${HIDDEN}${text}${RESET}`; + +const STRIKETHROUGH = "\x1b[9m"; +export const strikethrough = (text: string) => `${STRIKETHROUGH}${text}${RESET}`; + +const RESET = "\x1b[0m"; +export const reset = (text: string) => `${RESET}${text}${RESET}`; + +export const formatObject = (obj: unknown) => { + return util.inspect(obj, { + depth: null, + colors: true, + compact: false + }); +}; + +export const objectPrint = (obj: unknown) => { + console.log(formatObject(obj)); +}; \ No newline at end of file diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts new file mode 100644 index 0000000..d491fa8 --- /dev/null +++ b/src/cli/commands/index.ts @@ -0,0 +1,7 @@ +export type { CommandDependencies } from "./types.js"; + +export { handleMnemonicCommand, printMnemonicHelp } from "./mnemonic.js"; +export { handleTemplateCommand, printTemplateHelp } from "./template.js"; +export { handleInvitationCommand, printInvitationHelp } from "./invitation.js"; +export { handleReceiveCommand, printReceiveHelp } from "./receive.js"; +export { handleResourceCommand, printResourceHelp } from "./resource.js"; diff --git a/src/cli/commands/invitation.ts b/src/cli/commands/invitation.ts new file mode 100644 index 0000000..5c39fb7 --- /dev/null +++ b/src/cli/commands/invitation.ts @@ -0,0 +1,562 @@ +import { existsSync, readFileSync, writeFileSync } from "fs"; +import path from "path"; +import { generateTemplateIdentifier } from "@xo-cash/engine"; +import { binToHex, hexToBin } from "@bitauth/libauth"; + +import { bold, dim, formatObject } from "../cli-utils.js"; +import type { CommandDependencies } from "./types.js"; +import type { Invitation } from "../../services/invitation.js"; +import { + resolveProvidedLockingBytecodeHex, + mapUnspentOutputsToSelectable, + autoSelectGreedyUtxos, +} from "../../utils/invitation-flow.js"; +import { encodeExtendedJson } from "../../utils/ext-json.js"; + +const DEFAULT_FEE = 500n; +const DUST_THRESHOLD = 546n; + +/** + * Result of parsing CLI options into inputs and outputs for an append call. + * A `null` return signals a fatal error that was already logged to stderr. + */ +interface BuildAppendResult { + inputs: { outpointTransactionHash: Uint8Array; outpointIndex: number }[]; + outputs: any[]; +} + +/** + * Extracts invitation variables from CLI option flags. + * Keys starting with "var" are treated as variables — the prefix is stripped + * and the next character is lowercased to reconstruct the camelCase identifier. + * + * When a `-role` flag is present the role identifier is attached to every + * variable so the engine stores them under the correct role-level requirements. + * + * @example `-var-requested-satoshis 1000 -role sender` → `{ variableIdentifier: "requestedSatoshis", value: "1000", roleIdentifier: "sender" }` + */ +function parseVariablesFromOptions( + options: Record, +): { variableIdentifier: string; value: string; roleIdentifier?: string }[] { + const roleIdentifier = options["role"]; + + return Object.entries(options) + .filter(([key]) => key.startsWith("var")) + .map(([key, value]) => ({ + variableIdentifier: key.substring(3, 4).toLowerCase() + key.substring(4), + value, + ...(roleIdentifier ? { roleIdentifier } : {}), + })); +} + +/** + * Parses CLI options into the inputs and outputs needed for an invitation + * append call. Shared by both `create` and `append` so the same flags + * (`--add-input`, `--add-output`, `--auto-inputs`, `-role`) work in either. + * + * Variables should already be committed to the invitation before calling this + * so that `getSatsOut()` can resolve variable-dependent output values for the + * automatic change calculation. + * + * @param deps - Command dependencies (engine, logger, etc.) + * @param invitation - The invitation instance (variables should already be committed). + * @param options - Parsed CLI option flags. + * @returns The structured params, or `null` when a fatal error was printed. + */ +async function buildAppendParams( + deps: CommandDependencies, + invitation: Invitation, + options: Record, +): Promise { + // --- Inputs --- + // Accepts comma-separated : pairs via --add-input, + // OR automatic selection via --auto-inputs. + let inputs: { outpointTransactionHash: Uint8Array; outpointIndex: number }[] = []; + + if (options["autoInputs"] === "true") { + // Auto-select UTXOs using the greedy algorithm from invitation-flow. + const suitableResources = await invitation.findSuitableResources(); + const selectable = mapUnspentOutputsToSelectable(suitableResources); + + const requiredWithFee = (await invitation.getSatsOut().catch(() => 0n)) + DEFAULT_FEE; + autoSelectGreedyUtxos(selectable, requiredWithFee); + + inputs = selectable + .filter((u) => u.selected) + .map((u) => ({ + outpointTransactionHash: hexToBin(u.outpointTransactionHash), + outpointIndex: u.outpointIndex, + })); + + if (inputs.length === 0) { + console.error("No suitable UTXOs found for auto-input selection."); + return null; + } + deps.verboseLogger(`Auto-selected ${inputs.length} input(s)`); + } else if (options["addInput"]) { + inputs = options["addInput"].split(",").map((entry) => { + const separatorIndex = entry.lastIndexOf(":"); + if (separatorIndex === -1) { + throw new Error(`Invalid input format "${entry}". Expected : (e.g. abc123:0)`); + } + const txHash = entry.substring(0, separatorIndex); + const vout = parseInt(entry.substring(separatorIndex + 1), 10); + if (!txHash || isNaN(vout)) { + throw new Error(`Invalid input format "${entry}". Expected : (e.g. abc123:0)`); + } + return { + outpointTransactionHash: hexToBin(txHash), + outpointIndex: vout, + }; + }); + } + deps.verboseLogger(`Inputs: ${formatObject(inputs.map(i => ({ txHash: binToHex(i.outpointTransactionHash), vout: i.outpointIndex })))}`); + + // --- Outputs --- + // When --add-output is provided, use those identifiers explicitly. + // Otherwise, auto-discover all required outputs from the template so the + // user doesn't have to name them manually. + const roleIdentifier = options["role"]; + let outputIdentifiers: string[] = []; + + if (options["addOutput"]) { + outputIdentifiers = options["addOutput"].split(","); + } else { + // Pull every output identifier the template requires (top-level + role-specific). + const requirements = await invitation.getRequirements(); + const discovered = new Set(); + for (const id of requirements.outputs ?? []) discovered.add(id); + if (requirements.roles) { + for (const role of Object.values(requirements.roles)) { + for (const id of role.outputs ?? []) discovered.add(id); + } + } + outputIdentifiers = [...discovered]; + if (outputIdentifiers.length > 0) { + deps.verboseLogger(`Auto-discovered output(s) from template: ${outputIdentifiers.join(", ")}`); + } + } + + // Build a variable-values map from all committed variables so + // resolveProvidedLockingBytecodeHex can resolve outputs whose locking + // script depends on a variable (e.g. ). + const variableValuesByIdentifier: Record = {}; + for (const commit of invitation.data.commits) { + for (const v of commit.data?.variables ?? []) { + if (v.variableIdentifier && typeof v.value === "string") { + variableValuesByIdentifier[v.variableIdentifier] = v.value; + } + } + } + + const template = await deps.app.engine.getTemplate(invitation.data.templateIdentifier); + + const outputs: any[] = await Promise.all( + outputIdentifiers.map(async (outputId) => { + // Try variable-based resolution first (e.g. sendSatoshis → recipientLockingscript) + const providedHex = template + ? resolveProvidedLockingBytecodeHex(template, outputId, variableValuesByIdentifier) + : undefined; + + const lockingBytecodeHex = providedHex + ?? await invitation.generateLockingBytecode(outputId, roleIdentifier); + + deps.verboseLogger(`Locking bytecode for output "${outputId}": ${lockingBytecodeHex}`); + return { + outputIdentifier: outputId, + lockingBytecode: new Uint8Array(Buffer.from(lockingBytecodeHex, "hex")), + }; + }), + ); + deps.verboseLogger(`Outputs: ${formatObject(outputs.map(o => o.outputIdentifier))}`); + + // --- Auto change output --- + // When inputs are provided, look up each UTXO's value, compute the + // required sats, and return the excess minus fees back to the user. + if (inputs.length > 0) { + const allUtxos = await deps.app.engine.listUnspentOutputsData(); + const utxoMap = new Map(allUtxos.map(u => [`${u.outpointTransactionHash}:${u.outpointIndex}`, u])); + + let totalInputSats = 0n; + for (const input of inputs) { + const txHashHex = binToHex(input.outpointTransactionHash); + const utxo = utxoMap.get(`${txHashHex}:${input.outpointIndex}`); + if (!utxo) { + console.error(`UTXO not found: ${txHashHex}:${input.outpointIndex}. Make sure it exists in your wallet.`); + return null; + } + totalInputSats += BigInt(utxo.valueSatoshis); + } + deps.verboseLogger(`Total input value: ${totalInputSats} satoshis`); + + const requiredSats = await invitation.getSatsOut(); + deps.verboseLogger(`Required output value: ${requiredSats} satoshis`); + + const changeAmount = totalInputSats - requiredSats - DEFAULT_FEE; + deps.verboseLogger(`Change amount: ${changeAmount} satoshis (fee: ${DEFAULT_FEE})`); + + if (changeAmount < 0n) { + console.error(`Insufficient funds. Inputs total ${totalInputSats} sats, but need ${requiredSats + DEFAULT_FEE} sats (${requiredSats} required + ${DEFAULT_FEE} fee).`); + return null; + } + + if (changeAmount >= DUST_THRESHOLD) { + outputs.push({ valueSatoshis: changeAmount }); + console.log(`Auto-adding change output: ${changeAmount} satoshis`); + } else if (changeAmount > 0n) { + console.log(`Change ${changeAmount} sats is below dust threshold (${DUST_THRESHOLD} sats), donating to miners as fee.`); + } + } + + return { inputs, outputs }; +} + +/** + * Prints the help message for the invitation command + */ +export const printInvitationHelp = () => { + console.log( +` +${bold("Usage:")} xo-cli invitation + +${bold("Sub-commands:")} + - create ${dim("Create a new invitation")} + - append ${dim("Add variables/outputs to an invitation")} + - sign ${dim("Sign an invitation")} + - broadcast ${dim("Broadcast an invitation")} + - requirements ${dim("Show requirements for an invitation")} + - import ${dim("Import an invitation from a file")} + - list ${dim("List all invitations")} + +${bold("Create / Append options:")} + -var- ${dim("Set a variable (e.g. -var-requested-satoshis 1000)")} + --add-input ${dim("Add UTXO input(s), comma-separated (e.g. abc123:0,def456:1)")} + --add-output ${dim("Override output(s) — omit to auto-discover from template")} + --auto-inputs ${dim("Automatically select UTXOs as inputs")} + -role ${dim("Role for output bytecode generation (fallback)")} + --sign ${dim("Auto-sign after all requirements are satisfied")} + --broadcast ${dim("Auto-broadcast after signing (implies --sign)")} + + ${dim("When inputs are provided, a change output is automatically added if the")} + ${dim("input total exceeds the required amount + fee.")} + `); +}; + +/** + * Handles the invitation command. + * @param deps - The command dependencies. + * @param args - Positional args after the command name, e.g. ["create", "template.json", "action-id"]. + * @param options - Parsed option flags, e.g. { varRequestedSatohis: "1000", role: "receiver" }. + */ +export const handleInvitationCommand = async (deps: CommandDependencies, args: string[], options: Record): Promise => { + const subCommand = args[0]; + deps.verboseLogger(`Invitation sub-command: ${subCommand}`); + + if (!subCommand) { + deps.verboseLogger("No sub-command provided"); + printInvitationHelp(); + return; + } + + switch (subCommand) { + case "create": { + const templateFile = args[1]; + const actionIdentifier = args[2]; + deps.verboseLogger(`Template file: ${templateFile}, action identifier: ${actionIdentifier}`); + + if (!templateFile || !actionIdentifier) { + deps.verboseLogger("No template file or action identifier provided"); + printInvitationHelp(); + return; + } + + // Resolve and validate the template file path + const templatePath = path.resolve(`${process.cwd()}/${templateFile}`); + deps.verboseLogger(`Template path: ${templatePath}`); + + if (!existsSync(templatePath)) { + console.error(`Template file does not exist: ${templatePath}`); + printInvitationHelp(); + return; + } + + const template = await readFileSync(templatePath, "utf8"); + const templateIdentifier = generateTemplateIdentifier(JSON.parse(template)); + + // Create the base invitation via the engine + const rawInvitation = await deps.app.engine.createInvitation({ + templateIdentifier: templateIdentifier, + actionIdentifier: actionIdentifier, + }); + deps.verboseLogger(`XOInvitation created: ${formatObject(rawInvitation)}`); + + const invitationInstance = await deps.app.createInvitation(rawInvitation); + deps.verboseLogger(`Invitation created: ${formatObject(invitationInstance.data)}`); + + // Commit variables first so getSatsOut can resolve them for change calc + // and resolveProvidedLockingBytecodeHex can read them from commits. + const variables = parseVariablesFromOptions(options); + deps.verboseLogger(`Variables: ${formatObject(variables)}`); + if (variables.length > 0) { + await invitationInstance.addVariables(variables); + } + + // Parse inputs/outputs and calculate change (variables are now committed) + const params = await buildAppendParams(deps, invitationInstance, options); + if (!params) return; + + const { inputs, outputs } = params; + + if (inputs.length > 0 || outputs.length > 0) { + await invitationInstance.append({ inputs, outputs }); + } + + // Save the invitation to a file + const invitationFilePath = `${process.cwd()}/inv-${invitationInstance.data.invitationIdentifier}.json`; + deps.verboseLogger(`Invitation file path: ${invitationFilePath}`); + writeFileSync(invitationFilePath, encodeExtendedJson(invitationInstance.data, 2)); + console.log(`Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`); + + // Check remaining requirements + const missingRequirements = await invitationInstance.getMissingRequirements(); + const hasMissing = + (missingRequirements.variables?.length ?? 0) > 0 || + (missingRequirements.inputs?.length ?? 0) > 0 || + (missingRequirements.outputs?.length ?? 0) > 0 || + (missingRequirements.roles !== undefined && Object.keys(missingRequirements.roles).length > 0); + + if (hasMissing) { + console.log(`\n${bold("Remaining requirements:")}`); + console.log(formatObject(missingRequirements)); + } else { + // --broadcast implies --sign + const shouldSign = options["sign"] === "true" || options["broadcast"] === "true"; + const shouldBroadcast = options["broadcast"] === "true"; + + if (shouldSign) { + await invitationInstance.sign(); + console.log(`Invitation signed: ${invitationInstance.data.invitationIdentifier}`); + } + + if (shouldBroadcast) { + const txHash = await invitationInstance.broadcast(); + console.log(`Transaction broadcast: ${bold(txHash)}`); + } else if (!shouldSign) { + console.log(`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationInstance.data.invitationIdentifier}`); + } + } + break; + } + + case "append": { + const invitationIdentifier = args[1]; + deps.verboseLogger(`Invitation identifier: ${invitationIdentifier}`); + + if (!invitationIdentifier) { + deps.verboseLogger("No invitation identifier provided"); + printInvitationHelp(); + return; + } + + // Find the invitation by identifier + const invitation = deps.app.invitations.find(inv => inv.data.invitationIdentifier === invitationIdentifier); + if (!invitation) { + console.error(`Invitation not found: ${invitationIdentifier}`); + return; + } + deps.verboseLogger(`Invitation: ${formatObject(invitation.data)}`); + + // Commit variables first so getSatsOut can resolve them for change calc + const variables = parseVariablesFromOptions(options); + deps.verboseLogger(`Variables to append: ${formatObject(variables)}`); + if (variables.length > 0) { + await invitation.addVariables(variables); + } + + // Parse inputs/outputs and calculate change (variables are now committed) + const params = await buildAppendParams(deps, invitation, options); + if (!params) return; + + const { inputs, outputs } = params; + + if (variables.length === 0 && inputs.length === 0 && outputs.length === 0) { + console.error("Nothing to append. Provide variables (-var- ), inputs (--add-input :), or outputs (--add-output )."); + return; + } + + if (inputs.length > 0 || outputs.length > 0) { + await invitation.append({ inputs, outputs }); + } + deps.verboseLogger(`Invitation appended: ${formatObject(invitation.data)}`); + console.log(`Invitation appended: ${invitationIdentifier}`); + + // Save the updated invitation to a file + const invitationFilePath = `${process.cwd()}/inv-${invitation.data.invitationIdentifier}.json`; + writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2)); + console.log(`Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`); + + // Check remaining requirements + const missingRequirements = await invitation.getMissingRequirements(); + const hasMissing = + (missingRequirements.variables?.length ?? 0) > 0 || + (missingRequirements.inputs?.length ?? 0) > 0 || + (missingRequirements.outputs?.length ?? 0) > 0 || + (missingRequirements.roles !== undefined && Object.keys(missingRequirements.roles).length > 0); + + if (hasMissing) { + console.log(`\n${bold("Remaining requirements:")}`); + console.log(formatObject(missingRequirements)); + } else { + const shouldSign = options["sign"] === "true" || options["broadcast"] === "true"; + const shouldBroadcast = options["broadcast"] === "true"; + + if (shouldSign) { + await invitation.sign(); + console.log(`Invitation signed: ${invitationIdentifier}`); + } + + if (shouldBroadcast) { + const txHash = await invitation.broadcast(); + console.log(`Transaction broadcast: ${bold(txHash)}`); + } else if (!shouldSign) { + console.log(`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationIdentifier}`); + } + } + break; + } + + case "sign": { + const invitationIdentifier = args[1]; + deps.verboseLogger(`Invitation identifier: ${invitationIdentifier}`); + + // Check if the invitation identifier is provided + if (!invitationIdentifier) { + deps.verboseLogger("No invitation identifier provided"); + printInvitationHelp(); + return; + } + + // Find the invitation by identifier + const invitation = await deps.app.invitations.find(invitation => invitation.data.invitationIdentifier === invitationIdentifier); + if (!invitation) { + console.error(`Invitation not found: ${invitationIdentifier}`); + return; + } + deps.verboseLogger(`Invitation: ${formatObject(invitation.data)}`); + + // Sign the invitation + await invitation.sign(); + deps.verboseLogger(`Invitation signed: ${formatObject(invitation.data)}`); + console.log(`Invitation signed: ${invitationIdentifier}`); + break; + } + + case "broadcast": { + const invitationIdentifier = args[1]; + deps.verboseLogger(`Invitation identifier: ${invitationIdentifier}`); + + // Check if the invitation identifier is provided + if (!invitationIdentifier) { + deps.verboseLogger("No invitation identifier provided"); + printInvitationHelp(); + return; + } + + // Find the invitation by identifier + const invitation = await deps.app.invitations.find(invitation => invitation.data.invitationIdentifier === invitationIdentifier); + if (!invitation) { + console.error(`Invitation not found: ${invitationIdentifier}`); + return; + } + deps.verboseLogger(`Invitation: ${formatObject(invitation.data)}`); + + // Broadcast the invitation + const txHash = await invitation.broadcast(); + deps.verboseLogger(`Invitation broadcasted: ${formatObject(invitation.data)}`); + console.log(`Transaction broadcast: ${bold(txHash)}`); + break; + } + + case "requirements": { + const invitationIdentifier = args[1]; + deps.verboseLogger(`Invitation identifier: ${invitationIdentifier}`); + + // Check if the invitation identifier is provided + if (!invitationIdentifier) { + deps.verboseLogger("No invitation identifier provided"); + printInvitationHelp(); + return; + } + + // Find the invitation by identifier + const invitation = await deps.app.invitations.find(invitation => invitation.data.invitationIdentifier === invitationIdentifier); + if (!invitation) { + console.error(`Invitation not found: ${invitationIdentifier}`); + return; + } + deps.verboseLogger(`Invitation: ${formatObject(invitation.data)}`); + + // List the requirements for the invitation + const requirements = await deps.app.engine.listRequirements(invitation.data); + deps.verboseLogger(`Requirements: ${formatObject(requirements)}`); + console.log(formatObject(requirements)); + break; + } + + case "import": { + // Check if the invitation file path is provided + const invitationFilePath = args[1]; + deps.verboseLogger(`Invitation file path: ${invitationFilePath}`); + + // Check if the invitation file path is provided + if (!invitationFilePath) { + deps.verboseLogger("No invitation file provided"); + printInvitationHelp(); + return; + } + + // Read the invitation file + const invitationFile = await readFileSync(invitationFilePath, "utf8"); + deps.verboseLogger(`Invitation file: ${invitationFile}`); + + // Parse the invitation file + const invitation = JSON.parse(invitationFile); + deps.verboseLogger(`Invitation: ${formatObject(invitation)}`); + + // Create the invitation (internal to XO Engine) + const xoInvitation = await deps.app.engine.createInvitation(invitation); + + // Create the invitation instance + const invitationInstance = await deps.app.createInvitation(xoInvitation); + deps.verboseLogger(`Invitation created: ${formatObject(invitationInstance.data)}`); + break; + } + + case "list": { + // List all invitations + const invitations = await Promise.all(deps.app.invitations.map(async invitation => { + // Get the template for the invitation so we can display the name of it + const template = await deps.app.engine.getTemplate(invitation.data.templateIdentifier); + return { + invitationIdentifier: invitation.data.invitationIdentifier, + templateIdentifier: invitation.data.templateIdentifier, + actionIdentifier: invitation.data.actionIdentifier, + templateName: template?.name ?? "Unknown", + status: invitation.status, + roleIdentifier: 'TODO: Get role identifier', + }; + })); + deps.verboseLogger(`Invitations: ${formatObject(invitations)}`); + + // Format the invitations for display and print it + const formattedInvitations = invitations.map(invitation => `${bold(invitation.templateName)} ${dim(invitation.status)} ${dim(invitation.invitationIdentifier)} ${dim(invitation.actionIdentifier)} (${dim(invitation.roleIdentifier)})`); + console.log(formattedInvitations.join('\n')); + break; + } + + default: + deps.verboseLogger(`Unknown invitation sub-command: ${subCommand}`); + printInvitationHelp(); + return; + } +}; diff --git a/src/cli/commands/mnemonic.ts b/src/cli/commands/mnemonic.ts new file mode 100644 index 0000000..cc31a72 --- /dev/null +++ b/src/cli/commands/mnemonic.ts @@ -0,0 +1,73 @@ +import { bold, dim } from "../cli-utils.js"; +import { listMnemonicFiles, createMnemonicFile, createMnemonicSeed } from "../mnemonic.js"; +import type { CommandDependencies } from "./types.js"; + +/** + * Prints the help message for the mnemonic command + */ +export const printMnemonicHelp = () => { + console.log( +` +${bold("Usage:")} xo-cli mnemonic + +${bold("Sub-commands:")} + - create ${dim("Create a new mnemonic file")} + - list ${dim("List all mnemonic files")} + +${bold("Options:")} + -o --output ${dim("Output filename for the mnemonic file")} + -h --help ${dim("Show this help message")} +`); +}; + +/** + * Handles the mnemonic command. + * @param deps - The command dependencies. + * @param args - Positional args after the command name, e.g. ["create"] or ["import", "page", "pencil", ...]. + * @param options - Parsed option flags, e.g. { output: "mnemonic.txt" }. + */ +export const handleMnemonicCommand = async (deps: Omit, args: string[], options: Record): Promise => { + const subCommand = args[0]; + + if (!subCommand) { + deps.verboseLogger("No sub-command provided"); + printMnemonicHelp(); + return; + } + + switch (subCommand) { + case "create": { + const mnemonicSeed = createMnemonicSeed(); + await createMnemonicFile(mnemonicSeed, options["output"]); + + console.log(`Mnemonic file created: ${options["output"]} (${mnemonicSeed})`); + break; + } + + case "import": { + // The mnemonic seed words are all the positional args after the sub-command + const mnemonicSeed = args.slice(1).join(" "); + + if (!mnemonicSeed) { + deps.verboseLogger("No mnemonic seed provided"); + printMnemonicHelp(); + return; + } + + deps.verboseLogger(`Mnemonic seed: ${mnemonicSeed}`); + await createMnemonicFile(mnemonicSeed, options["output"]); + break; + } + + case "list": { + const mnemonicFiles = listMnemonicFiles(); + console.log(mnemonicFiles.join('\n')); + break; + } + + default: + console.error(`Unknown sub-command: ${subCommand}`); + printMnemonicHelp(); + return; + } +}; diff --git a/src/cli/commands/receive.ts b/src/cli/commands/receive.ts new file mode 100644 index 0000000..0764095 --- /dev/null +++ b/src/cli/commands/receive.ts @@ -0,0 +1,80 @@ +import { existsSync, readFileSync } from "fs"; +import path from "path"; +import { generateTemplateIdentifier } from "@xo-cash/engine"; +import { hexToBin, lockingBytecodeToCashAddress } from "@bitauth/libauth"; + +import { bold, dim } from "../cli-utils.js"; +import type { CommandDependencies } from "./types.js"; + +/** + * Prints the help message for the receive command + */ +export const printReceiveHelp = () => { + console.log( +` +${bold("Usage:")} xo-cli receive [role-identifier] + +${bold("Description:")} + Generate a single-use receiving address from a template. + +${bold("Arguments:")} + ${dim("Path to the template JSON file")} + ${dim("The output identifier within the template (e.g. 'receiveOutput')")} + [role-identifier] ${dim("The role identifier (e.g. 'receiver'). Auto-selects the first role if omitted.")} + +${bold("Options:")} + -h --help ${dim("Show this help message")} +`); +}; + +/** + * Command which creates a single-use address/lockingScript for a given template and role. + * @param deps - The command dependencies. + * @param args - Positional args after the command name, e.g. ["template.json", "receiveOutput", "receiver"]. + * @param options - Parsed option flags. + */ +export const handleReceiveCommand = async (deps: CommandDependencies, args: string[], options: Record): Promise => { + const templateFile = args[0]; + const outputIdentifier = args[1]; + const roleIdentifier = args[2]; + + deps.verboseLogger(`Receive args - template: ${templateFile}, output: ${outputIdentifier}, role: ${roleIdentifier}`); + + if (!templateFile || !outputIdentifier) { + deps.verboseLogger("Missing required arguments"); + printReceiveHelp(); + return; + } + + // Resolve and read the template file + const templatePath = path.resolve(`${process.cwd()}/${templateFile}`); + deps.verboseLogger(`Template path: ${templatePath}`); + + if (!existsSync(templatePath)) { + console.error(`Template file does not exist: ${templatePath}`); + printReceiveHelp(); + return; + } + + const template = readFileSync(templatePath, "utf8"); + const templateIdentifier = generateTemplateIdentifier(JSON.parse(template)); + deps.verboseLogger(`Template identifier: ${templateIdentifier}`); + + // Generate the locking bytecode (returned as a hex string) + const lockingBytecodeHex = await deps.app.engine.generateLockingBytecode( + templateIdentifier, + outputIdentifier, + roleIdentifier, + ); + deps.verboseLogger(`Locking bytecode hex: ${lockingBytecodeHex}`); + + // Convert the locking bytecode to a BCH cash address + const result = lockingBytecodeToCashAddress({ bytecode: hexToBin(lockingBytecodeHex), prefix: 'bitcoincash' }); + + if (typeof result === 'string') { + console.error(`Failed to encode address: ${result}`); + return; + } + + console.log(result.address); +}; diff --git a/src/cli/commands/resource.ts b/src/cli/commands/resource.ts new file mode 100644 index 0000000..6244c21 --- /dev/null +++ b/src/cli/commands/resource.ts @@ -0,0 +1,145 @@ +import { hexToBin } from "@bitauth/libauth"; + +import { bold, dim } from "../cli-utils.js"; +import type { CommandDependencies } from "./types.js"; + +/** + * Prints the help message for the resource command. + */ +export const printResourceHelp = () => { + console.log( +` +${bold("Usage:")} xo-cli resource + +${bold("Sub-commands:")} + - list ${dim("List all unreserved resources")} + - list reserved ${dim("List reserved resources")} + - list all ${dim("List all resources (reserved + unreserved)")} + - unreserve ${dim("Unreserve a specific UTXO")} + - unreserve-all ${dim("Unreserve all reserved UTXOs")} + `); +}; + +/** + * Formats a single UTXO for display, optionally including reservation info. + */ +function formatResource(resource: { outpointTransactionHash: string; outpointIndex: number; valueSatoshis: number; outputIdentifier: string; minedAtHeight: number; reserved?: boolean; invitationIdentifier?: string }, showReserved = false): string { + const outpoint = bold(`${resource.outpointTransactionHash}:${resource.outpointIndex}`); + const value = dim(`${resource.valueSatoshis} sats`); + const output = dim(resource.outputIdentifier); + const height = dim(`(height ${resource.minedAtHeight})`); + + if (showReserved && resource.reserved) { + const inv = dim(`reserved for ${resource.invitationIdentifier}`); + return `${outpoint} ${value} ${output} ${height} ${inv}`; + } + + return `${outpoint} ${value} ${output} ${height}`; +} + +/** + * Handles the resource command. + * @param deps - The command dependencies. + * @param args - Positional args after the command name, e.g. ["list"]. + * @param options - Parsed option flags. + */ +export const handleResourceCommand = async (deps: CommandDependencies, args: string[], options: Record): Promise => { + const subCommand = args[0]; + deps.verboseLogger(`Resource sub-command: ${subCommand}`); + + if (!subCommand) { + deps.verboseLogger("No sub-command provided"); + printResourceHelp(); + return; + } + + switch (subCommand) { + case "list": { + const qualifier = args[1]; // "reserved", "all", or undefined (defaults to unreserved) + const allResources = await deps.app.engine.listUnspentOutputsData(); + + let filtered; + if (qualifier === "reserved") { + filtered = allResources.filter((r) => r.reserved); + } else if (qualifier === "all") { + filtered = allResources; + } else { + // Default: show only unreserved (selectable) resources + filtered = allResources.filter((r) => !r.reserved); + } + + if (filtered.length === 0) { + console.log(dim("No resources found.")); + return; + } + + const showReserved = qualifier === "all" || qualifier === "reserved"; + const formattedResources = filtered.map((r) => formatResource(r, showReserved)); + console.log(formattedResources.join("\n")); + console.log(`Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`); + console.log(`Total resources: ${filtered.length}`); + break; + } + + case "unreserve": { + const outpointArg = args[1]; + if (!outpointArg) { + console.error("Please provide a UTXO in : format."); + printResourceHelp(); + return; + } + + const separatorIndex = outpointArg.lastIndexOf(":"); + if (separatorIndex === -1) { + console.error(`Invalid format "${outpointArg}". Expected :.`); + return; + } + + const txHash = outpointArg.substring(0, separatorIndex); + const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10); + if (!txHash || isNaN(vout)) { + console.error(`Invalid format "${outpointArg}". Expected :.`); + return; + } + + // Look up the UTXO to get its invitation identifier (required by the engine). + const allResources = await deps.app.engine.listUnspentOutputsData(); + const target = allResources.find( + (r) => r.outpointTransactionHash === txHash && r.outpointIndex === vout, + ); + + if (!target) { + console.error(`UTXO not found: ${txHash}:${vout}`); + return; + } + + if (!target.reserved) { + console.log(dim("UTXO is not reserved. Nothing to do.")); + return; + } + + await deps.app.engine.unreserveResources( + [{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }], + target.invitationIdentifier, + ); + console.log(`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.invitationIdentifier})`); + break; + } + + case "unreserve-all": { + const count = await deps.app.unreserveAllResources(); + if (count === 0) { + console.log(dim("No reserved resources to unreserve.")); + } else { + console.log(`Unreserved ${bold(String(count))} resource(s).`); + } + break; + } + + default: { + deps.verboseLogger(`Unknown resource sub-command: ${subCommand}`); + printResourceHelp(); + return; + } + } +}; diff --git a/src/cli/commands/template.ts b/src/cli/commands/template.ts new file mode 100644 index 0000000..8dd21ee --- /dev/null +++ b/src/cli/commands/template.ts @@ -0,0 +1,276 @@ +import { existsSync, readFileSync } from "fs"; +import path from "path"; +import { generateTemplateIdentifier } from "@xo-cash/engine"; +import type { XOTemplate } from "@xo-cash/types"; + +import { bold, dim, formatObject, objectPrint } from "../cli-utils.js"; +import { resolveTemplateReferences } from "../../utils/templates.js"; +import type { CommandDependencies } from "./types.js"; + +/** + * Prints the help message for the template command + */ +export const printTemplateHelp = () => { + console.log( +` +${bold("Usage:")} xo-cli template + +${bold("Sub-commands:")} + - import ${dim("Import a template from a file")} + - list ${dim("List all templates")} + - list ${dim("List all options of the field type in a template")} + - inspect ${dim("Inspect a field in a template")} + - set-default ${dim("Set the default template")} + `); +}; + +/** + * Handles the template list command. + * @param deps - The command dependencies. + * @param args - Positional args after the command name, e.g. ["list", "action"] or ["list", "action", "1234567890"]. + */ +export const handleTemplateListCommand = async (deps: CommandDependencies, args: string[]): Promise => { + const templateCategory = args[0]; + deps.verboseLogger(`Template list category: ${templateCategory}`); + + // If no category was provided to list, we assume its listing out the templates + if (!templateCategory) { + const templates = await deps.app.engine.listImportedTemplates(); + const formattedTemplates = templates.map((template: XOTemplate) => `${bold(generateTemplateIdentifier(template))} - ${dim(template.name)} ${dim(template.description)}`); + console.log(formattedTemplates.join('\n')); + return; + } + + // Extract the template identifier from the positional args + const templateIdentifier = args[1]; + deps.verboseLogger(`Template identifier: ${templateIdentifier}`); + + if (!templateIdentifier) { + console.error("No template identifier provided"); + return; + } + + // Get the template from the engine + const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier); + if (!rawTemplate) { + console.error(`No template found: ${templateIdentifier}`); + return; + } + + // Resolve the template references + const template = await resolveTemplateReferences(rawTemplate); + deps.verboseLogger(`Template: ${formatObject(template)}`); + + // List the templates in the category + switch (templateCategory) { + case "action": { + const actions = template.actions; + const formattedActions = Object.entries(actions).map(([actionIdentifier, action]) => `${bold(actionIdentifier)} ${dim(action.name)} ${dim(action.description)}`); + console.log(formattedActions.join('\n')); + break; + } + case "transaction": { + const transactions = template.transactions; + const formattedTransactions = Object.entries(transactions).map(([transactionIdentifier, transaction]) => `${bold(transactionIdentifier)} ${dim(transaction.name)} ${dim(transaction.description)}`); + console.log(formattedTransactions.join('\n')); + break; + } + case "output": { + const outputs = template.outputs; + const formattedOutputs = Object.entries(outputs).map(([outputIdentifier, output]) => `${bold(outputIdentifier)} ${dim(output.name)} ${dim(output.description)}`); + console.log(formattedOutputs.join('\n')); + break; + } + case "lockingscript": { + const lockingscripts = template.lockingScripts; + const formattedLockingscripts = Object.entries(lockingscripts).map(([lockingScriptIdentifier, lockingScript]) => `${bold(lockingScriptIdentifier)} ${dim(lockingScript.name)} ${dim(lockingScript.description)}`); + console.log(formattedLockingscripts.join('\n')); + break; + } + case "variable": { + const variables = template.variables || {}; + const formattedVariables = Object.entries(variables).map(([variableIdentifier, variable]) => `${bold(variableIdentifier)} ${dim(variable.name)} ${dim(variable.description)}`); + console.log(formattedVariables.join('\n')); + break; + } + default: { + deps.verboseLogger(`Unknown template category: ${templateCategory}`); + return; + } + } +} + +/** + * Prints the help message for the template inspect command + */ +export const printTemplateInspectHelp = () => { + console.log( +` +${bold("Usage:")} xo-cli template inspect + +${bold("Arguments:")} + ${dim("The category of the template to inspect")} + ${dim("The identifier of the template to inspect")} + ${dim("The field of the template to inspect")} + +${bold("Categories:")} + - action ${dim("Inspect an action")} + - transaction ${dim("Inspect a transaction")} + - output ${dim("Inspect an output")} + - lockingscript ${dim("Inspect a lockingscript")} + - variable ${dim("Inspect a variable")} + `); +}; + +/** + * Handles the template inspect command. + * @param deps - The command dependencies. + * @param args - Positional args after the command name, e.g. ["inspect", "transaction", "1234567890"]. + */ +export const handleTemplateInspectCommand = async (deps: CommandDependencies, args: string[]): Promise => { + const templateCategory = args[0]; + const templateIdentifier = args[1]; + const templateField = args[2]; + + deps.verboseLogger(`Template inspect args - category: ${templateCategory}, identifier: ${templateIdentifier}, field: ${templateField}`); + + if (!templateCategory || !templateIdentifier || !templateField) { + console.log("No template category, identifier, or field provided"); + printTemplateInspectHelp(); + return; + } + + // Get the template from the engine + const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier); + if (!rawTemplate) { + console.error(`No template found: ${templateIdentifier}`); + return; + } + + // Resolve the template references + const template = await resolveTemplateReferences(rawTemplate); + deps.verboseLogger(`Template: ${formatObject(template)}`); + + // Inspect the template in the category + switch (templateCategory) { + case "action": { + const action = template.actions[templateField]; + if (!action) { + console.error(`No action found: ${templateField}`); + return; + } + objectPrint(action); + break; + } + case "transaction": { + const transaction = template.transactions[templateField]; + if (!transaction) { + console.error(`No transaction found: ${templateField}`); + return; + } + objectPrint(transaction); + break; + } + case "output": { + const output = template.outputs[templateField]; + if (!output) { + console.error(`No output found: ${templateField}`); + return; + } + objectPrint(output); + break; + } + case "lockingscript": { + const lockingscript = template.lockingScripts[templateField]; + if (!lockingscript) { + console.error(`No lockingscript found: ${templateField}`); + return; + } + objectPrint(lockingscript); + break; + } + case "variable": { + const variable = template.variables?.[templateField]; + if (!variable) { + console.error(`No variable found: ${templateField}`); + return; + } + objectPrint(variable); + break; + } + default: { + deps.verboseLogger(`Unknown template category: ${templateCategory}`); + return; + } + } +} + +/** + * Handles the template command. + * @param deps - The command dependencies. + * @param args - Positional args after the command name, e.g. ["import", "template.json"] or ["set-default", "tpl", "out", "role"]. + * @param options - Parsed option flags. + */ +export const handleTemplateCommand = async (deps: CommandDependencies, args: string[], options: Record): Promise => { + const subCommand = args[0]; + + if (!subCommand) { + deps.verboseLogger("No sub-command provided"); + printTemplateHelp(); + return; + } + + switch (subCommand) { + case "import": { + const templateFile = args[1]; + deps.verboseLogger(`Template file: ${templateFile}`); + + if (!templateFile) { + deps.verboseLogger("No template file provided"); + printTemplateHelp(); + return; + } + + const templatePath = path.resolve(`${process.cwd()}/${templateFile}`); + deps.verboseLogger(`Template path: ${templatePath}`); + + if (!existsSync(templatePath)) { + console.error(`Template file does not exist: ${templatePath}`); + printTemplateHelp(); + return; + } + + const template = await readFileSync(templatePath, "utf8"); + + deps.verboseLogger(`Importing template: ${templateFile}`); + await deps.app.engine.importTemplate(template); + deps.verboseLogger(`Template imported: ${templateFile}`); + break; + } + case "list": { + await handleTemplateListCommand(deps, args.slice(1)); + break; + } + case "inspect": { + await handleTemplateInspectCommand(deps, args.slice(1)); + break; + } + case "set-default": { + const templateFile = args[1]; + const outputIdentifier = args[2]; + const roleIdentifier = args[3]; + if (!templateFile || !outputIdentifier || !roleIdentifier) { + deps.verboseLogger("No template file, output identifier, or role identifier provided"); + printTemplateHelp(); + return; + } + deps.verboseLogger(`Template file: ${templateFile}, output identifier: ${outputIdentifier}, role identifier: ${roleIdentifier}`); + await deps.app.engine.setDefaultLockingParameters(templateFile, outputIdentifier, roleIdentifier); + break; + } + default: + deps.verboseLogger(`Unknown template sub-command: ${subCommand}`); + printTemplateHelp(); + return; + } +}; diff --git a/src/cli/commands/types.ts b/src/cli/commands/types.ts new file mode 100644 index 0000000..cd7ba9e --- /dev/null +++ b/src/cli/commands/types.ts @@ -0,0 +1,6 @@ +import type { AppService } from "../../services/app.js"; + +export type CommandDependencies = { + verboseLogger: (message: string) => void; + app: AppService; +}; diff --git a/src/cli/completions.ts b/src/cli/completions.ts new file mode 100644 index 0000000..20ce42d --- /dev/null +++ b/src/cli/completions.ts @@ -0,0 +1,204 @@ +/** + * Shell completion script generation. + * + * Defines the CLI command tree in one place and generates + * bash/zsh/fish completion scripts from it. Users source the output + * in their shell profile for tab-completion support. + * + * Usage: + * eval "$(xo-cli completions bash)" + * eval "$(xo-cli completions zsh)" + * xo-cli completions fish | source + */ + +/** + * Single source of truth for the CLI command tree. + * Each top-level key is a command, and its value is an array of sub-commands. + */ +export const COMMAND_TREE: Record = { + mnemonic: ["create", "import", "list"], + template: ["import", "list", "set-default"], + invitation: ["create", "import", "list"], + receive: [], + resource: ["list"], + help: [], + completions: ["bash", "zsh", "fish"], +}; + +/** Global option flags available on every command. */ +const GLOBAL_OPTIONS = ["-h", "--help", "-v", "--verbose", "-m", "--mnemonic-file", "-o", "--output"]; + +/** + * Generates a bash completion script. + * @param binName - The name of the CLI binary (used in the `complete` registration). + */ +export function generateBashCompletions(binName: string): string { + const commands = Object.keys(COMMAND_TREE).join(" "); + const options = GLOBAL_OPTIONS.join(" "); + + // Build the case arms for each command's sub-commands + const caseArms = Object.entries(COMMAND_TREE) + .filter(([, subs]) => subs.length > 0) + .map(([cmd, subs]) => ` ${cmd})\n COMPREPLY=($(compgen -W "${subs.join(" ")}" -- "\${cur}"))\n return 0\n ;;`) + .join("\n"); + + return `# bash completion for ${binName} +# Add to ~/.bashrc: eval "$(${binName} completions bash)" +_${binName.replace(/-/g, "_")}_completions() { + local cur prev words cword + _init_completion || return + + # If the current word starts with "-", offer option flags + if [[ "\${cur}" == -* ]]; then + COMPREPLY=($(compgen -W "${options}" -- "\${cur}")) + return 0 + fi + + # Find the command (first non-option positional arg after the binary) + local cmd="" + for ((i=1; i < cword; i++)); do + if [[ "\${words[i]}" != -* ]]; then + cmd="\${words[i]}" + break + fi + done + + # No command yet — offer the top-level commands + if [[ -z "\${cmd}" ]]; then + COMPREPLY=($(compgen -W "${commands}" -- "\${cur}")) + return 0 + fi + + # Offer sub-commands for the matched command + case "\${cmd}" in +${caseArms} + esac +} + +complete -F _${binName.replace(/-/g, "_")}_completions ${binName} +`; +} + +/** + * Generates a zsh completion script. + * @param binName - The name of the CLI binary. + */ +export function generateZshCompletions(binName: string): string { + const commands = Object.keys(COMMAND_TREE).join(" "); + const options = GLOBAL_OPTIONS.join(" "); + + const caseArms = Object.entries(COMMAND_TREE) + .filter(([, subs]) => subs.length > 0) + .map(([cmd, subs]) => ` ${cmd})\n compadd -- ${subs.join(" ")}\n ;;`) + .join("\n"); + + return `# zsh completion for ${binName} +# Add to ~/.zshrc: eval "$(${binName} completions zsh)" +_${binName.replace(/-/g, "_")}_completions() { + local -a commands + commands=(${commands}) + + # If typing an option flag, complete options + if [[ "\${words[\${CURRENT}]}" == -* ]]; then + compadd -- ${options} + return + fi + + # Find the command (first non-option positional arg) + local cmd="" + for ((i=2; i < CURRENT; i++)); do + if [[ "\${words[i]}" != -* ]]; then + cmd="\${words[i]}" + break + fi + done + + # No command yet — offer top-level commands + if [[ -z "\${cmd}" ]]; then + compadd -- \${commands[@]} + return + fi + + # Offer sub-commands + case "\${cmd}" in +${caseArms} + esac +} + +compdef _${binName.replace(/-/g, "_")}_completions ${binName} +`; +} + +/** + * Generates a fish completion script. + * @param binName - The name of the CLI binary. + */ +export function generateFishCompletions(binName: string): string { + const lines: string[] = [ + `# fish completion for ${binName}`, + `# Add to fish config: ${binName} completions fish | source`, + "", + `# Disable file completions by default`, + `complete -c ${binName} -f`, + "", + ]; + + // Global options + for (const opt of GLOBAL_OPTIONS) { + const isShort = !opt.startsWith("--"); + const flag = opt.replace(/^-+/, ""); + if (isShort) { + lines.push(`complete -c ${binName} -s ${flag} -d "Option flag"`); + } else { + lines.push(`complete -c ${binName} -l ${flag} -d "Option flag"`); + } + } + lines.push(""); + + // Top-level commands (only when no sub-command is given yet) + const commandNames = Object.keys(COMMAND_TREE); + for (const cmd of commandNames) { + lines.push(`complete -c ${binName} -n "__fish_use_subcommand" -a "${cmd}" -d "${cmd} command"`); + } + lines.push(""); + + // Sub-commands for each command + for (const [cmd, subs] of Object.entries(COMMAND_TREE)) { + for (const sub of subs) { + lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from ${cmd}" -a "${sub}" -d "${cmd} ${sub}"`); + } + } + + return lines.join("\n") + "\n"; +} + +type ShellType = "bash" | "zsh" | "fish"; + +const generators: Record string> = { + bash: generateBashCompletions, + zsh: generateZshCompletions, + fish: generateFishCompletions, +}; + +/** + * Handles the `completions` command. + * Prints the generated completion script for the given shell to stdout. + * @param args - Positional args after "completions", e.g. ["bash"]. + * @param binName - The CLI binary name to use in the completion script. + */ +export function handleCompletionsCommand(args: string[], binName: string = "xo-cli"): void { + const shell = args[0] as ShellType | undefined; + + if (!shell || !generators[shell]) { + const supported = Object.keys(generators).join(", "); + console.error(`Usage: ${binName} completions <${supported}>`); + console.error(""); + console.error("Examples:"); + console.error(` eval "$(${binName} completions bash)" # Add to ~/.bashrc`); + console.error(` eval "$(${binName} completions zsh)" # Add to ~/.zshrc`); + console.error(` ${binName} completions fish | source # Add to fish config`); + process.exit(1); + } + + process.stdout.write(generators[shell](binName)); +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..a02e1d5 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,204 @@ +/** + * CLI entry point. + * + * TODO: Decide the best way to handle CLI arguments. We have the option of: + * - Handling it in the `bin` folder + * - Switch / if statements in here + * - Dedicated command parser + * - Separate files? + * + * What kind of commands do we want to support? + * Worth noting that we shouldn't need to list invitations? Maybe we will though? If we do, then we will need to reuse the storage + xo-invitations.db file. I think this is fine to do though? + * Nah, lets use the storage + xo-invitations.db file. Will allow us to persist invitations. + * How do we want to import invitations though? Should we just take in the ID still? Probably makes more sense to allow for reading from a file though... + * But thats an entirely different flow to what we have already. And how would we handle writing the invitation? Do we just overwrite the file? Probably... Just take in an -o option; default to overwrite? + * + * Commands: + * xo-cli mnemonic create [mnemonic seed] + * xo-cli mnemonic list + * + * xo-cli template import + * xo-cli template list + * xo-cli template set-default + * + * xo-cli invitation list + * xo-cli invitation create [-o Output file, var-${action-variable-name}=${value}, role=${value}] + * xo-cli invitation import + * xo-cli invitation sign + * xo-cli invitation broadcast + * + * xo-cli resource list + * + * universal Args: + * -h --help + * -m --mnemonic-file + */ + +import { existsSync, readFileSync, writeFileSync } from "fs"; + +import { AppService } from "../services/app.js"; +import { convertArgsToObject } from "./arguments.js"; +import { bold, dim, formatObject } from "./cli-utils.js"; +import { listMnemonicFiles, loadMnemonic } from "./mnemonic.js"; + +/** File that remembers the last-used mnemonic so `-m` can be omitted. */ +const WALLET_CONFIG_FILE = ".xo-cli-wallet"; + +import { + type CommandDependencies, + handleMnemonicCommand, + handleTemplateCommand, + handleInvitationCommand, + handleReceiveCommand, + handleResourceCommand, +} from "./commands/index.js"; + +import { handleCompletionsCommand } from "./completions.js"; + +const createConditionalLogger = (verbose: boolean) => { + return (message: string) => { + if (verbose) { + console.log(message); + } + }; +}; + +/** + * Main entry point. + * We will: + * - Initialize the app service? + * - Extract the command being called + * - Extract CLI Args (Depends on the command being called. Eww. But we can probably use Zod to validate the args in a decent way?) + * - Execute the command + * - Export if configured? + * - Exit with the appropriate code + */ +async function main(): Promise { + // Initialize the app service + // NOTE: We are going to assume that they are using a mnemonic file for now + const { args, options } = convertArgsToObject(process.argv.slice(2)); + + // Create a verbose logger if the user set the verbose flag + const verboseLogger = createConditionalLogger(options["verbose"] === "true"); + + // Log the parsed app args + verboseLogger(`Parsed args: ${formatObject(args)}`); + verboseLogger(`Parsed options: ${formatObject(options)}`); + + // Handle the command + const command = args[0]; + verboseLogger(`Command: ${command}`); + if (!command) { + // TODO: Print help, probably... + console.error("No command provided"); + process.exit(1); + } + + // Positional args after the command name (sub-command, files, etc.) + const subArgs = args.slice(1); + + // Early handling if we are calling the mnemonic command + // TODO: This is ugly. I would like to find a nicer way of doing this. + if (command === "completions") { + handleCompletionsCommand(subArgs); + return; + } + + if (command === "mnemonic") { + await handleMnemonicCommand({ verboseLogger }, subArgs, options); + return; + } + + // Resolve mnemonic file: explicit flag > persisted config > error. + let mnemonicFile = options["mnemonicFile"]; + if (!mnemonicFile && existsSync(WALLET_CONFIG_FILE)) { + mnemonicFile = readFileSync(WALLET_CONFIG_FILE, "utf8").trim(); + verboseLogger(`Using persisted wallet: ${mnemonicFile}`); + } + if (!mnemonicFile) { + console.error("No mnemonic file provided"); + console.log(`You can create a mnemonic file with the following command: xo-cli mnemonic create or use one of the following files: \n${listMnemonicFiles().join("\n")}`); + console.log(`\nTip: pass -m once and it will be remembered in ${WALLET_CONFIG_FILE}`); + process.exit(1); + } + + // Persist the choice so subsequent commands can omit -m. + writeFileSync(WALLET_CONFIG_FILE, mnemonicFile); + + const mnemonic = await loadMnemonic(mnemonicFile); + verboseLogger(`Loaded mnemonic from file: ${mnemonicFile} ${mnemonic}`); + + // Create an App instance + verboseLogger("Creating app instance..."); + const app = await AppService.create(mnemonic, { + syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000", + engineConfig: { + databasePath: options["databasePath"] ?? "./", + databaseFilename: options["databaseFilename"] ?? 'xo-wallet.db', + }, + invitationStoragePath: options["invitationStoragePath"] ?? "./xo-invitations.db", + }); + verboseLogger("App instance created"); + + // Start the app + verboseLogger("Starting app..."); + await app.start(); + verboseLogger("App started"); + + const commandDependencies: CommandDependencies = { + verboseLogger, + app, + }; + + // Handle the command + switch (command) { + case "template": + await handleTemplateCommand(commandDependencies, subArgs, options); + break; + case "invitation": + await handleInvitationCommand(commandDependencies, subArgs, options); + break; + case "receive": + await handleReceiveCommand(commandDependencies, subArgs, options); + break; + case "resource": + await handleResourceCommand(commandDependencies, subArgs, options); + break; + case "help": + await handleHelpCommand(commandDependencies, subArgs, options); + break; + default: + console.error(`Unknown command: ${command}`); + process.exit(1); + } + + // Exit the process + process.exit(0); +} + +const handleHelpCommand = async (deps: CommandDependencies, args: string[], options: Record): Promise => { + // Im sorry about the formatting here. I'm not sure how to handle this better. + console.log( +`${bold("XO-CLI Help:")} + +${bold("Usage:")} xo-cli [options] + +Commands: + mnemonic ${dim("Manage mnemonic files")} + template ${dim("Manage templates")} + invitation ${dim("Manage invitations")} + receive ${dim("Generate a single-use receiving address")} + resource ${dim("Manage resources")} + completions ${dim("Generate shell completion scripts (bash, zsh, fish)")} + +Options: + -h, --help ${dim("Show this help message")} + -m, --mnemonic-file ${dim("Use a specific mnemonic file")} + -v, --verbose ${dim("Show verbose output")}` + ); +}; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/cli/mnemonic.ts b/src/cli/mnemonic.ts new file mode 100644 index 0000000..2ac744a --- /dev/null +++ b/src/cli/mnemonic.ts @@ -0,0 +1,72 @@ +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { BCHMnemonicURL, } from "../utils/bch-mnemonic-url.js"; +import { encodeBip39Mnemonic, generateBip39Mnemonic } from "@bitauth/libauth"; + +/** + * Create a new mnemonic seed phrase + */ +export const createMnemonicSeed = (): string => { + // Generate a new mnemonic seed + const mnemonic = generateBip39Mnemonic(); + + // Return the mnemonic phrase + return mnemonic; +}; + +/** + * Creates a mnemonic file from a mnemonic seed + * @param mnemonic - The mnemonic seed + * @param outputFilename - The filename to write the mnemonic to. If not provided, the first word from the mnemonic will be used as the filename + * @returns The filename of the created mnemonic file + */ +export const createMnemonicFile = (mnemonic: string, outputFilename?: string): string => { + // Convert the mnemonic seed to a BCH Mnemonic URL + const mnemonicUrl = BCHMnemonicURL.fromSeed(mnemonic); + + // If no output filename is provided, use the first word from the mnemonic as the filename + let fileName = outputFilename; + if (!fileName) { + const firstWord = mnemonic.at(0)?.toLowerCase(); + if (!firstWord) { + throw new Error("Failed to create mnemonic file: Unable to extract first word from the mnemonic"); + } + fileName = `mnemonic-${firstWord}`; + } + + // Write the mnemonic URL to a file + // TODO: May need PWD or something to ensure we are writing to the correct directory + writeFileSync(fileName, mnemonicUrl.toURL()); + + return fileName; +}; + +/** + * Loads a mnemonic from a mnemonic file + * @param mnemonicFile - The filename of the mnemonic file + * @returns The mnemonic seed + */ +export const loadMnemonic = (mnemonicFile: string): string => { + const mnemonicUrl = BCHMnemonicURL.fromURL(readFileSync(mnemonicFile, "utf8")); + const { entropy } = mnemonicUrl.toObject(); + + // Convert the entropy to a mnemonic seed + const mnemonic = encodeBip39Mnemonic(entropy); + + // If the conversion failed, throw an error + if (typeof mnemonic === "string") { + throw new Error(`Failed to convert entropy to mnemonic: ${mnemonic}`); + } + + // Return the mnemonic phrase + return mnemonic.phrase; +}; + +/** + * Lists all mnemonic files in the current directory + * @returns An array of mnemonic file names + */ +export const listMnemonicFiles = (): string[] => { + const cwd = process.cwd(); + const filenames = readdirSync(cwd).filter((f: string) => f.startsWith('mnemonic-')); + return filenames; +}; diff --git a/src/services/app.ts b/src/services/app.ts index 00504f0..1c61277 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -22,6 +22,14 @@ import { hexToBin } from "@bitauth/libauth"; export type AppEventMap = { "invitation-added": Invitation; "invitation-removed": Invitation; + "wallet-state-changed": { + reason: + | "invitation-added" + | "invitation-removed" + | "invitation-updated" + | "invitation-status-changed"; + invitationIdentifier: string; + }; }; export interface AppConfig { @@ -40,6 +48,13 @@ export class AppService extends EventEmitter { public electrum: ElectrumService; public invitations: Invitation[] = []; + private invitationEventCleanup = new Map< + string, + { + onUpdated: (invitation: XOInvitation) => void; + onStatusChanged: (status: string) => void; + } + >(); static async create(seed: string, config: AppConfig): Promise { // 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. @@ -57,9 +72,10 @@ export class AppService extends EventEmitter { // TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here // Import the default P2PKH template - await engine.importTemplate(p2pkhTemplate); + const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate); - // console.log('p2pkhTemplate', JSON.stringify(p2pkhTemplate.transactions, null, 2)); + 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}`)); // Set default locking parameters for P2PKH // To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically. @@ -80,32 +96,6 @@ export class AppService extends EventEmitter { applicationIdentifier: config.electrumApplicationIdentifier, }); - // TEMP because testing is painful - // Remove all reserved UTXOs on startup - // First, get every unspent output - const allUnspentOutputs = await engine.listUnspentOutputsData(); - - // Get a set of all the invitation identifiers - const allInvitationIdentifiers = new Set( - allUnspentOutputs.map((output) => output.invitationIdentifier), - ); - - // Iterate over the invitation identifiers and unreserve the outputs - for (const invitationIdentifier of allInvitationIdentifiers) { - // Get the outputs for the invitation - const outputs = allUnspentOutputs.filter( - (output) => output.invitationIdentifier === invitationIdentifier, - ); - // Unreserve the outputs - await engine.unreserveResources( - outputs.map((output) => ({ - outpointTransactionHash: hexToBin(output.outpointTransactionHash), - outpointIndex: output.outpointIndex, - })), - invitationIdentifier, - ); - } - return new AppService(engine, walletStorage, config, electrum); } @@ -145,27 +135,116 @@ export class AppService extends EventEmitter { // Create the invitation const invitationInstance = await Invitation.create(invitation, deps); - + // Add the invitation to the invitations array await this.addInvitation(invitationInstance); - + return invitationInstance; } async addInvitation(invitation: Invitation): Promise { + this.attachInvitationListeners(invitation); + // Add the invitation to the invitations array this.invitations.push(invitation); // Emit the invitation-added event this.emit("invitation-added", invitation); + this.emit("wallet-state-changed", { + reason: "invitation-added", + invitationIdentifier: invitation.data.invitationIdentifier, + }); } async removeInvitation(invitation: Invitation): Promise { - // Remove the invitation from the invitations array - this.invitations = this.invitations.filter((i) => i !== invitation); + const invitationIdentifier = invitation.data.invitationIdentifier; + this.detachInvitationListeners(invitationIdentifier); + + // Remove the invitation from the invitations array while preserving the array reference. + const invitationIndex = this.invitations.indexOf(invitation); + if (invitationIndex >= 0) { + this.invitations.splice(invitationIndex, 1); + } // Emit the invitation-removed event this.emit("invitation-removed", invitation); + this.emit("wallet-state-changed", { + reason: "invitation-removed", + invitationIdentifier, + }); + } + + private attachInvitationListeners(invitation: Invitation): void { + const invitationIdentifier = invitation.data.invitationIdentifier; + if (this.invitationEventCleanup.has(invitationIdentifier)) return; + + const onUpdated = () => { + this.emit("wallet-state-changed", { + reason: "invitation-updated", + invitationIdentifier, + }); + }; + const onStatusChanged = () => { + this.emit("wallet-state-changed", { + reason: "invitation-status-changed", + invitationIdentifier, + }); + }; + + invitation.on("invitation-updated", onUpdated); + invitation.on("invitation-status-changed", onStatusChanged); + + this.invitationEventCleanup.set(invitationIdentifier, { + onUpdated, + onStatusChanged, + }); + } + + private detachInvitationListeners(invitationIdentifier: string): void { + const trackedInvitation = this.invitations.find( + (candidate) => + candidate.data.invitationIdentifier === invitationIdentifier, + ); + const cleanup = this.invitationEventCleanup.get(invitationIdentifier); + if (!trackedInvitation || !cleanup) return; + + trackedInvitation.off("invitation-updated", cleanup.onUpdated); + trackedInvitation.off( + "invitation-status-changed", + cleanup.onStatusChanged, + ); + this.invitationEventCleanup.delete(invitationIdentifier); + } + + /** + * Unreserves all reserved UTXOs across every invitation. + * Useful when stale reservations from previous sessions block spending. + * + * @returns The number of UTXOs that were unreserved. + */ + async unreserveAllResources(): Promise { + const allUnspentOutputs = await this.engine.listUnspentOutputsData(); + const reserved = allUnspentOutputs.filter((o) => o.reserved); + + // Group by invitation identifier so the engine can clear them properly. + const byInvitation = new Map(); + for (const output of reserved) { + const existing = byInvitation.get(output.invitationIdentifier) ?? []; + existing.push(output); + byInvitation.set(output.invitationIdentifier, existing); + } + + for (const [invitationIdentifier, outputs] of byInvitation) { + await this.engine.unreserveResources( + outputs.map((o) => ({ + outpointTransactionHash: hexToBin(o.outpointTransactionHash), + outpointIndex: o.outpointIndex, + })), + invitationIdentifier, + ); + } + + return reserved.length; } async start(): Promise { @@ -180,7 +259,7 @@ export class AppService extends EventEmitter { await Promise.all( invitations.map(async ({ key }) => { - await this.createInvitation(key); + await this.createInvitation(key).catch(err => console.error(`Error creating invitation ${key}: ${err}`)); }), ); } diff --git a/src/services/invitation.ts b/src/services/invitation.ts index 672ccba..8c25643 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -34,6 +34,7 @@ import { compileCashAssemblyString } from "@xo-cash/engine"; export type InvitationEventMap = { "invitation-updated": XOInvitation; "invitation-status-changed": string; + "error": Error; }; export type InvitationDependencies = { @@ -146,32 +147,38 @@ export class Invitation extends EventEmitter { * Start the invitation - Connect sync server and download latest invitation data. */ async start(): Promise { - // Connect to the sync server and get the invitation (in parallel) - const [_, invitation] = await Promise.all([ - this.syncServer.connect(), - this.syncServer.getInvitation(this.data.invitationIdentifier), - ]); - - // There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits - const sseCommits = this.data.commits; - - // Merge the commits - const combinedCommits = this.mergeCommits( - sseCommits, - invitation?.commits ?? [], - ); - - // Set the invitation data with the combined commits - this.data = { ...this.data, ...invitation, commits: combinedCommits }; - - // Store the invitation in the storage - await this.storage.set(this.data.invitationIdentifier, this.data); - - // Publish the invitation to the sync server - this.syncServer.publishInvitation(this.data); - - // Compute and emit initial status - await this.updateStatus(); + try { + // Connect to the sync server and get the invitation (in parallel) + const [_, invitation] = await Promise.all([ + this.syncServer.connect(), + this.syncServer.getInvitation(this.data.invitationIdentifier), + ]); + + // There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits + const sseCommits = this.data.commits; + + // Merge the commits + const combinedCommits = this.mergeCommits( + sseCommits, + invitation?.commits ?? [], + ); + + // Set the invitation data with the combined commits + this.data = { ...this.data, ...invitation, commits: combinedCommits }; + + // Store the invitation in the storage + await this.storage.set(this.data.invitationIdentifier, this.data); + + // Publish the invitation to the sync server + this.publishInvitation(this.data); + + // Compute and emit initial status + await this.updateStatus(); + } catch (err) { + // console.error(`Error starting invitation, could not connect to sync server or get invitation`, err); + // Emit the error event. We might want to throw? but we need a better way of handling errors in the invitation system because we need the invitation to successfully initialize. + this.emit("error", err instanceof Error ? err : new Error(String(err))); + } } /** @@ -205,6 +212,18 @@ export class Invitation extends EventEmitter { } } + /** + * Publish the invitation to the sync server + */ + private async publishInvitation(invitation: XOInvitation = this.data): Promise { + try { + await this.syncServer.publishInvitation(invitation); + } catch (err) { + // Emit the error event. We might want to throw? but we need a better way of handling errors in the invitation system because we need the invitation to successfully initialize. + this.emit("error", err instanceof Error ? err : new Error(String(err))); + } + } + /** * Merge the commits * @param initial - The initial commits @@ -359,7 +378,7 @@ export class Invitation extends EventEmitter { this.data = await this.engine.acceptInvitation(this.data, acceptParams); // Sync the invitation to the sync server - this.syncServer.publishInvitation(this.data); + this.publishInvitation(this.data); // Update the status of the invitation await this.updateStatus(); @@ -373,7 +392,7 @@ export class Invitation extends EventEmitter { const signedInvitation = await this.engine.signInvitation(this.data); // Publish the signed invitation to the sync server - this.syncServer.publishInvitation(signedInvitation); + this.publishInvitation(signedInvitation); // Store the signed invitation in the storage await this.storage.set(this.data.invitationIdentifier, signedInvitation); @@ -385,16 +404,17 @@ export class Invitation extends EventEmitter { } /** - * Broadcast the invitation + * Broadcast the invitation. + * @returns The transaction hash returned by the network after broadcast. */ - async broadcast(): Promise { - // Broadcast the transaction (executeAction returns transaction hash when broadcastTransaction: true) - await this.engine.executeAction(this.data, { + async broadcast(): Promise { + const txHash = await this.engine.executeAction(this.data, { broadcastTransaction: true, }); - // Update the status of the invitation await this.updateStatus(); + + return String(txHash); } // ============================================================================ @@ -409,7 +429,7 @@ export class Invitation extends EventEmitter { this.data = await this.engine.appendInvitation(this.data, data); // Sync the invitation to the sync server - await this.syncServer.publishInvitation(this.data); + await this.publishInvitation(this.data); // Store the invitation in the storage await this.storage.set(this.data.invitationIdentifier, this.data); @@ -426,7 +446,7 @@ export class Invitation extends EventEmitter { await this.append({ inputs }); // Sync the invitation to the sync server - await this.syncServer.publishInvitation(this.data); + await this.publishInvitation(this.data); } /** @@ -449,7 +469,7 @@ export class Invitation extends EventEmitter { await this.append({ outputs }); // Sync the invitation to the sync server - await this.syncServer.publishInvitation(this.data); + await this.publishInvitation(this.data); } async addVariables(variables: XOInvitationVariable[]): Promise { @@ -457,7 +477,7 @@ export class Invitation extends EventEmitter { await this.append({ variables }); // Sync the invitation to the sync server - await this.syncServer.publishInvitation(this.data); + await this.publishInvitation(this.data); } async findSuitableResources( diff --git a/src/tui/screens/Transaction.tsx b/src/tui/screens/Transaction.tsx index fd72eb3..ec1bbfb 100644 --- a/src/tui/screens/Transaction.tsx +++ b/src/tui/screens/Transaction.tsx @@ -17,7 +17,6 @@ import { useBlockableInput } from '../hooks/useInputLayer.js'; import { useInvitation } from '../hooks/useInvitations.js'; import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js'; import { copyToClipboard } from '../utils/clipboard.js'; -import type { XOInvitation } from '@xo-cash/types'; /** * Action menu items. diff --git a/src/tui/screens/WalletState.tsx b/src/tui/screens/WalletState.tsx index 1c662b4..85e6ccb 100644 --- a/src/tui/screens/WalletState.tsx +++ b/src/tui/screens/WalletState.tsx @@ -58,6 +58,7 @@ const menuItems: ListItemData[] = [ { 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: 'unreserve-all', label: 'Unreserve All Resources', value: 'unreserve-all' }, { key: 'refresh', label: 'Refresh', value: 'refresh' }, ]; @@ -160,6 +161,21 @@ export function WalletStateScreen(): React.ReactElement { refresh(); }, [refresh]); + // Keep wallet state in sync with invitation lifecycle and updates. + useEffect(() => { + if (!appService) return; + + const onWalletStateChanged = () => { + void refresh(); + }; + + appService.on('wallet-state-changed', onWalletStateChanged); + + return () => { + appService.off('wallet-state-changed', onWalletStateChanged); + }; + }, [appService, refresh]); + /** * Generates a new receiving address and displays it as a QR code. */ @@ -211,6 +227,25 @@ export function WalletStateScreen(): React.ReactElement { } }, [appService, setStatus, showError, refresh]); + /** + * Unreserves all reserved UTXOs and refreshes the wallet state. + */ + const unreserveAll = useCallback(async () => { + if (!appService) { + showError('AppService not initialized'); + return; + } + + try { + setStatus('Unreserving all resources...'); + const count = await appService.unreserveAllResources(); + showInfo(`Unreserved ${count} resource(s)`); + await refresh(); + } catch (error) { + showError(`Failed to unreserve resources: ${error instanceof Error ? error.message : String(error)}`); + } + }, [appService, setStatus, showError, showInfo, refresh]); + /** * Handles menu action. */ @@ -228,11 +263,14 @@ export function WalletStateScreen(): React.ReactElement { case 'new-address': generateNewAddress(); break; + case 'unreserve-all': + unreserveAll(); + break; case 'refresh': refresh(); break; } - }, [navigate, generateNewAddress, refresh]); + }, [navigate, generateNewAddress, unreserveAll, refresh]); /** * Handle menu item activation. diff --git a/src/tui/screens/invitations/invitation-import/steps/InputsSelectStep.tsx b/src/tui/screens/invitations/invitation-import/steps/InputsSelectStep.tsx index 143cefb..a64df5f 100644 --- a/src/tui/screens/invitations/invitation-import/steps/InputsSelectStep.tsx +++ b/src/tui/screens/invitations/invitation-import/steps/InputsSelectStep.tsx @@ -142,7 +142,7 @@ export function InputsSelectStep({ setFocusedIndex(prev => Math.max(0, prev - 1)); } else if (key.downArrow || input === 'j') { setFocusedIndex(prev => Math.min(utxos.length - 1, prev + 1)); - } else if (input === ' ' || (key.return && utxos.length > 0)) { + } else if (input === ' ') { if (utxos.length > 0) toggleSelection(focusedIndex); } else if (input === 'a') { setUtxos(prev => prev.map(u => ({ ...u, selected: true }))); diff --git a/src/utils/bch-mnemonic-url.ts b/src/utils/bch-mnemonic-url.ts index d8e9d8d..ff56691 100644 --- a/src/utils/bch-mnemonic-url.ts +++ b/src/utils/bch-mnemonic-url.ts @@ -3,6 +3,7 @@ * Pulled directly from the old stack package. */ import { z } from "zod"; +import { decodeBip39Mnemonic } from "@bitauth/libauth"; export type BCHMnemonicURLRaw = { entropy: Uint8Array; @@ -86,6 +87,18 @@ export class BCHMnemonicURL { return new BCHMnemonicURL(raw); } + static fromSeed(seed: string): BCHMnemonicURL { + // Encode the seed to a Uint8Array + const entropy = decodeBip39Mnemonic(seed); + + // If the decode failed, throw an error + if (typeof entropy === "string") { + throw new Error(`Invalid seed: ${entropy}`); + } + + return BCHMnemonicURL.fromRaw({ entropy }); + } + constructor(protected raw: BCHMnemonicURLRaw) {} toObject() { diff --git a/src/utils/invitation-flow.ts b/src/utils/invitation-flow.ts index 3fcb614..b32f122 100644 --- a/src/utils/invitation-flow.ts +++ b/src/utils/invitation-flow.ts @@ -1,5 +1,6 @@ import type { XOTemplate, XOTemplateTransactionOutput } from "@xo-cash/types"; import type { Invitation } from "../services/invitation.js"; +import { cashAddressToLockingBytecode, binToHex } from "@bitauth/libauth"; export interface SelectableUtxoLike { outpointTransactionHash: string; @@ -97,6 +98,34 @@ export const getTransactionOutputIdentifier = ( export const normalizeLockingBytecodeHex = (value: string): string => value.trim().replace(/^0x/i, ""); +/** + * Checks whether a string looks like a CashAddress and, if so, converts it + * to locking bytecode hex. Returns undefined when the value is not a + * recognizable CashAddress (callers should fall through to treat it as raw hex). + */ +export const tryCashAddressToLockingBytecodeHex = ( + value: string, +): string | undefined => { + const trimmed = value.trim(); + + // Quick prefix check so we don't run the decoder on obvious hex strings. + const looksLikeCashAddress = + trimmed.startsWith("bitcoincash:") || + trimmed.startsWith("bchtest:") || + trimmed.startsWith("bchreg:") || + // Handle prefix-less addresses (e.g. "qp..." or "pp...") + /^[qpQP][a-zA-Z0-9]{41,}$/.test(trimmed); + + if (!looksLikeCashAddress) return undefined; + + const result = cashAddressToLockingBytecode(trimmed); + + // cashAddressToLockingBytecode returns a string on failure. + if (typeof result === "string") return undefined; + + return binToHex(result.bytecode); +}; + export const resolveProvidedLockingBytecodeHex = ( template: XOTemplate, outputIdentifier: string, @@ -128,6 +157,10 @@ export const resolveProvidedLockingBytecodeHex = ( const providedValue = variableValues[variableIdentifier]; if (!providedValue) return undefined; + // If the user pasted a CashAddress, convert it to locking bytecode hex. + const fromAddress = tryCashAddressToLockingBytecodeHex(providedValue); + if (fromAddress) return fromAddress; + return normalizeLockingBytecodeHex(providedValue); }; diff --git a/tests/cli/arguments.test.ts b/tests/cli/arguments.test.ts new file mode 100644 index 0000000..f04cb86 --- /dev/null +++ b/tests/cli/arguments.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest" + +import { convertArgsToObject } from "../../src/cli/arguments"; + +const testCases = [ + { + input: ["-h", "--help", "-m", "--mnemonic-file", "mnemonic.txt"], + expected: { + args: [], + options: { help: "true", mnemonicFile: "mnemonic.txt" }, + }, + }, + { + input: ['-var-requested-satohis', '1000', '-role', 'receiver'], + expected: { + args: [], + options: { "varRequestedSatohis": "1000", role: "receiver" }, + }, + }, + { + input: ['-o', 'output.json', '-var-requested-satohis', '1000', '-role', 'receiver'], + expected: { + args: [], + options: { output: "output.json", "varRequestedSatohis": "1000", role: "receiver" }, + }, + }, + { + input: ['mnemonic', 'create', 'page', 'pencil', '-v', '-o', 'mnemonic.txt'], + expected: { + args: ['mnemonic', 'create', 'page', 'pencil'], + options: { verbose: "true", output: "mnemonic.txt" }, + }, + }, + { + input: ['-v', 'invitation', 'list', '-m', 'mnemonicFile'], + expected: { + args: ['invitation', 'list'], + options: { verbose: "true", mnemonicFile: "mnemonicFile" }, + }, + }, + { + input: ['--help', 'template', 'import', 'template.json'], + expected: { + args: ['template', 'import', 'template.json'], + options: { help: "true" }, + }, + }, +]; + +describe("convertArgsToObject", () => { + it.each(testCases)("should split positional args from options", ({ input, expected }) => { + const result = convertArgsToObject(input); + expect(result).toEqual(expected); + }); +}); \ No newline at end of file