Compare commits

...

21 Commits

Author SHA1 Message Date
2f2e515d72 Fix change output and default locking script function 2026-05-04 10:00:49 +00:00
7ffb5c44b5 Fix history. Fix invitation accept 2026-05-04 09:28:23 +00:00
f978d740fe Add autocomplete installation scripts to package.json. Update readme. 2026-05-04 05:09:40 +00:00
6196d33b2a Fix build issues 2026-05-04 05:04:28 +00:00
ccfaf3fd70 Update to use published packages. Update types. Update readme. Fix tests. 2026-05-04 04:45:31 +00:00
531e53d2ae Fix test 2026-04-27 12:47:02 +00:00
b708c8c1f8 Fix invitation import reactivity and focus imported invitation 2026-04-27 12:46:52 +00:00
53ad7b729e Fix clipboard actions over ssh 2026-04-27 12:45:04 +00:00
e73fb24422 Add installation instruction as readme 2026-04-27 12:35:54 +00:00
b282bbf5d6 Update readme for cli and command parsing 2026-04-27 09:48:10 +00:00
bd1ae909b5 Fix tests 2026-04-27 09:45:38 +00:00
e97054fa34 Fix help docs 2026-04-27 09:45:07 +00:00
a43a45831c Missed the utils file during previous commit 2026-04-27 09:44:42 +00:00
1bbc21c742 Remove [next] from template actions 2026-04-27 09:14:44 +00:00
9fa87d01b3 Combine cli-utils with utils 2026-04-27 09:14:30 +00:00
7ad17a7c0e Add oracle rates 2026-04-27 08:42:51 +00:00
dbfb2c68d2 Formatting 2026-04-20 12:26:35 +00:00
32c42cdc2d Remove ESBuild experiment. Add --install option for bash completions. Move shell scripts to separate files for auditability. Fix template inspect command autocomplete and output formatting 2026-04-20 11:12:26 +00:00
ff2fe126c6 Tests. Autocomplete. Few Fixes. Mocks for Electrum Service. Template-to-Json parser. Fix global paths. Use IO Dependency injection for logging from cli. Additional commands in CLI. 2026-04-20 10:30:38 +00:00
df4f438f6d Add readme for CLI 2026-04-06 12:05:56 +00:00
55c75501d5 Huge commit. Multiple fixes. Refactored commands. Invitations, resources, template inspection, mnemonic stuff, cli utils, pretty printing, remove unreserve on start, fix connectino requirement for invitations, format cashAddress to lockingBytecode on send, lots and lots of other stuff. 2026-04-06 11:56:09 +00:00
72 changed files with 13405 additions and 946 deletions

3
.gitignore vendored
View File

@@ -3,6 +3,7 @@ Electrum.sqlite
XO.sqlite XO.sqlite
node_modules/ node_modules/
dist/ dist/
coverage/
*.db *.db
*.db-shm *.db-shm
*.db-wal *.db-wal
@@ -10,3 +11,5 @@ dist/
*.sqlite-journal *.sqlite-journal
resolvedTemplate.json resolvedTemplate.json
mnemonic-* mnemonic-*
inv-*.json
.xo-cli-wallet

904
p2pkh-template.json Normal file
View File

@@ -0,0 +1,904 @@
{
"$schema": "https://libauth.org/schemas/wallet-template-v0.schema.json",
"name": "Wallet (P2PKH)",
"description": "A standard single-factor wallet template that uses Pay-to-Public-Key-Hash (P2PKH) locking scripts.",
"icon": "wallet",
"version": 0,
"supported": ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"],
"roles": {
"owner": {
"name": "Wallet Owner",
"description": "The party who can spend from this wallet.",
"icon": "owner"
},
"receiver": {
"name": "Receiver",
"description": "A party that is receiving value.",
"icon": "receiver"
},
"sender": {
"name": "Sender",
"description": "A party that is sending value.",
"icon": "sender"
}
},
"start": [
{ "action": "receive", "role": "receiver" },
{ "action": "requestSatoshis", "role": "receiver" },
{ "action": "requestFungibleTokens", "role": "receiver" },
{ "action": "requestNonfungibleTokens", "role": "receiver" }
],
"actions": {
"receive": {
"name": "Receive",
"description": "Receive an unspecified amount of cash and/or tokens from one or more senders.",
"icon": "receive",
"roles": {
"receiver": {
"name": "Receive",
"description": "Receive an unspecified amount of cash and/or tokens from one or more senders.",
"icon": "receive",
"requirements": { "generate": ["ownerKey"] }
},
"sender": {
"name": "Send",
"description": "Send an unspecified amount of cash and/or tokens to the provided receiver.",
"icon": "send"
}
},
"requirements": {
"roles": [
{ "role": "receiver", "slots": { "min": 1, "max": 1 } },
{ "role": "sender", "slots": { "min": 1 } }
],
"variables": ["requestedSatoshis"]
},
"transaction": "receiveTransaction"
},
"requestSatoshis": {
"name": "Request Satoshis",
"description": "Requests a specific amount of Bitcoin Cash from one or more senders.",
"icon": "request",
"roles": {
"receiver": {
"name": "Request Satoshis",
"description": "Requests a specific amount of Bitcoin Cash from one or more senders.",
"icon": "request",
"requirements": {
"generate": ["ownerKey"],
"variables": ["requestedSatoshis"]
}
},
"sender": {
"name": "Send",
"description": "Send a specific amount of Bitcoin Cash to the provided receiver.",
"icon": "send"
}
},
"requirements": {
"roles": [
{ "role": "receiver", "slots": { "min": 1, "max": 1 } },
{ "role": "sender", "slots": { "min": 1 } }
]
},
"transaction": "requestSatoshisTransaction"
},
"requestFungibleTokens": {
"name": "Request Fungible Tokens",
"description": "Requests a specific amount of a fungible tokens from one or more senders.",
"icon": "request",
"roles": {
"receiver": {
"name": "Request Fungible Tokens",
"description": "Requests a specific amount of a fungible tokens from one or more senders.",
"icon": "request",
"requirements": {
"generate": ["ownerKey"],
"variables": ["requestedTokenCategory", "requestedTokenAmount"]
}
},
"sender": {
"name": "Send",
"description": "Send a specific amount of fungible tokens to the provided receiver.",
"icon": "send"
}
},
"requirements": {
"roles": [
{ "role": "receiver", "slots": { "min": 1, "max": 1 } },
{ "role": "sender", "slots": { "min": 1 } }
]
},
"transaction": "requestFungibleTokensTransaction"
},
"requestNonfungibleTokens": {
"name": "Request a Non-fungible Token",
"description": "Requests a non-fungible token from one or more senders.",
"icon": "request",
"roles": {
"receiver": {
"name": "Request a Non-fungible Token",
"description": "Requests a non-fungible token from one or more senders.",
"icon": "request",
"requirements": {
"generate": ["ownerKey"],
"variables": [
"requestedTokenCategory",
"requestedTokenCapability",
"requestedTokenCommitment"
]
}
},
"sender": {
"name": "Send",
"description": "Send a non-fungible token to the provided receiver.",
"icon": "send"
}
},
"requirements": {
"roles": [
{ "role": "receiver", "slots": { "min": 1, "max": 1 } },
{ "role": "sender", "slots": { "min": 1 } }
]
},
"transaction": "requestNonfungibleTokensTransaction"
},
"sendSatoshis": {
"name": "Send Satoshis",
"description": "Sends a specific amount of Bitcoin Cash to a given recipient.",
"icon": "send",
"roles": {
"sender": {
"requirements": {
"variables": ["transferredSatoshis", "recipientLockingscript"],
"secrets": ["ownerKey"]
}
}
},
"requirements": {
"roles": [{ "role": "sender", "slots": { "min": 1, "max": 1 } }]
},
"condition": "$(OP_INPUTINDEX OP_UTXOVALUE <dustLimit> OP_GREATERTHAN)",
"transaction": "transferSatoshisTransaction"
},
"sendFungibleTokens": {
"name": "Send Fungible Tokens",
"description": "Send a specific amount of a fungible token to a given recipient.",
"icon": "send",
"roles": {
"sender": {
"requirements": {
"variables": [
"transferredTokenCategory",
"transferredTokenAmount",
"recipientLockingscript"
],
"secrets": ["ownerKey"]
}
}
},
"requirements": {
"roles": [{ "role": "sender", "slots": { "min": 1, "max": 1 } }]
},
"condition": "$(OP_INPUTINDEX OP_UTXOTOKENAMOUNT <0> OP_GREATERTHAN)",
"transaction": "transferFungibleTokensTransaction"
},
"sendNonfungibleTokens": {
"name": "Send a Non-fungible Token",
"description": "Send a non-fungible token to a given recipient.",
"icon": "send",
"roles": {
"sender": {
"requirements": {
"variables": [
"transferredTokenCategory",
"transferredTokenCapability",
"transferredTokenCommitment",
"recipientLockingscript"
],
"secrets": ["ownerKey"]
}
}
},
"requirements": {
"roles": [{ "role": "sender", "slots": { "min": 1, "max": 1 } }]
},
"condition": "$(OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_SIZE OP_NIP <32> OP_GREATERTHAN)",
"transaction": "transferNonfungibleTokensTransaction"
},
"burnFungibleTokens": {
"name": "Delete Fungible Tokens",
"description": "Permanently and irreversibly deletes one or more fungible tokens.",
"icon": "burn",
"roles": {
"owner": {
"requirements": {
"variables": ["burnedTokenCategory", "burnedTokenAmount"],
"secrets": ["ownerKey"]
}
}
},
"requirements": {
"roles": [{ "role": "owner", "slots": { "min": 1, "max": 1 } }]
},
"condition": "$(OP_INPUTINDEX OP_UTXOTOKENAMOUNT <0> OP_GREATERTHAN)",
"transaction": "burnFungibleTokensTransaction"
},
"burnNonfungibleTokens": {
"name": "Delete a Non-fungible Token",
"description": "Permanently and irreversibly deletes one non-fungible token.",
"icon": "burn",
"roles": {
"owner": {
"requirements": {
"variables": [
"burnedTokenCategory",
"burnedTokenCapability",
"burnedTokenCommitment"
],
"secrets": ["ownerKey"]
}
}
},
"requirements": {
"roles": [{ "role": "owner", "slots": { "min": 1, "max": 1 } }]
},
"condition": "$(OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_SIZE OP_NIP <32> OP_GREATERTHAN)",
"transaction": "burnNonfungibleTokenTransaction"
},
"sign": {
"name": "Sign Message",
"description": "Signs a provided message using the Bitcoin message signing protocol.",
"icon": "sign",
"roles": {
"owner": {
"requirements": {
"variables": ["messageToSign"],
"secrets": ["ownerKey"]
}
}
},
"requirements": {
"roles": [{ "role": "owner", "slots": { "min": 1, "max": 1 } }]
},
"data": ["messageSignature"]
},
"verify": {
"name": "Verify Message Signature",
"description": "Verifies a provided message signature according to the Bitcoin message signing protocol.",
"icon": "verify",
"roles": {
"owner": {
"requirements": {
"variables": ["messageSignature", "messageToVerify"],
"secrets": ["ownerKey"]
}
}
},
"requirements": {
"roles": [{ "role": "owner", "slots": { "min": 1, "max": 1 } }]
},
"data": ["messageSignatureValidity"]
}
},
"data": {
"messageSignature": {
"value": "$(<messagePrefix> <message> OP_CAT <ownerKey.data_signature.top_stack_element>)",
"type": "bytes",
"hint": "signature"
},
"messageSignatureValidity": {
"value": "$(<messageSignature> <messagePrefix> <message> OP_CAT <ownerKey.public_key> OP_CHECKDATASIG)",
"type": "integer",
"hint": "script_boolean"
}
},
"transactions": {
"receiveTransaction": {
"name": "Transfer request",
"description": "Transfer request for an unspecified amount of cash and/or tokens.",
"icon": "request",
"roles": {
"receiver": {
"name": "Received",
"description": "Received an unspecified amount of cash and/or tokens.",
"icon": "receive"
},
"sender": {
"name": "Sent",
"description": "Sent an unspecified amount of cash and/or tokens.",
"icon": "send"
}
},
"inputs": [],
"outputs": [{ "output": "receiveOutput" }],
"version": 2,
"locktime": 0,
"composable": true
},
"requestSatoshisTransaction": {
"name": "Transfer request",
"description": "Transfer request for $(<requestedSatoshis>) satoshis.",
"icon": "request",
"roles": {
"receiver": {
"name": "Received",
"description": "Received $(<requestedSatoshis>) satoshis.",
"icon": "receive"
},
"sender": {
"name": "Sent",
"description": "Sent $(<requestedSatoshis>) satoshis.",
"icon": "send"
}
},
"inputs": [],
"outputs": [{ "output": "requestSatoshisOutput" }],
"version": 2,
"locktime": 0,
"composable": true
},
"requestFungibleTokensTransaction": {
"name": "Transfer request",
"description": "Transfer request for $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens.",
"icon": "request",
"roles": {
"receiver": {
"name": "Received",
"description": "Received $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens.",
"icon": "receive"
},
"sender": {
"name": "Sent",
"description": "Sent $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens.",
"icon": "send"
}
},
"inputs": [],
"outputs": [{ "output": "requestFungibleTokensOutput" }],
"version": 2,
"locktime": 0,
"composable": true
},
"requestNonfungibleTokensTransaction": {
"name": "Transfer request",
"description": "Transfer request for one non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<requestedTokenCategory.symbol>) token, with $(<requestedTokenCommitment>) commitment.",
"icon": "request",
"roles": {
"receiver": {
"name": "Received",
"description": "Received one non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<requestedTokenCategory.symbol>) token, with $(<requestedTokenCommitment>) commitment.",
"icon": "receive"
},
"sender": {
"name": "Sent",
"description": "Sent the requested non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<requestedTokenCategory.symbol>) token, with $(<requestedTokenCommitment>) commitment.",
"icon": "send"
}
},
"inputs": [],
"outputs": [{ "output": "requestNonfungibleTokensOutput" }],
"version": 2,
"locktime": 0,
"composable": true
},
"transferSatoshisTransaction": {
"name": "Satoshis Transferred",
"description": "$(<transferredSatoshis>) satoshis were transferred to a recipient.",
"icon": "send",
"roles": {
"receiver": {
"name": "Received",
"description": "Received $(<transferredSatoshis>) satoshis.",
"icon": "receive"
},
"sender": {
"name": "Sent",
"description": "Sent $(<transferredSatoshis>) satoshis.",
"icon": "send"
}
},
"inputs": [],
"outputs": [{ "output": "transferSatoshisOutput" }],
"version": 2,
"locktime": 0,
"composable": true
},
"transferFungibleTokensTransaction": {
"name": "Fungible Tokens Transferred",
"description": "$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens were transferred to a recipient.",
"icon": "send",
"roles": {
"receiver": {
"name": "Received",
"description": "Received $(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens.",
"icon": "receive"
},
"sender": {
"name": "Sent",
"description": "Sent $(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens.",
"icon": "send"
}
},
"inputs": [],
"outputs": [{ "output": "transferFungibleTokensOutput" }],
"version": 2,
"locktime": 0,
"composable": true
},
"transferNonfungibleTokensTransaction": {
"name": "Non-fungible Token Transferred",
"description": "One non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<transferredTokenCategory.symbol>) token was transferred to a recipient, with $(<transferredTokenCommitment>) commitment.",
"icon": "send",
"roles": {
"receiver": {
"name": "Received",
"description": "Received one non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<transferredTokenCategory.symbol>) token, with $(<transferredTokenCommitment>) commitment.",
"icon": "receive"
},
"sender": {
"name": "Sent",
"description": "Sent one non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<transferredTokenCategory.symbol>) token, with $(<transferredTokenCommitment>) commitment.",
"icon": "send"
}
},
"inputs": [],
"outputs": [{ "output": "transferNonfungibleTokenOutput" }],
"version": 2,
"locktime": 0,
"composable": true
},
"burnFungibleTokensTransaction": {
"name": "Deleted fungible tokens",
"description": "Permanently and irreversibly deleted $(<burnedTokenAmount> <burnedTokenCategory.decimalsFactor> OP_DIV).$(<burnedTokenAmount> <burnedTokenCategory.decimalsFactor> OP_MOD) $(<burnedTokenCategory.symbol>) tokens.",
"icon": "burn",
"inputs": [{ "input": "burnFungibleTokensInput" }],
"outputs": [],
"version": 2,
"locktime": 0,
"composable": true
},
"burnNonfungibleTokenTransaction": {
"name": "Deleted non fungible token",
"description": "Permanently and irreversibly deleted a non-fungible $(<burnedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <burnedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<burnedTokenCategory.symbol>) token, with $(<burnedTokenCommitment>) commitment.",
"icon": "burn",
"inputs": [{ "input": "burnNonfungibleTokenInput" }],
"outputs": [],
"version": 2,
"locktime": 0,
"composable": true
}
},
"outputs": {
"receiveOutput": {
"name": "Recipient output",
"description": "Transferred an unspecified amount of cash and/or tokens to a recipient.",
"icon": "receive",
"roles": {
"receiver": {
"name": "Received",
"description": "Received an unspecified amount of cash and/or tokens."
},
"sender": {
"name": "Sent",
"description": "Sent an unspecified amount of cash and/or tokens."
}
},
"lockscript": "receivingLockingScript"
},
"requestSatoshisOutput": {
"name": "Recipient output",
"description": "Transferred $(<requestedSatoshis>) satoshis to a recipient.",
"icon": "request",
"roles": {
"receiver": {
"name": "Received",
"description": "Received $(<requestedSatoshis>) satoshis."
},
"sender": {
"name": "Sent",
"description": "Sent $(<requestedSatoshis>) satoshis."
}
},
"lockscript": "receivingLockingScript",
"valueSatoshis": "$(<requestedSatoshis>)",
"token": null
},
"requestFungibleTokensOutput": {
"name": "Recipient output",
"description": "Transferred $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens to a recipient.",
"icon": "request",
"roles": {
"receiver": {
"name": "Received",
"description": "Received $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens."
},
"sender": {
"name": "Sent",
"description": "Sent $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens."
}
},
"lockscript": "receivingLockingScript",
"valueSatoshis": "1000",
"token": {
"category": "$(<requestedTokenCategory>)",
"amount": "$(<requestedTokenAmount>)",
"nft": null
}
},
"requestNonfungibleTokensOutput": {
"name": "Recipient output",
"description": "Transferred one non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<requestedTokenCategory.symbol>) token to a recipient, with $(<requestedTokenCommitment>) commitment.",
"icon": "request",
"roles": {
"receiver": {
"name": "Received",
"description": "Received one non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<requestedTokenCategory.symbol>) token, with $(<requestedTokenCommitment>) commitment."
},
"sender": {
"name": "Sent",
"description": "Sent the requested non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<requestedTokenCategory.symbol>) token, with $(<requestedTokenCommitment>) commitment."
}
},
"lockscript": "receivingLockingScript",
"valueSatoshis": "1000",
"token": {
"category": "$(<requestedTokenCategory>)",
"amount": null,
"nft": {
"capability": "$(<requestedTokenCapability>)",
"commitment": "$(<requestedTokenCommitment>)"
}
}
},
"transferSatoshisOutput": {
"name": "Recipient output",
"description": "Transferred $(<transferredSatoshis>) satoshis to a recipient.",
"icon": "send",
"roles": {
"receiver": {
"name": "Received",
"description": "Received $(<transferredSatoshis>) satoshis."
},
"sender": {
"name": "Sent",
"description": "Sent $(<transferredSatoshis>) satoshis."
}
},
"lockscript": "sendingLockingscript",
"valueSatoshis": "$(<transferredSatoshis>)"
},
"transferFungibleTokensOutput": {
"name": "Recipient output",
"description": "Transferred $(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens to a recipient.",
"icon": "send",
"roles": {
"receiver": {
"name": "Received",
"description": "Received $(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens."
},
"sender": {
"name": "Sent",
"description": "Sent $(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens."
}
},
"lockscript": "sendingLockingscript",
"token": {
"category": "$(<transferredTokenCategory>)",
"amount": "$(<transferredTokenAmount>)"
}
},
"transferNonfungibleTokenOutput": {
"name": "Recipient output",
"description": "Transferred one non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<transferredTokenCategory.symbol>) token to a recipient, with $(<transferredTokenCommitment>) commitment.",
"icon": "send",
"roles": {
"receiver": {
"name": "Received",
"description": "Received one non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<transferredTokenCategory.symbol>) token, with $(<transferredTokenCommitment>) commitment."
},
"sender": {
"name": "Sent",
"description": "Sent one non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<transferredTokenCategory.symbol>) token, with $(<transferredTokenCommitment>) commitment."
}
},
"lockscript": "sendingLockingscript",
"token": {
"category": "$(<transferredTokenCategory>)",
"nft": {
"capability": "$(<transferredTokenCapability>)",
"commitment": "$(<transferredTokenCommitment>)"
}
}
}
},
"inputs": {
"burnFungibleTokensInput": {
"name": "Deleted fungible tokens",
"description": "Permanently and irreversibly deleted $(<burnedTokenAmount>) $(<burnedTokenCategory>).",
"icon": "burn",
"unlockingScript": "unlockP2PKH",
"token": {
"category": "$(<burnedTokenCategory>)",
"amount": "$(<this.fungibleTokenAmount> <burnedTokenAmount> OP_GREATERTHANOREQUAL OP_IF <this.fungibleTokens> OP_ENDIF)"
},
"omitChangeAmounts": { "fungibleTokens": "${<burnedTokenAmount>}" }
},
"burnNonfungibleTokenInput": {
"name": "Deleted non-fungible token",
"description": "Permanently and irreversibly burned one non-fungible $(<burnedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <burnedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) token of category $(<burnedTokenCategory>), with a $(<burnedTokenCommitment>) commitment.",
"icon": "burn",
"unlockingScript": "unlockP2PKH",
"token": {
"category": "$(<burnedTokenCategory>)",
"nft": {
"capability": "$(<burnedTokenCapability>)",
"commitment": "$(<burnedTokenCommitment>)"
}
},
"omitChangeAmounts": { "nonfungibleTokens": 1 }
}
},
"lockingScripts": {
"sendingLockingscript": {
"name": "Sent",
"description": "Funds sent to an external recipient",
"icon": "address",
"lockingType": "standard",
"lockingScript": "lockToRecipient",
"actions": [],
"state": [],
"secrets": [],
"balance": false,
"selectable": false,
"privacy": false
},
"receivingLockingScript": {
"name": "Received",
"description": "Funds received without wallet coordination.",
"icon": "address",
"lockingType": "standard",
"lockingScript": "lockP2PKH",
"unlockingScript": "unlockP2PKH",
"roles": {
"receiver": {
"state": { "variables": [], "secrets": ["ownerKey"] },
"actions": [
{ "action": "sign", "role": "owner", "secrets": ["ownerKey"] },
{ "action": "verify", "role": "owner", "secrets": ["ownerKey"] },
{
"action": "sendSatoshis",
"role": "sender",
"secrets": ["ownerKey"]
},
{
"action": "sendFungibleTokens",
"role": "sender",
"secrets": ["ownerKey"]
},
{
"action": "sendNonfungibleTokens",
"role": "sender",
"secrets": ["ownerKey"]
},
{
"action": "burnFungibleTokens",
"role": "sender",
"secrets": ["ownerKey"]
},
{
"action": "burnNonfungibleTokens",
"role": "sender",
"secrets": ["ownerKey"]
}
],
"balance": { "satoshis": true, "fungibleTokens": true },
"selectable": true,
"privacy": false
}
}
}
},
"scripts": {
"lockP2PKH": "OP_DUP OP_HASH160 <$(<ownerKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG",
"unlockP2PKH": "<ownerKey.schnorr_signature.all_outputs> <ownerKey.public_key>",
"lockToRecipient": "<recipientLockingscript>"
},
"constants": {
"dustLimit": {
"name": "Dust Limit",
"description": "Standard required minimum satoshis for Pay to Public Key Hash outputs.",
"type": "integer",
"value": 546
},
"messagePrefix": {
"name": "Message Prefix",
"description": "Standard message prefix used in the bitcoin signed message protocol.",
"type": "bytes",
"value": "0x18426974636f696e205369676e6564204d6573736167653a0a"
}
},
"variables": {
"ownerKey": {
"name": "Owners Private Key",
"description": "The private key used to authorize spending of received funds.",
"type": "bytes",
"hint": "private_key"
},
"messageToSign": {
"name": "Message",
"description": "The text message to sign.",
"type": "string"
},
"messageToVerify": {
"name": "Message",
"description": "The text message to verify.",
"type": "string"
},
"messageSignature": {
"name": "Message Signature",
"description": "The signature for the message.",
"type": "bytes",
"hint": "signature"
},
"requestedSatoshis": {
"name": "Requested Amount",
"description": "The Bitcoin Cash amount requested",
"type": "integer",
"hint": "satoshis"
},
"requestedTokenCategory": {
"name": "Requested Token Category",
"description": "The token category requested",
"type": "bytes",
"hint": "token_category"
},
"requestedTokenAmount": {
"name": "Requested Token Amount",
"description": "The fungible token amount requested",
"type": "integer",
"hint": "token_amount"
},
"requestedTokenCapability": {
"name": "Requested Token Capability",
"description": "The non-fungible token capability requested",
"type": "bytes",
"hint": "token_capability"
},
"requestedTokenCommitment": {
"name": "Requested Token Commitment",
"description": "The non-fungible token commitment requested",
"type": "bytes",
"hint": "token_commitment"
},
"transferredTokenCategory": {
"name": "Sending Token Category",
"description": "The token category of the token(s) to send",
"type": "bytes",
"hint": "token_category"
},
"transferredTokenAmount": {
"name": "Sending Token Amount",
"description": "The fungible token amount to send",
"type": "integer",
"hint": "token_amount"
},
"transferredTokenCapability": {
"name": "Sending Token Capability",
"description": "The token capability for the non-fungible token to send",
"type": "bytes",
"hint": "token_capability"
},
"transferredTokenCommitment": {
"name": "Sending Token Commitment",
"description": "The token commitment for the non-fungible token to send",
"type": "bytes",
"hint": "token_commitment"
},
"burnedTokenCategory": {
"name": "Deleted Token Category",
"description": "The token category of the token(s) to delete",
"type": "bytes",
"hint": "token_category"
},
"burnedTokenAmount": {
"name": "Deleted Token Amount",
"description": "The fungible token amount to delete",
"type": "integer",
"hint": "token_amount"
},
"burnedTokenCapability": {
"name": "Deleted Token Capability",
"description": "The token capability for the non-fungible token to delete",
"type": "bytes",
"hint": "token_capability"
},
"burnedTokenCommitment": {
"name": "Deleted Token Commitment",
"description": "The token commitment for the non-fungible token to delete",
"type": "bytes",
"hint": "token_commitment"
}
},
"icons": [
{ "name": "wallet", "hash": "0000000000000000000000" },
{ "name": "owner", "hash": "0000000000000000000000" },
{ "name": "sender", "hash": "0000000000000000000000" },
{ "name": "address", "hash": "0000000000000000000000" },
{ "name": "receive", "hash": "0000000000000000000000" },
{ "name": "request", "hash": "0000000000000000000000" },
{ "name": "send", "hash": "0000000000000000000000" },
{ "name": "burn", "hash": "0000000000000000000000" },
{ "name": "sign", "hash": "0000000000000000000000" },
{ "name": "verify", "hash": "0000000000000000000000" }
],
"scenarios": [
{
"name": "requesting satoshis",
"description": "happy-path evaluation for requesting satoshis.",
"action": "requestSatoshis",
"roles": [
{
"role": "receiver",
"values": {
"generated": {
"ownerKey": "KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8"
},
"variables": { "requestedSatoshis": 2000 },
"secrets": {},
"inputs": [],
"outputs": [
{
"lockingBytecode": "76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac",
"valueSatoshis": 2000
}
]
}
},
{
"role": "sender",
"values": {
"generated": {},
"variables": {},
"secrets": {},
"inputs": [
{
"outpointTransactionHash": "4ef28553a31a266719e66ba97fee3aeecd6d1788f7ff6ab12f8ebceda49660c0",
"outpointIndex": 0,
"sequenceNumber": 0,
"unlockingBytecode": "41226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db04a422c9ee49f5eefd26fee24e91910edbbb032b90cc54c34da80a61e69b0ee3d22412103e7ab26c36a7c7f45b2c26f33c08b0fa43a633268700f47216646d4cb37ae5696"
}
],
"outputs": [
{
"lockingBytecode": "76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac",
"valueSatoshis": 2000
}
]
}
}
],
"values": { "generated": {}, "variables": {}, "secrets": {} },
"outcome": {
"roles": {
"receiver": {
"name": "Request",
"description": "Requested a specific amount of satoshis from one or more senders.",
"icon": "request"
},
"sender": {
"name": "Send",
"description": "Sent a specific amount of satoshis to the provided receiver.",
"icon": "send"
}
},
"transactions": [
{
"transaction": "0200000001c06096a4edbc8e2fb16afff788176dcdee3aee7fa96be61967261aa35385f24e000000006441226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db04a422c9ee49f5eefd26fee24e91910edbbb032b90cc54c34da80a61e69b0ee3d22412103e7ab26c36a7c7f45b2c26f33c08b0fa43a633268700f47216646d4cb37ae5696000000000267530300000000001976a91475c715ecb74178fe87933e57e947e5e92d904b8188acd0070000000000001976a91475c715ecb74178fe87933e57e947e5e92d904b8188ac00000000",
"value": ""
}
]
}
}
]
}

2779
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,15 +3,27 @@
"version": "1.0.0", "version": "1.0.0",
"main": "dist/index.js", "main": "dist/index.js",
"type": "module", "type": "module",
"bin": {
"xo-cli": "./dist/cli/index.js",
"xo-tui": "./dist/index.js",
"xo-complete": "./dist/cli/autocomplete/complete.js"
},
"scripts": { "scripts": {
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts", "dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
"build": "tsc", "build": "tsc && npm run build:copy-scripts",
"build:copy-scripts": "cp -r src/cli/autocomplete/scripts dist/cli/autocomplete/",
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js", "start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
"test": "echo \"Error: no test specified\" && exit 1", "test": "vitest --run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"nuke": "tsx scripts/rm-dbs.ts", "nuke": "tsx scripts/rm-dbs.ts",
"nuke:dry": "tsx scripts/rm-dbs.ts --dry", "nuke:dry": "tsx scripts/rm-dbs.ts --dry",
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore", "format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
"format:check": "prettier --check ." "format:check": "prettier --check .",
"autocomplete:install": "node dist/cli/index.js completions bash --install",
"autocomplete:install:bash": "node dist/cli/index.js completions bash --install",
"autocomplete:install:zsh": "node dist/cli/index.js completions zsh --install",
"autocomplete:install:fish": "node dist/cli/index.js completions fish --install"
}, },
"keywords": [ "keywords": [
"crypto", "crypto",
@@ -25,10 +37,12 @@
"dependencies": { "dependencies": {
"@bitauth/libauth": "^3.0.0", "@bitauth/libauth": "^3.0.0",
"@electrum-cash/protocol": "^2.3.1", "@electrum-cash/protocol": "^2.3.1",
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
"@xo-cash/crypto": "^0.0.1",
"@xo-cash/engine": "file:../engine", "@xo-cash/engine": "file:../engine",
"@xo-cash/state": "file:../state", "@xo-cash/state": "file:../state",
"@xo-cash/templates": "file:../templates", "@xo-cash/templates": "^0.0.1",
"@xo-cash/types": "file:../types", "@xo-cash/types": "^0.0.1",
"better-sqlite3": "^12.6.2", "better-sqlite3": "^12.6.2",
"clipboardy": "^5.1.0", "clipboardy": "^5.1.0",
"ink": "^6.6.0", "ink": "^6.6.0",
@@ -42,7 +56,9 @@
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@vitest/coverage-v8": "^4.1.2",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"vitest": "^4.1.2"
} }
} }

107
readme.md Normal file
View File

@@ -0,0 +1,107 @@
# XO-CLI & XO-TUI
## Installation
### Full Installation
```bash
# Create a new directory since we are going to be pulling in engine too
mkdir xo-terminal && cd xo-terminal
# ----- Start Engine Setup -----
# Clone the Engine Repo (Note, this uses harvey's fork of the engine repo to access the cli-test branch)
git clone git@gitlab.com:Harvmaster/engine.git
# Move into teh engine directory
cd engine
# Checkout the cli-test branch
git checkout cli-test
# Install the dependencies
npm ci
# Build the engine
npm run build
# ----- End Engine Setup -----
# Move back to the top level directory
cd ..
# ----- Start State Setup -----
# Clone the State Repo
git clone git@gitlab.com:Harvmaster/state.git
# Move into the state directory
cd state
git checkout in-memory-adapter
# Install the dependencies
npm ci
# Build the state
npm run build
# ----- End State Setup -----
# Move back to the top level directory
cd ..
# ----- Start CLI Setup -----
# Clone the CLI Repo
git clone git@git.harvmaster.com:Harvmaster/xo-cli.git
# Move into the cli directory
cd xo-cli
# Install the dependencies
npm ci
# Build the cli
npm run build
# ----- End CLI Setup -----
```
### Run TUI in dev mode
```bash
npm run dev
```
### Install globally
```bash
# (From the xo-cli directory)
npm install -g .
```
### Install autocomplete completions (From the xo-cli directory)
#### Install for bash
```bash
npm run autocomplete:install:bash
```
#### Install for zsh
```bash
npm run autocomplete:install:zsh
```
#### Install for fish
```bash
npm run autocomplete:install:fish
```
### Run the CLI
```bash
# If globally installed (Not really usable if not globally installed)
xo-cli
```
### Run the TUI
```bash
# If globally installed
xo-tui
# If not globally installed
npm run dev
```

104
scripts/template-to-json.ts Normal file
View File

@@ -0,0 +1,104 @@
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
/**
* This just convers the <template>.ts file to a <template>.json file.
* Im fairly sure there is a util in the engine or engine-packages for this, but I decided to just keep it as simple as possible because I didn't feel like digging around for it.
*
* Usage:
* tsx scripts/template-to-json.ts ../templates/source/p2pkh.ts ./p2pkh.json p2pkhTemplate
*/
/**
* Prints usage to stderr and exits with a non-zero code.
*/
function printUsageAndExit(): never {
console.error(
[
"Usage: tsx scripts/template-to-json.ts <input.ts> <output.json> [exportName]",
"",
"Loads a TypeScript module, picks one exported value, and writes JSON.stringify to the output path.",
"If exportName is omitted: uses default export, or the only non-function export if there is exactly one.",
"",
"Example:",
" tsx scripts/template-to-json.ts ../templates/source/p2pkh.ts ./p2pkh.json p2pkhTemplate",
].join("\n"),
);
process.exit(1);
}
/**
* Collects runtime export keys whose values are not functions (typical for data/template objects).
*/
function listDataExportKeys(mod: Record<string, unknown>): string[] {
return Object.keys(mod).filter((key) => {
if (key === "__esModule") {
return false;
}
const value = mod[key];
return typeof value !== "function";
});
}
/**
* Resolves which export to serialize: explicit name, default, or a single unambiguous data export.
*/
function resolveExportedValue(
mod: Record<string, unknown>,
exportName: string | undefined,
): unknown {
if (exportName !== undefined) {
if (!(exportName in mod)) {
const keys = listDataExportKeys(mod);
console.error(
`Export "${exportName}" not found. Available data exports: ${keys.length ? keys.join(", ") : "(none)"}`,
);
process.exit(1);
}
return mod[exportName];
}
if ("default" in mod && mod.default !== undefined) {
return mod.default;
}
const keys = listDataExportKeys(mod);
if (keys.length === 1) {
return mod[keys[0]!];
}
if (keys.length === 0) {
console.error(
"No suitable exports found (need default or a non-function export).",
);
} else {
console.error(
`Multiple data exports found; pass exportName. Candidates: ${keys.join(", ")}`,
);
}
process.exit(1);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length < 2) {
printUsageAndExit();
}
const [inputRel, outputRel, exportName] = args;
const inputPath = path.resolve(process.cwd(), inputRel!);
const outputPath = path.resolve(process.cwd(), outputRel!);
/** Dynamic import needs a file URL so Windows paths and ESM resolution behave. */
const fileUrl = pathToFileURL(inputPath).href;
const mod = (await import(fileUrl)) as Record<string, unknown>;
const value = resolveExportedValue(mod, exportName);
const json = `${JSON.stringify(value, null, 2)}\n`;
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, json, "utf8");
console.log(`Wrote ${outputPath}`);
}
await main();

View File

@@ -3,9 +3,11 @@
* Simplified to render TUI immediately and let it handle AppService creation. * Simplified to render TUI immediately and let it handle AppService creation.
*/ */
import { join } from "node:path";
import React from "react"; import React from "react";
import { render, type Instance } from "ink"; import { render, type Instance } from "ink";
import { App as AppComponent } from "./tui/App.js"; import { App as AppComponent } from "./tui/App.js";
import { getDataDir } from "./utils/paths.js";
/** /**
* Configuration options for the CLI application. * Configuration options for the CLI application.
@@ -46,13 +48,14 @@ export class App {
* @returns Running App instance * @returns Running App instance
*/ */
static async create(config: Partial<AppConfig> = {}): Promise<App> { static async create(config: Partial<AppConfig> = {}): Promise<App> {
const dataDir = getDataDir();
// Set default configuration // Set default configuration
const fullConfig: AppConfig = { const fullConfig: AppConfig = {
syncServerUrl: config.syncServerUrl ?? "http://localhost:3000", syncServerUrl: config.syncServerUrl ?? "http://localhost:3000",
databasePath: config.databasePath ?? "./", databasePath: config.databasePath ?? dataDir,
databaseFilename: config.databaseFilename ?? "xo-wallet.db", databaseFilename: config.databaseFilename ?? "xo-wallet.db",
invitationStoragePath: invitationStoragePath:
config.invitationStoragePath ?? "./xo-invitations.db", config.invitationStoragePath ?? join(dataDir, "xo-invitations.db"),
}; };
console.log("Full config:", fullConfig); console.log("Full config:", fullConfig);

195
src/cli/README.md Normal file
View File

@@ -0,0 +1,195 @@
# XO CLI
Command-line interface for the XO Engine. Create wallets, manage templates, build invitations, sign transactions, and broadcast them to the Bitcoin Cash network.
There are two global commands after install:
- **`xo-cli`** — non-interactive commands (this document).
- **`xo-tui`** — interactive terminal wallet UI (Ink/React).
## Global config directory
Wallet state lives under **`~/.config/xo-cli/`** (XDG-style), so you can run commands from any directory:
| Path | Purpose |
| ----------------------------- | ----------------------------------------------------------------------- |
| `~/.config/xo-cli/mnemonics/` | Mnemonic files (`mnemonic-*`) |
| `~/.config/xo-cli/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) |
| `~/.config/xo-cli/.wallet` | Last-used mnemonic reference (so `-m` can be omitted) |
**Local to your shells current directory:** template JSON paths, invitation JSON you create/import, and any path you pass explicitly (e.g. `-m /abs/path/to/file`).
## Install (global, from this repo)
`@xo-cash/*` dependencies use `file:` paths, so publish to npm is a separate step. For local development:
```bash
cd engine/cli
npm install
npm run build
npm link
# xo-cli and xo-tui are now on your PATH
```
Development without linking:
```bash
cd engine/cli
npx tsx src/cli/index.ts <command> [options]
npx tsx src/index.ts # TUI
```
### Environment variables (TUI / `xo-tui`)
| Variable | Default |
| ------------------------- | ----------------------------------------- |
| `SYNC_SERVER_URL` | `http://localhost:3000` |
| `DB_PATH` | `~/.config/xo-cli/data` |
| `DB_FILENAME` | `xo-wallet.db` |
| `INVITATION_STORAGE_PATH` | `~/.config/xo-cli/data/xo-invitations.db` |
## Getting Started
### Wallet Setup
```bash
# Generate a new mnemonic (saved under ~/.config/xo-cli/mnemonics/)
xo-cli mnemonic create
# Import an existing mnemonic seed phrase
xo-cli mnemonic import page pencil stock planet limb cluster assault speak off joke private pioneer
# List mnemonic basenames (use with -m)
xo-cli mnemonic list
```
**Options:** `-o <filename>` — basename only; file is written under the global mnemonics directory.
### Wallet Persistence
The first time you pass `-m <name>`, that reference is saved to `~/.config/xo-cli/.wallet`. Later runs can omit `-m`.
Mnemonic resolution order:
1. Absolute path, if the file exists
2. Path relative to the current working directory
3. `~/.config/xo-cli/mnemonics/<basename>`
```bash
xo-cli resource list -m mnemonic-nuclear
xo-cli resource list
```
## Global Options (`xo-cli`)
| Flag | Description |
| ------------------------------ | --------------------------------------------------- |
| `-m`, `--mnemonic-file <file>` | Mnemonic file (basename, cwd-relative, or absolute) |
| `-o`, `--output <filename>` | Output filename (used by `mnemonic create`/`import`) |
| `-v`, `--verbose` | Verbose output |
| `-h`, `--help` | Help |
Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `~/.config/xo-cli/data/` (see `src/cli/index.ts`).
## Commands
### `mnemonic` — Manage Wallet Files
```bash
xo-cli mnemonic create
xo-cli mnemonic import <seed words...>
xo-cli mnemonic list
xo-cli mnemonic expose <mnemonic-file>
```
### `template` — Manage Templates
```bash
xo-cli template import <template-file>
xo-cli template list
xo-cli template list <category> <template-id>
xo-cli template inspect <category> <template-id> <field>
xo-cli template set-default <template-file> <output-id> <role>
```
**Categories:** `action`, `transaction`, `output`, `lockingscript`, `variable`
Template paths are resolved relative to the **current working directory**.
### `resource` — Manage UTXOs
```bash
xo-cli resource list
xo-cli resource list reserved
xo-cli resource list all
xo-cli resource unreserve <txhash:vout>
xo-cli resource unreserve-all
```
### `receive` — Generate a Receiving Address
```bash
xo-cli receive <template-file> <output-identifier> [role-identifier]
```
### `invitation` — Build, Sign & Broadcast
```bash
xo-cli invitation create <template-file> <action-id> [options]
xo-cli invitation append <invitation-id> [options]
xo-cli invitation sign <invitation-id>
xo-cli invitation broadcast <invitation-id>
xo-cli invitation requirements <invitation-id>
xo-cli invitation import <invitation-file>
xo-cli invitation inspect <invitation-file>
xo-cli invitation list
```
**Create / append options:**
| Flag | Description |
| --------------------------- | ---------------------------------------- |
| `-var-<name> <value>` | Template variable |
| `--add-input <txhash:vout>` | Inputs (comma-separated) |
| `--add-output <id>` | Override outputs (omit to auto-discover) |
| `--auto-inputs` | Auto-select UTXOs |
| `-role <role>` | Role for variables / bytecode |
| `--sign` | Auto-sign when complete |
| `--broadcast` | Auto-broadcast (implies `--sign`) |
Invitation JSON files from `create` / `append` are written to the **current working directory**.
### One-command send
```bash
xo-cli resource list
xo-cli invitation create p2pkh-template.json sendSatoshis \
-var-transferred-satoshis 4678 \
-var-recipient-lockingscript "bitcoincash:qz..." \
--add-input <txhash>:<vout> \
-role sender \
--broadcast
```
### `xo-tui`
```bash
xo-tui
```
Launches the full-screen wallet UI; uses the same global data directory unless overridden by env vars.
## Shell Completions
```bash
eval "$(xo-cli completions bash)"
eval "$(xo-cli completions zsh)"
xo-cli completions fish | source
```
## File Conventions
| Location | Purpose |
| ------------------- | ------------------------------------------ |
| `~/.config/xo-cli/` | Global wallet state |
| `./` (cwd) | Templates, invitation JSON, explicit paths |

86
src/cli/arguments.ts Normal file
View File

@@ -0,0 +1,86 @@
/**
* 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<string, string>;
} {
// Map of single-character short flags to their canonical long names
const shortToFull: Record<string, string> = {
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<string>([
"verbose",
"help",
"autoInputs",
"sign",
"broadcast",
"install",
]);
const positionalArgs: string[] = [];
const optionsObject: Record<string, string> = {};
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 };
}

View File

@@ -0,0 +1,393 @@
#!/usr/bin/env node
/**
* Lightweight shell completion helper for xo-cli.
*
* This script reads from local SQLite only - no network connections.
* It's designed to be fast enough for interactive tab completion.
*
* Usage: xo-complete <context> [args...]
*
* Contexts:
* mnemonics - List mnemonic file names
* templates - List template names/IDs
* actions <template> - List actions for a template
* fields <category> <template> - List fields for a template category
* invitations - List invitation IDs
* resources - List UTXO outpoints (txhash:vout)
* subcommands <command> - List subcommands for a top-level command
*
* Output: One completion suggestion per line, suitable for shell completion.
*
* Exit codes:
* 0 - Success (may output zero or more completions)
* 1 - Error (no output, fails silently for shell integration)
*/
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
import {
getDataDir,
getMnemonicsDir,
getWalletConfigPath,
} from "../../utils/paths.js";
import { loadMnemonic } from "../mnemonic.js";
import { Storage } from "../../services/storage.js";
import { COMMAND_TREE } from "./completions.js";
// Lazy-loaded modules (only loaded when needed for dynamic completions)
let _offlineEngineModule: typeof import("./offline-engine.js") | null = null;
let _engineModule: typeof import("@xo-cash/engine") | null = null;
async function getOfflineEngineModule() {
if (!_offlineEngineModule) {
_offlineEngineModule = await import("./offline-engine.js");
}
return _offlineEngineModule;
}
async function getEngineModule() {
if (!_engineModule) {
_engineModule = await import("@xo-cash/engine");
}
return _engineModule;
}
/**
* Outputs completions to stdout, one per line.
* Optionally filters by a prefix (for partial word completion).
*/
function outputCompletions(items: readonly string[], prefix?: string): void {
const filtered = prefix
? items.filter((item) =>
item.toLowerCase().startsWith(prefix.toLowerCase()),
)
: items;
for (const item of filtered) {
console.log(item);
}
}
/**
* Lists mnemonic file names from the mnemonics directory.
* Fast path: no engine needed, just filesystem.
*/
function listMnemonics(prefix?: string): void {
try {
const mnemonicsDir = getMnemonicsDir();
const files = readdirSync(mnemonicsDir).filter((f) =>
f.startsWith("mnemonic-"),
);
outputCompletions(files, prefix);
} catch {
// Silently fail - no completions available
}
}
/**
* Lists subcommands for a given top-level command.
* Uses the static COMMAND_TREE.
*/
function listSubcommands(command: string, prefix?: string): void {
if (command in COMMAND_TREE) {
const subcommands = COMMAND_TREE[command as keyof typeof COMMAND_TREE];
outputCompletions(subcommands, prefix);
}
}
/**
* Gets the current wallet's mnemonic seed from the saved config.
* Returns null if no wallet is configured.
*/
function getCurrentMnemonic(): string | null {
try {
const walletConfigPath = getWalletConfigPath();
if (!existsSync(walletConfigPath)) {
return null;
}
const mnemonicFile = readFileSync(walletConfigPath, "utf8").trim();
if (!mnemonicFile) {
return null;
}
const mnemonicsDir = getMnemonicsDir();
return loadMnemonic(mnemonicsDir, mnemonicFile);
} catch {
return null;
}
}
/**
* Lists templates from the engine.
*/
async function listTemplates(prefix?: string): Promise<void> {
const mnemonic = getCurrentMnemonic();
if (!mnemonic) return;
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
const { generateTemplateIdentifier } = await getEngineModule();
const engine = await tryCreateOfflineEngine(mnemonic, {
databasePath: getDataDir(),
databaseFilename: "xo-wallet.db",
});
if (!engine) return;
try {
const templates = await engine.listImportedTemplates();
const completions: string[] = [];
for (const template of templates) {
// Add template name (for user-friendly completion)
if (template.name) {
completions.push(template.name);
}
// Also add template identifier (for precise matching)
const id = generateTemplateIdentifier(template);
if (id && !completions.includes(id)) {
completions.push(id);
}
}
outputCompletions(completions, prefix);
} finally {
await engine.stop();
}
}
/**
* Resolves a template by name or ID.
*/
async function resolveTemplate(
engine: Awaited<
ReturnType<
Awaited<
ReturnType<typeof getOfflineEngineModule>
>["tryCreateOfflineEngine"]
>
>,
templateQuery: string,
) {
if (!engine) return null;
const { generateTemplateIdentifier } = await getEngineModule();
const templates = await engine.listImportedTemplates();
// Try exact match on name or ID
let template = templates.find(
(t) =>
t.name === templateQuery ||
generateTemplateIdentifier(t) === templateQuery,
);
// Try partial match on name
if (!template) {
template = templates.find((t) =>
t.name?.toLowerCase().includes(templateQuery.toLowerCase()),
);
}
return template ?? null;
}
/**
* Lists actions for a specific template.
*/
async function listActions(
templateQuery: string,
prefix?: string,
): Promise<void> {
const mnemonic = getCurrentMnemonic();
if (!mnemonic) return;
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
const engine = await tryCreateOfflineEngine(mnemonic, {
databasePath: getDataDir(),
databaseFilename: "xo-wallet.db",
});
if (!engine) return;
try {
const template = await resolveTemplate(engine, templateQuery);
if (template && template.actions) {
const actions = Object.keys(template.actions);
outputCompletions(actions, prefix);
}
} finally {
await engine.stop();
}
}
/**
* Lists fields (actions, transactions, outputs, etc.) for a specific template category.
* Used for completing the 3rd argument of `template inspect <category> <template> <field>`.
*/
async function listFields(
category: string,
templateQuery: string,
prefix?: string,
): Promise<void> {
const mnemonic = getCurrentMnemonic();
if (!mnemonic) return;
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
const engine = await tryCreateOfflineEngine(mnemonic, {
databasePath: getDataDir(),
databaseFilename: "xo-wallet.db",
});
if (!engine) return;
try {
const template = await resolveTemplate(engine, templateQuery);
if (!template) return;
let fields: string[] = [];
switch (category) {
case "action":
fields = Object.keys(template.actions || {});
break;
case "transaction":
fields = Object.keys(template.transactions || {});
break;
case "output":
fields = Object.keys(template.outputs || {});
break;
case "lockingscript":
fields = Object.keys(template.lockingScripts || {});
break;
case "variable":
fields = Object.keys(template.variables || {});
break;
}
outputCompletions(fields, prefix);
} finally {
await engine.stop();
}
}
/**
* Lists invitation IDs from the invitation storage.
*/
async function listInvitations(prefix?: string): Promise<void> {
const mnemonic = getCurrentMnemonic();
if (!mnemonic) return;
try {
// Compute seed hash to find the right storage namespace
const seedHash = createHash("sha256").update(mnemonic).digest("hex");
const invitationsDbPath = join(getDataDir(), "xo-invitations.db");
if (!existsSync(invitationsDbPath)) {
return;
}
const storage = await Storage.create(invitationsDbPath);
const walletStorage = storage.child(seedHash.slice(0, 8));
const invitationsStorage = walletStorage.child("invitations");
const invitations = await invitationsStorage.all();
const ids = invitations.map((inv) => inv.key);
outputCompletions(ids, prefix);
} catch {
// Silently fail - no completions available
}
}
/**
* Lists UTXO outpoints (resources) from the engine.
*/
async function listResources(prefix?: string): Promise<void> {
const mnemonic = getCurrentMnemonic();
if (!mnemonic) return;
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
const engine = await tryCreateOfflineEngine(mnemonic, {
databasePath: getDataDir(),
databaseFilename: "xo-wallet.db",
});
if (!engine) return;
try {
const utxos = await engine.listUnspentOutputsData();
const outpoints = utxos.map(
(u) => `${u.outpointTransactionHash}:${u.outpointIndex}`,
);
outputCompletions(outpoints, prefix);
} finally {
await engine.stop();
}
}
/**
* Main entry point.
*/
async function main(): Promise<void> {
const context = process.argv[2];
const arg1 = process.argv[3];
const arg2 = process.argv[4];
if (!context) {
// No context provided - output nothing
process.exit(0);
}
switch (context) {
case "mnemonics":
listMnemonics(arg1);
break;
case "subcommands":
if (arg1) {
listSubcommands(arg1, arg2);
}
break;
case "templates":
await listTemplates(arg1);
break;
case "actions":
if (arg1) {
await listActions(arg1, arg2);
}
break;
case "fields":
// fields <category> <template> [prefix]
if (arg1 && arg2) {
await listFields(arg1, arg2, process.argv[5]);
}
break;
case "invitations":
await listInvitations(arg1);
break;
case "resources":
await listResources(arg1);
break;
default:
// Unknown context - output nothing
break;
}
}
main().catch(() => {
// Silently fail for shell integration
process.exit(1);
});

View File

@@ -0,0 +1,307 @@
/**
* Shell completion script generation.
*
* Loads shell-native template files and replaces placeholders with
* dynamic values. This approach keeps the shell scripts readable
* and auditable in their native format.
*
* The generated scripts use the `xo-complete` helper binary for dynamic
* completions (invitation IDs, template names, resources, etc.).
*
* Usage:
* eval "$(xo-cli completions bash)"
* eval "$(xo-cli completions zsh)"
* xo-cli completions fish | source
*
* Install to shell config:
* xo-cli completions bash --install
* xo-cli completions zsh --install
* xo-cli completions fish --install
*/
import {
existsSync,
readFileSync,
appendFileSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
/**
* 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.
*
* IMPORTANT: Keep this in sync with actual switch statements in command handlers:
* - mnemonic.ts: create, import, list, expose
* - template.ts: import, list, inspect, set-default
* - invitation.ts: create, append, sign, broadcast, requirements, import, inspect, list
* - resource.ts: list, unreserve, unreserve-all
*/
/** Subcommands for the mnemonic command */
const MNEMONIC_SUBS = ["create", "import", "list", "expose"];
/** Subcommands for the template command */
const TEMPLATE_SUBS = ["import", "list", "inspect", "set-default"];
/** Subcommands for the invitation command */
const INVITATION_SUBS = [
"create",
"append",
"sign",
"broadcast",
"requirements",
"import",
"inspect",
"list",
];
/** Subcommands for the resource command */
const RESOURCE_SUBS = ["list", "unreserve", "unreserve-all"];
/** Subcommands for the completions command */
const COMPLETIONS_SUBS = ["bash", "zsh", "fish"];
export const COMMAND_TREE = {
mnemonic: MNEMONIC_SUBS,
template: TEMPLATE_SUBS,
invitation: INVITATION_SUBS,
receive: [],
resource: RESOURCE_SUBS,
help: [],
completions: COMPLETIONS_SUBS,
} as const;
/** Global option flags available on every command. */
const GLOBAL_OPTIONS = [
"-h",
"--help",
"-v",
"--verbose",
"-m",
"--mnemonic-file",
"-o",
"--output",
];
/**
* Gets the path to the scripts directory containing shell templates.
*/
function getScriptsDir(): string {
const currentFile = fileURLToPath(import.meta.url);
return join(dirname(currentFile), "scripts");
}
/**
* Loads a shell template file and replaces placeholders.
* @param templateName - The template file name (e.g., "bash.sh")
* @param binName - The CLI binary name
*/
function loadAndProcessTemplate(templateName: string, binName: string): string {
const scriptsDir = getScriptsDir();
const templatePath = join(scriptsDir, templateName);
if (!existsSync(templatePath)) {
throw new Error(`Template file not found: ${templatePath}`);
}
let content = readFileSync(templatePath, "utf8");
const funcName = binName.replace(/-/g, "_");
const commands = Object.keys(COMMAND_TREE).join(" ");
const options = GLOBAL_OPTIONS.join(" ");
// Replace all placeholders
content = content.replace(/\{\{BIN_NAME\}\}/g, binName);
content = content.replace(/\{\{FUNC_NAME\}\}/g, funcName);
content = content.replace(/\{\{COMMANDS\}\}/g, commands);
content = content.replace(/\{\{OPTIONS\}\}/g, options);
content = content.replace(/\{\{MNEMONIC_SUBS\}\}/g, MNEMONIC_SUBS.join(" "));
content = content.replace(/\{\{TEMPLATE_SUBS\}\}/g, TEMPLATE_SUBS.join(" "));
content = content.replace(
/\{\{INVITATION_SUBS\}\}/g,
INVITATION_SUBS.join(" "),
);
content = content.replace(/\{\{RESOURCE_SUBS\}\}/g, RESOURCE_SUBS.join(" "));
// Fish-specific placeholders
if (templateName.endsWith(".fish")) {
content = content.replace(
/\{\{TOP_LEVEL_COMMANDS\}\}/g,
generateFishTopLevelCommands(binName),
);
content = content.replace(
/\{\{STATIC_SUBCOMMANDS\}\}/g,
generateFishStaticSubcommands(binName),
);
}
return content;
}
/**
* Generates fish top-level command completions.
*/
function generateFishTopLevelCommands(binName: string): string {
const lines: string[] = [];
for (const cmd of Object.keys(COMMAND_TREE)) {
lines.push(
`complete -c ${binName} -n "__fish_use_subcommand" -a "${cmd}" -d "${cmd} command"`,
);
}
return lines.join("\n");
}
/**
* Generates fish static subcommand completions.
*/
function generateFishStaticSubcommands(binName: string): string {
const lines: string[] = [];
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}; and not __fish_seen_subcommand_from ${subs.join(" ")}" -a "${sub}" -d "${cmd} ${sub}"`,
);
}
}
return lines.join("\n");
}
/**
* Generates a bash completion script.
* @param binName - The name of the CLI binary.
*/
export function generateBashCompletions(binName: string): string {
return loadAndProcessTemplate("bash.sh", binName);
}
/**
* Generates a zsh completion script.
* @param binName - The name of the CLI binary.
*/
export function generateZshCompletions(binName: string): string {
return loadAndProcessTemplate("zsh.zsh", binName);
}
/**
* Generates a fish completion script.
* @param binName - The name of the CLI binary.
*/
export function generateFishCompletions(binName: string): string {
return loadAndProcessTemplate("fish.fish", binName);
}
type ShellType = "bash" | "zsh" | "fish";
const generators: Record<ShellType, (binName: string) => string> = {
bash: generateBashCompletions,
zsh: generateZshCompletions,
fish: generateFishCompletions,
};
/**
* Shell config file paths and eval commands for each shell type.
*/
const shellConfigs: Record<
ShellType,
{ configFile: string; evalCommand: (binName: string) => string }
> = {
bash: {
configFile: join(homedir(), ".bashrc"),
evalCommand: (binName) => `eval "$(${binName} completions bash)"`,
},
zsh: {
configFile: join(homedir(), ".zshrc"),
evalCommand: (binName) => `eval "$(${binName} completions zsh)"`,
},
fish: {
configFile: join(homedir(), ".config", "fish", "config.fish"),
evalCommand: (binName) => `${binName} completions fish | source`,
},
};
/**
* Installs completions to the user's shell config file.
* Adds the eval command if not already present.
* @param shell - The shell type
* @param binName - The CLI binary name
* @returns true if installed, false if already present
*/
function installCompletions(shell: ShellType, binName: string): boolean {
const config = shellConfigs[shell];
const evalCommand = config.evalCommand(binName);
// Check if config file exists and already has the completion line
let existingContent = "";
if (existsSync(config.configFile)) {
existingContent = readFileSync(config.configFile, "utf8");
if (existingContent.includes(evalCommand)) {
return false; // Already installed
}
}
// Append the completion line
const newLine =
existingContent.endsWith("\n") || existingContent === "" ? "" : "\n";
const completionBlock = `${newLine}\n# ${binName} shell completions\n${evalCommand}\n`;
appendFileSync(config.configFile, completionBlock);
return true;
}
/**
* Handles the `completions` command.
* Prints the generated completion script for the given shell to stdout,
* or installs it to the shell config file with --install.
* @param args - Positional args after "completions", e.g. ["bash"].
* @param options - Parsed command options (may include "install").
* @param binName - The CLI binary name to use in the completion script.
*/
export function handleCompletionsCommand(
args: string[],
options: Record<string, string> = {},
binName: string = "xo-cli",
): void {
const shell = args[0] as ShellType | undefined;
const installFlag = options["install"] === "true";
if (!shell || !generators[shell]) {
const supported = Object.keys(generators).join(", ");
console.error(`Usage: ${binName} completions <${supported}> [--install]`);
console.error("");
console.error("Examples:");
console.error(
` eval "$(${binName} completions bash)" # Output to stdout (add to ~/.bashrc)`,
);
console.error(
` eval "$(${binName} completions zsh)" # Output to stdout (add to ~/.zshrc)`,
);
console.error(
` ${binName} completions fish | source # Output to stdout (add to fish config)`,
);
console.error("");
console.error("Install directly to shell config:");
console.error(
` ${binName} completions bash --install # Appends to ~/.bashrc`,
);
console.error(
` ${binName} completions zsh --install # Appends to ~/.zshrc`,
);
console.error(
` ${binName} completions fish --install # Appends to ~/.config/fish/config.fish`,
);
process.exit(1);
}
if (installFlag) {
const config = shellConfigs[shell];
const installed = installCompletions(shell, binName);
if (installed) {
console.log(`Completions installed to ${config.configFile}`);
console.log(`Restart your shell or run: source ${config.configFile}`);
} else {
console.log(`Completions already installed in ${config.configFile}`);
}
return;
}
process.stdout.write(generators[shell](binName));
}

View File

@@ -0,0 +1,103 @@
/**
* Offline Engine Factory
*
* Creates a lightweight engine instance that reads from SQLite without any
* network connections. Used for fast shell completion queries.
*
* This bypasses the normal Engine.create() which initializes electrum connections,
* and instead constructs the engine directly with an in-memory blockchain provider.
*/
import {
BlockchainMonitor,
Engine,
InMemoryBlockchainProvider,
} from "@xo-cash/engine";
import { createStorageAdapter, State, StorageType } from "@xo-cash/state";
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
import { binToHex, hash256 } from "@bitauth/libauth";
import { createHash } from "crypto";
/**
* Options for creating an offline engine.
*/
export interface OfflineEngineOptions {
/** Path to the directory containing SQLite database files. */
databasePath: string;
/** Filename of the SQLite database (will be prefixed with seed hash). */
databaseFilename: string;
}
/**
* Creates an engine instance in offline mode - SQLite only, no network.
* Used for fast completion queries where we only need to read local data.
*
* This is significantly faster than Engine.create() because it:
* - Skips electrum application initialization
* - Uses an in-memory blockchain provider (no network)
* - Skips state sync initialization
*
* @param seed - The wallet seed phrase
* @param options - Database configuration options
* @returns An engine instance configured for offline/read-only use
*/
export async function createOfflineEngine(
seed: string,
options: OfflineEngineOptions,
): Promise<Engine> {
// Compute the seed hash (same logic as AppService.create)
const seedHash = createHash("sha256").update(seed).digest("hex");
const prefixedDatabaseFilename = `${seedHash.slice(0, 8)}-${options.databaseFilename}`;
// Generate account hash for storage namespace (must match Engine.create which uses hash256)
const seedAccountHash = hash256(convertMnemonicToSeedBytes(seed));
// Create the IndexedDB storage adapter (matches Engine.create default)
// Note: IndexedDB in Node uses a shim that stores data in SQLite files with .sqlite extension
const storageAdapter = await createStorageAdapter({
storageType: StorageType.INDEXEDDB,
databasePath: options.databasePath,
databaseFilename: prefixedDatabaseFilename,
accountHash: binToHex(seedAccountHash),
});
// Initialize the storage adapter
await storageAdapter.initialize();
// Create the state instance
const state = new State(storageAdapter);
// Use in-memory blockchain provider (no network connections)
const blockchainProvider = new InMemoryBlockchainProvider();
await blockchainProvider.initialize({
applicationIdentifier: "xo-cli-completions",
electrumOptions: {},
});
// Create a minimal blockchain monitor
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
// Construct engine directly without state sync
const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider);
return engine;
}
/**
* Attempts to create an offline engine, returning null on failure.
* Useful for completion scripts where we don't want to crash on errors.
*
* @param seed - The wallet seed phrase
* @param options - Database configuration options
* @returns An engine instance or null if creation failed
*/
export async function tryCreateOfflineEngine(
seed: string,
options: OfflineEngineOptions,
): Promise<Engine | null> {
try {
return await createOfflineEngine(seed, options);
} catch {
return null;
}
}

View File

@@ -0,0 +1,199 @@
# bash completion for {{BIN_NAME}}
# Add to ~/.bashrc: eval "$({{BIN_NAME}} completions bash)"
# Find xo-complete in the same directory as xo-cli
__xo_complete_bin=""
if command -v xo-complete &>/dev/null; then
__xo_complete_bin="xo-complete"
elif command -v {{BIN_NAME}} &>/dev/null; then
__xo_complete_bin="$(dirname "$(command -v {{BIN_NAME}})")/xo-complete"
fi
# Wrapper to call xo-complete helper
__xo_complete() {
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
}
_{{FUNC_NAME}}_completions() {
local cur prev words cword
_init_completion || return
# Handle -m/--mnemonic-file argument (previous word was -m)
if [[ "${prev}" == "-m" || "${prev}" == "--mnemonic-file" ]]; then
local mnemonics
mnemonics=$(__xo_complete mnemonics "${cur}")
if [[ -n "${mnemonics}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${mnemonics}"
return 0
fi
fi
# If the current word starts with "-", offer option flags
if [[ "${cur}" == -* ]]; then
COMPREPLY=($(compgen -W "{{OPTIONS}}" -- "${cur}"))
return 0
fi
# Find the command and subcommand positions
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
for ((i=1; i < cword; i++)); do
if [[ "${words[i]}" != -* ]]; then
if [[ -z "${cmd}" ]]; then
cmd="${words[i]}"
cmd_idx=$i
else
subcmd="${words[i]}"
subcmd_idx=$i
break
fi
fi
done
# No command yet — offer the top-level commands
if [[ -z "${cmd}" ]]; then
COMPREPLY=($(compgen -W "{{COMMANDS}}" -- "${cur}"))
return 0
fi
# Handle each command's completion
case "${cmd}" in
mnemonic)
if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "{{MNEMONIC_SUBS}}" -- "${cur}"))
fi
;;
template)
if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "{{TEMPLATE_SUBS}}" -- "${cur}"))
elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then
# template list/inspect <category> <template> [field] - category first, then template, then field
local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then
COMPREPLY=($(compgen -W "action transaction output lockingscript variable" -- "${cur}"))
elif [[ $pos -eq 2 ]]; then
local templates
templates=$(__xo_complete templates "${cur}")
if [[ -n "${templates}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${templates}"
fi
elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then
# Get the category and template from previous args
local category="${words[subcmd_idx + 1]}"
local template_arg="${words[subcmd_idx + 2]}"
local fields
fields=$(__xo_complete fields "${category}" "${template_arg}" "${cur}")
if [[ -n "${fields}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${fields}"
fi
fi
elif [[ "${subcmd}" == "set-default" ]]; then
# template set-default <template> <output> <role> - template first
local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
templates=$(__xo_complete templates "${cur}")
if [[ -n "${templates}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${templates}"
fi
fi
fi
;;
invitation)
if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "{{INVITATION_SUBS}}" -- "${cur}"))
else
case "${subcmd}" in
create)
# invitation create <template> <action> - offer templates then actions
local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
templates=$(__xo_complete templates "${cur}")
if [[ -n "${templates}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${templates}"
fi
elif [[ $pos -eq 2 ]]; then
local template_arg="${words[subcmd_idx + 1]}"
local actions
actions=$(__xo_complete actions "${template_arg}" "${cur}")
if [[ -n "${actions}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${actions}"
fi
fi
;;
append|sign|broadcast|requirements|inspect)
# These take an invitation ID
local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local invitations
invitations=$(__xo_complete invitations "${cur}")
if [[ -n "${invitations}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${invitations}"
fi
fi
;;
import)
# import takes a file path - use default file completion
COMPREPLY=($(compgen -f -- "${cur}"))
;;
esac
fi
;;
resource)
if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "{{RESOURCE_SUBS}}" -- "${cur}"))
elif [[ "${subcmd}" == "unreserve" ]]; then
# resource unreserve <txhash:vout> - offer resources
local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local resources
resources=$(__xo_complete resources "${cur}")
if [[ -n "${resources}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${resources}"
fi
fi
fi
;;
receive)
# receive <template> [output] - offer templates
local pos=$((cword - cmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
templates=$(__xo_complete templates "${cur}")
if [[ -n "${templates}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${templates}"
fi
fi
;;
completions)
if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "bash zsh fish" -- "${cur}"))
fi
;;
esac
}
complete -F _{{FUNC_NAME}}_completions {{BIN_NAME}}

View File

@@ -0,0 +1,70 @@
# fish completion for {{BIN_NAME}}
# Add to fish config: {{BIN_NAME}} completions fish | source
# Disable file completions by default
complete -c {{BIN_NAME}} -f
# Helper function to get dynamic completions
# Finds xo-complete in the same directory as {{BIN_NAME}}
function __{{FUNC_NAME}}_complete_dynamic
set -l xo_complete_bin ""
if command -q xo-complete
set xo_complete_bin xo-complete
else if command -q {{BIN_NAME}}
set xo_complete_bin (dirname (command -s {{BIN_NAME}}))/xo-complete
end
if test -n "$xo_complete_bin"
$xo_complete_bin $argv 2>/dev/null
end
end
# Global options
complete -c {{BIN_NAME}} -s h -d "Show help"
complete -c {{BIN_NAME}} -l help -d "Show help"
complete -c {{BIN_NAME}} -s v -d "Verbose output"
complete -c {{BIN_NAME}} -l verbose -d "Verbose output"
complete -c {{BIN_NAME}} -s o -d "Output file"
complete -c {{BIN_NAME}} -l output -d "Output file"
# Dynamic mnemonic file completion for -m
complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_dynamic mnemonics)'
# Top-level commands
{{TOP_LEVEL_COMMANDS}}
# Static sub-commands
{{STATIC_SUBCOMMANDS}}
# Dynamic completions
# invitation create: template names
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
# invitation create: action names (2nd arg)
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic actions (commandline -opc)[4])'
# invitation append/sign/broadcast/requirements/inspect: invitation IDs
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from append; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from sign; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from broadcast; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from requirements; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
# invitation import: file completion
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from import" -F
# template list/inspect: category first (pos 3), then template (pos 4), then field (pos 5 for inspect)
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 5" -xa '(__{{FUNC_NAME}}_complete_dynamic fields (commandline -opc)[4] (commandline -opc)[5])'
# template set-default: template first
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from set-default; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
# resource unreserve: UTXO outpoints
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from resource; and __fish_seen_subcommand_from unreserve; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic resources)'
# receive: template names
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from receive; and test (count (commandline -opc)) -eq 2" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'

View File

@@ -0,0 +1,176 @@
# zsh completion for {{BIN_NAME}}
# Add to ~/.zshrc: eval "$({{BIN_NAME}} completions zsh)"
# Find xo-complete in the same directory as xo-cli
__xo_complete_bin=""
if (( $+commands[xo-complete] )); then
__xo_complete_bin="xo-complete"
elif (( $+commands[{{BIN_NAME}}] )); then
__xo_complete_bin="${commands[{{BIN_NAME}}]:h}/xo-complete"
fi
# Wrapper to call xo-complete helper
__xo_complete() {
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
}
_{{FUNC_NAME}}_completions() {
local -a commands
commands=({{COMMANDS}})
# Handle -m/--mnemonic-file argument (previous word was -m)
if [[ "${words[CURRENT-1]}" == "-m" || "${words[CURRENT-1]}" == "--mnemonic-file" ]]; then
local mnemonics
mnemonics=("${(@f)$(__xo_complete mnemonics "${words[CURRENT]}")}")
if [[ ${#mnemonics[@]} -gt 0 ]]; then
compadd -- "${mnemonics[@]}"
return
fi
fi
# If typing an option flag, complete options
if [[ "${words[${CURRENT}]}" == -* ]]; then
compadd -- {{OPTIONS}}
return
fi
# Find the command and subcommand
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
for ((i=2; i < CURRENT; i++)); do
if [[ "${words[i]}" != -* ]]; then
if [[ -z "${cmd}" ]]; then
cmd="${words[i]}"
cmd_idx=$i
else
subcmd="${words[i]}"
subcmd_idx=$i
break
fi
fi
done
# No command yet — offer top-level commands
if [[ -z "${cmd}" ]]; then
compadd -- ${commands[@]}
return
fi
# Handle each command's completion
case "${cmd}" in
mnemonic)
if [[ -z "${subcmd}" ]]; then
compadd -- {{MNEMONIC_SUBS}}
fi
;;
template)
if [[ -z "${subcmd}" ]]; then
compadd -- {{TEMPLATE_SUBS}}
elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then
# template list/inspect <category> <template> [field] - category first, then template, then field
local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then
compadd -- action transaction output lockingscript variable
elif [[ $pos -eq 2 ]]; then
local templates
templates=("${(@f)$(__xo_complete templates "${words[CURRENT]}")}")
if [[ ${#templates[@]} -gt 0 ]]; then
compadd -- "${templates[@]}"
fi
elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then
# Get the category and template from previous args
local category="${words[subcmd_idx + 1]}"
local template_arg="${words[subcmd_idx + 2]}"
local fields
fields=("${(@f)$(__xo_complete fields "${category}" "${template_arg}" "${words[CURRENT]}")}")
if [[ ${#fields[@]} -gt 0 ]]; then
compadd -- "${fields[@]}"
fi
fi
elif [[ "${subcmd}" == "set-default" ]]; then
# template set-default <template> <output> <role> - template first
local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
templates=("${(@f)$(__xo_complete templates "${words[CURRENT]}")}")
if [[ ${#templates[@]} -gt 0 ]]; then
compadd -- "${templates[@]}"
fi
fi
fi
;;
invitation)
if [[ -z "${subcmd}" ]]; then
compadd -- {{INVITATION_SUBS}}
else
case "${subcmd}" in
create)
local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
templates=("${(@f)$(__xo_complete templates "${words[CURRENT]}")}")
if [[ ${#templates[@]} -gt 0 ]]; then
compadd -- "${templates[@]}"
fi
elif [[ $pos -eq 2 ]]; then
local template_arg="${words[subcmd_idx + 1]}"
local actions
actions=("${(@f)$(__xo_complete actions "${template_arg}" "${words[CURRENT]}")}")
if [[ ${#actions[@]} -gt 0 ]]; then
compadd -- "${actions[@]}"
fi
fi
;;
append|sign|broadcast|requirements|inspect)
local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local invitations
invitations=("${(@f)$(__xo_complete invitations "${words[CURRENT]}")}")
if [[ ${#invitations[@]} -gt 0 ]]; then
compadd -- "${invitations[@]}"
fi
fi
;;
import)
_files
;;
esac
fi
;;
resource)
if [[ -z "${subcmd}" ]]; then
compadd -- {{RESOURCE_SUBS}}
elif [[ "${subcmd}" == "unreserve" ]]; then
local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local resources
resources=("${(@f)$(__xo_complete resources "${words[CURRENT]}")}")
if [[ ${#resources[@]} -gt 0 ]]; then
compadd -- "${resources[@]}"
fi
fi
fi
;;
receive)
local pos=$((CURRENT - cmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
templates=("${(@f)$(__xo_complete templates "${words[CURRENT]}")}")
if [[ ${#templates[@]} -gt 0 ]]; then
compadd -- "${templates[@]}"
fi
fi
;;
completions)
if [[ -z "${subcmd}" ]]; then
compadd -- bash zsh fish
fi
;;
esac
}
compdef _{{FUNC_NAME}}_completions {{BIN_NAME}}

View File

@@ -0,0 +1,7 @@
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";
export * from "./types.js";

View File

@@ -0,0 +1,774 @@
import { 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 "../utils.js";
import type { CommandDependencies, CommandIO } from "./types.js";
import { CommandError } 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";
import { resolveTemplate } from "../utils.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<string, string>,
): { 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<string, string>,
): Promise<BuildAppendResult | null> {
// --- Inputs ---
// Accepts comma-separated <txhash>:<vout> 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) {
deps.io.err("No suitable UTXOs found for auto-input selection.");
return null;
}
deps.io.verbose(`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 <txhash>:<vout> (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 <txhash>:<vout> (e.g. abc123:0)`,
);
}
return {
outpointTransactionHash: hexToBin(txHash),
outpointIndex: vout,
};
});
}
deps.io.verbose(
`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<string>();
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.io.verbose(
`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. <recipientLockingscript>).
const variableValuesByIdentifier: Record<string, string> = {};
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.io.verbose(
`Locking bytecode for output "${outputId}": ${lockingBytecodeHex}`,
);
return {
outputIdentifier: outputId,
lockingBytecode: new Uint8Array(Buffer.from(lockingBytecodeHex, "hex")),
};
}),
);
deps.io.verbose(
`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) {
deps.io.err(
`UTXO not found: ${txHashHex}:${input.outpointIndex}. Make sure it exists in your wallet.`,
);
return null;
}
totalInputSats += BigInt(utxo.valueSatoshis);
}
deps.io.verbose(`Total input value: ${totalInputSats} satoshis`);
const requiredSats = await invitation.getSatsOut();
deps.io.verbose(`Required output value: ${requiredSats} satoshis`);
const changeAmount = totalInputSats - requiredSats - DEFAULT_FEE;
deps.io.verbose(
`Change amount: ${changeAmount} satoshis (fee: ${DEFAULT_FEE})`,
);
if (changeAmount < 0n) {
deps.io.err(
`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 });
deps.io.out(`Auto-adding change output: ${changeAmount} satoshis`);
} else if (changeAmount > 0n) {
deps.io.out(
`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 = (io: CommandIO): void => {
io.out(
`
${bold("Usage:")} xo-cli invitation <sub-command>
${bold("Sub-commands:")}
- create <template-file> <action-id> ${dim("Create a new invitation")}
- append <invitation-id> ${dim("Add variables/outputs to an invitation")}
- sign <invitation-id> ${dim("Sign an invitation")}
- broadcast <invitation-id> ${dim("Broadcast an invitation")}
- requirements <invitation-id> ${dim("Show requirements for an invitation")}
- import <invitation-file> ${dim("Import an invitation from a file")}
- inspect <invitation-id | invitation-file> ${dim("Inspect an invitation")}
- list ${dim("List all invitations")}
${bold("Create / Append options:")}
-var-<name> <value> ${dim("Set a variable (e.g. -var-requested-satoshis 1000)")}
--add-input <txhash:vout> ${dim("Add UTXO input(s), comma-separated (e.g. abc123:0,def456:1)")}
--add-output <id> ${dim("Override output(s) — omit to auto-discover from template")}
--auto-inputs ${dim("Automatically select UTXOs as inputs")}
-role <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.")}
`,
);
};
/**
* Result data returned by invitation commands on success.
*/
export type InvitationCommandResult = {
invitationIdentifier?: string;
txHash?: string;
count?: number;
templateName?: string;
actionIdentifier?: string;
status?: string;
entities?: { entityIdentifier: string; roles: (string | undefined)[] }[];
inputs?: unknown[];
outputs?: unknown[];
variables?: unknown[];
};
/**
* Handles the invitation command.
* Throws CommandError on failure, returns result data on success.
* @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<string, string>,
): Promise<InvitationCommandResult> => {
const subCommand = args[0];
deps.io.verbose(`Invitation sub-command: ${subCommand}`);
if (!subCommand) {
deps.io.verbose("No sub-command provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.subcommand.missing",
"No sub-command provided",
);
}
switch (subCommand) {
case "create": {
const templateQuery = args[1];
const actionIdentifier = args[2];
deps.io.verbose(
`Template query: ${templateQuery}, action identifier: ${actionIdentifier}`,
);
if (!templateQuery || !actionIdentifier) {
deps.io.verbose("No template file or action identifier provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.create.arguments_missing",
"No template file or action identifier provided",
);
}
const template = await resolveTemplate(deps, templateQuery);
const templateIdentifier = generateTemplateIdentifier(template);
const rawInvitation = await deps.app.engine.createInvitation({
templateIdentifier,
actionIdentifier,
});
deps.io.verbose(`XOInvitation created: ${formatObject(rawInvitation)}`);
const invitationInstance = await deps.app.createInvitation(rawInvitation);
deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`,
);
const variables = parseVariablesFromOptions(options);
deps.io.verbose(`Variables: ${formatObject(variables)}`);
if (variables.length > 0) {
await invitationInstance.addVariables(variables);
}
const params = await buildAppendParams(deps, invitationInstance, options);
if (!params) {
throw new CommandError(
"invitation.create.append_params_failed",
"Failed to build append parameters",
);
}
const { inputs, outputs } = params;
if (inputs.length > 0 || outputs.length > 0) {
await invitationInstance.append({ inputs, outputs });
}
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitationInstance.data.invitationIdentifier}.json`;
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
writeFileSync(
invitationFilePath,
encodeExtendedJson(invitationInstance.data, 2),
);
deps.io.out(
`Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`,
);
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) {
deps.io.out(`\n${bold("Remaining requirements:")}`);
deps.io.out(formatObject(missingRequirements));
} else {
const shouldSign =
options["sign"] === "true" || options["broadcast"] === "true";
const shouldBroadcast = options["broadcast"] === "true";
if (shouldSign) {
await invitationInstance.sign();
deps.io.out(
`Invitation signed: ${invitationInstance.data.invitationIdentifier}`,
);
}
if (shouldBroadcast) {
const txHash = await invitationInstance.broadcast();
deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
} else if (!shouldSign) {
deps.io.out(
`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationInstance.data.invitationIdentifier}`,
);
}
}
return {
invitationIdentifier: invitationInstance.data.invitationIdentifier,
};
}
case "append": {
const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.append.identifier_missing",
"No invitation identifier provided",
);
}
const invitation = deps.app.invitations.find(
(inv) => inv.data.invitationIdentifier === invitationIdentifier,
);
if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError(
"invitation.append.not_found",
`Invitation not found: ${invitationIdentifier}`,
);
}
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
const variables = parseVariablesFromOptions(options);
deps.io.verbose(`Variables to append: ${formatObject(variables)}`);
if (variables.length > 0) {
await invitation.addVariables(variables);
}
const params = await buildAppendParams(deps, invitation, options);
if (!params) {
throw new CommandError(
"invitation.append.params_failed",
"Failed to build append parameters",
);
}
const { inputs, outputs } = params;
if (
variables.length === 0 &&
inputs.length === 0 &&
outputs.length === 0
) {
const error =
"Nothing to append. Provide variables (-var-<name> <value>), inputs (--add-input <txhash>:<vout>), or outputs (--add-output <identifier>).";
deps.io.err(error);
throw new CommandError("invitation.append.empty", error);
}
if (inputs.length > 0 || outputs.length > 0) {
await invitation.append({ inputs, outputs });
}
deps.io.verbose(`Invitation appended: ${formatObject(invitation.data)}`);
deps.io.out(`Invitation appended: ${invitationIdentifier}`);
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`;
writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2));
deps.io.out(
`Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`,
);
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) {
deps.io.out(`\n${bold("Remaining requirements:")}`);
deps.io.out(formatObject(missingRequirements));
} else {
const shouldSign =
options["sign"] === "true" || options["broadcast"] === "true";
const shouldBroadcast = options["broadcast"] === "true";
if (shouldSign) {
await invitation.sign();
deps.io.out(`Invitation signed: ${invitationIdentifier}`);
}
if (shouldBroadcast) {
const txHash = await invitation.broadcast();
deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
} else if (!shouldSign) {
deps.io.out(
`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationIdentifier}`,
);
}
}
return { invitationIdentifier };
}
case "sign": {
const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.sign.identifier_missing",
"No invitation identifier provided",
);
}
const invitation = deps.app.invitations.find(
(candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
);
if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError(
"invitation.sign.not_found",
`Invitation not found: ${invitationIdentifier}`,
);
}
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
await invitation.sign();
deps.io.verbose(`Invitation signed: ${formatObject(invitation.data)}`);
deps.io.out(`Invitation signed: ${invitationIdentifier}`);
return { invitationIdentifier };
}
case "broadcast": {
const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.broadcast.identifier_missing",
"No invitation identifier provided",
);
}
const invitation = deps.app.invitations.find(
(candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
);
if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError(
"invitation.broadcast.not_found",
`Invitation not found: ${invitationIdentifier}`,
);
}
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
const txHash = await invitation.broadcast();
deps.io.verbose(
`Invitation broadcasted: ${formatObject(invitation.data)}`,
);
deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
return { invitationIdentifier, txHash };
}
case "requirements": {
const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.requirements.identifier_missing",
"No invitation identifier provided",
);
}
const invitation = deps.app.invitations.find(
(candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
);
if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError(
"invitation.requirements.not_found",
`Invitation not found: ${invitationIdentifier}`,
);
}
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
const requirements = await deps.app.engine.listRequirements(
invitation.data,
);
deps.io.verbose(`Requirements: ${formatObject(requirements)}`);
deps.io.out(formatObject(requirements));
return { invitationIdentifier };
}
case "inspect": {
const invitationFilePath = args[1];
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
if (!invitationFilePath) {
deps.io.verbose("No invitation file provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.inspect.file_missing",
"No invitation file provided",
);
}
const invitationFile = await readFileSync(invitationFilePath, "utf8");
deps.io.verbose(`Invitation file: ${invitationFile}`);
const invitation = JSON.parse(invitationFile);
deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
const invitationInstance = await deps.app.createInvitation(invitation);
deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`,
);
const template = await deps.app.engine.getTemplate(
invitationInstance.data.templateIdentifier,
);
const action =
template?.actions[invitationInstance.data.actionIdentifier];
deps.io.verbose(`Action: ${formatObject(action)}`);
if (!action) {
deps.io.err(
`Action not found: ${invitationInstance.data.actionIdentifier}`,
);
throw new CommandError(
"invitation.inspect.action_not_found",
`Action not found: ${invitationInstance.data.actionIdentifier}`,
);
}
const status = invitationInstance.status;
deps.io.verbose(`Status: ${status}`);
const entities = Array.from(
new Set(
invitationInstance.data.commits.map(
(commit) => commit.entityIdentifier,
),
),
);
deps.io.verbose(`Entities: ${formatObject(entities)}`);
const entitiesWithRoles = entities.map((entity) => {
return {
entityIdentifier: entity,
roles: invitationInstance.data.commits
.filter((commit) => commit.entityIdentifier === entity)
.map((commit) => {
return [
...(commit.data.inputs?.map((input) => input.roleIdentifier) ??
[]),
...(commit.data.outputs?.map(
(output) => output.roleIdentifier,
) ?? []),
...(commit.data.variables?.map(
(variable) => variable.roleIdentifier,
) ?? []),
];
})
.flat()
.filter((role) => role !== undefined),
};
});
const inputs = invitationInstance.data.commits.flatMap(
(commit) => commit.data.inputs ?? [],
);
deps.io.verbose(`Inputs: ${formatObject(inputs)}`);
const outputs = invitationInstance.data.commits.flatMap(
(commit) => commit.data.outputs ?? [],
);
deps.io.verbose(`Outputs: ${formatObject(outputs)}`);
const variables = invitationInstance.data.commits.flatMap(
(commit) => commit.data.variables ?? [],
);
deps.io.verbose(`Variables: ${formatObject(variables)}`);
return {
templateName: template?.name ?? "Unknown",
actionIdentifier: invitationInstance.data.actionIdentifier,
status: status,
entities: entitiesWithRoles,
inputs: inputs,
outputs: outputs,
variables: variables,
};
}
case "import": {
const invitationFilePath = args[1];
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
if (!invitationFilePath) {
deps.io.verbose("No invitation file provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.import.file_missing",
"No invitation file provided",
);
}
const invitationFile = await readFileSync(invitationFilePath, "utf8");
deps.io.verbose(`Invitation file: ${invitationFile}`);
const invitation = JSON.parse(invitationFile);
deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
const xoInvitation = await deps.app.engine.createInvitation(invitation);
const invitationInstance = await deps.app.createInvitation(xoInvitation);
deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`,
);
return {
invitationIdentifier: invitationInstance.data.invitationIdentifier,
};
}
case "list": {
const invitations = await Promise.all(
deps.app.invitations.map(async (invitation) => {
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.io.verbose(`Invitations: ${formatObject(invitations)}`);
const formattedInvitations = invitations.map(
(invitation) =>
`${bold(invitation.templateName)} ${dim(invitation.status)} ${dim(invitation.invitationIdentifier)} ${dim(invitation.actionIdentifier)} (${dim(invitation.roleIdentifier)})`,
);
deps.io.out(formattedInvitations.join("\n"));
return { count: invitations.length };
}
default:
deps.io.verbose(`Unknown invitation sub-command: ${subCommand}`);
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.subcommand.unknown",
`Unknown invitation sub-command: ${subCommand}`,
);
}
};

View File

@@ -0,0 +1,129 @@
import { bold, dim } from "../utils.js";
import {
listMnemonicFiles,
createMnemonicFile,
createMnemonicSeed,
loadMnemonic,
} from "../mnemonic.js";
import type { BaseCommandDependencies, CommandIO } from "./types.js";
import { CommandError } from "./types.js";
/**
* Prints the help message for the mnemonic command
*/
export const printMnemonicHelp = (io: CommandIO): void => {
io.out(
`
${bold("Usage:")} xo-cli mnemonic <sub-command>
${bold("Sub-commands:")}
- create <mnemonic-seed> ${dim("Create a new mnemonic file")}
- list ${dim("List all mnemonic files")}
- import <mnemonic-seed> ${dim("Import a mnemonic seed from a file")}
- expose <mnemonic-file> ${dim("Expose a mnemonic file")}
${bold("Options:")}
-o --output <output-filename> ${dim("Output filename for the mnemonic file")}
-h --help ${dim("Show this help message")}
`,
);
};
/**
* Handles the mnemonic command.
* Throws CommandError on failure, returns result data on success.
* @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: BaseCommandDependencies,
args: string[],
options: Record<string, string>,
): Promise<{ savedAs?: string; count?: number; mnemonic?: string }> => {
const subCommand = args[0];
const { mnemonicsDir } = deps.paths;
if (!subCommand) {
deps.io.verbose("No sub-command provided");
printMnemonicHelp(deps.io);
throw new CommandError(
"mnemonic.subcommand.missing",
"No sub-command provided",
);
}
switch (subCommand) {
case "create": {
const mnemonicSeed = createMnemonicSeed();
const savedAs = createMnemonicFile(
mnemonicsDir,
mnemonicSeed,
options["output"],
);
deps.io.out(`Mnemonic file created: ${savedAs} (${mnemonicSeed})`);
return { savedAs };
}
case "import": {
const mnemonicSeed = args.slice(1).join(" ");
if (!mnemonicSeed) {
deps.io.verbose("No mnemonic seed provided");
printMnemonicHelp(deps.io);
throw new CommandError(
"mnemonic.import.seed_missing",
"No mnemonic seed provided",
);
}
deps.io.verbose(`Mnemonic seed: ${mnemonicSeed}`);
const savedAs = createMnemonicFile(
mnemonicsDir,
mnemonicSeed,
options["output"],
);
deps.io.out(`Mnemonic file created: ${savedAs}`);
return { savedAs };
}
case "list": {
const mnemonicFiles = listMnemonicFiles(mnemonicsDir);
deps.io.out(mnemonicFiles.join("\n"));
return { count: mnemonicFiles.length };
}
case "expose": {
const mnemonicFile = args[1];
if (!mnemonicFile) {
deps.io.verbose("No mnemonic file provided");
printMnemonicHelp(deps.io);
throw new CommandError(
"mnemonic.expose.file_missing",
"No mnemonic file provided",
);
}
try {
const mnemonic = loadMnemonic(mnemonicsDir, mnemonicFile);
deps.io.out(mnemonic);
return { mnemonic };
} catch (error) {
throw new CommandError(
"mnemonic.expose.file_not_found",
`Mnemonic file not found: ${mnemonicFile}`,
);
}
}
default:
deps.io.err(`Unknown sub-command: ${subCommand}`);
printMnemonicHelp(deps.io);
throw new CommandError(
"mnemonic.subcommand.unknown",
`Unknown sub-command: ${subCommand}`,
);
}
};

View File

@@ -0,0 +1,93 @@
import { generateTemplateIdentifier } from "@xo-cash/engine";
import { hexToBin, lockingBytecodeToCashAddress } from "@bitauth/libauth";
import { bold, dim } from "../utils.js";
import type { CommandDependencies, CommandIO } from "./types.js";
import { CommandError } from "./types.js";
import { resolveTemplate } from "../utils.js";
/**
* Prints the help message for the receive command
*/
export const printReceiveHelp = (io: CommandIO): void => {
io.out(
`
${bold("Usage:")} xo-cli receive <template-file> <output-identifier> [role-identifier]
${bold("Description:")}
Generate a single-use receiving address from a template.
${bold("Arguments:")}
<template-file> ${dim("Path to the template JSON file")}
<output-identifier> ${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.
* Throws CommandError on failure, returns address data on success.
*
* @param deps - The command dependencies.
* @param args - Positional args after the command name, e.g. ["template.json", "receiveOutput", "receiver"].
* @param options - Parsed option flags.
* @returns The address data.
* @throws CommandError if the command fails.
*/
export const handleReceiveCommand = async (
deps: CommandDependencies,
args: string[],
_options: Record<string, string>,
): Promise<{ address: string }> => {
const templateQuery = args[0];
const outputIdentifier = args[1];
const roleIdentifier = args[2];
deps.io.verbose(
`Receive args - template: ${templateQuery}, output: ${outputIdentifier}, role: ${roleIdentifier}`,
);
if (!templateQuery || !outputIdentifier) {
deps.io.verbose("Missing required arguments");
printReceiveHelp(deps.io);
throw new CommandError(
"receive.arguments.missing",
"Missing required arguments",
);
}
// Resolve and read the template file
const template = await resolveTemplate(deps, templateQuery);
const templateIdentifier = generateTemplateIdentifier(template);
deps.io.verbose(`Template identifier: ${templateIdentifier}`);
// Generate the locking bytecode (returned as a hex string)
const lockingBytecodeHex = await deps.app.engine.generateLockingBytecode(
templateIdentifier,
outputIdentifier,
roleIdentifier,
);
deps.io.verbose(`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") {
deps.io.err(`Failed to encode address: ${result}`);
throw new CommandError(
"receive.address.encode_failed",
`Failed to encode address: ${result}`,
);
}
deps.io.out(result.address);
return { address: result.address };
};

View File

@@ -0,0 +1,184 @@
import { hexToBin } from "@bitauth/libauth";
import { bold, dim } from "../utils.js";
import type { CommandDependencies, CommandIO } from "./types.js";
import type { UnspentOutputData } from "@xo-cash/state";
import { CommandError } from "./types.js";
/**
* Prints the help message for the resource command.
*/
export const printResourceHelp = (io: CommandIO): void => {
io.out(
`
${bold("Usage:")} xo-cli resource <sub-command>
${bold("Sub-commands:")}
- list ${dim("List all unreserved resources")}
- list reserved ${dim("List reserved resources")}
- list all ${dim("List all resources (reserved + unreserved)")}
- unreserve <txhash:vout> ${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: UnspentOutputData,
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.reservedBy) {
const inv = dim(`reserved for ${resource.reservedBy}`);
return `${outpoint} ${value} ${output} ${height} ${inv}`;
}
return `${outpoint} ${value} ${output} ${height}`;
}
/**
* Handles the resource command.
* Throws CommandError on failure, returns result data on success.
* @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<string, string>,
): Promise<{ count?: number }> => {
const subCommand = args[0];
deps.io.verbose(`Resource sub-command: ${subCommand}`);
if (!subCommand) {
deps.io.verbose("No sub-command provided");
printResourceHelp(deps.io);
throw new CommandError(
"resource.subcommand.missing",
"No sub-command provided",
);
}
switch (subCommand) {
case "list": {
const qualifier = args[1];
const allResources = await deps.app.engine.listUnspentOutputsData();
let filtered;
if (qualifier === "reserved") {
filtered = allResources.filter((r) => r.reservedBy);
} else if (qualifier === "all") {
filtered = allResources;
} else {
filtered = allResources.filter((r) => !r.reservedBy);
}
if (filtered.length === 0) {
deps.io.out(dim("No resources found."));
return { count: 0 };
}
const showReserved = qualifier === "all" || qualifier === "reserved";
const formattedResources = filtered.map((r) =>
formatResource(r, showReserved),
);
deps.io.out(formattedResources.join("\n"));
deps.io.out(
`Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`,
);
deps.io.out(`Total resources: ${filtered.length}`);
return { count: filtered.length };
}
case "unreserve": {
const outpointArg = args[1];
if (!outpointArg) {
deps.io.err("Please provide a UTXO in <txhash>:<vout> format.");
printResourceHelp(deps.io);
throw new CommandError(
"resource.unreserve.outpoint_missing",
"Please provide a UTXO in <txhash>:<vout> format.",
);
}
const separatorIndex = outpointArg.lastIndexOf(":");
if (separatorIndex === -1) {
deps.io.err(
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
);
throw new CommandError(
"resource.unreserve.outpoint_invalid",
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
);
}
const txHash = outpointArg.substring(0, separatorIndex);
const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10);
if (!txHash || isNaN(vout)) {
deps.io.err(
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
);
throw new CommandError(
"resource.unreserve.outpoint_invalid",
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
);
}
const allResources = await deps.app.engine.listUnspentOutputsData();
const target = allResources.find(
(r) => r.outpointTransactionHash === txHash && r.outpointIndex === vout,
);
if (!target) {
deps.io.err(`UTXO not found: ${txHash}:${vout}`);
throw new CommandError(
"resource.unreserve.utxo_missing",
`UTXO not found: ${txHash}:${vout}`,
);
}
if (!target.reservedBy) {
deps.io.out(dim("UTXO is not reserved. Nothing to do."));
return {};
}
await deps.app.engine.unreserveResources(
[{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }],
target.reservedBy,
);
deps.io.out(
`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`,
);
return {};
}
case "unreserve-all": {
const count = await deps.app.unreserveAllResources();
if (count === 0) {
deps.io.out(dim("No reserved resources to unreserve."));
} else {
deps.io.out(`Unreserved ${bold(String(count))} resource(s).`);
}
return { count };
}
default: {
deps.io.verbose(`Unknown resource sub-command: ${subCommand}`);
printResourceHelp(deps.io);
throw new CommandError(
"resource.subcommand.unknown",
`Unknown resource sub-command: ${subCommand}`,
);
}
}
};

View File

@@ -0,0 +1,353 @@
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 } from "../utils.js";
import { resolveTemplateReferences } from "../../utils/templates.js";
import type { CommandDependencies, CommandIO } from "./types.js";
import { CommandError } from "./types.js";
import { resolveTemplate } from "../utils.js";
/**
* Prints the help message for the template command
*/
export const printTemplateHelp = (io: CommandIO): void => {
io.out(
`
${bold("Usage:")} xo-cli template <sub-command>
${bold("Sub-commands:")}
- import <template-file> ${dim("Import a template from a file")}
- list ${dim("List all templates")}
- list <category> <identifier> ${dim("List all options of the field type in a template")}
- inspect <category> <identifier> <field> ${dim("Inspect a field in a template")}
- set-default <template-file> <output-identifier> <role-identifier> ${dim("Set the default template")}
`,
);
};
/**
* Handles the template list command.
* Throws CommandError on failure, returns result data on success.
* @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<{ count?: number }> => {
const templateCategory = args[0];
deps.io.verbose(`Template list category: ${templateCategory}`);
if (!templateCategory) {
const templates = await deps.app.engine.listImportedTemplates();
const formattedTemplates = templates.map(
(template: XOTemplate) =>
`${bold(generateTemplateIdentifier(template))} - ${dim(template.name)} ${dim(template.description)}`,
);
deps.io.out(formattedTemplates.join("\n"));
return { count: templates.length };
}
const templateIdentifier = args[1];
deps.io.verbose(`Template identifier: ${templateIdentifier}`);
if (!templateIdentifier) {
deps.io.err("No template identifier provided");
throw new CommandError(
"template.list.identifier_missing",
"No template identifier provided",
);
}
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
if (!rawTemplate) {
deps.io.err(`No template found: ${templateIdentifier}`);
throw new CommandError(
"template.list.not_found",
`No template found: ${templateIdentifier}`,
);
}
const template = await resolveTemplateReferences(rawTemplate);
deps.io.verbose(`Template: ${formatObject(template)}`);
switch (templateCategory) {
case "action": {
const actions = template.actions;
const formattedActions = Object.entries(actions).map(
([actionIdentifier, action]) =>
`${bold(actionIdentifier)} ${dim(action.name)} ${dim(action.description)}`,
);
deps.io.out(formattedActions.join("\n"));
return {};
}
case "transaction": {
const transactions = template.transactions;
const formattedTransactions = Object.entries(transactions).map(
([transactionIdentifier, transaction]) =>
`${bold(transactionIdentifier)} ${dim(transaction.name)} ${dim(transaction.description)}`,
);
deps.io.out(formattedTransactions.join("\n"));
return {};
}
case "output": {
const outputs = template.outputs;
const formattedOutputs = Object.entries(outputs).map(
([outputIdentifier, output]) =>
`${bold(outputIdentifier)} ${dim(output.name)} ${dim(output.description)}`,
);
deps.io.out(formattedOutputs.join("\n"));
return {};
}
case "lockingscript": {
const lockingscripts = template.lockingScripts;
const formattedLockingscripts = Object.entries(lockingscripts).map(
([lockingScriptIdentifier, lockingScript]) =>
`${bold(lockingScriptIdentifier)} ${dim(lockingScript.name)} ${dim(lockingScript.description)}`,
);
deps.io.out(formattedLockingscripts.join("\n"));
return {};
}
case "variable": {
const variables = template.variables || {};
const formattedVariables = Object.entries(variables).map(
([variableIdentifier, variable]) =>
`${bold(variableIdentifier)} ${dim(variable.name)} ${dim(variable.description)}`,
);
deps.io.out(formattedVariables.join("\n"));
return {};
}
default: {
deps.io.verbose(`Unknown template category: ${templateCategory}`);
throw new CommandError(
"template.list.category_unknown",
`Unknown template category: ${templateCategory}`,
);
}
}
};
/**
* Prints the help message for the template inspect command
*/
export const printTemplateInspectHelp = (io: CommandIO): void => {
io.out(
`
${bold("Usage:")} xo-cli template inspect <category> <identifier> <field>
${bold("Arguments:")}
<category> ${dim("The category of the template to inspect")}
<identifier> ${dim("The identifier of the template to inspect")}
<field> ${dim("The field of the template to inspect")}
${bold("Categories:")}
- action <action-identifier> ${dim("Inspect an action")}
- transaction <transaction-identifier> ${dim("Inspect a transaction")}
- output <output-identifier> ${dim("Inspect an output")}
- lockingscript <lockingscript-identifier> ${dim("Inspect a lockingscript")}
- variable <variable-identifier> ${dim("Inspect a variable")}
`,
);
};
/**
* Handles the template inspect command.
* Throws CommandError on failure, returns empty object on success.
* @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<Record<string, never>> => {
const templateCategory = args[0];
const templateQuery = args[1];
const templateField = args[2];
deps.io.verbose(
`Template inspect args - category: ${templateCategory}, identifier: ${templateQuery}, field: ${templateField}`,
);
if (!templateCategory || !templateQuery || !templateField) {
deps.io.err("No template category, identifier, or field provided");
printTemplateInspectHelp(deps.io);
throw new CommandError(
"template.inspect.arguments_missing",
"No template category, identifier, or field provided",
);
}
const originalTemplate = await resolveTemplate(deps, templateQuery);
deps.io.verbose(`Original Template: ${formatObject(originalTemplate)}`);
const template = await resolveTemplateReferences(originalTemplate);
deps.io.verbose(`Extended Template: ${formatObject(template)}`);
switch (templateCategory) {
case "action": {
const action = template.actions[templateField];
if (!action) {
deps.io.err(`No action found: ${templateField}`);
throw new CommandError(
"template.inspect.action_missing",
`No action found: ${templateField}`,
);
}
deps.io.out(formatObject(action));
return {};
}
case "transaction": {
const transaction = template.transactions?.[templateField];
if (!transaction) {
deps.io.err(`No transaction found: ${templateField}`);
throw new CommandError(
"template.inspect.transaction_missing",
`No transaction found: ${templateField}`,
);
}
deps.io.out(formatObject(transaction));
return {};
}
case "output": {
const output = template.outputs[templateField];
if (!output) {
deps.io.err(`No output found: ${templateField}`);
throw new CommandError(
"template.inspect.output_missing",
`No output found: ${templateField}`,
);
}
deps.io.out(formatObject(output));
return {};
}
case "lockingscript": {
const lockingscript = template.lockingScripts[templateField];
if (!lockingscript) {
deps.io.err(`No lockingscript found: ${templateField}`);
throw new CommandError(
"template.inspect.lockingscript_missing",
`No lockingscript found: ${templateField}`,
);
}
deps.io.out(formatObject(lockingscript));
return {};
}
case "variable": {
const variable = template.variables?.[templateField];
if (!variable) {
deps.io.err(`No variable found: ${templateField}`);
throw new CommandError(
"template.inspect.variable_missing",
`No variable found: ${templateField}`,
);
}
deps.io.out(formatObject(variable));
return {};
}
default: {
deps.io.verbose(`Unknown template category: ${templateCategory}`);
throw new CommandError(
"template.inspect.category_unknown",
`Unknown template category: ${templateCategory}`,
);
}
}
};
/**
* Handles the template command.
* Throws CommandError on failure, returns result data on success.
* @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<string, string>,
): Promise<{ templateFile?: string; count?: number }> => {
const subCommand = args[0];
if (!subCommand) {
deps.io.verbose("No sub-command provided");
printTemplateHelp(deps.io);
throw new CommandError(
"template.subcommand.missing",
"No sub-command provided",
);
}
switch (subCommand) {
case "import": {
const templateFile = args[1];
deps.io.verbose(`Template file: ${templateFile}`);
if (!templateFile) {
deps.io.verbose("No template file provided");
printTemplateHelp(deps.io);
throw new CommandError(
"template.import.file_missing",
"No template file provided",
);
}
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`);
deps.io.verbose(`Template path: ${templatePath}`);
if (!existsSync(templatePath)) {
deps.io.err(`Template file does not exist: ${templatePath}`);
printTemplateHelp(deps.io);
throw new CommandError(
"template.import.file_not_found",
`Template file does not exist: ${templatePath}`,
);
}
const template = await readFileSync(templatePath, "utf8");
deps.io.verbose(`Importing template: ${templateFile}`);
await deps.app.engine.importTemplate(template);
deps.io.verbose(`Template imported: ${templateFile}`);
return { templateFile };
}
case "list": {
return handleTemplateListCommand(deps, args.slice(1));
}
case "inspect": {
return handleTemplateInspectCommand(deps, args.slice(1));
}
case "set-default": {
const templateFile = args[1];
const outputIdentifier = args[2];
const roleIdentifier = args[3];
if (!templateFile || !outputIdentifier || !roleIdentifier) {
deps.io.verbose(
"No template file, output identifier, or role identifier provided",
);
printTemplateHelp(deps.io);
throw new CommandError(
"template.default.arguments_missing",
"No template file, output identifier, or role identifier provided",
);
}
deps.io.verbose(
`Template file: ${templateFile}, output identifier: ${outputIdentifier}, role identifier: ${roleIdentifier}`,
);
await deps.app.engine.setDefaultLockingParameters(
templateFile,
outputIdentifier,
roleIdentifier,
);
return {};
}
default:
deps.io.verbose(`Unknown template sub-command: ${subCommand}`);
printTemplateHelp(deps.io);
throw new CommandError(
"template.subcommand.unknown",
`Unknown template sub-command: ${subCommand}`,
);
}
};

59
src/cli/commands/types.ts Normal file
View File

@@ -0,0 +1,59 @@
import type { AppService } from "../../services/app.js";
/**
* IO contract for CLI command handlers.
* Handlers write user-visible output through this abstraction so unit tests can
* assert behavior without spying on global console methods.
*/
export type CommandIO = {
out: (message: string) => void;
err: (message: string) => void;
verbose: (message: string) => void;
};
/**
* Paths configuration for CLI commands.
* Allows injection of custom paths for testing.
*/
export type CommandPaths = {
/** Directory for mnemonic wallet files */
mnemonicsDir: string;
/** Directory for engine DB and invitation storage files */
dataDir: string;
/** File storing the last-used mnemonic reference */
walletConfigPath: string;
/** Working directory for file output (invitation files, etc.) */
workingDir: string;
};
/**
* Base dependencies available to every command handler.
*/
export type BaseCommandDependencies = {
io: CommandIO;
paths: CommandPaths;
};
/**
* Dependencies for app-backed commands.
*/
export type CommandDependencies = BaseCommandDependencies & {
app: AppService;
};
/**
* Custom error class for command failures.
* Thrown by command handlers when an operation fails.
* The `event` property can be used for telemetry/testing to identify failure types.
*/
export class CommandError extends Error {
public readonly event: string;
public readonly code: number;
constructor(event: string, message: string, code = 1) {
super(message);
this.name = "CommandError";
this.event = event;
this.code = code;
}
}

272
src/cli/index.ts Normal file
View File

@@ -0,0 +1,272 @@
#!/usr/bin/env node
/**
* 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 <template-file>
* xo-cli template list
* xo-cli template set-default <template-file> <output-identifier> <role-identifier>
*
* xo-cli invitation list
* xo-cli invitation create <template-file> <action-id> [-o Output file, var-${action-variable-name}=${value}, role=${value}]
* xo-cli invitation import <invitation-file>
* xo-cli invitation sign <invitation-file>
* xo-cli invitation broadcast <invitation-file>
*
* xo-cli resource list
*
* universal Args:
* -h --help
* -m --mnemonic-file <mnemonic-file>
*/
import { existsSync, readFileSync, writeFileSync } from "fs";
import { join } from "path";
import { AppService } from "../services/app.js";
import { convertArgsToObject } from "./arguments.js";
import { bold, dim, formatObject } from "./utils.js";
import { listGlobalMnemonicFiles, loadMnemonic } from "./mnemonic.js";
import {
getDataDir,
getMnemonicsDir,
getWalletConfigPath,
} from "../utils/paths.js";
import {
type CommandDependencies,
type CommandIO,
type CommandPaths,
CommandError,
handleMnemonicCommand,
handleTemplateCommand,
handleInvitationCommand,
handleReceiveCommand,
handleResourceCommand,
} from "./commands/index.js";
import { handleCompletionsCommand } from "./autocomplete/completions.js";
const createCommandIO = (verbose: boolean): CommandIO => ({
out: (message: string) => {
console.log(message);
},
err: (message: string) => {
console.error(message);
},
verbose: (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<void> {
// 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 io = createCommandIO(options["verbose"] === "true");
// Log the parsed app args
io.verbose(`Parsed args: ${formatObject(args)}`);
io.verbose(`Parsed options: ${formatObject(options)}`);
// Handle the command
const command = args[0];
io.verbose(`Command: ${command}`);
if (!command) {
// TODO: Print help, probably...
io.err("No command provided");
process.exit(1);
}
// Positional args after the command name (sub-command, files, etc.)
const subArgs = args.slice(1);
// Build paths object from global path functions
const paths: CommandPaths = {
mnemonicsDir: getMnemonicsDir(),
dataDir: getDataDir(),
walletConfigPath: getWalletConfigPath(),
workingDir: process.cwd(),
};
// Early handling for completions command
if (command === "completions") {
handleCompletionsCommand(subArgs, options);
process.exit(0);
}
if (command === "mnemonic") {
try {
await handleMnemonicCommand({ io, paths }, subArgs, options);
process.exit(0);
} catch (error) {
if (error instanceof CommandError) {
process.exit(error.code);
}
throw error;
}
}
// Resolve mnemonic file: explicit flag > persisted config > error.
let mnemonicFile = options["mnemonicFile"];
if (!mnemonicFile && existsSync(paths.walletConfigPath)) {
mnemonicFile = readFileSync(paths.walletConfigPath, "utf8").trim();
io.verbose(`Using persisted wallet: ${mnemonicFile}`);
}
if (!mnemonicFile) {
io.err("No mnemonic file provided");
io.out(
`You can create a mnemonic file with the following command: xo-cli mnemonic create <mnemonic-seed> or use one of the following files: \n${listGlobalMnemonicFiles().join("\n")}`,
);
io.out(
`\nTip: pass -m <file> once and it will be remembered in ${paths.walletConfigPath}`,
);
process.exit(1);
}
// Persist the choice so subsequent commands can omit -m.
writeFileSync(paths.walletConfigPath, mnemonicFile);
const mnemonic = loadMnemonic(paths.mnemonicsDir, mnemonicFile);
io.verbose(`Loaded mnemonic from file: ${mnemonicFile} ${mnemonic}`);
// Create an App instance
io.verbose("Creating app instance...");
const app = await AppService.create(mnemonic, {
syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000",
engineConfig: {
databasePath: options["databasePath"] ?? paths.dataDir,
databaseFilename: options["databaseFilename"] ?? "xo-wallet.db",
},
invitationStoragePath:
options["invitationStoragePath"] ??
join(paths.dataDir, "xo-invitations.db"),
});
io.verbose("App instance created");
// Start the app
// TODO: Rethink this. Do we really want to start the app here? It just slows it down if we dont actually have to have it started for the command
io.verbose("Starting app...");
await app.start();
io.verbose("App started");
const commandDependencies: CommandDependencies = {
io,
paths,
app,
};
// Handle the command
try {
let result: unknown;
switch (command) {
case "template":
result = await handleTemplateCommand(
commandDependencies,
subArgs,
options,
);
break;
case "invitation":
result = await handleInvitationCommand(
commandDependencies,
subArgs,
options,
);
break;
case "receive":
result = await handleReceiveCommand(
commandDependencies,
subArgs,
options,
);
break;
case "resource":
result = await handleResourceCommand(
commandDependencies,
subArgs,
options,
);
break;
case "help":
result = await handleHelpCommand(commandDependencies, subArgs, options);
break;
default:
io.err(`Unknown command: ${command}`);
throw new CommandError(
"cli.command.unknown",
`Unknown command: ${command}`,
);
}
// console.log(result);
// objectPrint(result);
process.exit(0);
} catch (error) {
if (error instanceof CommandError) {
io.err(error.message);
process.exit(error.code);
}
throw error;
}
}
const handleHelpCommand = async (
deps: CommandDependencies,
_args: string[],
_options: Record<string, string>,
): Promise<Record<string, never>> => {
deps.io.out(
`${bold("XO-CLI Help:")}
${bold("Usage:")} xo-cli <command> [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)")}
help ${dim("Show this help message")}
Options:
-h, --help ${dim("Show this help message")}
-m, --mnemonic-file <mnemonic-file> ${dim("Use a specific mnemonic file")}
-v, --verbose ${dim("Show verbose output")}`,
);
return {};
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

123
src/cli/mnemonic.ts Normal file
View File

@@ -0,0 +1,123 @@
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, isAbsolute, join, resolve } from "node:path";
import { encodeBip39Mnemonic, generateBip39Mnemonic } from "@bitauth/libauth";
import { BCHMnemonicURL } from "../utils/bch-mnemonic-url.js";
import { getMnemonicsDir as getGlobalMnemonicsDir } from "../utils/paths.js";
/**
* Create a new mnemonic seed phrase
*/
export const createMnemonicSeed = (): string => {
return generateBip39Mnemonic();
};
/**
* Creates a mnemonic file from a mnemonic seed
* @param mnemonicsDir - Directory to store mnemonic files
* @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 = (
mnemonicsDir: string,
mnemonic: string,
outputFilename?: string,
): string => {
const mnemonicUrl = BCHMnemonicURL.fromSeed(mnemonic);
let fileName = outputFilename;
if (!fileName) {
const firstWord = mnemonic.split(" ")[0]?.toLowerCase();
if (!firstWord) {
throw new Error(
"Failed to create mnemonic file: Unable to extract first word from the mnemonic",
);
}
fileName = `mnemonic-${firstWord}`;
}
const safeName = basename(fileName);
const outPath = join(mnemonicsDir, safeName);
writeFileSync(outPath, mnemonicUrl.toURL());
return safeName;
};
/**
* Resolves a mnemonic reference to an absolute path.
* Order: absolute path if it exists → path relative to cwd → mnemonicsDir/<basename>.
*
* @param mnemonicsDir - Directory containing mnemonic files
* @param mnemonicRef - Path or basename (e.g. `mnemonic-nuclear`)
* @returns Absolute path to the mnemonic file
* @throws If no matching file exists
*/
export const resolveMnemonicFilePath = (
mnemonicsDir: string,
mnemonicRef: string,
): string => {
if (isAbsolute(mnemonicRef) && existsSync(mnemonicRef)) {
return mnemonicRef;
}
const relativeToCwd = resolve(process.cwd(), mnemonicRef);
if (existsSync(relativeToCwd)) {
return relativeToCwd;
}
const inMnemonics = join(mnemonicsDir, basename(mnemonicRef));
if (existsSync(inMnemonics)) {
return inMnemonics;
}
throw new Error(
`Mnemonic file not found: ${mnemonicRef}. Run "xo-cli mnemonic list" to see available files.`,
);
};
/**
* Loads a mnemonic from a mnemonic file
* @param mnemonicsDir - Directory containing mnemonic files
* @param mnemonicFile - The filename of the mnemonic file
* @returns The mnemonic seed
*/
export const loadMnemonic = (
mnemonicsDir: string,
mnemonicFile: string,
): string => {
const resolvedPath = resolveMnemonicFilePath(mnemonicsDir, mnemonicFile);
const mnemonicUrl = BCHMnemonicURL.fromURL(
readFileSync(resolvedPath, "utf8"),
);
const { entropy } = mnemonicUrl.toObject();
const mnemonic = encodeBip39Mnemonic(entropy);
if (typeof mnemonic === "string") {
throw new Error(`Failed to convert entropy to mnemonic: ${mnemonic}`);
}
return mnemonic.phrase;
};
/**
* Lists mnemonic files in the given directory.
* @param mnemonicsDir - Directory containing mnemonic files
* @returns Basenames suitable for `-m <name>`
*/
export const listMnemonicFiles = (mnemonicsDir: string): string[] => {
const filenames = readdirSync(mnemonicsDir).filter((f: string) =>
f.startsWith("mnemonic-"),
);
return filenames;
};
/**
* Lists mnemonic files using the global mnemonics directory.
* Convenience function for use in the CLI entry point before paths are resolved.
* @returns Basenames suitable for `-m <name>`
*/
export const listGlobalMnemonicFiles = (): string[] => {
return listMnemonicFiles(getGlobalMnemonicsDir());
};

106
src/cli/utils.ts Normal file
View File

@@ -0,0 +1,106 @@
import util from "node:util";
import type { XOTemplate } from "@xo-cash/types";
import { generateTemplateIdentifier } from "@xo-cash/engine";
import type { CommandDependencies } from "./commands/types.js";
import { CommandError } from "./commands/types.js";
/**
* Iterate through the templates, trying to match the id or the name with the given input.
* Use multiple for-loops.
* First, check the id of every template
* Then, check the name of every template. If multiple names match, throw an error.
* If no match is found, throw an error.
*
* @param deps - The command dependencies.
* @param query - The id or name of the template to resolve.
* @returns The template object.
* @throws CommandError if no template is found.
* @throws CommandError if multiple templates are found.
*/
export const resolveTemplate = async (
deps: CommandDependencies,
query: string,
): Promise<XOTemplate> => {
const templates = await deps.app.engine.listImportedTemplates();
const matches = new Set<XOTemplate>();
for (const template of templates) {
if (generateTemplateIdentifier(template) === query) {
return template;
}
}
for (const template of templates) {
if (template.name === query) {
matches.add(template);
}
}
if (matches.size > 1) {
throw new CommandError(
"template.resolve.multiple_matches",
`Multiple templates found for "${query}": ${Array.from(matches)
.map(
(template) =>
`${template.name} (${generateTemplateIdentifier(template)})`,
)
.join(", ")}`,
);
}
if (matches.size === 1) {
return matches.values().next().value!;
}
throw new CommandError(
"template.resolve.not_found",
`Template not found: ${query}`,
);
};
/**
* 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,
});
};

View File

@@ -1,3 +1,4 @@
#!/usr/bin/env node
/** /**
* XO Wallet CLI - Terminal User Interface for XO crypto wallet. * XO Wallet CLI - Terminal User Interface for XO crypto wallet.
* *
@@ -9,18 +10,25 @@
* 5. Real-time updates via SSE * 5. Real-time updates via SSE
*/ */
import { join } from "node:path";
import { App } from "./app.js"; import { App } from "./app.js";
import { getDataDir } from "./utils/paths.js";
/** /**
* Main entry point. * Main entry point.
*/ */
async function main(): Promise<void> { async function main(): Promise<void> {
try { try {
const dataDir = getDataDir();
// Create and start the application // Create and start the application
await App.create({ await App.create({
syncServerUrl: process.env["SYNC_SERVER_URL"] ?? "http://localhost:3000", syncServerUrl: process.env["SYNC_SERVER_URL"] ?? "http://localhost:3000",
databasePath: process.env["DB_PATH"] ?? "./", databasePath: process.env["DB_PATH"] ?? dataDir,
databaseFilename: process.env["DB_FILENAME"] ?? "xo-wallet.db", databaseFilename: process.env["DB_FILENAME"] ?? "xo-wallet.db",
invitationStoragePath:
process.env["INVITATION_STORAGE_PATH"] ??
join(dataDir, "xo-invitations.db"),
}); });
} catch (error) { } catch (error) {
console.error("Failed to start XO Wallet CLI:", error); console.error("Failed to start XO Wallet CLI:", error);

View File

@@ -7,10 +7,11 @@ import {
import type { XOInvitation } from "@xo-cash/types"; import type { XOInvitation } from "@xo-cash/types";
import { Invitation } from "./invitation.js"; import { Invitation } from "./invitation.js";
import { Storage } from "./storage.js"; import { BaseStorage, Storage } from "./storage.js";
import { SyncServer } from "../utils/sync-server.js"; import { SyncServer } from "../utils/sync-server.js";
import { HistoryService } from "./history.js"; import { HistoryService } from "./history.js";
import { ElectrumService } from "./electrum.js"; import { type BlockchainService, ElectrumService } from "./electrum.js";
import { RatesService } from "./rates.js";
import { EventEmitter } from "../utils/event-emitter.js"; import { EventEmitter } from "../utils/event-emitter.js";
@@ -18,10 +19,19 @@ import { EventEmitter } from "../utils/event-emitter.js";
import { createHash } from "crypto"; import { createHash } from "crypto";
import { p2pkhTemplate } from "@xo-cash/templates"; import { p2pkhTemplate } from "@xo-cash/templates";
import { hexToBin } from "@bitauth/libauth"; import { hexToBin } from "@bitauth/libauth";
import { parseTemplate } from "@xo-cash/engine";
export type AppEventMap = { export type AppEventMap = {
"invitation-added": Invitation; "invitation-added": Invitation;
"invitation-removed": Invitation; "invitation-removed": Invitation;
"wallet-state-changed": {
reason:
| "invitation-added"
| "invitation-removed"
| "invitation-updated"
| "invitation-status-changed";
invitationIdentifier: string;
};
}; };
export interface AppConfig { export interface AppConfig {
@@ -34,12 +44,20 @@ export interface AppConfig {
export class AppService extends EventEmitter<AppEventMap> { export class AppService extends EventEmitter<AppEventMap> {
public engine: Engine; public engine: Engine;
public storage: Storage; public storage: BaseStorage;
public config: AppConfig; public config: AppConfig;
public history: HistoryService; public history: HistoryService;
public electrum: ElectrumService; public electrum: BlockchainService;
public rates: RatesService;
public invitations: Invitation[] = []; public invitations: Invitation[] = [];
private invitationEventCleanup = new Map<
string,
{
onUpdated: (invitation: XOInvitation) => void;
onStatusChanged: (status: string) => void;
}
>();
static async create(seed: string, config: AppConfig): Promise<AppService> { static async create(seed: string, config: AppConfig): Promise<AppService> {
// Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app. // 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,15 +75,28 @@ export class AppService extends EventEmitter<AppEventMap> {
// TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here // 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 // 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 // Set default locking parameters for P2PKH
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically. // To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
// TODO: Add discovery for funds in the first index? Or until we return 0 TXs? // TODO: Add discovery for funds in the first index? Or until we return 0 TXs?
await engine.setDefaultLockingParameters( await engine.setDefaultLockingParameters(
generateTemplateIdentifier(p2pkhTemplate), generateTemplateIdentifier(parseTemplate(p2pkhTemplate)),
"receiveOutput", "receiveOutput",
"receiver", "receiver",
); );
@@ -79,41 +110,17 @@ export class AppService extends EventEmitter<AppEventMap> {
host: config.electrumHost, host: config.electrumHost,
applicationIdentifier: config.electrumApplicationIdentifier, applicationIdentifier: config.electrumApplicationIdentifier,
}); });
const rates = await RatesService.create();
// TEMP because testing is painful return new AppService(engine, walletStorage, config, electrum, rates);
// 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);
} }
constructor( constructor(
engine: Engine, engine: Engine,
storage: Storage, storage: BaseStorage,
config: AppConfig, config: AppConfig,
electrum: ElectrumService, electrum: BlockchainService,
rates: RatesService,
) { ) {
super(); super();
@@ -121,6 +128,7 @@ export class AppService extends EventEmitter<AppEventMap> {
this.storage = storage; this.storage = storage;
this.config = config; this.config = config;
this.electrum = electrum; this.electrum = electrum;
this.rates = rates;
this.history = new HistoryService(engine, this.invitations); this.history = new HistoryService(engine, this.invitations);
} }
@@ -153,22 +161,113 @@ export class AppService extends EventEmitter<AppEventMap> {
} }
async addInvitation(invitation: Invitation): Promise<void> { async addInvitation(invitation: Invitation): Promise<void> {
this.attachInvitationListeners(invitation);
// Add the invitation to the invitations array // Add the invitation to the invitations array
this.invitations.push(invitation); this.invitations.push(invitation);
// Emit the invitation-added event // Emit the invitation-added event
this.emit("invitation-added", invitation); this.emit("invitation-added", invitation);
this.emit("wallet-state-changed", {
reason: "invitation-added",
invitationIdentifier: invitation.data.invitationIdentifier,
});
} }
async removeInvitation(invitation: Invitation): Promise<void> { async removeInvitation(invitation: Invitation): Promise<void> {
// Remove the invitation from the invitations array const invitationIdentifier = invitation.data.invitationIdentifier;
this.invitations = this.invitations.filter((i) => i !== invitation); 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 // Emit the invitation-removed event
this.emit("invitation-removed", invitation); 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<number> {
const allUnspentOutputs = await this.engine.listUnspentOutputsData();
const reserved = allUnspentOutputs.filter((o) => o.reservedBy);
// Group by invitation identifier so the engine can clear them properly.
const byInvitation = new Map<string, typeof reserved>();
for (const output of reserved) {
const existing = byInvitation.get(output.reservedBy!) ?? [];
existing.push(output);
byInvitation.set(output.reservedBy!, 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<void> { async start(): Promise<void> {
// Start rates in the background so BCH -> fiat conversions become reactive in the TUI.
this.rates.start().catch((err) =>
console.error('Error starting rates service:', err),
);
// Get the invitations db // Get the invitations db
const invitationsDb = this.storage.child("invitations"); const invitationsDb = this.storage.child("invitations");
@@ -180,7 +279,9 @@ export class AppService extends EventEmitter<AppEventMap> {
await Promise.all( await Promise.all(
invitations.map(async ({ key }) => { invitations.map(async ({ key }) => {
await this.createInvitation(key); await this.createInvitation(key).catch((err) =>
console.error(`Error creating invitation ${key}: ${err}`),
);
}), }),
); );
} }

View File

@@ -8,6 +8,10 @@ export interface ElectrumServiceConfig {
applicationIdentifier?: string; applicationIdentifier?: string;
} }
export abstract class BlockchainService {
abstract hasSeenTransaction(transactionHash: string): Promise<boolean>;
}
/** /**
* Small Electrum adapter used by CLI services. * Small Electrum adapter used by CLI services.
* Keeps connection logic in one place and exposes a tiny API. * Keeps connection logic in one place and exposes a tiny API.

View File

@@ -1,5 +1,9 @@
import { binToHex } from "@bitauth/libauth"; import { binToHex } from "@bitauth/libauth";
import { compileCashAssemblyString, type Engine } from "@xo-cash/engine"; import {
compileCashAssemblyString,
type Engine,
listInvitationCommitsByEntity,
} from "@xo-cash/engine";
import type { UnspentOutputData } from "@xo-cash/state"; import type { UnspentOutputData } from "@xo-cash/state";
import type { import type {
XOInvitation, XOInvitation,
@@ -59,6 +63,7 @@ interface InvitationContext {
invitation: Invitation; invitation: Invitation;
template: XOTemplate | null; template: XOTemplate | null;
variables: Record<string, XOInvitationVariableValue>; variables: Record<string, XOInvitationVariableValue>;
walletCommits: XOInvitationCommit[];
walletEntityIdentifier?: string; walletEntityIdentifier?: string;
} }
@@ -73,12 +78,8 @@ export class HistoryService {
private invitations: Invitation[], private invitations: Invitation[],
) {} ) {}
async extractEntities(invitation: XOInvitation): Promise<string[]> { extractEntities(invitation: XOInvitation): Record<string, XOInvitationCommit[]> {
const entities = new Set<string>(); return listInvitationCommitsByEntity(invitation);
for (const commit of invitation.commits) {
entities.add(commit.entityIdentifier);
}
return Array.from(entities);
} }
// Entities are currently static per invitation. So, we can try to match the roles to entities by: // Entities are currently static per invitation. So, we can try to match the roles to entities by:
@@ -127,8 +128,6 @@ export class HistoryService {
async getHistory(): Promise<HistoryItem[]> { async getHistory(): Promise<HistoryItem[]> {
const allUtxos = await this.engine.listUnspentOutputsData(); const allUtxos = await this.engine.listUnspentOutputsData();
const ownOutpoints = new Set<string>();
const ownLockingBytecodes = new Set<string>();
const invitationByOrigin = new Map<string, UtxoOriginContext>(); const invitationByOrigin = new Map<string, UtxoOriginContext>();
const outpointValueSatoshis = new Map<string, bigint>(); const outpointValueSatoshis = new Map<string, bigint>();
@@ -137,8 +136,6 @@ export class HistoryService {
utxo.outpointTransactionHash, utxo.outpointTransactionHash,
utxo.outpointIndex, utxo.outpointIndex,
); );
ownOutpoints.add(outpointKey);
ownLockingBytecodes.add(utxo.lockingBytecode);
outpointValueSatoshis.set(outpointKey, BigInt(utxo.valueSatoshis)); outpointValueSatoshis.set(outpointKey, BigInt(utxo.valueSatoshis));
} }
@@ -148,15 +145,15 @@ export class HistoryService {
const template = const template =
(await this.engine.getTemplate(invitation.data.templateIdentifier)) ?? (await this.engine.getTemplate(invitation.data.templateIdentifier)) ??
null; null;
const walletEntityIdentifier = this.resolveWalletEntityIdentifier( const walletCommits = await this.getWalletCommitsForInvitation(
invitation, invitation.data,
ownOutpoints,
ownLockingBytecodes,
); );
const walletEntityIdentifier = walletCommits[0]?.entityIdentifier;
contexts.set(invitation.data.invitationIdentifier, { contexts.set(invitation.data.invitationIdentifier, {
invitation, invitation,
template, template,
variables, variables,
walletCommits,
walletEntityIdentifier, walletEntityIdentifier,
}); });
this.indexInvitationOutputsByUtxoOrigin(invitationByOrigin, invitation); this.indexInvitationOutputsByUtxoOrigin(invitationByOrigin, invitation);
@@ -186,7 +183,6 @@ export class HistoryService {
const invitationInputs = this.buildWalletInputItemsForInvitation( const invitationInputs = this.buildWalletInputItemsForInvitation(
context, context,
roles[0], roles[0],
invitationOutputs.length > 0,
outpointValueSatoshis, outpointValueSatoshis,
); );
const invitationDescription = this.deriveInvitationDescription( const invitationDescription = this.deriveInvitationDescription(
@@ -287,51 +283,25 @@ export class HistoryService {
return outputs; return outputs;
} }
private async getWalletCommitsForInvitation(
invitation: XOInvitation,
): Promise<XOInvitationCommit[]> {
try {
return await this.engine.getOwnCommits(invitation);
} catch {
return [];
}
}
private buildWalletInputItemsForInvitation( private buildWalletInputItemsForInvitation(
context: InvitationContext, context: InvitationContext,
walletRole?: string, walletRole?: string,
hasWalletOutputs: boolean = false,
outpointValueSatoshis: Map<string, bigint> = new Map(), outpointValueSatoshis: Map<string, bigint> = new Map(),
): HistoryUtxoItem[] { ): HistoryUtxoItem[] {
const invitation = context.invitation.data; const invitation = context.invitation.data;
const commits = invitation.commits ?? []; const relevantCommits = context.walletCommits.filter(
const commitsByEntity = context.walletEntityIdentifier
? commits.filter(
(commit) =>
commit.entityIdentifier === context.walletEntityIdentifier,
)
: [];
const commitsByRole = walletRole
? commits.filter(
(commit) =>
this.deriveCommitRoleIdentifier(
commit,
invitation,
context.template,
) === walletRole,
)
: [];
let relevantCommits = commitsByEntity.filter(
(commit) => (commit.data.inputs?.length ?? 0) > 0, (commit) => (commit.data.inputs?.length ?? 0) > 0,
); );
if (relevantCommits.length === 0) {
relevantCommits = commitsByRole.filter(
(commit) => (commit.data.inputs?.length ?? 0) > 0,
);
}
if (relevantCommits.length === 0 && walletRole === "sender") {
relevantCommits = commits.filter(
(commit) => (commit.data.inputs?.length ?? 0) > 0,
);
}
// Sender fallback only when no wallet outputs were matched.
if (relevantCommits.length === 0 && !hasWalletOutputs) {
relevantCommits = commits.filter(
(commit) => (commit.data.inputs?.length ?? 0) > 0,
);
}
const txDescription = this.deriveTransactionActivityDescription( const txDescription = this.deriveTransactionActivityDescription(
invitation, invitation,
context.template, context.template,
@@ -355,7 +325,10 @@ export class HistoryService {
context.variables, context.variables,
); );
const templateName = context.template?.name ?? "UnknownTemplate"; const templateName = context.template?.name ?? "UnknownTemplate";
const role = walletRole ?? "sender"; const role =
this.deriveCommitRoleIdentifier(commit, invitation, context.template) ??
walletRole ??
"sender";
const inputValue = this.resolveInputSatoshis( const inputValue = this.resolveInputSatoshis(
txHash, txHash,
inputIndex, inputIndex,
@@ -401,7 +374,7 @@ export class HistoryService {
return { return {
kind: "utxo", kind: "utxo",
id: this.getUtxoId(utxo), id: this.getUtxoId(utxo),
invitationIdentifier: utxo.invitationIdentifier || undefined, invitationIdentifier: utxo.reservedBy || undefined,
templateIdentifier: utxo.templateIdentifier, templateIdentifier: utxo.templateIdentifier,
outputIdentifier: utxo.outputIdentifier, outputIdentifier: utxo.outputIdentifier,
outpoint: { outpoint: {
@@ -409,7 +382,7 @@ export class HistoryService {
index: utxo.outpointIndex, index: utxo.outpointIndex,
}, },
valueSatoshis: BigInt(utxo.valueSatoshis), valueSatoshis: BigInt(utxo.valueSatoshis),
reserved: utxo.reserved, reserved: utxo.reservedBy ? true : false,
direction, direction,
description, description,
descriptionParts: { descriptionParts: {
@@ -422,13 +395,6 @@ export class HistoryService {
}; };
} }
/**
* TODO: This is completely incorrect. It should be deriving the roles of the user based on the entity IDs in the commits, then mapping each commit to Inputs/Outputs so we know what belongs to the user.
* There are a few changes that will need to be made to make this work:
* 1. Provide a way to derive all entity IDs of the user for the invitation (If we are going with XPub)
* 2. Provide a way to get only the User's commits (and their inputs/outputs)
* 3. (Maybe) Include role on inputs and outputs - This one might be fine with just using the commit entity id
*/
private deriveWalletRolesForInvitation( private deriveWalletRolesForInvitation(
context: InvitationContext, context: InvitationContext,
outputs: HistoryUtxoItem[], outputs: HistoryUtxoItem[],
@@ -444,33 +410,20 @@ export class HistoryService {
roles.add("receiver"); roles.add("receiver");
} }
const hasInputCommit = ( for (const commit of context.walletCommits) {
context.walletEntityIdentifier const role = this.deriveCommitRoleIdentifier(
? context.invitation.data.commits.filter( commit,
(c) => c.entityIdentifier === context.walletEntityIdentifier,
)
: context.invitation.data.commits
).some((c) => (c.data.inputs?.length ?? 0) > 0);
if (hasInputCommit) roles.add("sender");
if (
!hasInputCommit &&
outputs.length === 0 &&
context.invitation.data.commits.some(
(c) => (c.data.inputs?.length ?? 0) > 0,
)
) {
roles.add("sender");
}
if (roles.size === 0) {
const inferred = this.extractInvitationRoleIdentifier(
context.invitation.data, context.invitation.data,
context.template, context.template,
context.walletEntityIdentifier,
); );
if (inferred) roles.add(inferred); if (role) roles.add(role);
} }
const hasInputCommit = context.walletCommits.some(
(c) => (c.data.inputs?.length ?? 0) > 0,
);
if (hasInputCommit) roles.add("sender");
return roles.size > 0 ? Array.from(roles) : ["unknown"]; return roles.size > 0 ? Array.from(roles) : ["unknown"];
} }
@@ -517,11 +470,11 @@ export class HistoryService {
utxo: UnspentOutputData, utxo: UnspentOutputData,
invitationByUtxoOrigin: Map<string, UtxoOriginContext>, invitationByUtxoOrigin: Map<string, UtxoOriginContext>,
): string | undefined { ): string | undefined {
if (utxo.invitationIdentifier) return utxo.invitationIdentifier; if (utxo.reservedBy) return utxo.reservedBy;
const originKey = this.getUtxoOriginKey( const originKey = this.getUtxoOriginKey(
utxo.templateIdentifier, utxo.templateIdentifier,
utxo.outputIdentifier, utxo.outputIdentifier,
utxo.lockingBytecode, utxo.scriptHash,
); );
return invitationByUtxoOrigin.get(originKey)?.invitationIdentifier; return invitationByUtxoOrigin.get(originKey)?.invitationIdentifier;
} }
@@ -533,59 +486,11 @@ export class HistoryService {
const originKey = this.getUtxoOriginKey( const originKey = this.getUtxoOriginKey(
utxo.templateIdentifier, utxo.templateIdentifier,
utxo.outputIdentifier, utxo.outputIdentifier,
utxo.lockingBytecode, utxo.scriptHash,
); );
return invitationByUtxoOrigin.get(originKey)?.roleIdentifier; return invitationByUtxoOrigin.get(originKey)?.roleIdentifier;
} }
private resolveWalletEntityIdentifier(
invitation: Invitation,
ownUtxoOutpointKeys: Set<string>,
ownLockingBytecodes: Set<string>,
): string | undefined {
const scores = new Map<string, number>();
const addScore = (entityIdentifier: string, delta: number): void => {
scores.set(entityIdentifier, (scores.get(entityIdentifier) ?? 0) + delta);
};
for (const commit of invitation.data.commits) {
for (const input of commit.data.inputs ?? []) {
const txHash = input.outpointTransactionHash
? input.outpointTransactionHash instanceof Uint8Array
? binToHex(input.outpointTransactionHash)
: String(input.outpointTransactionHash)
: undefined;
if (!txHash || input.outpointIndex === undefined) continue;
if (
ownUtxoOutpointKeys.has(
this.getOutpointKey(txHash, input.outpointIndex),
)
) {
addScore(commit.entityIdentifier, 3);
}
}
for (const output of commit.data.outputs ?? []) {
const lockingBytecodeHex = output.lockingBytecode
? this.toLockingBytecodeHex(output.lockingBytecode)
: undefined;
if (!lockingBytecodeHex) continue;
if (ownLockingBytecodes.has(lockingBytecodeHex)) {
addScore(commit.entityIdentifier, 2);
}
}
}
let bestEntity: string | undefined;
let bestScore = 0;
for (const [entity, score] of scores.entries()) {
if (score > bestScore) {
bestScore = score;
bestEntity = entity;
}
}
return bestEntity;
}
private deriveUtxoDescription( private deriveUtxoDescription(
utxo: UnspentOutputData, utxo: UnspentOutputData,
template: XOTemplate | null, template: XOTemplate | null,
@@ -715,27 +620,6 @@ export class HistoryService {
return undefined; return undefined;
} }
private extractInvitationRoleIdentifier(
invitation: XOInvitation,
template: XOTemplate | null,
walletEntityIdentifier?: string,
): string | undefined {
if (walletEntityIdentifier) {
const commits = invitation.commits.filter(
(commit) => commit.entityIdentifier === walletEntityIdentifier,
);
for (const commit of commits) {
const role = this.deriveCommitRoleIdentifier(
commit,
invitation,
template,
);
if (role) return role;
}
}
return undefined;
}
private inferRoleFromOutputIdentifier( private inferRoleFromOutputIdentifier(
outputIdentifier: string, outputIdentifier: string,
): string | undefined { ): string | undefined {

View File

@@ -2,7 +2,7 @@ import type {
AcceptInvitationParameters, AcceptInvitationParameters,
AppendInvitationParameters, AppendInvitationParameters,
Engine, Engine,
FindSuitableResourcesParameters, GetSpendableResourcesParameters,
} from "@xo-cash/engine"; } from "@xo-cash/engine";
import { hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine"; import { hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
import type { import type {
@@ -24,8 +24,8 @@ import {
import type { SSEvent } from "../utils/sse-client.js"; import type { SSEvent } from "../utils/sse-client.js";
import type { SyncServer } from "../utils/sync-server.js"; import type { SyncServer } from "../utils/sync-server.js";
import type { Storage } from "./storage.js"; import type { BaseStorage } from "./storage.js";
import type { ElectrumService } from "./electrum.js"; import type { BlockchainService } from "./electrum.js";
import { EventEmitter } from "../utils/event-emitter.js"; import { EventEmitter } from "../utils/event-emitter.js";
import { decodeExtendedJsonObject } from "../utils/ext-json.js"; import { decodeExtendedJsonObject } from "../utils/ext-json.js";
@@ -34,13 +34,14 @@ import { compileCashAssemblyString } from "@xo-cash/engine";
export type InvitationEventMap = { export type InvitationEventMap = {
"invitation-updated": XOInvitation; "invitation-updated": XOInvitation;
"invitation-status-changed": string; "invitation-status-changed": string;
error: Error;
}; };
export type InvitationDependencies = { export type InvitationDependencies = {
syncServer: SyncServer; syncServer: SyncServer;
storage: Storage; storage: BaseStorage;
engine: Engine; engine: Engine;
electrum: ElectrumService; electrum: BlockchainService;
}; };
export class Invitation extends EventEmitter<InvitationEventMap> { export class Invitation extends EventEmitter<InvitationEventMap> {
@@ -84,8 +85,11 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
throw new Error(`Template not found: ${invitation.templateIdentifier}`); throw new Error(`Template not found: ${invitation.templateIdentifier}`);
} }
// engine invitation (I have no idea if this is required)
const engineInvitation = await dependencies.engine.acceptInvitation(invitation);
// Create the invitation // Create the invitation
const invitationInstance = new Invitation(invitation, dependencies); const invitationInstance = new Invitation(engineInvitation, dependencies);
// Start the invitation and its tracking // Start the invitation and its tracking
await invitationInstance.start(); await invitationInstance.start();
@@ -118,8 +122,8 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
* The storage instance. * The storage instance.
* TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid) * TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid)
*/ */
private storage: Storage; private storage: BaseStorage;
private electrum: ElectrumService; private electrum: BlockchainService;
/** /**
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown). * The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
@@ -146,32 +150,38 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
* Start the invitation - Connect sync server and download latest invitation data. * Start the invitation - Connect sync server and download latest invitation data.
*/ */
async start(): Promise<void> { async start(): Promise<void> {
// Connect to the sync server and get the invitation (in parallel) try {
const [_, invitation] = await Promise.all([ // Connect to the sync server and get the invitation (in parallel)
this.syncServer.connect(), const [_, invitation] = await Promise.all([
this.syncServer.getInvitation(this.data.invitationIdentifier), 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 // 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; const sseCommits = this.data.commits;
// Merge the commits // Merge the commits
const combinedCommits = this.mergeCommits( const combinedCommits = this.mergeCommits(
sseCommits, sseCommits,
invitation?.commits ?? [], invitation?.commits ?? [],
); );
// Set the invitation data with the combined commits // Set the invitation data with the combined commits
this.data = { ...this.data, ...invitation, commits: combinedCommits }; this.data = { ...this.data, ...invitation, commits: combinedCommits };
// Store the invitation in the storage // Store the invitation in the storage
await this.storage.set(this.data.invitationIdentifier, this.data); await this.storage.set(this.data.invitationIdentifier, this.data);
// Publish the invitation to the sync server // Publish the invitation to the sync server
this.syncServer.publishInvitation(this.data); this.publishInvitation(this.data);
// Compute and emit initial status // Compute and emit initial status
await this.updateStatus(); 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 +215,20 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
} }
} }
/**
* Publish the invitation to the sync server
*/
private async publishInvitation(
invitation: XOInvitation = this.data,
): Promise<void> {
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 * Merge the commits
* @param initial - The initial commits * @param initial - The initial commits
@@ -359,7 +383,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
this.data = await this.engine.acceptInvitation(this.data, acceptParams); this.data = await this.engine.acceptInvitation(this.data, acceptParams);
// Sync the invitation to the sync server // Sync the invitation to the sync server
this.syncServer.publishInvitation(this.data); this.publishInvitation(this.data);
// Update the status of the invitation // Update the status of the invitation
await this.updateStatus(); await this.updateStatus();
@@ -373,7 +397,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
const signedInvitation = await this.engine.signInvitation(this.data); const signedInvitation = await this.engine.signInvitation(this.data);
// Publish the signed invitation to the sync server // Publish the signed invitation to the sync server
this.syncServer.publishInvitation(signedInvitation); this.publishInvitation(signedInvitation);
// Store the signed invitation in the storage // Store the signed invitation in the storage
await this.storage.set(this.data.invitationIdentifier, signedInvitation); await this.storage.set(this.data.invitationIdentifier, signedInvitation);
@@ -385,16 +409,17 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
} }
/** /**
* Broadcast the invitation * Broadcast the invitation.
* @returns The transaction hash returned by the network after broadcast.
*/ */
async broadcast(): Promise<void> { async broadcast(): Promise<string> {
// Broadcast the transaction (executeAction returns transaction hash when broadcastTransaction: true) const txHash = await this.engine.executeAction(this.data, {
await this.engine.executeAction(this.data, {
broadcastTransaction: true, broadcastTransaction: true,
}); });
// Update the status of the invitation
await this.updateStatus(); await this.updateStatus();
return String(txHash);
} }
// ============================================================================ // ============================================================================
@@ -409,7 +434,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
this.data = await this.engine.appendInvitation(this.data, data); this.data = await this.engine.appendInvitation(this.data, data);
// Sync the invitation to the sync server // Sync the invitation to the sync server
await this.syncServer.publishInvitation(this.data); await this.publishInvitation(this.data);
// Store the invitation in the storage // Store the invitation in the storage
await this.storage.set(this.data.invitationIdentifier, this.data); await this.storage.set(this.data.invitationIdentifier, this.data);
@@ -426,7 +451,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
await this.append({ inputs }); await this.append({ inputs });
// Sync the invitation to the sync server // Sync the invitation to the sync server
await this.syncServer.publishInvitation(this.data); await this.publishInvitation(this.data);
} }
/** /**
@@ -449,7 +474,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
await this.append({ outputs }); await this.append({ outputs });
// Sync the invitation to the sync server // Sync the invitation to the sync server
await this.syncServer.publishInvitation(this.data); await this.publishInvitation(this.data);
} }
async addVariables(variables: XOInvitationVariable[]): Promise<void> { async addVariables(variables: XOInvitationVariable[]): Promise<void> {
@@ -457,16 +482,31 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
await this.append({ variables }); await this.append({ variables });
// Sync the invitation to the sync server // Sync the invitation to the sync server
await this.syncServer.publishInvitation(this.data); await this.publishInvitation(this.data);
} }
async findSuitableResources( async findSuitableResources(
options: Partial<FindSuitableResourcesParameters> = {}, options: Partial<GetSpendableResourcesParameters> = {},
): Promise<UnspentOutputData[]> { ): Promise<UnspentOutputData[]> {
const templateIdentifier =
options.templateIdentifier ?? this.data.templateIdentifier;
const template = await this.engine.getTemplate(templateIdentifier);
const fallbackOutputIdentifier = Object.keys(template?.outputs ?? {})[0];
if (!fallbackOutputIdentifier && !options.outputIdentifier) {
throw new Error(
`No output identifiers found for template: ${templateIdentifier}`,
);
}
const resolvedOptions: GetSpendableResourcesParameters = {
templateIdentifier,
outputIdentifier: options.outputIdentifier ?? fallbackOutputIdentifier ?? "",
};
// Find the suitable resources // Find the suitable resources
const { unspentOutputs } = await this.engine.findSuitableResources( const { unspentOutputs } = await this.engine.getSpendableResources(
this.data, this.data,
options, resolvedOptions,
); );
// Update the status of the invitation // Update the status of the invitation

197
src/services/rates.ts Normal file
View File

@@ -0,0 +1,197 @@
import { EventEmitter } from '../utils/event-emitter.js';
import {
type RatesEventMap,
} from '../utils/rates/base-rates.js';
import { RatesOracle } from '../utils/rates/rates-oracles.js';
/**
* Event map emitted by {@link RatesService}.
*/
export type RatesServiceEventMap = {
'rate-updated': {
numeratorUnitCode: string;
denominatorUnitCode: string;
price: number;
pair: string;
updatedAt: number;
};
};
/**
* In-memory representation of a market rate.
*/
type CachedRate = {
price: number;
updatedAt: number;
};
/**
* Minimal adapter contract that RatesService depends on.
*
* Using a small interface keeps the service decoupled and avoids inheriting
* implementation-specific type constraints from concrete adapters.
*/
export interface RatesAdapter {
start(): Promise<void>;
stop(): Promise<void>;
listPairs(): Promise<Set<string>>;
formatCurrency(amount: number, targetCurrency: string): string;
on(
type: 'rateUpdated',
listener: (detail: RatesEventMap['rateUpdated']) => void,
): () => void;
}
/**
* Orchestrates the rates adapter lifecycle and provides BCH -> fiat helpers
* for the TUI.
*
* This service keeps a small in-memory snapshot of the latest prices and emits
* a normalized event whenever a pair changes. React components can subscribe
* through `useSyncExternalStore` for clean and predictable reactivity.
*/
export class RatesService extends EventEmitter<RatesServiceEventMap> {
private readonly adapter: RatesAdapter;
private readonly ratesByPair = new Map<string, CachedRate>();
private unsubscribeFromAdapter: (() => void) | null = null;
private started = false;
constructor(adapter: RatesAdapter) {
super();
this.adapter = adapter;
}
/**
* Creates a rates service.
*
* If no adapter is passed, this defaults to the Oracle-backed adapter.
*/
public static async create(adapter?: RatesAdapter): Promise<RatesService> {
const resolvedAdapter = adapter ?? (await RatesOracle.from());
return new RatesService(resolvedAdapter);
}
/**
* Starts the underlying adapter and begins collecting live updates.
*/
public async start(): Promise<void> {
if (this.started) {
return;
}
this.started = true;
this.unsubscribeFromAdapter = this.adapter.on('rateUpdated', (event) => {
this.handleRateUpdated(event);
});
try {
await this.adapter.start();
} catch (error) {
this.unsubscribeFromAdapter?.();
this.unsubscribeFromAdapter = null;
this.started = false;
throw error;
}
}
/**
* Stops live rate collection.
*/
public async stop(): Promise<void> {
if (!this.started) {
return;
}
this.started = false;
this.unsubscribeFromAdapter?.();
this.unsubscribeFromAdapter = null;
await this.adapter.stop();
}
/**
* Returns the latest price for a pair in NUMERATOR/DENOMINATOR form.
*
* Example: `getRate("USD", "BCH")`.
*/
public getRate(
numeratorUnitCode: string,
denominatorUnitCode: string,
): number | null {
const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode);
return this.ratesByPair.get(pair)?.price ?? null;
}
/**
* Converts satoshis to fiat using the latest BCH/fiat rate.
*
* Example: `convertBchToFiat(1234n, "USD")`.
*/
public convertBchToFiat(
satoshis: bigint,
targetCurrency: string = 'USD',
): number | null {
const rate = this.getRate(targetCurrency, 'BCH');
if (rate === null) {
return null;
}
const amountInBch = Number(satoshis) / 100_000_000;
return amountInBch * rate;
}
/**
* Formats a BCH -> fiat converted amount using the adapter formatter.
*/
public formatBchToFiat(
satoshis: bigint,
targetCurrency: string = 'USD',
): string | null {
const normalizedCurrency = targetCurrency.toUpperCase();
const amount = this.convertBchToFiat(satoshis, normalizedCurrency);
if (amount === null) {
return null;
}
return this.adapter.formatCurrency(amount, normalizedCurrency);
}
/**
* Formats an arbitrary fiat amount in a currency-aware way.
*/
public formatCurrency(amount: number, currencyCode: string): string {
return this.adapter.formatCurrency(amount, currencyCode.toUpperCase());
}
/**
* Handles normalized updates from the underlying adapter.
*/
private handleRateUpdated(event: RatesEventMap['rateUpdated']): void {
const numeratorUnitCode = event.numeratorUnitCode.toUpperCase();
const denominatorUnitCode = event.denominatorUnitCode.toUpperCase();
const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode);
const updatedAt = Date.now();
this.ratesByPair.set(pair, {
price: event.price,
updatedAt,
});
this.emit('rate-updated', {
numeratorUnitCode,
denominatorUnitCode,
price: event.price,
pair,
updatedAt,
});
}
/**
* Creates a stable key for pair lookups.
*/
private getPairKey(
numeratorUnitCode: string,
denominatorUnitCode: string,
): string {
return `${numeratorUnitCode.toUpperCase()}/${denominatorUnitCode.toUpperCase()}`;
}
}

View File

@@ -1,7 +1,16 @@
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js"; import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js";
export class Storage { export abstract class BaseStorage {
abstract all(): Promise<{ key: string; value: any }[]>;
abstract set(key: string, value: any): Promise<void>;
abstract get(key: string): Promise<any>;
abstract remove(key: string): Promise<void>;
abstract clear(): Promise<void>;
abstract child(key: string): BaseStorage;
}
export class Storage extends BaseStorage {
static async create(dbPath: string): Promise<Storage> { static async create(dbPath: string): Promise<Storage> {
// Create the database // Create the database
const database = new Database(dbPath); const database = new Database(dbPath);
@@ -19,7 +28,9 @@ export class Storage {
constructor( constructor(
private readonly database: Database.Database, private readonly database: Database.Database,
private readonly basePath: string, private readonly basePath: string,
) {} ) {
super();
}
/** /**
* Get the full key with basePath prefix * Get the full key with basePath prefix
@@ -117,3 +128,104 @@ export class Storage {
return new Storage(this.database, this.getFullKey(key)); return new Storage(this.database, this.getFullKey(key));
} }
} }
/**
* In-memory storage adapter with the same namespaced API as {@link Storage}.
*
* This adapter is useful for tests and short-lived sessions where persisted
* SQLite state is not needed.
*/
export class InMemoryStorage extends BaseStorage {
static async create(): Promise<InMemoryStorage> {
return new InMemoryStorage(new Map<string, string>(), "");
}
constructor(
private readonly store: Map<string, string>,
private readonly basePath: string,
) {
super();
}
/**
* Get the full key with basePath prefix.
*/
private getFullKey(key: string): string {
return this.basePath ? `${this.basePath}.${key}` : key;
}
/**
* Strip the basePath prefix from a key.
*/
private stripBasePath(fullKey: string): string {
if (!this.basePath) return fullKey;
const prefix = `${this.basePath}.`;
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
}
async set(key: string, value: any): Promise<void> {
const fullKey = this.getFullKey(key);
const encodedValue = encodeExtendedJson(value);
this.store.set(fullKey, encodedValue);
}
/**
* Get all key-value pairs from this storage namespace (shallow only).
*/
async all(): Promise<{ key: string; value: any }[]> {
const rows: Array<{ key: string; value: string }> = [];
const prefix = this.basePath ? `${this.basePath}.` : "";
for (const [key, value] of this.store.entries()) {
if (this.basePath && !key.startsWith(prefix)) continue;
rows.push({ key, value });
}
const filteredRows = rows.filter((row) => {
const strippedKey = this.stripBasePath(row.key);
return !strippedKey.includes(".");
});
return filteredRows.map((row) => ({
key: this.stripBasePath(row.key),
value: decodeExtendedJson(row.value),
}));
}
async get(key: string): Promise<any> {
const fullKey = this.getFullKey(key);
const encodedValue = this.store.get(fullKey);
if (encodedValue === undefined) return null;
return decodeExtendedJson(encodedValue);
}
async remove(key: string): Promise<void> {
const fullKey = this.getFullKey(key);
this.store.delete(fullKey);
}
async clear(): Promise<void> {
if (!this.basePath) {
this.store.clear();
return;
}
const prefix = `${this.basePath}.`;
const keysToDelete: string[] = [];
for (const key of this.store.keys()) {
if (key.startsWith(prefix)) {
keysToDelete.push(key);
}
}
for (const key of keysToDelete) {
this.store.delete(key);
}
}
child(key: string): InMemoryStorage {
return new InMemoryStorage(this.store, this.getFullKey(key));
}
}

View File

@@ -1,6 +1,7 @@
import React from "react"; import React, { useMemo } from "react";
import { Box, Text } from "ink"; import { Box, Text } from "ink";
import TextInput from "./TextInput.js"; import TextInput from "./TextInput.js";
import { useSatoshisConversion } from "../hooks/useSatoshisConversion.js";
interface VariableInputFieldProps { interface VariableInputFieldProps {
variable: { variable: {
@@ -18,6 +19,45 @@ interface VariableInputFieldProps {
focusColor: string; focusColor: string;
} }
const SATOSHIS_PER_BCH = 100_000_000n;
/**
* Returns true when the variable is an integer satoshis field.
*/
function isSatoshisVariable(variable: VariableInputFieldProps["variable"]): boolean {
return (
variable.type === "integer" &&
variable.hint?.toLowerCase().includes("satoshi") === true
);
}
/**
* Parse a strict integer string into bigint.
*/
function parseSatoshis(value: string): bigint | null {
const trimmed = value.trim();
if (!/^[-]?\d+$/.test(trimmed)) {
return null;
}
try {
return BigInt(trimmed);
} catch {
return null;
}
}
/**
* Format satoshis as BCH with fixed 8 decimals, preserving bigint precision.
*/
function formatBchFromSatoshis(satoshis: bigint): string {
const sign = satoshis < 0n ? "-" : "";
const absolute = satoshis < 0n ? satoshis * -1n : satoshis;
const whole = absolute / SATOSHIS_PER_BCH;
const fractional = absolute % SATOSHIS_PER_BCH;
return `${sign}${whole.toString()}.${fractional.toString().padStart(8, "0")} BCH`;
}
export function VariableInputField({ export function VariableInputField({
variable, variable,
index, index,
@@ -27,6 +67,26 @@ export function VariableInputField({
borderColor, borderColor,
focusColor, focusColor,
}: VariableInputFieldProps): React.ReactElement { }: VariableInputFieldProps): React.ReactElement {
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
useSatoshisConversion("USD");
const satoshisValue = useMemo(
() => parseSatoshis(variable.value),
[variable.value],
);
const formattedBch = useMemo(() => {
if (satoshisValue === null) {
return null;
}
return formatBchFromSatoshis(satoshisValue);
}, [satoshisValue]);
const formattedFiat = useMemo(() => {
if (satoshisValue === null) {
return null;
}
return formatSatoshisToFiat(satoshisValue);
}, [satoshisValue, formatSatoshisToFiat]);
const shouldShowSatoshisConversion = isSatoshisVariable(variable);
return ( return (
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<Text color={focusColor}>{variable.name}</Text> <Text color={focusColor}>{variable.name}</Text>
@@ -54,12 +114,29 @@ export function VariableInputField({
<Text color={borderColor} dimColor>{variable.hint}</Text> <Text color={borderColor} dimColor>{variable.hint}</Text>
</Box> </Box>
{variable.type === 'integer' && variable.hint === 'satoshis' && ( {shouldShowSatoshisConversion && (
<Box> <Box flexDirection="column">
<Text color={borderColor} dimColor> {formattedBch ? (
{/* Convert from sats to bch. NOTE: we can't use the formatSatoshis function because it is too verbose and returns too many values in the string*/} <>
{(Number(variable.value) / 100_000_000).toFixed(8)} BCH <Text color={borderColor} dimColor>
</Text> {formattedBch}
</Text>
<Text color={borderColor} dimColor>
{formattedFiat
? `Approx. ${currencyCode}: ${formattedFiat}`
: `Approx. ${currencyCode}: waiting for live rate...`}
</Text>
{formattedFiatPerBchRate && (
<Text color={borderColor} dimColor>
1 BCH = {formattedFiatPerBchRate}
</Text>
)}
</>
) : (
<Text color={borderColor} dimColor>
Enter a whole satoshi amount to preview BCH/{currencyCode} conversion.
</Text>
)}
</Box> </Box>
)} )}
</Box> </Box>

View File

@@ -23,3 +23,5 @@ export {
useBlockableInput, useBlockableInput,
useIsInputCaptured, useIsInputCaptured,
} from "./useInputLayer.js"; } from "./useInputLayer.js";
export { useRate, useBchToFiatRate } from "./useRates.js";
export { useSatoshisConversion } from "./useSatoshisConversion.js";

View File

@@ -0,0 +1,68 @@
import { useCallback, useMemo, useSyncExternalStore } from 'react';
import type { RatesServiceEventMap } from '../../services/rates.js';
import { useAppContext } from './useAppContext.js';
/**
* Reactive hook for a single market pair.
*
* Pair format is NUMERATOR / DENOMINATOR, e.g. USD / BCH.
*/
export function useRate(
numeratorUnitCode: string,
denominatorUnitCode: string,
): number | null {
const { appService } = useAppContext();
const normalizedNumerator = useMemo(
() => numeratorUnitCode.toUpperCase(),
[numeratorUnitCode],
);
const normalizedDenominator = useMemo(
() => denominatorUnitCode.toUpperCase(),
[denominatorUnitCode],
);
const subscribe = useCallback(
(callback: () => void) => {
if (!appService) {
return () => {};
}
const onRateUpdated = (event: RatesServiceEventMap['rate-updated']) => {
if (
event.numeratorUnitCode === normalizedNumerator &&
event.denominatorUnitCode === normalizedDenominator
) {
callback();
}
};
const unsubscribe = appService.rates.on('rate-updated', onRateUpdated);
return () => {
unsubscribe();
};
},
[appService, normalizedNumerator, normalizedDenominator],
);
const getSnapshot = useCallback(() => {
if (!appService) {
return null;
}
return appService.rates.getRate(normalizedNumerator, normalizedDenominator);
}, [appService, normalizedNumerator, normalizedDenominator]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
/**
* Convenience hook for BCH -> fiat market rates.
*/
export function useBchToFiatRate(
targetCurrency: string = 'USD',
): number | null {
return useRate(targetCurrency, 'BCH');
}

View File

@@ -0,0 +1,42 @@
import { useCallback, useMemo } from 'react';
import { useAppContext } from './useAppContext.js';
import { useBchToFiatRate } from './useRates.js';
/**
* Reactive BCH satoshis -> fiat conversion helpers for TUI screens.
*
* This hook subscribes to rate updates through `useBchToFiatRate`, so any
* component using it will re-render automatically when the selected pair
* receives a new quote.
*/
export function useSatoshisConversion(targetCurrency: string = 'USD') {
const { appService } = useAppContext();
const currencyCode = useMemo(() => targetCurrency.toUpperCase(), [targetCurrency]);
const fiatPerBchRate = useBchToFiatRate(currencyCode);
const formattedFiatPerBchRate = useMemo(() => {
if (!appService || fiatPerBchRate === null) {
return null;
}
return appService.rates.formatCurrency(fiatPerBchRate, currencyCode);
}, [appService, fiatPerBchRate, currencyCode]);
const formatSatoshisToFiat = useCallback(
(satoshis: bigint): string | null => {
if (!appService || fiatPerBchRate === null) {
return null;
}
return appService.rates.formatBchToFiat(satoshis, currencyCode);
},
[appService, fiatPerBchRate, currencyCode],
);
return {
currencyCode,
fiatPerBchRate,
formattedFiatPerBchRate,
formatSatoshisToFiat,
} as const;
}

View File

@@ -16,6 +16,8 @@ import { colors, logo } from '../theme.js';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { createMnemonicFile } from '../../cli/mnemonic.js';
import { getMnemonicsDir } from '../../utils/paths.js';
import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js'; import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js';
import { encodeBip39Mnemonic } from '@bitauth/libauth'; import { encodeBip39Mnemonic } from '@bitauth/libauth';
@@ -39,33 +41,41 @@ interface MnemonicFileEntry {
* Focus sections the user can tab between. * Focus sections the user can tab between.
* When saved wallets exist the file list is shown first. * When saved wallets exist the file list is shown first.
*/ */
type FocusSection = 'files' | 'input' | 'button'; type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'button';
/** /**
* Reads mnemonic-* files from cwd, parses each as a BCHMnemonicURL, * Reads mnemonic-* files from ~/.config/xo-cli/mnemonics/ (same as xo-cli),
* and converts the entropy back to a BIP39 English mnemonic phrase. * then from cwd for legacy installs. Parses each as a BCHMnemonicURL.
*/ */
function loadMnemonicFiles(): MnemonicFileEntry[] { function loadMnemonicFiles(): MnemonicFileEntry[] {
const cwd = process.cwd(); const dirs = [getMnemonicsDir(), process.cwd()];
const filenames = fs.readdirSync(cwd).filter((f) => f.startsWith('mnemonic-')); const seenBasenames = new Set<string>();
const entries: MnemonicFileEntry[] = []; const entries: MnemonicFileEntry[] = [];
for (const filename of filenames) { for (const dir of dirs) {
try { if (!fs.existsSync(dir)) continue;
const content = fs.readFileSync(path.join(cwd, filename), 'utf-8').trim(); const filenames = fs
const parsed = BCHMnemonicURL.fromURL(content); .readdirSync(dir)
const raw = parsed.toObject(); .filter((f) => f.startsWith('mnemonic-'));
for (const filename of filenames) {
if (seenBasenames.has(filename)) continue;
try {
const content = fs.readFileSync(path.join(dir, filename), 'utf-8').trim();
const parsed = BCHMnemonicURL.fromURL(content);
const raw = parsed.toObject();
const mnemonicResult = encodeBip39Mnemonic(raw.entropy); const mnemonicResult = encodeBip39Mnemonic(raw.entropy);
if (typeof mnemonicResult === 'string') continue; if (typeof mnemonicResult === 'string') continue;
/** Use the URL comment as the label, falling back to a cleaned-up filename. */ /** Use the URL comment as the label, falling back to a cleaned-up filename. */
const label = raw.comment const label = raw.comment
?? filename.replace(/^mnemonic-/, '').replace(/\.[^.]+$/, ''); ?? filename.replace(/^mnemonic-/, '').replace(/\.[^.]+$/, '');
entries.push({ filename, label, mnemonic: mnemonicResult.phrase }); entries.push({ filename, label, mnemonic: mnemonicResult.phrase });
} catch { seenBasenames.add(filename);
// Skip files that can't be parsed } catch {
// Skip files that can't be parsed
}
} }
} }
return entries; return entries;
@@ -91,6 +101,9 @@ export function SeedInputScreen(): React.ReactElement {
const [mnemonicFiles, setMnemonicFiles] = useState<MnemonicFileEntry[]>([]); const [mnemonicFiles, setMnemonicFiles] = useState<MnemonicFileEntry[]>([]);
const [selectedFileIndex, setSelectedFileIndex] = useState(0); const [selectedFileIndex, setSelectedFileIndex] = useState(0);
/** When set, manual seed is written to ~/.config/xo-cli/mnemonics/ after a successful unlock. */
const [saveMnemonicChecked, setSaveMnemonicChecked] = useState(false);
// Focus: when saved wallets exist default to the file list, otherwise the input. // Focus: when saved wallets exist default to the file list, otherwise the input.
const [focusedSection, setFocusedSection] = useState<FocusSection>('input'); const [focusedSection, setFocusedSection] = useState<FocusSection>('input');
@@ -104,8 +117,8 @@ export function SeedInputScreen(): React.ReactElement {
* The ordered list of focusable sections (files section only when entries exist). * The ordered list of focusable sections (files section only when entries exist).
*/ */
const focusSections: FocusSection[] = mnemonicFiles.length > 0 const focusSections: FocusSection[] = mnemonicFiles.length > 0
? ['files', 'input', 'button'] ? ['files', 'input', 'saveCheckbox', 'button']
: ['input', 'button']; : ['input', 'saveCheckbox', 'button'];
/** /**
* Shows a status message with the given type. * Shows a status message with the given type.
@@ -118,28 +131,46 @@ export function SeedInputScreen(): React.ReactElement {
/** /**
* Shared wallet initialization handler used by both manual entry and file selection. * Shared wallet initialization handler used by both manual entry and file selection.
*/ */
const doInitialize = useCallback(async (seed: string) => { const doInitialize = useCallback(
showStatus('Initializing wallet...', 'loading'); async (seed: string, options?: { saveMnemonic?: boolean }) => {
setStatus('Initializing wallet...'); showStatus('Initializing wallet...', 'loading');
setIsSubmitting(true); setStatus('Initializing wallet...');
setIsSubmitting(true);
try { try {
await initializeWallet(seed); await initializeWallet(seed);
showStatus('Wallet initialized successfully!', 'success'); let statusText = 'Wallet initialized successfully!';
setStatus('Wallet ready'); if (options?.saveMnemonic) {
setSeedPhrase(''); try {
const savedAs = createMnemonicFile(getMnemonicsDir(), seed);
setMnemonicFiles(loadMnemonicFiles());
statusText = `Wallet initialized! Mnemonic saved as ${savedAs}`;
} catch (saveErr) {
const saveMsg =
saveErr instanceof Error ? saveErr.message : String(saveErr);
statusText = `Wallet initialized, but could not save mnemonic: ${saveMsg}`;
}
}
setTimeout(() => { showStatus(statusText, 'success');
navigate('wallet'); setStatus('Wallet ready');
}, 500); setSeedPhrase('');
} catch (error) { setSaveMnemonicChecked(false);
const message = error instanceof Error ? error.message : 'Failed to initialize wallet';
showStatus(message, 'error'); setTimeout(() => {
setStatus('Initialization failed'); navigate('wallet');
setIsSubmitting(false); }, 500);
} } catch (error) {
}, [initializeWallet, navigate, showStatus, setStatus]); const message =
error instanceof Error ? error.message : 'Failed to initialize wallet';
showStatus(message, 'error');
setStatus('Initialization failed');
setIsSubmitting(false);
}
},
[initializeWallet, navigate, showStatus, setStatus],
);
/** /**
* Handles manual seed phrase submission with validation. * Handles manual seed phrase submission with validation.
@@ -158,8 +189,8 @@ export function SeedInputScreen(): React.ReactElement {
return; return;
} }
await doInitialize(seed); await doInitialize(seed, { saveMnemonic: saveMnemonicChecked });
}, [seedPhrase, doInitialize, showStatus]); }, [seedPhrase, saveMnemonicChecked, doInitialize, showStatus]);
/** /**
* Handles selecting a mnemonic file from the list. * Handles selecting a mnemonic file from the list.
@@ -186,6 +217,14 @@ export function SeedInputScreen(): React.ReactElement {
return; return;
} }
// Space or Enter toggles "save mnemonic" when that row is focused
if (focusedSection === 'saveCheckbox') {
if (_input === ' ' || key.return) {
setSaveMnemonicChecked((v) => !v);
return;
}
}
// Arrow keys inside the file list // Arrow keys inside the file list
if (focusedSection === 'files' && mnemonicFiles.length > 0) { if (focusedSection === 'files' && mnemonicFiles.length > 0) {
if (key.upArrow) { if (key.upArrow) {
@@ -319,6 +358,32 @@ export function SeedInputScreen(): React.ReactElement {
/> />
</Box> </Box>
{/* Save mnemonic checkbox (manual entry only; applies on Continue) */}
<Box
marginTop={1}
paddingX={1}
borderStyle='single'
borderColor={
focusedSection === 'saveCheckbox' ? colors.focus : colors.borderMuted
}
>
<Text
color={focusedSection === 'saveCheckbox' ? colors.focus : colors.text}
bold={focusedSection === 'saveCheckbox'}
>
{saveMnemonicChecked ? '[x] ' : '[ ] '}
</Text>
<Text color={colors.text}>Save this mnemonic</Text>
<Text color={colors.textMuted}> (~/.config/xo-cli/mnemonics/)</Text>
</Box>
{focusedSection === 'saveCheckbox' && (
<Box marginTop={0} paddingX={1}>
<Text color={colors.textMuted} dimColor>
Space / Enter: toggle
</Text>
</Box>
)}
{/* Status message */} {/* Status message */}
<Box marginTop={1} height={1}> <Box marginTop={1} height={1}>
{statusMessage && ( {statusMessage && (
@@ -345,7 +410,7 @@ export function SeedInputScreen(): React.ReactElement {
{/* Help text */} {/* Help text */}
<Box marginTop={2}> <Box marginTop={2}>
<Text color={colors.textMuted} dimColor> <Text color={colors.textMuted} dimColor>
Tab: navigate sections Enter: submit Esc: back Tab: navigate Enter: submit, load wallet, or toggle save Space: toggle save Esc: back
</Text> </Text>
</Box> </Box>
</Box> </Box>

View File

@@ -100,8 +100,8 @@ export function TemplateListScreen(): React.ReactElement {
for (const startingAction of rawStartingActions) { for (const startingAction of rawStartingActions) {
const existing = actionMap.get(startingAction.action); const existing = actionMap.get(startingAction.action);
if (existing) { if (existing) {
if (!existing.roles.includes(startingAction.role)) { if (!existing.roles.includes(startingAction.role ?? '')) {
existing.roles.push(startingAction.role); existing.roles.push(startingAction.role ?? '');
} }
continue; continue;
} }
@@ -111,7 +111,7 @@ export function TemplateListScreen(): React.ReactElement {
actionIdentifier: startingAction.action, actionIdentifier: startingAction.action,
name: actionDef?.name || startingAction.action, name: actionDef?.name || startingAction.action,
description: actionDef?.description, description: actionDef?.description,
roles: [startingAction.role], roles: [startingAction.role ?? ''],
source: 'starting', source: 'starting',
}); });
} }
@@ -119,9 +119,9 @@ export function TemplateListScreen(): React.ReactElement {
const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>(); const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>();
for (const outputIdentifier of ownedOutputIdentifiers) { for (const outputIdentifier of ownedOutputIdentifiers) {
const outputDef = template.outputs?.[outputIdentifier]; const outputDef = template.outputs?.[outputIdentifier];
if (!outputDef || typeof outputDef.lockscript !== 'string') continue; if (!outputDef || typeof outputDef.lockingScript !== 'string') continue;
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockscript] as const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockingScript] as
| { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> } | { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> }
| undefined; | undefined;
if (!lockingScriptDefinition?.roles) continue; if (!lockingScriptDefinition?.roles) continue;
@@ -217,14 +217,10 @@ export function TemplateListScreen(): React.ReactElement {
action.roles.length, action.roles.length,
index index
); );
const sourceSuffix = action.source === 'next'
? ' [next]'
: action.source === 'starting+next'
? ' [start+next]'
: '';
return { return {
key: action.actionIdentifier, key: action.actionIdentifier,
label: `${formatted.label}${sourceSuffix}`, label: `${formatted.label}`,
description: formatted.description, description: formatted.description,
value: action, value: action,
hidden: !formatted.isValid, hidden: !formatted.isValid,

View File

@@ -17,7 +17,6 @@ import { useBlockableInput } from '../hooks/useInputLayer.js';
import { useInvitation } from '../hooks/useInvitations.js'; import { useInvitation } from '../hooks/useInvitations.js';
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js'; import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
import { copyToClipboard } from '../utils/clipboard.js'; import { copyToClipboard } from '../utils/clipboard.js';
import type { XOInvitation } from '@xo-cash/types';
/** /**
* Action menu items. * Action menu items.

View File

@@ -13,6 +13,7 @@ import { ScrollableList, type ListItemData } from '../components/List.js';
import { QRCode } from '../components/QRCode.js'; import { QRCode } from '../components/QRCode.js';
import { useNavigation } from '../hooks/useNavigation.js'; import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { useSatoshisConversion } from '../hooks/useSatoshisConversion.js';
import { useInputLayer, useLayeredInput, useBlockableInput, useIsInputCaptured } from '../hooks/useInputLayer.js'; import { useInputLayer, useLayeredInput, useBlockableInput, useIsInputCaptured } from '../hooks/useInputLayer.js';
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js'; import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
import type { HistoryItem } from '../../services/history.js'; import type { HistoryItem } from '../../services/history.js';
@@ -58,6 +59,7 @@ const menuItems: ListItemData<string>[] = [
{ key: 'import', label: 'Import Invitation', value: 'import' }, { key: 'import', label: 'Import Invitation', value: 'import' },
{ key: 'invitations', label: 'View Invitations', value: 'invitations' }, { key: 'invitations', label: 'View Invitations', value: 'invitations' },
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' }, { 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' }, { key: 'refresh', label: 'Refresh', value: 'refresh' },
]; ];
@@ -107,6 +109,12 @@ export function WalletStateScreen(): React.ReactElement {
const { navigate } = useNavigation(); const { navigate } = useNavigation();
const { appService, showError, showInfo } = useAppContext(); const { appService, showError, showInfo } = useAppContext();
const { setStatus } = useStatus(); const { setStatus } = useStatus();
const {
currencyCode,
fiatPerBchRate,
formattedFiatPerBchRate,
formatSatoshisToFiat,
} = useSatoshisConversion('USD');
// State // State
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null); const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
@@ -160,6 +168,21 @@ export function WalletStateScreen(): React.ReactElement {
refresh(); refresh();
}, [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. * Generates a new receiving address and displays it as a QR code.
*/ */
@@ -211,6 +234,25 @@ export function WalletStateScreen(): React.ReactElement {
} }
}, [appService, setStatus, showError, refresh]); }, [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. * Handles menu action.
*/ */
@@ -228,11 +270,14 @@ export function WalletStateScreen(): React.ReactElement {
case 'new-address': case 'new-address':
generateNewAddress(); generateNewAddress();
break; break;
case 'unreserve-all':
unreserveAll();
break;
case 'refresh': case 'refresh':
refresh(); refresh();
break; break;
} }
}, [navigate, generateNewAddress, refresh]); }, [navigate, generateNewAddress, unreserveAll, refresh]);
/** /**
* Handle menu item activation. * Handle menu item activation.
@@ -259,6 +304,26 @@ export function WalletStateScreen(): React.ReactElement {
}); });
}, [history]); }, [history]);
/**
* Fiat values are memoized so we only recompute when balance or rate changes.
*/
const formattedUsdPerBchRate = useMemo(() => {
return formattedFiatPerBchRate;
}, [formattedFiatPerBchRate]);
const formattedUsdBalance = useMemo(() => {
if (!balance || fiatPerBchRate === null) {
return null;
}
return formatSatoshisToFiat(balance.totalSatoshis);
}, [balance, fiatPerBchRate, formatSatoshisToFiat]);
const getFiatSuffix = useCallback((satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
}, [formatSatoshisToFiat]);
// Screen input — automatically blocked when any dialog/overlay is capturing. // Screen input — automatically blocked when any dialog/overlay is capturing.
const isCaptured = useIsInputCaptured(); const isCaptured = useIsInputCaptured();
@@ -297,11 +362,16 @@ export function WalletStateScreen(): React.ReactElement {
} }
if (row.type === 'invitation_input') { if (row.type === 'invitation_input') {
const inputSatoshis = row.utxo?.valueSatoshis;
const inputFiatSuffix = inputSatoshis !== undefined
? getFiatSuffix(inputSatoshis)
: '';
return ( return (
<Box flexDirection="row" justifyContent="space-between"> <Box flexDirection="row" justifyContent="space-between">
<Box> <Box>
<Text color={itemColor}> <Text color={itemColor}>
{indicator}{groupingPrefix}[Input] {row.label} {indicator}{groupingPrefix}[Input] {row.label}
{inputFiatSuffix}
</Text> </Text>
{row.description && <Text color={colors.textMuted}> {row.description}</Text>} {row.description && <Text color={colors.textMuted}> {row.description}</Text>}
</Box> </Box>
@@ -317,6 +387,7 @@ export function WalletStateScreen(): React.ReactElement {
<Box flexDirection="row"> <Box flexDirection="row">
<Text color={itemColor}> <Text color={itemColor}>
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)} {indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
{getFiatSuffix(sats)}
</Text> </Text>
{row.description && <Text color={colors.textMuted}> {row.description}</Text>} {row.description && <Text color={colors.textMuted}> {row.description}</Text>}
</Box> </Box>
@@ -331,7 +402,10 @@ export function WalletStateScreen(): React.ReactElement {
return ( return (
<Box flexDirection="row" justifyContent="space-between"> <Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row"> <Box flexDirection="row">
<Text color={itemColor}>{indicator}{formatSatoshis(sats)}</Text> <Text color={itemColor}>
{indicator}{formatSatoshis(sats)}
{getFiatSuffix(sats)}
</Text>
{row.description && <Text color={colors.textMuted}> {row.description}{reservedTag}</Text>} {row.description && <Text color={colors.textMuted}> {row.description}{reservedTag}</Text>}
</Box> </Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>} {dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
@@ -348,7 +422,7 @@ export function WalletStateScreen(): React.ReactElement {
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>} {dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box> </Box>
); );
}, []); }, [getFiatSuffix]);
return ( return (
<Box flexDirection="column" flexGrow={1}> <Box flexDirection="column" flexGrow={1}>
@@ -380,6 +454,20 @@ export function WalletStateScreen(): React.ReactElement {
<Text color={colors.success} bold> <Text color={colors.success} bold>
{formatSatoshis(balance.totalSatoshis)} {formatSatoshis(balance.totalSatoshis)}
</Text> </Text>
{formattedUsdBalance ? (
<Text color={colors.info}>
Approx. Fiat ({currencyCode}): {formattedUsdBalance}
</Text>
) : (
<Text color={colors.textMuted}>
Approx. Fiat ({currencyCode}): Waiting for BCH/{currencyCode} rate...
</Text>
)}
{formattedUsdPerBchRate && (
<Text color={colors.textMuted}>
1 BCH = {formattedUsdPerBchRate}
</Text>
)}
<Text color={colors.textMuted}> <Text color={colors.textMuted}>
UTXOs: {balance.utxoCount} UTXOs: {balance.utxoCount}
</Text> </Text>

View File

@@ -15,7 +15,7 @@ export { DataWizardFlow } from "./DataWizardFlow.js";
*/ */
export function createWizardFlow(action: XOTemplateAction): WizardFlow { export function createWizardFlow(action: XOTemplateAction): WizardFlow {
if (action.data?.length && !action.transaction) { if (action.data?.length && !action.transaction) {
return new DataWizardFlow(action.data); return new DataWizardFlow([action.data]);
} }
return new TransactionWizardFlow(); return new TransactionWizardFlow();
} }

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { colors, formatSatoshis, formatHex } from '../../../theme.js'; import { colors, formatSatoshis, formatHex } from '../../../theme.js';
import { useSatoshisConversion } from '../../../hooks/useSatoshisConversion.js';
import type { SelectableUTXO, FocusArea } from '../types.js'; import type { SelectableUTXO, FocusArea } from '../types.js';
interface Props { interface Props {
@@ -22,6 +23,13 @@ export function InputsStep({
changeAmount, changeAmount,
focusArea, focusArea,
}: Props): React.ReactElement { }: Props): React.ReactElement {
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
};
return ( return (
<Box flexDirection='column'> <Box flexDirection='column'>
<Text color={colors.text} bold> <Text color={colors.text} bold>
@@ -32,6 +40,7 @@ export function InputsStep({
<Text color={colors.textMuted}> <Text color={colors.textMuted}>
Required: {formatSatoshis(requiredAmount)} +{' '} Required: {formatSatoshis(requiredAmount)} +{' '}
{formatSatoshis(fee)} fee {formatSatoshis(fee)} fee
{getFiatSuffix(requiredAmount + fee)}
</Text> </Text>
<Text <Text
color={ color={
@@ -41,10 +50,12 @@ export function InputsStep({
} }
> >
Selected: {formatSatoshis(selectedAmount)} Selected: {formatSatoshis(selectedAmount)}
{getFiatSuffix(selectedAmount)}
</Text> </Text>
{selectedAmount > requiredAmount + fee && ( {selectedAmount > requiredAmount + fee && (
<Text color={colors.info}> <Text color={colors.info}>
Change: {formatSatoshis(changeAmount)} Change: {formatSatoshis(changeAmount)}
{getFiatSuffix(changeAmount)}
</Text> </Text>
)} )}
</Box> </Box>
@@ -65,6 +76,7 @@ export function InputsStep({
return ( return (
<Box <Box
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`} key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
flexDirection='column'
> >
<Text <Text
color={isCursor ? colors.focus : colors.text} color={isCursor ? colors.focus : colors.text}
@@ -75,6 +87,15 @@ export function InputsStep({
{formatHex(utxo.outpointTransactionHash, 12)}: {formatHex(utxo.outpointTransactionHash, 12)}:
{utxo.outpointIndex} {utxo.outpointIndex}
</Text> </Text>
{(() => {
const fiatValue = formatSatoshisToFiat(utxo.valueSatoshis);
if (!fiatValue) return null;
return (
<Text color={colors.textMuted}>
{' '} {fiatValue}
</Text>
);
})()}
</Box> </Box>
); );
}) })

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { colors, formatSatoshis } from '../../../theme.js'; import { colors, formatSatoshis } from '../../../theme.js';
import { useSatoshisConversion } from '../../../hooks/useSatoshisConversion.js';
import type { VariableInput, SelectableUTXO } from '../types.js'; import type { VariableInput, SelectableUTXO } from '../types.js';
import type { XOTemplate } from '@xo-cash/types'; import type { XOTemplate } from '@xo-cash/types';
@@ -22,6 +23,32 @@ export function ReviewStep({
changeAmount, changeAmount,
}: ReviewStepProps): React.ReactElement { }: ReviewStepProps): React.ReactElement {
const selectedUtxos = availableUtxos.filter((u) => u.selected); const selectedUtxos = availableUtxos.filter((u) => u.selected);
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
};
const getVariableFiatSuffix = (variable: VariableInput): string => {
if (variable.type !== 'integer') {
return '';
}
if (variable.hint?.toLowerCase().includes('satoshi') !== true) {
return '';
}
if (!/^[-]?\d+$/.test(variable.value.trim())) {
return '';
}
try {
return getFiatSuffix(BigInt(variable.value));
} catch {
return '';
}
};
return ( return (
<Box flexDirection='column'> <Box flexDirection='column'>
@@ -44,6 +71,7 @@ export function ReviewStep({
<Text key={v.id} color={colors.textMuted}> <Text key={v.id} color={colors.textMuted}>
{' '} {' '}
{v.name}: {v.value || '(empty)'} {v.name}: {v.value || '(empty)'}
{v.value ? getVariableFiatSuffix(v) : ''}
</Text> </Text>
))} ))}
</Box> </Box>
@@ -62,6 +90,7 @@ export function ReviewStep({
> >
{' '} {' '}
{formatSatoshis(u.valueSatoshis)} {formatSatoshis(u.valueSatoshis)}
{getFiatSuffix(u.valueSatoshis)}
</Text> </Text>
))} ))}
{selectedUtxos.length > 3 && ( {selectedUtxos.length > 3 && (
@@ -78,6 +107,7 @@ export function ReviewStep({
<Text color={colors.text}>Outputs:</Text> <Text color={colors.text}>Outputs:</Text>
<Text color={colors.textMuted}> <Text color={colors.textMuted}>
{' '}Change: {formatSatoshis(changeAmount)} {' '}Change: {formatSatoshis(changeAmount)}
{getFiatSuffix(changeAmount)}
</Text> </Text>
</Box> </Box>
)} )}

View File

@@ -61,13 +61,16 @@ export function RoleSelectStep({
{availableRoles.length === 0 ? ( {availableRoles.length === 0 ? (
<Text color={colors.textMuted}>No roles available</Text> <Text color={colors.textMuted}>No roles available</Text>
) : ( ) : (
availableRoles.map((roleId, index) => { availableRoles.map((roleId: string, index: number) => {
const isCursor = const isCursor =
selectedRoleIndex === index && focusArea === 'content'; selectedRoleIndex === index && focusArea === 'content';
const roleDef = template.roles?.[roleId]; const roleDef = template.roles?.[roleId];
const actionRole = action?.roles?.[roleId]; const actionRole = action?.roles?.[roleId];
const requirements = actionRole?.requirements; const requirements = actionRole?.requirements;
const actionRequirements = action?.requirements;
const actionRoleRequirements = actionRole && actionRequirements && actionRequirements.participants?.find((participant) => participant.role === roleId);
return ( return (
<Box key={roleId} flexDirection="column" marginY={0}> <Box key={roleId} flexDirection="column" marginY={0}>
<Text <Text
@@ -96,10 +99,10 @@ export function RoleSelectStep({
{' '} {' '}
</Text> </Text>
)} )}
{requirements.slots && requirements.slots.min > 0 && ( {actionRoleRequirements && actionRoleRequirements.slots && actionRoleRequirements.slots.min > 0 && (
<Text color={colors.textMuted} dimColor> <Text color={colors.textMuted} dimColor>
{requirements.slots.min} input slot {actionRoleRequirements.slots.min} input slot
{requirements.slots.min !== 1 ? 's' : ''} {actionRoleRequirements.slots.min !== 1 ? 's' : ''}
</Text> </Text>
)} )}
</Box> </Box>

View File

@@ -17,6 +17,7 @@ import { useNavigation } from '../../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../../hooks/useAppContext.js'; import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
import { useBlockableInput, useIsInputCaptured } from '../../hooks/useInputLayer.js'; import { useBlockableInput, useIsInputCaptured } from '../../hooks/useInputLayer.js';
import { useInvitations } from '../../hooks/useInvitations.js'; import { useInvitations } from '../../hooks/useInvitations.js';
import { useSatoshisConversion } from '../../hooks/useSatoshisConversion.js';
import { colors, logoSmall, formatSatoshis } from '../../theme.js'; import { colors, logoSmall, formatSatoshis } from '../../theme.js';
import { copyToClipboard } from '../../utils/clipboard.js'; import { copyToClipboard } from '../../utils/clipboard.js';
import type { Invitation } from '../../../services/invitation.js'; import type { Invitation } from '../../../services/invitation.js';
@@ -88,6 +89,8 @@ export function InvitationScreen(): React.ReactElement {
const { setStatus } = useStatus(); const { setStatus } = useStatus();
const invitations = useInvitations(); const invitations = useInvitations();
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
useSatoshisConversion('USD');
// ── UI state ───────────────────────────────────────────────────────────── // ── UI state ─────────────────────────────────────────────────────────────
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
@@ -99,6 +102,7 @@ export function InvitationScreen(): React.ReactElement {
// Two phases: first the ID input dialog, then the multi-step import flow. // Two phases: first the ID input dialog, then the multi-step import flow.
const [showIdDialog, setShowIdDialog] = useState(false); const [showIdDialog, setShowIdDialog] = useState(false);
const [importingId, setImportingId] = useState<string | null>(null); const [importingId, setImportingId] = useState<string | null>(null);
const [pendingImportedInvitationId, setPendingImportedInvitationId] = useState<string | null>(null);
// ── Template cache ─────────────────────────────────────────────────────── // ── Template cache ───────────────────────────────────────────────────────
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map()); const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
@@ -158,7 +162,7 @@ export function InvitationScreen(): React.ReactElement {
}); });
return [importItem, ...invitationItems]; return [importItem, ...invitationItems];
}, [invitations, templateCache]); }, [invitations.length, templateCache]);
const selectedItem = listItems[selectedIndex]; const selectedItem = listItems[selectedIndex];
const selectedInvitation = selectedItem?.value ?? null; const selectedInvitation = selectedItem?.value ?? null;
@@ -193,10 +197,30 @@ export function InvitationScreen(): React.ReactElement {
/** /**
* Import flow closed (completed or cancelled). * Import flow closed (completed or cancelled).
*/ */
const handleImportFlowClose = useCallback(() => { const handleImportFlowClose = useCallback((importedInvitationId?: string) => {
if (importedInvitationId) {
setPendingImportedInvitationId(importedInvitationId);
}
setImportingId(null); setImportingId(null);
}, []); }, []);
/**
* Once imported invitation is visible in the list, select and focus it.
*/
useEffect(() => {
if (!pendingImportedInvitationId) return;
const importedIndex = listItems.findIndex((item) => {
return item.value?.data.invitationIdentifier === pendingImportedInvitationId;
});
if (importedIndex >= 0) {
setSelectedIndex(importedIndex);
setFocusedPanel('list');
setPendingImportedInvitationId(null);
}
}, [pendingImportedInvitationId, listItems]);
// ── Action handlers ──────────────────────────────────────────────────── // ── Action handlers ────────────────────────────────────────────────────
const acceptInvitation = useCallback(async () => { const acceptInvitation = useCallback(async () => {
@@ -327,10 +351,10 @@ export function InvitationScreen(): React.ReactElement {
const seenLockingBytecodes = new Set<string>(); const seenLockingBytecodes = new Set<string>();
for (const utxo of utxos) { for (const utxo of utxos) {
const lockingBytecodeHex = utxo.lockingBytecode const lockingBytecodeHex = utxo.scriptHash
? typeof utxo.lockingBytecode === 'string' ? typeof utxo.scriptHash === 'string'
? utxo.lockingBytecode ? utxo.scriptHash
: Buffer.from(utxo.lockingBytecode).toString('hex') : Buffer.from(utxo.scriptHash).toString('hex')
: undefined; : undefined;
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue; if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue;
@@ -494,6 +518,44 @@ export function InvitationScreen(): React.ReactElement {
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole]; const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null; const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
};
const parseNumberishToBigInt = (value: unknown): bigint | null => {
if (typeof value === 'bigint') {
return value;
}
const asString = String(value).trim();
if (!/^[-]?\d+$/.test(asString)) {
return null;
}
try {
return BigInt(asString);
} catch {
return null;
}
};
const isSatoshisVariable = (variableIdentifier: string): boolean => {
const templateVariable = selectedTemplate?.variables?.[variableIdentifier];
const templateType = templateVariable?.type?.toLowerCase();
const templateHint = templateVariable?.hint?.toLowerCase();
const identifier = variableIdentifier.toLowerCase();
if (templateHint?.includes('satoshi')) {
return true;
}
return (
templateType === 'integer' &&
(identifier.includes('satoshi') || identifier.includes('amount'))
);
};
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
{/* Type & Status */} {/* Type & Status */}
@@ -514,6 +576,11 @@ export function InvitationScreen(): React.ReactElement {
<Text color={colors.textMuted}> <Text color={colors.textMuted}>
Action: {action?.name ?? selectedInvitation.data.actionIdentifier} Action: {action?.name ?? selectedInvitation.data.actionIdentifier}
</Text> </Text>
{formattedFiatPerBchRate && (
<Text color={colors.textMuted}>
1 BCH = {formattedFiatPerBchRate}
</Text>
)}
{action?.description && ( {action?.description && (
<Text color={colors.textMuted} dimColor>{action.description}</Text> <Text color={colors.textMuted} dimColor>{action.description}</Text>
)} )}
@@ -542,6 +609,11 @@ export function InvitationScreen(): React.ReactElement {
inputs.map((input, idx) => { inputs.map((input, idx) => {
const isUserInput = input.entityIdentifier === userEntityId; const isUserInput = input.entityIdentifier === userEntityId;
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? '']; const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
const inputSatoshis = (
'valueSatoshis' in input && input.valueSatoshis !== undefined
)
? parseNumberishToBigInt(input.valueSatoshis)
: null;
return ( return (
<Text <Text
key={`input-${idx}`} key={`input-${idx}`}
@@ -550,6 +622,7 @@ export function InvitationScreen(): React.ReactElement {
{' '}{isUserInput ? '• ' : '○ '} {' '}{isUserInput ? '• ' : '○ '}
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`} {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{input.roleIdentifier && ` (${input.roleIdentifier})`} {input.roleIdentifier && ` (${input.roleIdentifier})`}
{inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`}
</Text> </Text>
); );
}) })
@@ -564,6 +637,9 @@ export function InvitationScreen(): React.ReactElement {
outputs.map((output, idx) => { outputs.map((output, idx) => {
const isUserOutput = output.entityIdentifier === userEntityId; const isUserOutput = output.entityIdentifier === userEntityId;
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? '']; const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
const outputSatoshis = output.valueSatoshis !== undefined
? parseNumberishToBigInt(output.valueSatoshis)
: null;
return ( return (
<Text <Text
key={`output-${idx}`} key={`output-${idx}`}
@@ -571,7 +647,7 @@ export function InvitationScreen(): React.ReactElement {
> >
{' '}{isUserOutput ? '• ' : '○ '} {' '}{isUserOutput ? '• ' : '○ '}
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`} {outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}
</Text> </Text>
); );
}) })
@@ -591,6 +667,9 @@ export function InvitationScreen(): React.ReactElement {
const displayValue = typeof variable.value === 'bigint' const displayValue = typeof variable.value === 'bigint'
? variable.value.toString() ? variable.value.toString()
: String(variable.value); : String(variable.value);
const parsedVariableSatoshis = isSatoshisVariable(variable.variableIdentifier)
? parseNumberishToBigInt(variable.value)
: null;
return ( return (
<Text <Text
key={`var-${idx}`} key={`var-${idx}`}
@@ -598,6 +677,8 @@ export function InvitationScreen(): React.ReactElement {
> >
{' '}{isUserVariable ? '• ' : '○ '} {' '}{isUserVariable ? '• ' : '○ '}
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue} {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
{parsedVariableSatoshis !== null &&
` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`}
{varTemplate?.description && ( {varTemplate?.description && (
<Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text> <Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text>
)} )}

View File

@@ -148,7 +148,7 @@ export function InvitationImportFlow({
`Action: ${invitation?.data.actionIdentifier ?? 'Unknown'}` `Action: ${invitation?.data.actionIdentifier ?? 'Unknown'}`
); );
setStatus('Ready'); setStatus('Ready');
onClose(); onClose(invitation?.data.invitationIdentifier);
}, [selectedRole, template, invitation, showInfo, setStatus, onClose]); }, [selectedRole, template, invitation, showInfo, setStatus, onClose]);
// ── Keyboard handling ──────────────────────────────────────────────────── // ── Keyboard handling ────────────────────────────────────────────────────

View File

@@ -9,6 +9,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { colors, formatSatoshis } from '../../../../theme.js'; import { colors, formatSatoshis } from '../../../../theme.js';
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
import { useLayeredInput } from '../../../../hooks/useInputLayer.js'; import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js'; import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js'; import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js';
@@ -32,6 +33,7 @@ export function InputsSelectStep({
const [requiredAmount, setRequiredAmount] = useState(0n); const [requiredAmount, setRequiredAmount] = useState(0n);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
const fee = DEFAULT_FEE; const fee = DEFAULT_FEE;
@@ -42,6 +44,11 @@ export function InputsSelectStep({
const changeAmount = selectedAmount - requiredAmount - fee; const changeAmount = selectedAmount - requiredAmount - fee;
const hasEnough = selectedAmount >= requiredAmount + fee; const hasEnough = selectedAmount >= requiredAmount + fee;
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
};
/** /**
* Determine the required satoshi amount from the invitation's variables. * Determine the required satoshi amount from the invitation's variables.
*/ */
@@ -94,10 +101,7 @@ export function InputsSelectStep({
// Create a map of the utxoID to suitable resource // Create a map of the utxoID to suitable resource
const utxoIdToSuitableResource = new Map<string, UnspentOutputData>(); const utxoIdToSuitableResource = new Map<string, UnspentOutputData>();
for (const outputIdentifier of outputIdentifiers) { for (const outputIdentifier of outputIdentifiers) {
const suitableResources = await invitation.findSuitableResources({ const suitableResources = await invitation.findSuitableResources();
outputIdentifier,
});
for (const suitableResource of suitableResources) { for (const suitableResource of suitableResources) {
utxoIdToSuitableResource.set(suitableResource.outpointTransactionHash + ':' + suitableResource.outpointIndex, suitableResource); utxoIdToSuitableResource.set(suitableResource.outpointTransactionHash + ':' + suitableResource.outpointIndex, suitableResource);
} }
@@ -142,7 +146,7 @@ export function InputsSelectStep({
setFocusedIndex(prev => Math.max(0, prev - 1)); setFocusedIndex(prev => Math.max(0, prev - 1));
} else if (key.downArrow || input === 'j') { } else if (key.downArrow || input === 'j') {
setFocusedIndex(prev => Math.min(utxos.length - 1, prev + 1)); 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); if (utxos.length > 0) toggleSelection(focusedIndex);
} else if (input === 'a') { } else if (input === 'a') {
setUtxos(prev => prev.map(u => ({ ...u, selected: true }))); setUtxos(prev => prev.map(u => ({ ...u, selected: true })));
@@ -196,18 +200,32 @@ export function InputsSelectStep({
{/* Summary bar */} {/* Summary bar */}
<Box flexDirection="row" marginBottom={1}> <Box flexDirection="row" marginBottom={1}>
<Text color={colors.primary} bold>Required: </Text> <Text color={colors.primary} bold>Required: </Text>
<Text color={colors.text}>{formatSatoshis(requiredAmount + fee)}</Text> <Text color={colors.text}>
{formatSatoshis(requiredAmount + fee)}
{getFiatSuffix(requiredAmount + fee)}
</Text>
<Text color={colors.textMuted}> (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)})</Text> <Text color={colors.textMuted}> (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)})</Text>
</Box> </Box>
<Box flexDirection="row" marginBottom={1}> <Box flexDirection="row" marginBottom={1}>
<Text color={colors.primary} bold>Selected: </Text> <Text color={colors.primary} bold>Selected: </Text>
<Text color={hasEnough ? colors.success : colors.error}>{formatSatoshis(selectedAmount)}</Text> <Text color={hasEnough ? colors.success : colors.error}>
{formatSatoshis(selectedAmount)}
{getFiatSuffix(selectedAmount)}
</Text>
{hasEnough && changeAmount >= DUST_THRESHOLD && ( {hasEnough && changeAmount >= DUST_THRESHOLD && (
<Text color={colors.textMuted}> (change: {formatSatoshis(changeAmount)})</Text> <Text color={colors.textMuted}>
{' '}
(change: {formatSatoshis(changeAmount)}
{getFiatSuffix(changeAmount)})
</Text>
)} )}
{!hasEnough && ( {!hasEnough && (
<Text color={colors.error}> need {formatSatoshis(requiredAmount + fee - selectedAmount)} more</Text> <Text color={colors.error}>
{' '}
need {formatSatoshis(requiredAmount + fee - selectedAmount)}
{getFiatSuffix(requiredAmount + fee - selectedAmount)} more
</Text>
)} )}
</Box> </Box>
@@ -219,13 +237,22 @@ export function InputsSelectStep({
const txShort = utxo.outpointTransactionHash.slice(0, 8); const txShort = utxo.outpointTransactionHash.slice(0, 8);
return ( return (
<Text <Box
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`} key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text} flexDirection="column"
bold={isFocused}
> >
{isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}:{utxo.outpointIndex}) <Text
</Text> color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text}
bold={isFocused}
>
{isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}:{utxo.outpointIndex})
</Text>
{formatSatoshisToFiat(utxo.valueSatoshis) && (
<Text color={colors.textMuted}>
{' '} {formatSatoshisToFiat(utxo.valueSatoshis)}
</Text>
)}
</Box>
); );
})} })}

View File

@@ -9,6 +9,7 @@
import React from 'react'; import React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { colors, formatSatoshis } from '../../../../theme.js'; import { colors, formatSatoshis } from '../../../../theme.js';
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
import { useLayeredInput } from '../../../../hooks/useInputLayer.js'; import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
import { import {
getInvitationState, getInvitationState,
@@ -41,6 +42,8 @@ export function PreviewInvitationStep({
onCancel, onCancel,
isActive, isActive,
}: PreviewStepProps): React.ReactElement { }: PreviewStepProps): React.ReactElement {
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
useLayeredInput('import-flow', (_input, key) => { useLayeredInput('import-flow', (_input, key) => {
if (key.return) onComplete(); if (key.return) onComplete();
if (key.escape) onCancel(); if (key.escape) onCancel();
@@ -168,11 +171,15 @@ export function PreviewInvitationStep({
) : ( ) : (
outputs.map((output, idx) => { outputs.map((output, idx) => {
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? '']; const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
const fiatValue = output.valueSatoshis !== undefined
? formatSatoshisToFiat(output.valueSatoshis)
: null;
return ( return (
<Box key={`output-${idx}`}> <Box key={`output-${idx}`}>
<Text color={colors.text}> <Text color={colors.text}>
{' '} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} {' '} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`} {output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
{fiatValue && ` (~${fiatValue})`}
</Text> </Text>
</Box> </Box>
); );

View File

@@ -10,6 +10,7 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { colors, formatSatoshis } from '../../../../theme.js'; import { colors, formatSatoshis } from '../../../../theme.js';
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
import { useLayeredInput } from '../../../../hooks/useInputLayer.js'; import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
import type { ReviewStepProps, SelectableUTXO } from '../types.js'; import type { ReviewStepProps, SelectableUTXO } from '../types.js';
@@ -32,6 +33,7 @@ export function ReviewStep({
}: ReviewStepProps): React.ReactElement { }: ReviewStepProps): React.ReactElement {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
const fee = DEFAULT_FEE; const fee = DEFAULT_FEE;
const action = template?.actions?.[invitation.data.actionIdentifier]; const action = template?.actions?.[invitation.data.actionIdentifier];
@@ -39,6 +41,11 @@ export function ReviewStep({
// Compute totals from selected inputs // Compute totals from selected inputs
const totalSelected = selectedInputs.reduce((sum, u) => sum + u.valueSatoshis, 0n); const totalSelected = selectedInputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
};
/** /**
* Execute the import: add inputs (with role) and optional change output. * Execute the import: add inputs (with role) and optional change output.
*/ */
@@ -85,14 +92,34 @@ export function ReviewStep({
<Box marginTop={1} flexDirection="column"> <Box marginTop={1} flexDirection="column">
<Text color={colors.primary} bold>Funding:</Text> <Text color={colors.primary} bold>Funding:</Text>
<Text color={colors.text}> UTXOs: {selectedInputs.length}</Text> <Text color={colors.text}> UTXOs: {selectedInputs.length}</Text>
<Text color={colors.text}> Total: {formatSatoshis(totalSelected)}</Text> <Text color={colors.text}> Total: {formatSatoshis(totalSelected)}{getFiatSuffix(totalSelected)}</Text>
<Text color={colors.text}> Required: {formatSatoshis(requiredAmount)}</Text> <Text color={colors.text}> Required: {formatSatoshis(requiredAmount)}{getFiatSuffix(requiredAmount)}</Text>
<Text color={colors.text}> Fee: {formatSatoshis(fee)}</Text> <Text color={colors.text}> Fee: {formatSatoshis(fee)}{getFiatSuffix(fee)}</Text>
{changeAmount >= DUST_THRESHOLD && ( {changeAmount >= DUST_THRESHOLD && (
<Text color={colors.text}> Change: {formatSatoshis(changeAmount)}</Text> <Text color={colors.text}> Change: {formatSatoshis(changeAmount)}{getFiatSuffix(changeAmount)}</Text>
)} )}
</Box> </Box>
{selectedInputs.length > 0 && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.primary} bold>Selected UTXOs:</Text>
{selectedInputs.slice(0, 3).map((utxo) => (
<Text
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
color={colors.textMuted}
>
{' '} {formatSatoshis(utxo.valueSatoshis)}
{getFiatSuffix(utxo.valueSatoshis)}
</Text>
))}
{selectedInputs.length > 3 && (
<Text color={colors.textMuted}>
{' '}...and {selectedInputs.length - 3} more
</Text>
)}
</Box>
)}
{/* Error display */} {/* Error display */}
{error && ( {error && (
<Box marginTop={1}> <Box marginTop={1}>

View File

@@ -116,8 +116,12 @@ export interface ImportFlowProps {
mode: ImportFlowMode; mode: ImportFlowMode;
/** The application service — injected, not pulled from context. */ /** The application service — injected, not pulled from context. */
appService: AppService; appService: AppService;
/** Called when the flow completes or is cancelled. */ /**
onClose: () => void; * Called when the flow completes or is cancelled.
* When import succeeds, the invitation identifier is provided so callers can
* select/focus the imported invitation in their UI.
*/
onClose: (importedInvitationId?: string) => void;
/** Display an error message to the user. */ /** Display an error message to the user. */
showError: (message: string) => void; showError: (message: string) => void;
/** Display an info message to the user. */ /** Display an info message to the user. */

View File

@@ -8,6 +8,36 @@ import { promisify } from "util";
const execAsync = promisify(exec); const execAsync = promisify(exec);
// Define a list of clipboard methods with their platform and command.
// The platform is a function that returns true if the method is available on the current platform.
// The command is a function that returns a promise that resolves to the result of the command.
const clipboardMethods = {
pbCopy: {
platform: (platform: string) => platform === 'darwin',
command: async (text: string) => execAsync(`printf '%s' '${text}' | pbcopy`),
},
xclip: {
platform: (platform: string) => platform === 'linux',
command: async (text: string) => execAsync(`printf '%s' '${text}' | xclip -selection clipboard`),
},
xsel: {
platform: (platform: string) => platform === 'linux',
command: async (text: string) => execAsync(`printf '%s' '${text}' | xsel --clipboard --input`),
},
ssh: {
platform: (platform: string) => platform === 'linux',
command: async (text: string) => process.stdout.write(`\x1b]52;c;${Buffer.from(text, 'utf-8').toString('base64')}\x07`),
},
clip: {
platform: (platform: string) => platform === 'windows',
command: async (text: string) => execAsync(`echo|set /p="${text}" | clip`),
},
clipboardy: {
platform: (platform: string) => platform === 'windows',
command: async (text: string) => clipboardy.writeSync(text),
},
}
/** /**
* Attempts to copy text to clipboard using multiple methods. * Attempts to copy text to clipboard using multiple methods.
* Tries native commands first (most reliable), then clipboardy as fallback. * Tries native commands first (most reliable), then clipboardy as fallback.
@@ -21,46 +51,25 @@ export async function copyToClipboard(text: string): Promise<void> {
// Escape the text for shell commands // Escape the text for shell commands
const escapedText = text.replace(/'/g, "'\\''"); const escapedText = text.replace(/'/g, "'\\''");
// Try native commands first - they're more reliable const availableMethods = Object.values(clipboardMethods).filter(method => method.platform(platform));
try {
if (platform === "darwin") {
// macOS - use pbcopy directly
await execAsync(`printf '%s' '${escapedText}' | pbcopy`);
return;
} else if (platform === "linux") {
// Linux - try xclip, then xsel
try {
await execAsync(
`printf '%s' '${escapedText}' | xclip -selection clipboard`,
);
return;
} catch {
try {
await execAsync(
`printf '%s' '${escapedText}' | xsel --clipboard --input`,
);
return;
} catch {
// Fall through to clipboardy
}
}
} else if (platform === "win32") {
// Windows - use clip.exe
await execAsync(`echo|set /p="${text}" | clip`);
return;
}
} catch {
// Native command failed, try clipboardy
}
// Fallback to clipboardy const errors: Error[] = [];
try {
clipboardy.writeSync(text); for (const method of availableMethods) {
return; try {
} catch { if (method.platform(platform)) {
// clipboardy also failed await method.command(escapedText);
} else {
continue;
}
return;
} catch(error) {
if (error instanceof Error) {
errors.push(error);
}
}
} }
// All methods failed // All methods failed
throw new Error(`Clipboard not available. Install xclip or xsel on Linux.`); throw new Error(`Clipboard not available. ${errors.map(error => error.message).join('\n')}`);
} }

View File

@@ -3,6 +3,7 @@
* Pulled directly from the old stack package. * Pulled directly from the old stack package.
*/ */
import { z } from "zod"; import { z } from "zod";
import { decodeBip39Mnemonic } from "@bitauth/libauth";
export type BCHMnemonicURLRaw = { export type BCHMnemonicURLRaw = {
entropy: Uint8Array; entropy: Uint8Array;
@@ -86,6 +87,18 @@ export class BCHMnemonicURL {
return new BCHMnemonicURL(raw); 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) {} constructor(protected raw: BCHMnemonicURLRaw) {}
toObject() { toObject() {

View File

@@ -1,5 +1,6 @@
import type { XOTemplate, XOTemplateTransactionOutput } from "@xo-cash/types"; import type { XOTemplate, XOTemplateTransactionOutput } from "@xo-cash/types";
import type { Invitation } from "../services/invitation.js"; import type { Invitation } from "../services/invitation.js";
import { cashAddressToLockingBytecode, binToHex } from "@bitauth/libauth";
export interface SelectableUtxoLike { export interface SelectableUtxoLike {
outpointTransactionHash: string; outpointTransactionHash: string;
@@ -44,7 +45,8 @@ export const resolveActionRoles = (
const starts = template.start ?? []; const starts = template.start ?? [];
const roleIds = starts const roleIds = starts
.filter((entry) => entry.action === actionIdentifier) .filter((entry) => entry.action === actionIdentifier)
.map((entry) => entry.role); .map((entry) => entry.role)
.filter((roleId) => roleId !== undefined);
return [...new Set(roleIds)]; return [...new Set(roleIds)];
}; };
@@ -59,17 +61,11 @@ export const roleRequiresInputs = (
if (!action) return false; if (!action) return false;
const actionRole = action.roles?.[roleIdentifier]; const actionRole = action.roles?.[roleIdentifier];
const roleSlotsMin = actionRole?.requirements?.slots?.min ?? 0; const actionRequirements = action.requirements;
const actionRoleRequirements = actionRole && actionRequirements && actionRequirements.participants?.find((participant) => participant.role === roleIdentifier);
const roleSlotsMin = actionRoleRequirements && actionRoleRequirements.slots && actionRoleRequirements.slots.min > 0 ? actionRoleRequirements.slots.min : 0;
if (roleSlotsMin > 0) return true; if (roleSlotsMin > 0) return true;
// Some templates specify slot/input requirements at action.requirements.roles
// instead of role.requirements. Respect those as well.
const roleRequirement = action.requirements?.roles?.find(
(requirement) => requirement.role === roleIdentifier,
);
const actionLevelSlotsMin = roleRequirement?.slots?.min ?? 0;
if (actionLevelSlotsMin > 0) return true;
const transactionIdentifier = action.transaction; const transactionIdentifier = action.transaction;
const transaction = transactionIdentifier const transaction = transactionIdentifier
? template.transactions?.[transactionIdentifier] ? template.transactions?.[transactionIdentifier]
@@ -97,19 +93,46 @@ export const getTransactionOutputIdentifier = (
export const normalizeLockingBytecodeHex = (value: string): string => export const normalizeLockingBytecodeHex = (value: string): string =>
value.trim().replace(/^0x/i, ""); 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 = ( export const resolveProvidedLockingBytecodeHex = (
template: XOTemplate, template: XOTemplate,
outputIdentifier: string, outputIdentifier: string,
variableValues: Record<string, string>, variableValues: Record<string, string>,
): string | undefined => { ): string | undefined => {
const outputDefinition = template.outputs?.[outputIdentifier]; const outputDefinition = template.outputs?.[outputIdentifier];
if (!outputDefinition || typeof outputDefinition.lockscript !== "string") if (!outputDefinition || typeof outputDefinition.lockingScript !== "string") {
return undefined; return undefined;
}
const lockingScriptDefinition = ( const lockingScriptDefinition = template.lockingScripts?.[outputDefinition.lockingScript];
template.lockingScripts as Record<string, unknown> | undefined const scriptIdentifier = lockingScriptDefinition?.lockingBytecode;
)?.[outputDefinition.lockscript] as { lockingScript?: string } | undefined;
const scriptIdentifier = lockingScriptDefinition?.lockingScript;
if (!scriptIdentifier) return undefined; if (!scriptIdentifier) return undefined;
const scriptExpression = ( const scriptExpression = (
@@ -128,6 +151,10 @@ export const resolveProvidedLockingBytecodeHex = (
const providedValue = variableValues[variableIdentifier]; const providedValue = variableValues[variableIdentifier];
if (!providedValue) return undefined; 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); return normalizeLockingBytecodeHex(providedValue);
}; };

70
src/utils/paths.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* Global XO CLI config layout (XDG-style: ~/.config/xo-cli/).
* User-provided paths (templates, invitation JSON) stay relative to cwd.
*/
import { existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { basename, isAbsolute, join, resolve } from "node:path";
/**
* Base config directory. Created on first access.
*/
export function getConfigDir(): string {
const dir = join(homedir(), ".config", "xo-cli");
mkdirSync(dir, { recursive: true });
return dir;
}
/**
* Directory for mnemonic wallet files (mnemonic-*).
*/
export function getMnemonicsDir(): string {
const dir = join(getConfigDir(), "mnemonics");
mkdirSync(dir, { recursive: true });
return dir;
}
/**
* Directory for engine DB and invitation storage SQLite files.
*/
export function getDataDir(): string {
const dir = join(getConfigDir(), "data");
mkdirSync(dir, { recursive: true });
return dir;
}
/**
* File storing the last-used mnemonic reference for `-m` omission.
*/
export function getWalletConfigPath(): string {
return join(getConfigDir(), ".wallet");
}
/**
* Resolves a mnemonic reference to an absolute path.
* Order: absolute path if it exists → path relative to cwd → ~/.config/xo-cli/mnemonics/<basename>.
*
* @param mnemonicRef - Path or basename (e.g. `mnemonic-nuclear`)
* @returns Absolute path to the mnemonic file
* @throws If no matching file exists
*/
export function resolveMnemonicFilePath(mnemonicRef: string): string {
if (isAbsolute(mnemonicRef) && existsSync(mnemonicRef)) {
return mnemonicRef;
}
const relativeToCwd = resolve(process.cwd(), mnemonicRef);
if (existsSync(relativeToCwd)) {
return relativeToCwd;
}
const inMnemonics = join(getMnemonicsDir(), basename(mnemonicRef));
if (existsSync(inMnemonics)) {
return inMnemonics;
}
throw new Error(
`Mnemonic file not found: ${mnemonicRef}. Run "xo-cli mnemonic list" to see available files.`,
);
}

View File

@@ -0,0 +1,56 @@
import { EventEmitter } from '../event-emitter.js';
/**
* Events emitted by our Rates Adapters
*/
export type RatesEventMap = {
rateUpdated: {
numeratorUnitCode: string;
denominatorUnitCode: string;
price: number;
};
};
export abstract class BaseRates<
T extends RatesEventMap = RatesEventMap,
> extends EventEmitter<T> {
/** Starts the given rates adapter so that it will emit events on price updates. */
public abstract start(): Promise<void>;
/** Stops the given rates adapter so that it will stop checking for price updates. */
public abstract stop(): Promise<void>;
/**
* List all available market products (pairs).
* @returns A set of strings in the format "NUMERATOR/DENOMINATOR"
*/
public abstract listPairs(): Promise<Set<string>>;
// TODO: Consider whether we actually want the below.
// Ideally, we will want to replace this with something like the Units class:
// See: https://gitlab.com/GeneralProtocols/xo/stack/-/issues/44
/**
* Format the amount in the target currency to the correct number of decimal places.
*
* @param {number} amount - The amount to format.
* @param {string} targetCurrency - The target currency.
*
* @returns The formatted amount.
*/
public formatCurrency(amount: number, targetCurrency: string): string {
const minimumFractionDigitsMap: { [currency: string]: number } = {
AUD: 2,
BCH: 8,
USD: 2,
};
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: targetCurrency,
currencyDisplay: 'narrowSymbol',
minimumFractionDigits: minimumFractionDigitsMap[targetCurrency] || 0,
});
return formatter.format(amount);
}
}

View File

@@ -0,0 +1,170 @@
import {
OracleClient,
OracleMetadataMessage,
OraclePriceMessage,
type OracleMetadataMap,
} from '@generalprotocols/oracle-client';
import { type RatesEventMap, BaseRates } from './base-rates.js';
// Add the Oracle Price Message to our Events for this Adapter.
export type RatesOracleEventMap = RatesEventMap & {
rateUpdated: {
oraclePriceMessage: OraclePriceMessage;
};
};
// TODO: Add RatesHistorical trait since Oracles can provide historical rates.
export class RatesOracle extends BaseRates<RatesOracleEventMap> {
/**
* Create a new rates oracle.
*
* @param client The underlying oracle client. If not provided, a new client will be created.
* @returns The rates oracle.
*/
static async from(client?: OracleClient) {
const ratesOracle = new RatesOracle(client ?? (await OracleClient.from()));
return ratesOracle;
}
private client: OracleClient;
private oracles: OracleMetadataMap;
private started: boolean = false;
private constructor(client: OracleClient) {
super();
this.client = client;
this.oracles = {};
}
/**
* Start the rates oracle and the underlying client.
*/
async start() {
if (this.started) {
return;
}
this.started = true;
// Create event listeners for the client.
this.client.setOnMetadataMessage(this.handleMetadataMessage.bind(this));
this.client.setOnPriceMessage(this.handlePriceMessage.bind(this));
// Get the metadata for the client.
this.oracles = await this.client.getMetadataMap();
// Start the client.
await this.client.start();
// Refresh the prices.
await this.refreshPrices();
}
/**
* Stop the rates oracle and the underlying client.
*/
async stop() {
if (!this.started) {
return;
}
this.started = false;
// Remove event listeners by setting them to empty functions.
this.client.setOnMetadataMessage(() => {});
this.client.setOnPriceMessage(() => {});
await this.client.stop();
}
/**
* List the pairs that we are tracking.
*
* @returns A set of pairs.
*/
async listPairs() {
return new Set(
Object.values(this.oracles).map((oracle) => {
return `${oracle.SOURCE_NUMERATOR_UNIT_CODE}/${oracle.SOURCE_DENOMINATOR_UNIT_CODE}`;
}),
);
}
/**
* Get the latest prices for all the pairs and emit a rate updated event for each.
*/
public async refreshPrices() {
const oracles = await this.client.getOracles();
// For each oracle, get the lastest dataSequence (price) message and emit a rate updated event.
await Promise.allSettled(
oracles.map(async (oracle) => {
try {
const messages = await this.client.getOracleMessages({
publicKey: oracle.publicKey,
minDataSequence: 1,
count: 1,
});
// We are only expecting a single message back. Just in case, we take the latest one.
const message = messages.reduce((latest, msg) => {
if (
msg instanceof OraclePriceMessage &&
msg.messageSequence > (latest?.messageSequence ?? 0)
) {
return msg;
}
return latest;
}, messages[0]);
// If the message is a price message, handle it.
if (message instanceof OraclePriceMessage) {
this.handlePriceMessage(message);
}
} catch (error) {
console.error('Error refreshing prices for oracle:', oracle.publicKey, error);
}
}),
);
}
/**
* Update the metadata map that we use to track the pairs.
*
* @param message The metadata message.
*/
private handleMetadataMessage(message: OracleMetadataMessage) {
this.oracles = OracleClient.updateMetadataMap(this.oracles, message);
}
/**
* Emit a rate updated event for the given pair.
*
* @param message The price message.
*/
private handlePriceMessage(message: OraclePriceMessage) {
const oracle = this.oracles[message.toHexObject().publicKey];
// If the oracle doesn't have the required metadata, we can't use it.
if (
!oracle ||
!oracle.SOURCE_NUMERATOR_UNIT_CODE ||
!oracle.SOURCE_DENOMINATOR_UNIT_CODE ||
!oracle.ATTESTATION_SCALING
) {
return;
}
// Scale the price
const priceValue = message.priceValue / oracle.ATTESTATION_SCALING;
this.emit('rateUpdated', {
numeratorUnitCode: oracle.SOURCE_NUMERATOR_UNIT_CODE,
denominatorUnitCode: oracle.SOURCE_DENOMINATOR_UNIT_CODE,
price: priceValue,
oraclePriceMessage: message,
});
}
}

View File

@@ -188,11 +188,13 @@ export function getRolesForAction(
); );
return startEntries.map((entry) => { return startEntries.map((entry) => {
const roleDef = template.roles?.[entry.role]; const roleDef = template.roles?.[entry.role || ''];
const roleObj = typeof roleDef === "object" ? roleDef : null; const roleObj = typeof roleDef === "object" ? roleDef : null;
// TODO: This is ugly. Lot of conditionals. Need to take a much closer look at this.
return { return {
roleId: entry.role, roleId: entry.role || '',
name: roleObj?.name || entry.role, name: roleObj?.name || entry.role || '',
description: roleObj?.description, description: roleObj?.description,
}; };
}); });

View File

@@ -0,0 +1,69 @@
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);
},
);
});

View File

@@ -0,0 +1,94 @@
import { describe, expect, test } from "vitest";
import { handleMnemonicCommand } from "../../../src/cli/commands/mnemonic";
import { handleTemplateCommand } from "../../../src/cli/commands/template";
import { handleReceiveCommand } from "../../../src/cli/commands/receive";
import { handleResourceCommand } from "../../../src/cli/commands/resource";
import { CommandError } from "../../../src/cli/commands/types";
import {
createBaseCommandDeps,
createCommandDeps,
createMockIO,
} from "../mocks/command";
const fakeApp = {
engine: {},
invitations: [],
unreserveAllResources: async () => 0,
} as any;
describe("command handler contracts", () => {
test("mnemonic throws and prints help for missing subcommand", async () => {
const { io, capture } = createMockIO();
await expect(
handleMnemonicCommand(createBaseCommandDeps(io), [], {}),
).rejects.toThrow(CommandError);
try {
await handleMnemonicCommand(createBaseCommandDeps(io), [], {});
} catch (error) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe("mnemonic.subcommand.missing");
}
expect(capture.out.join("\n")).toContain("Usage:");
});
test("template throws for missing subcommand", async () => {
const { io, capture } = createMockIO();
await expect(
handleTemplateCommand(createCommandDeps(fakeApp, io), [], {}),
).rejects.toThrow(CommandError);
try {
await handleTemplateCommand(createCommandDeps(fakeApp, io), [], {});
} catch (error) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe("template.subcommand.missing");
}
expect(capture.out.join("\n")).toContain("Usage:");
});
test("receive throws for missing args", async () => {
const { io, capture } = createMockIO();
await expect(
handleReceiveCommand(createCommandDeps(fakeApp, io), [], {}),
).rejects.toThrow(CommandError);
try {
await handleReceiveCommand(createCommandDeps(fakeApp, io), [], {});
} catch (error) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe("receive.arguments.missing");
}
expect(capture.out.join("\n")).toContain("Usage:");
});
test("resource throws for unknown subcommand", async () => {
const { io } = createMockIO();
await expect(
handleResourceCommand(
createCommandDeps(fakeApp, io),
["does-not-exist"],
{},
),
).rejects.toThrow(CommandError);
try {
await handleResourceCommand(
createCommandDeps(fakeApp, io),
["does-not-exist"],
{},
);
} catch (error) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe("resource.subcommand.unknown");
}
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,165 @@
import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { DEFAULT_SEED } from "../mocks/engine";
import { handleMnemonicCommand } from "../../../src/cli/commands/mnemonic";
import { CommandError } from "../../../src/cli/commands/types";
import {
createMockIO,
createMockPaths,
expectLogs,
type LogExpectation,
} from "../mocks/command";
import { BCHMnemonicURL } from "../../../src/utils/bch-mnemonic-url";
type TestCase = {
inputs: string[];
options?: Record<string, string>;
shouldThrow: boolean;
expectedEvent?: string;
expectedData?: Record<string, unknown>;
logs?: LogExpectation[];
};
const testCases: TestCase[] = [
// Successful creation of a mnemonic file
{
inputs: ["create"],
shouldThrow: false,
expectedData: {
savedAs: expect.stringMatching(/^mnemonic-\w+$/),
},
logs: [{ out: "Mnemonic file created" }],
},
// Successfully creating a mnemonic file with a custom filename
{
inputs: ["create"],
options: { output: "custom-filename" },
shouldThrow: false,
expectedData: {
savedAs: "custom-filename",
},
logs: [{ out: "custom-filename" }],
},
// Successfully listing mnemonic files
{
inputs: ["list"],
shouldThrow: false,
expectedData: {
count: expect.toSatisfy((count: number) => count >= 1),
},
logs: [{ out: "mnemonic-test" }],
},
// Successfully exposing a mnemonic file
{
inputs: ["expose", "mnemonic-test"],
shouldThrow: false,
expectedData: {
mnemonic: DEFAULT_SEED,
},
logs: [{ out: DEFAULT_SEED }],
},
// Successfully importing a mnemonic file
{
inputs: ["import", ...DEFAULT_SEED.split(" ")],
shouldThrow: false,
expectedData: {
savedAs: expect.stringMatching(/^mnemonic-\w+$/),
},
logs: [{ out: "Mnemonic file created" }],
},
// Failure to import a mnemonic file due to missing arguments
{
inputs: ["import"],
shouldThrow: true,
expectedEvent: "mnemonic.import.seed_missing",
},
// Failure to expose a mnemonic file due to missing arguments
{
inputs: ["expose"],
shouldThrow: true,
expectedEvent: "mnemonic.expose.file_missing",
},
// Failure to expose a mnemonic file due to unknown mnemonic file
{
inputs: ["expose", "unknown-mnemonic-file"],
shouldThrow: true,
expectedEvent: "mnemonic.expose.file_not_found",
},
// Missing sub-command
{
inputs: [],
shouldThrow: true,
expectedEvent: "mnemonic.subcommand.missing",
},
// Unknown sub-command
{
inputs: ["unknown"],
shouldThrow: true,
expectedEvent: "mnemonic.subcommand.unknown",
},
];
describe("mnemonic commands", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-mnemonic-tests-"));
// Write a single test mnemonic file to the temp directory
writeFileSync(
path.join(tempDir, "mnemonic-test"),
BCHMnemonicURL.fromSeed(DEFAULT_SEED).toURL(),
"utf8",
);
});
afterEach(async () => {
rmSync(tempDir, { recursive: true, force: true });
});
test.each(testCases)(
"mnemonic command: $inputs",
async ({
inputs,
options,
shouldThrow,
expectedEvent,
expectedData,
logs,
}) => {
const { io, spies } = createMockIO();
const paths = createMockPaths(tempDir);
if (shouldThrow) {
try {
await handleMnemonicCommand({ io, paths }, inputs, options ?? {});
expect.fail("Expected command to throw");
} catch (error) {
if (expectedEvent) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe(expectedEvent);
}
}
} else {
const result = await handleMnemonicCommand(
{ io, paths },
inputs,
options ?? {},
);
if (expectedData) {
Object.entries(expectedData).forEach(([key, value]) => {
expect(result[key as keyof typeof result]).toEqual(value);
});
}
}
if (logs) {
expectLogs(spies, logs);
}
},
);
});

View File

@@ -0,0 +1,142 @@
import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
createMockAppService,
createMockEngine,
DEFAULT_SEED,
} from "../mocks/engine";
import { type Engine } from "@xo-cash/engine";
import { p2pkhTemplate } from "../mocks/template-p2pkh";
import { AppService } from "../../../src/services/app";
import { handleReceiveCommand } from "../../../src/cli/commands/receive";
import { CommandError } from "../../../src/cli/commands/types";
import {
createCommandDeps,
createMockIO,
expectLogs,
type LogExpectation,
} from "../mocks/command";
type TestCase = {
name: string;
inputs: string[];
options?: Record<string, string>;
shouldThrow: boolean;
expectedEvent?: string;
expectedData?: Record<string, unknown>;
logs?: LogExpectation[];
};
const testCases: TestCase[] = [
// Successful address generation with template name and output identifier
{
name: "generates address with template name and output",
inputs: ["Wallet (P2PKH)", "receiveOutput"],
shouldThrow: false,
expectedData: {
address: expect.stringMatching(/^bitcoincash:q[a-z0-9]+$/),
},
logs: [{ out: "bitcoincash:q" }],
},
// Successful address generation with role specified
{
name: "generates address with template name, output, and role",
inputs: ["Wallet (P2PKH)", "receiveOutput", "receiver"],
shouldThrow: false,
expectedData: {
address: expect.stringMatching(/^bitcoincash:q[a-z0-9]+$/),
},
logs: [{ out: "bitcoincash:q" }],
},
// Missing all required arguments
{
name: "throws when no arguments provided",
inputs: [],
shouldThrow: true,
expectedEvent: "receive.arguments.missing",
},
// Missing output identifier
{
name: "throws when output identifier missing",
inputs: ["Wallet (P2PKH)"],
shouldThrow: true,
expectedEvent: "receive.arguments.missing",
},
// Unknown template
{
name: "throws when template not found",
inputs: ["unknown-template", "receiveOutput"],
shouldThrow: true,
expectedEvent: "template.resolve.not_found",
},
];
describe("receive command", () => {
let engine: Engine;
let app: AppService;
let tempDir: string;
beforeEach(async () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-receive-tests-"));
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
test.each(testCases)(
"$name",
async ({
inputs,
options,
shouldThrow,
expectedEvent,
expectedData,
logs,
}) => {
const { io, spies } = createMockIO();
if (shouldThrow) {
try {
await handleReceiveCommand(
createCommandDeps(app, io),
inputs,
options ?? {},
);
expect.fail("Expected command to throw");
} catch (error) {
if (expectedEvent) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe(expectedEvent);
}
}
} else {
const result = await handleReceiveCommand(
createCommandDeps(app, io),
inputs,
options ?? {},
);
if (expectedData) {
Object.entries(expectedData).forEach(([key, value]) => {
expect(result[key as keyof typeof result]).toEqual(value);
});
}
}
if (logs) {
expectLogs(spies, logs);
}
},
);
});

View File

@@ -0,0 +1,366 @@
import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
addFakeResource,
createMockAppService,
createMockEngine,
DEFAULT_SEED,
reserveResource,
} from "../mocks/engine";
import { type Engine } from "@xo-cash/engine";
import { p2pkhTemplate } from "../mocks/template-p2pkh";
import { AppService } from "../../../src/services/app";
import { handleResourceCommand } from "../../../src/cli/commands/resource";
import { CommandError } from "../../../src/cli/commands/types";
import {
createCommandDeps,
createMockIO,
expectLogs,
type LogExpectation,
} from "../mocks/command";
import { State } from "@xo-cash/state";
type TestCase = {
name: string;
inputs: string[];
options?: Record<string, string>;
shouldThrow: boolean;
expectedEvent?: string;
expectedData?: Record<string, unknown>;
logs?: LogExpectation[];
};
const testCases: TestCase[] = [
// List commands (no resources in empty wallet)
{
name: "list returns empty count when no resources",
inputs: ["list"],
shouldThrow: false,
expectedData: {
count: 0,
},
logs: [{ out: "No resources found" }],
},
{
name: "list reserved returns empty count when no reserved resources",
inputs: ["list", "reserved"],
shouldThrow: false,
expectedData: {
count: 0,
},
logs: [{ out: "No resources found" }],
},
{
name: "list all returns empty count when no resources",
inputs: ["list", "all"],
shouldThrow: false,
expectedData: {
count: 0,
},
logs: [{ out: "No resources found" }],
},
// Unreserve-all with no resources
{
name: "unreserve-all returns zero count when no reserved resources",
inputs: ["unreserve-all"],
shouldThrow: false,
expectedData: {
count: 0,
},
logs: [{ out: "No reserved resources" }],
},
// Error cases
{
name: "throws when no subcommand provided",
inputs: [],
shouldThrow: true,
expectedEvent: "resource.subcommand.missing",
},
{
name: "throws when unknown subcommand provided",
inputs: ["unknown-subcommand"],
shouldThrow: true,
expectedEvent: "resource.subcommand.unknown",
},
{
name: "throws when unreserve called without outpoint",
inputs: ["unreserve"],
shouldThrow: true,
expectedEvent: "resource.unreserve.outpoint_missing",
},
{
name: "throws when unreserve called with invalid outpoint format (no colon)",
inputs: ["unreserve", "invalid-outpoint"],
shouldThrow: true,
expectedEvent: "resource.unreserve.outpoint_invalid",
},
{
name: "throws when unreserve called with invalid outpoint format (no vout)",
inputs: ["unreserve", "abc123:"],
shouldThrow: true,
expectedEvent: "resource.unreserve.outpoint_invalid",
},
{
name: "throws when unreserve called with non-existent UTXO",
inputs: [
"unreserve",
"0000000000000000000000000000000000000000000000000000000000000000:0",
],
shouldThrow: true,
expectedEvent: "resource.unreserve.utxo_missing",
},
];
describe("resource command", () => {
let engine: Engine;
let app: AppService;
let tempDir: string;
beforeEach(async () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-"));
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
test.each(testCases)(
"$name",
async ({
inputs,
options,
shouldThrow,
expectedEvent,
expectedData,
logs,
}) => {
const { io, spies } = createMockIO();
if (shouldThrow) {
try {
await handleResourceCommand(
createCommandDeps(app, io),
inputs,
options ?? {},
);
expect.fail("Expected command to throw");
} catch (error) {
if (expectedEvent) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe(expectedEvent);
}
}
} else {
const result = await handleResourceCommand(
createCommandDeps(app, io),
inputs,
options ?? {},
);
if (expectedData) {
Object.entries(expectedData).forEach(([key, value]) => {
expect(result[key as keyof typeof result]).toEqual(value);
});
}
}
if (logs) {
expectLogs(spies, logs);
}
},
);
});
describe("resource command with populated data", () => {
let engine: Engine;
let state: State;
let app: AppService;
let tempDir: string;
beforeEach(async () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
state = mockEngine.state;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-"));
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
test("list returns count when resources exist", async () => {
await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(state, { valueSatoshis: 25000 });
const { io, spies } = createMockIO();
const result = await handleResourceCommand(
createCommandDeps(app, io),
["list"],
{},
);
expect(result.count).toBe(2);
expectLogs(spies, [{ out: "Total resources: 2" }]);
});
test("list shows total satoshis", async () => {
await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(state, { valueSatoshis: 25000 });
const { io, spies } = createMockIO();
await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
expectLogs(spies, [{ out: "Total satoshis: 75000" }]);
});
test("list excludes reserved resources by default", async () => {
await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(state, {
valueSatoshis: 25000,
reservedBy: "inv-123",
});
const { io } = createMockIO();
const result = await handleResourceCommand(
createCommandDeps(app, io),
["list"],
{},
);
expect(result.count).toBe(1);
});
test("list reserved shows only reserved resources", async () => {
await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(state, {
valueSatoshis: 25000,
reservedBy: "inv-123",
});
await addFakeResource(state, {
valueSatoshis: 10000,
reservedBy: "inv-456",
});
const { io, spies } = createMockIO();
const result = await handleResourceCommand(
createCommandDeps(app, io),
["list", "reserved"],
{},
);
expect(result.count).toBe(2);
expectLogs(spies, [{ out: "reserved for inv-123" }]);
});
test("list all shows both reserved and unreserved", async () => {
await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(state, {
valueSatoshis: 25000,
reservedBy: "inv-123",
});
const { io } = createMockIO();
const result = await handleResourceCommand(
createCommandDeps(app, io),
["list", "all"],
{},
);
expect(result.count).toBe(2);
});
test("unreserve releases a reserved UTXO", async () => {
const resource = await addFakeResource(state, {
valueSatoshis: 25000,
reservedBy: "inv-123",
});
const { io, spies } = createMockIO();
await handleResourceCommand(
createCommandDeps(app, io),
[
"unreserve",
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
],
{},
);
expectLogs(spies, [
{ out: "Unreserved" },
{ out: "was reserved for inv-123" },
]);
const resources = await engine.listUnspentOutputsData();
const target = resources.find(
(r) => r.outpointTransactionHash === resource.outpointTransactionHash,
);
expect(target?.reservedBy).toBeUndefined();
});
test("unreserve reports when UTXO is not reserved", async () => {
const resource = await addFakeResource(state, { valueSatoshis: 25000 });
const { io, spies } = createMockIO();
await handleResourceCommand(
createCommandDeps(app, io),
[
"unreserve",
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
],
{},
);
expectLogs(spies, [{ out: "UTXO is not reserved" }]);
});
test("unreserve-all releases all reserved UTXOs", async () => {
await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(state, {
valueSatoshis: 25000,
reservedBy: "inv-123",
});
await addFakeResource(state, {
valueSatoshis: 10000,
reservedBy: "inv-456",
});
const { io, spies } = createMockIO();
const result = await handleResourceCommand(
createCommandDeps(app, io),
["unreserve-all"],
{},
);
expect(result.count).toBe(2);
expectLogs(spies, [{ out: "Unreserved" }, { out: "2" }]);
const resources = await engine.listUnspentOutputsData();
const reserved = resources.filter((r) => r.reservedBy);
expect(reserved).toHaveLength(0);
});
test("list displays outpoint information", async () => {
const resource = await addFakeResource(state, { valueSatoshis: 12345 });
const { io, spies } = createMockIO();
await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
expectLogs(spies, [
{ out: resource.outpointTransactionHash },
{ out: "12345 sats" },
]);
});
});

View File

@@ -0,0 +1,266 @@
import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
createMockAppService,
createMockEngine,
DEFAULT_SEED,
} from "../mocks/engine";
import { type Engine } from "@xo-cash/engine";
import {
p2pkhTemplate,
p2pkhTemplateIdentifier,
} from "../mocks/template-p2pkh";
import { AppService } from "../../../src/services/app";
import { handleTemplateCommand } from "../../../src/cli/commands/template";
import { CommandError } from "../../../src/cli/commands/types";
import {
createCommandDeps,
createMockIO,
expectLogs,
type LogExpectation,
} from "../mocks/command";
type TestCase = {
name: string;
inputs: string[];
options?: Record<string, string>;
shouldThrow: boolean;
expectedEvent?: string;
expectedData?: Record<string, unknown>;
logs?: LogExpectation[];
};
const testCases: TestCase[] = [
// List command
{
name: "list returns count of imported templates",
inputs: ["list"],
shouldThrow: false,
expectedData: {
count: 1,
},
logs: [{ out: "Wallet (P2PKH)" }],
},
// List by category
{
name: "list action returns actions for template",
inputs: ["list", "action", p2pkhTemplateIdentifier],
shouldThrow: false,
expectedData: {},
logs: [{ out: "receive" }],
},
{
name: "list output returns outputs for template",
inputs: ["list", "output", p2pkhTemplateIdentifier],
shouldThrow: false,
expectedData: {},
logs: [{ out: "receiveOutput" }],
},
{
name: "list variable returns variables for template",
inputs: ["list", "variable", p2pkhTemplateIdentifier],
shouldThrow: false,
expectedData: {},
logs: [{ out: "ownerKey" }],
},
{
name: "list transaction returns transactions for template",
inputs: ["list", "transaction", p2pkhTemplateIdentifier],
shouldThrow: false,
expectedData: {},
},
{
name: "list lockingscript returns locking scripts for template",
inputs: ["list", "lockingscript", p2pkhTemplateIdentifier],
shouldThrow: false,
expectedData: {},
},
// Inspect command
{
name: "inspect action returns action details",
inputs: ["inspect", "action", p2pkhTemplateIdentifier, "receive"],
shouldThrow: false,
expectedData: {},
},
{
name: "inspect output returns output details",
inputs: ["inspect", "output", p2pkhTemplateIdentifier, "receiveOutput"],
shouldThrow: false,
expectedData: {},
},
{
name: "inspect variable returns variable details",
inputs: ["inspect", "variable", p2pkhTemplateIdentifier, "ownerKey"],
shouldThrow: false,
expectedData: {},
},
// Error cases - subcommand
{
name: "throws when no subcommand provided",
inputs: [],
shouldThrow: true,
expectedEvent: "template.subcommand.missing",
},
{
name: "throws when unknown subcommand provided",
inputs: ["unknown-subcommand"],
shouldThrow: true,
expectedEvent: "template.subcommand.unknown",
},
// Error cases - import
{
name: "throws when import called without file",
inputs: ["import"],
shouldThrow: true,
expectedEvent: "template.import.file_missing",
},
{
name: "throws when import called with non-existent file",
inputs: ["import", "non-existent-file.json"],
shouldThrow: true,
expectedEvent: "template.import.file_not_found",
},
// Error cases - list category
{
name: "throws when list category called without template identifier",
inputs: ["list", "action"],
shouldThrow: true,
expectedEvent: "template.list.identifier_missing",
},
{
name: "throws when list category called with unknown template",
inputs: ["list", "action", "unknown-template"],
shouldThrow: true,
expectedEvent: "template.list.not_found",
},
{
name: "throws when list called with unknown category",
inputs: ["list", "unknown-category", p2pkhTemplateIdentifier],
shouldThrow: true,
expectedEvent: "template.list.category_unknown",
},
// Error cases - inspect
{
name: "throws when inspect called without all arguments",
inputs: ["inspect", "action"],
shouldThrow: true,
expectedEvent: "template.inspect.arguments_missing",
},
{
name: "throws when inspect called with unknown template",
inputs: ["inspect", "action", "unknown-template", "receive"],
shouldThrow: true,
expectedEvent: "template.resolve.not_found",
},
{
name: "throws when inspect called with unknown action",
inputs: ["inspect", "action", p2pkhTemplateIdentifier, "unknown-action"],
shouldThrow: true,
expectedEvent: "template.inspect.action_missing",
},
{
name: "throws when inspect called with unknown category",
inputs: ["inspect", "unknown-category", p2pkhTemplateIdentifier, "field"],
shouldThrow: true,
expectedEvent: "template.inspect.category_unknown",
},
// Error cases - set-default
{
name: "throws when set-default called without all arguments",
inputs: ["set-default", "template"],
shouldThrow: true,
expectedEvent: "template.default.arguments_missing",
},
];
describe("template command", () => {
let engine: Engine;
let app: AppService;
let tempDir: string;
beforeEach(async () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-template-tests-"));
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
test.each(testCases)(
"$name",
async ({
inputs,
options,
shouldThrow,
expectedEvent,
expectedData,
logs,
}) => {
const { io, spies } = createMockIO();
if (shouldThrow) {
try {
await handleTemplateCommand(
createCommandDeps(app, io),
inputs,
options ?? {},
);
expect.fail("Expected command to throw");
} catch (error) {
if (expectedEvent) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe(expectedEvent);
}
}
} else {
const result = await handleTemplateCommand(
createCommandDeps(app, io),
inputs,
options ?? {},
);
if (expectedData) {
Object.entries(expectedData).forEach(([key, value]) => {
expect(result[key as keyof typeof result]).toEqual(value);
});
}
}
if (logs) {
expectLogs(spies, logs);
}
},
);
test("import imports template from file", async () => {
const templatePath = path.join(tempDir, "test-template.json");
writeFileSync(templatePath, JSON.stringify(p2pkhTemplate));
const { io } = createMockIO();
const originalCwd = process.cwd();
process.chdir(tempDir);
try {
const result = await handleTemplateCommand(
createCommandDeps(app, io),
["import", "test-template.json"],
{},
);
expect(result.templateFile).toBe("test-template.json");
} finally {
process.chdir(originalCwd);
}
});
});

View File

@@ -0,0 +1,28 @@
import { describe, expect, test } from "vitest";
import { spawnSync } from "node:child_process";
import path from "node:path";
const runCli = (args: string[]) => {
const tsxPath = path.resolve(process.cwd(), "node_modules/.bin/tsx");
const cliPath = path.resolve(process.cwd(), "src/cli/index.ts");
return spawnSync(tsxPath, [cliPath, ...args], {
encoding: "utf8",
cwd: process.cwd(),
});
};
describe("cli entry boundary behavior", () => {
test("returns non-zero for incomplete mnemonic invocation", () => {
const result = runCli(["mnemonic"]);
expect(result.status).toBe(1);
expect(result.stdout).toContain("Usage:");
});
test("returns zero for mnemonic list invocation", () => {
const result = runCli(["mnemonic", "list"]);
expect(result.status).toBe(0);
});
});

214
tests/cli/mnemonic.test.ts Normal file
View File

@@ -0,0 +1,214 @@
import { expect, test, describe, beforeEach, afterEach } from "vitest";
import {
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
createMnemonicSeed,
createMnemonicFile,
resolveMnemonicFilePath,
loadMnemonic,
listMnemonicFiles,
} from "../../src/cli/mnemonic";
import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url";
const TEST_SEED =
"page pencil stock planet limb cluster assault speak off joke private pioneer";
describe("mnemonic utilities", () => {
let tempDir: string;
beforeEach(() => {
tempDir = path.join(tmpdir(), `xo-cli-mnemonic-utils-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
describe("createMnemonicSeed", () => {
test("generates a valid BIP39 mnemonic", () => {
const mnemonic = createMnemonicSeed();
expect(typeof mnemonic).toBe("string");
const words = mnemonic.split(" ");
expect(words.length).toBe(12);
});
test("generates unique mnemonics on each call", () => {
const mnemonic1 = createMnemonicSeed();
const mnemonic2 = createMnemonicSeed();
expect(mnemonic1).not.toBe(mnemonic2);
});
});
describe("createMnemonicFile", () => {
test("creates a mnemonic file with auto-generated name", () => {
const filename = createMnemonicFile(tempDir, TEST_SEED);
expect(filename).toMatch(/^mnemonic-page$/);
expect(existsSync(path.join(tempDir, filename))).toBe(true);
});
test("creates a mnemonic file with custom name", () => {
const filename = createMnemonicFile(tempDir, TEST_SEED, "my-wallet");
expect(filename).toBe("my-wallet");
expect(existsSync(path.join(tempDir, filename))).toBe(true);
});
test("writes valid BCHMnemonicURL format", () => {
const filename = createMnemonicFile(tempDir, TEST_SEED, "test-wallet");
const content = readFileSync(path.join(tempDir, filename), "utf8");
expect(content).toMatch(/^bch-mnemonic:/);
const parsed = BCHMnemonicURL.fromURL(content);
expect(parsed).toBeDefined();
});
test("sanitizes filename to basename only", () => {
const filename = createMnemonicFile(
tempDir,
TEST_SEED,
"../../../evil-path",
);
expect(filename).toBe("evil-path");
expect(existsSync(path.join(tempDir, "evil-path"))).toBe(true);
expect(existsSync(path.join(tempDir, "../../../evil-path"))).toBe(false);
});
test("throws when mnemonic is empty", () => {
expect(() => createMnemonicFile(tempDir, "")).toThrow();
});
});
describe("resolveMnemonicFilePath", () => {
test("resolves absolute path when file exists", () => {
const filePath = path.join(tempDir, "mnemonic-absolute");
writeFileSync(filePath, "test");
const resolved = resolveMnemonicFilePath(tempDir, filePath);
expect(resolved).toBe(filePath);
});
test("resolves path relative to cwd when file exists", () => {
const originalCwd = process.cwd();
process.chdir(tempDir);
try {
writeFileSync(path.join(tempDir, "mnemonic-relative"), "test");
const resolved = resolveMnemonicFilePath(
"/nonexistent",
"mnemonic-relative",
);
expect(resolved).toBe(path.join(tempDir, "mnemonic-relative"));
} finally {
process.chdir(originalCwd);
}
});
test("resolves from mnemonicsDir when file exists there", () => {
writeFileSync(path.join(tempDir, "mnemonic-test"), "test");
const resolved = resolveMnemonicFilePath(tempDir, "mnemonic-test");
expect(resolved).toBe(path.join(tempDir, "mnemonic-test"));
});
test("throws when file not found anywhere", () => {
expect(() =>
resolveMnemonicFilePath(tempDir, "nonexistent-file"),
).toThrow(/Mnemonic file not found/);
});
test("strips path components and looks up basename in mnemonicsDir", () => {
writeFileSync(path.join(tempDir, "mnemonic-basename"), "test");
const resolved = resolveMnemonicFilePath(
tempDir,
"some/path/mnemonic-basename",
);
expect(resolved).toBe(path.join(tempDir, "mnemonic-basename"));
});
});
describe("loadMnemonic", () => {
test("loads mnemonic from file", () => {
createMnemonicFile(tempDir, TEST_SEED, "test-load");
const loaded = loadMnemonic(tempDir, "test-load");
expect(loaded).toBe(TEST_SEED);
});
test("loads mnemonic from absolute path", () => {
const filePath = path.join(tempDir, "mnemonic-absolute-load");
createMnemonicFile(tempDir, TEST_SEED, "mnemonic-absolute-load");
const loaded = loadMnemonic(tempDir, filePath);
expect(loaded).toBe(TEST_SEED);
});
test("throws when file not found", () => {
expect(() => loadMnemonic(tempDir, "nonexistent")).toThrow(
/Mnemonic file not found/,
);
});
test("throws when file contains invalid data", () => {
writeFileSync(
path.join(tempDir, "mnemonic-invalid"),
"not a valid mnemonic url",
);
expect(() => loadMnemonic(tempDir, "mnemonic-invalid")).toThrow();
});
});
describe("listMnemonicFiles", () => {
test("returns empty array when no mnemonic files exist", () => {
const files = listMnemonicFiles(tempDir);
expect(files).toEqual([]);
});
test("lists only files starting with 'mnemonic-'", () => {
writeFileSync(path.join(tempDir, "mnemonic-one"), "test");
writeFileSync(path.join(tempDir, "mnemonic-two"), "test");
writeFileSync(path.join(tempDir, "other-file"), "test");
writeFileSync(path.join(tempDir, "wallet.json"), "test");
const files = listMnemonicFiles(tempDir);
expect(files).toHaveLength(2);
expect(files).toContain("mnemonic-one");
expect(files).toContain("mnemonic-two");
expect(files).not.toContain("other-file");
expect(files).not.toContain("wallet.json");
});
test("returns sorted or consistent ordering", () => {
writeFileSync(path.join(tempDir, "mnemonic-zebra"), "test");
writeFileSync(path.join(tempDir, "mnemonic-alpha"), "test");
writeFileSync(path.join(tempDir, "mnemonic-beta"), "test");
const files = listMnemonicFiles(tempDir);
expect(files).toHaveLength(3);
});
});
describe("round-trip", () => {
test("create and load preserves mnemonic exactly", () => {
const original = createMnemonicSeed();
createMnemonicFile(tempDir, original, "roundtrip-test");
const loaded = loadMnemonic(tempDir, "roundtrip-test");
expect(loaded).toBe(original);
});
});
});

171
tests/cli/mocks/command.ts Normal file
View File

@@ -0,0 +1,171 @@
import { vi, expect, type Mock } from "vitest";
import type {
BaseCommandDependencies,
CommandDependencies,
CommandIO,
CommandPaths,
} from "../../../src/cli/commands/types";
import type { AppService } from "../../../src/services/app";
/**
* Captured CLI IO buffers used by tests.
*/
export type MockIOCapture = {
out: string[];
err: string[];
verbose: string[];
};
/**
* Spy functions for each IO channel.
*/
export type MockIOSpies = {
out: Mock;
err: Mock;
verbose: Mock;
};
/**
* Complete mock IO result including the IO adapter, capture buffers, and spies.
*/
export type MockIO = {
io: CommandIO;
capture: MockIOCapture;
spies: MockIOSpies;
};
/**
* Defines an expected log message for assertion.
* At least one of out, err, or verbose should be specified.
*/
export type LogExpectation = {
/** Expected substring (or exact match if exact=true) in io.out */
out?: string;
/** Expected substring (or exact match if exact=true) in io.err */
err?: string;
/** Expected substring (or exact match if exact=true) in io.verbose */
verbose?: string;
/** If true, match the string exactly instead of using contains (default: false) */
exact?: boolean;
};
/**
* Creates a command IO adapter that records every message using vi.fn() spies.
* This enables vitest's built-in matchers like toHaveBeenCalledWith.
*/
export const createMockIO = (): MockIO => {
const capture: MockIOCapture = {
out: [],
err: [],
verbose: [],
};
const outSpy = vi.fn((message: string) => {
capture.out.push(message);
});
const errSpy = vi.fn((message: string) => {
capture.err.push(message);
});
const verboseSpy = vi.fn((message: string) => {
capture.verbose.push(message);
});
const io: CommandIO = {
out: outSpy,
err: errSpy,
verbose: verboseSpy,
};
return {
io,
capture,
spies: {
out: outSpy,
err: errSpy,
verbose: verboseSpy,
},
};
};
/**
* Asserts that the expected log messages were printed to the appropriate IO channels.
* @param spies - The mock IO spies from createMockIO
* @param logs - Array of log expectations to validate
*/
export const expectLogs = (
spies: MockIOSpies,
logs: LogExpectation[],
): void => {
for (const log of logs) {
if (log.out !== undefined) {
if (log.exact) {
expect(spies.out).toHaveBeenCalledWith(log.out);
} else {
expect(spies.out).toHaveBeenCalledWith(
expect.stringContaining(log.out),
);
}
}
if (log.err !== undefined) {
if (log.exact) {
expect(spies.err).toHaveBeenCalledWith(log.err);
} else {
expect(spies.err).toHaveBeenCalledWith(
expect.stringContaining(log.err),
);
}
}
if (log.verbose !== undefined) {
if (log.exact) {
expect(spies.verbose).toHaveBeenCalledWith(log.verbose);
} else {
expect(spies.verbose).toHaveBeenCalledWith(
expect.stringContaining(log.verbose),
);
}
}
}
};
/**
* Creates mock paths for testing.
* @param tempDir - Optional temp directory to use as base for all paths
*/
export const createMockPaths = (tempDir?: string): CommandPaths => {
const base = tempDir ?? "/tmp/xo-cli-test";
return {
mnemonicsDir: base,
dataDir: base,
walletConfigPath: `${base}/.wallet`,
workingDir: base,
};
};
/**
* Creates base command dependencies for commands that do not require the app.
* @param io - Command IO adapter
* @param paths - Optional custom paths (defaults to mock paths)
*/
export const createBaseCommandDeps = (
io: CommandIO,
paths?: CommandPaths,
): BaseCommandDependencies => ({
io,
paths: paths ?? createMockPaths(),
});
/**
* Creates command dependencies for app-backed command handlers.
* @param app - App service instance
* @param io - Command IO adapter
* @param paths - Optional custom paths (defaults to mock paths)
*/
export const createCommandDeps = (
app: AppService,
io: CommandIO,
paths?: CommandPaths,
): CommandDependencies => ({
app,
io,
paths: paths ?? createMockPaths(),
});

View File

@@ -0,0 +1,12 @@
/**
* Mock Electrum service for testing.
* NOTE & TODO: Do we even need this in the actual app? I forget why we had this, but it seems like its just overly complicating things
* And we end up in stupid situations where we are creating a mock for a single function class.
*/
export class MockElectrumService {
constructor() {}
async hasSeenTransaction(transactionHash: string): Promise<boolean> {
return true;
}
}

180
tests/cli/mocks/engine.ts Normal file
View File

@@ -0,0 +1,180 @@
import { BlockchainMonitor, Engine } from "@xo-cash/engine";
import {
createStorageAdapter,
State,
StorageType,
UnspentOutputStatus,
type UnspentOutputData,
} from "@xo-cash/state";
import { InMemoryBlockchainProvider } from "@xo-cash/engine";
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
import { binToHex, sha256 } from "@bitauth/libauth";
import { AppService } from "../../../src/services/app";
import { InMemoryStorage } from "../../../src/services/storage";
import { MockElectrumService } from "./electrum-service";
import { MockRatesService } from "./rates-service";
import { RatesService } from "../../../src/services/rates";
export const DEFAULT_SEED =
"page pencil stock planet limb cluster assault speak off joke private pioneer";
/**
* Options for creating a fake resource (UTXO) in tests.
*/
export type FakeResourceOptions = {
/** Transaction hash of the outpoint. Auto-generated if not provided. */
outpointTransactionHash?: string;
/** Index of the outpoint in the transaction. Defaults to 0. */
outpointIndex?: number;
/** Value in satoshis. Defaults to 10000. */
valueSatoshis?: number;
/** Template identifier. Defaults to "test-template". */
templateIdentifier?: string;
/** Output identifier from the template. Defaults to "receiveOutput". */
outputIdentifier?: string;
/** Locking bytecode for this output. Defaults to a placeholder. */
lockingBytecode?: string;
/** Block height where the UTXO was mined. Defaults to 800000. */
minedAtHeight?: number;
/** Invitation identifier that reserves this output. Undefined means unreserved. */
reservedBy?: string;
};
/**
* Generates a random 64-character hex string representing a transaction hash.
*/
export const randomTxHash = (): string => {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
};
/**
* Adds a fake resource (UTXO) to the engine's state for testing purposes.
* @param engine - The engine instance to add the resource to.
* @param options - Options for the fake resource. All fields have sensible defaults.
* @returns The created UnspentOutputData object.
*/
export const addFakeResource = async (
state: State,
options: FakeResourceOptions = {},
): Promise<UnspentOutputData> => {
const resource: UnspentOutputData = {
status: UnspentOutputStatus.CONFIRMED,
selectable: true,
privacy: false,
templateIdentifier: options.templateIdentifier ?? "test-template",
outputIdentifier: options.outputIdentifier ?? "receiveOutput",
outpointIndex: options.outpointIndex ?? 0,
outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(),
minedAtHeight: options.minedAtHeight ?? 800000,
valueSatoshis: options.valueSatoshis ?? 10000,
scriptHash:
options.lockingBytecode ??
"76a914000000000000000000000000000000000000000088ac",
reservedBy: options.reservedBy,
};
await state.storeUnspentOutputData(resource);
return resource;
};
/**
* Reserves a resource for a specific invitation.
* @param engine - The engine instance.
* @param outpointTransactionHash - The transaction hash of the UTXO to reserve.
* @param outpointIndex - The output index of the UTXO to reserve.
* @param invitationIdentifier - The invitation identifier to reserve for.
*/
export const reserveResource = async (
state: State,
outpointTransactionHash: string,
outpointIndex: number,
invitationIdentifier: string,
): Promise<void> => {
await state.executeBulkUnspentOutputReservation(
[{ outpointTransactionHash, outpointIndex }],
true,
invitationIdentifier,
);
};
/**
* Unreserves a resource from a specific invitation.
* @param engine - The engine instance.
* @param outpointTransactionHash - The transaction hash of the UTXO to unreserve.
* @param outpointIndex - The output index of the UTXO to unreserve.
* @param invitationIdentifier - The invitation identifier to unreserve from.
*/
export const unreserveResource = async (
state: State,
outpointTransactionHash: string,
outpointIndex: number,
invitationIdentifier: string,
): Promise<void> => {
await state.executeBulkUnspentOutputReservation(
[{ outpointTransactionHash, outpointIndex }],
false,
invitationIdentifier,
);
};
/**
* Create a mock engine instance with a given seed. Uses the in-memory storage and blockchain provider.
* @param seed - The seed to use for the engine.
* @returns A mock engine instance.
*/
export const createMockEngine = async (seed: string) => {
// Create the in-memory storage adapter.
const storage = await createStorageAdapter({
storageType: "inmemory",
accountHash: binToHex(sha256.hash(convertMnemonicToSeedBytes(seed))),
});
// Initialize the storage adapter.
await storage.initialize();
// Create the state instance.
const state = new State(storage);
// Create the in-memory blockchain provider.
const blockchainProvider = new InMemoryBlockchainProvider();
await blockchainProvider.initialize({
applicationIdentifier: "xo-cli-tests",
electrumOptions: {},
});
// Create the blockchain monitor instance.
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
await blockchainMonitor.initializeEventListeners();
// Create the engine instance.
const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider);
await engine.initializeStateSync();
return { engine, state, blockchainMonitor, blockchainProvider };
};
export const createMockAppService = async (engine: Engine) => {
const storage = await InMemoryStorage.create();
const mockRates = new MockRatesService();
const rates = new RatesService(mockRates);
const mockElectrum = new MockElectrumService();
const config = {
syncServerUrl: "http://localhost:3000",
engineConfig: {
databasePath: "test-data",
databaseFilename: "xo-wallet.db",
},
invitationStoragePath: "test-invitations.db",
};
return new AppService(engine, storage, config, mockElectrum, rates);
};

View File

@@ -0,0 +1,23 @@
import { BaseRates } from "../../../src/utils/rates/base-rates";
export class MockRatesService extends BaseRates {
constructor() {
super();
}
async getRate(numeratorUnitCode: string, denominatorUnitCode: string): Promise<number> {
return 1;
}
async start(): Promise<void> {
return;
}
async stop(): Promise<void> {
return;
}
async listPairs(): Promise<Set<string>> {
return new Set();
}
}

File diff suppressed because it is too large Load Diff

153
tests/cli/paths.test.ts Normal file
View File

@@ -0,0 +1,153 @@
import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import {
getConfigDir,
getMnemonicsDir,
getDataDir,
getWalletConfigPath,
resolveMnemonicFilePath,
} from "../../src/utils/paths";
describe("paths utilities", () => {
describe("getConfigDir", () => {
test("returns path under ~/.config/xo-cli", () => {
const configDir = getConfigDir();
expect(configDir).toBe(path.join(homedir(), ".config", "xo-cli"));
});
test("creates the directory if it does not exist", () => {
const configDir = getConfigDir();
expect(existsSync(configDir)).toBe(true);
});
});
describe("getMnemonicsDir", () => {
test("returns path under config dir", () => {
const mnemonicsDir = getMnemonicsDir();
expect(mnemonicsDir).toBe(
path.join(homedir(), ".config", "xo-cli", "mnemonics"),
);
});
test("creates the directory if it does not exist", () => {
const mnemonicsDir = getMnemonicsDir();
expect(existsSync(mnemonicsDir)).toBe(true);
});
});
describe("getDataDir", () => {
test("returns path under config dir", () => {
const dataDir = getDataDir();
expect(dataDir).toBe(path.join(homedir(), ".config", "xo-cli", "data"));
});
test("creates the directory if it does not exist", () => {
const dataDir = getDataDir();
expect(existsSync(dataDir)).toBe(true);
});
});
describe("getWalletConfigPath", () => {
test("returns .wallet file path under config dir", () => {
const walletConfigPath = getWalletConfigPath();
expect(walletConfigPath).toBe(
path.join(homedir(), ".config", "xo-cli", ".wallet"),
);
});
});
describe("resolveMnemonicFilePath (global)", () => {
let tempDir: string;
beforeEach(() => {
tempDir = path.join(tmpdir(), `xo-cli-paths-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test("resolves absolute path when file exists", () => {
const filePath = path.join(tempDir, "mnemonic-test");
writeFileSync(filePath, "test");
const resolved = resolveMnemonicFilePath(filePath);
expect(resolved).toBe(filePath);
});
test("resolves path relative to cwd when file exists", () => {
const originalCwd = process.cwd();
process.chdir(tempDir);
try {
writeFileSync(path.join(tempDir, "mnemonic-cwd-test"), "test");
const resolved = resolveMnemonicFilePath("mnemonic-cwd-test");
expect(resolved).toBe(path.join(tempDir, "mnemonic-cwd-test"));
} finally {
process.chdir(originalCwd);
}
});
test("resolves from global mnemonics dir when file exists there", () => {
const mnemonicsDir = getMnemonicsDir();
const testFile = path.join(mnemonicsDir, "mnemonic-global-test");
try {
writeFileSync(testFile, "test");
const resolved = resolveMnemonicFilePath("mnemonic-global-test");
expect(resolved).toBe(testFile);
} finally {
if (existsSync(testFile)) {
rmSync(testFile);
}
}
});
test("throws when file not found anywhere", () => {
expect(() =>
resolveMnemonicFilePath("nonexistent-mnemonic-file-xyz"),
).toThrow(/Mnemonic file not found/);
});
test("does not resolve absolute path if file does not exist", () => {
const nonExistentPath = "/nonexistent/path/mnemonic-test";
expect(() => resolveMnemonicFilePath(nonExistentPath)).toThrow(
/Mnemonic file not found/,
);
});
});
describe("path hierarchy", () => {
test("mnemonics dir is under config dir", () => {
const configDir = getConfigDir();
const mnemonicsDir = getMnemonicsDir();
expect(mnemonicsDir.startsWith(configDir)).toBe(true);
});
test("data dir is under config dir", () => {
const configDir = getConfigDir();
const dataDir = getDataDir();
expect(dataDir.startsWith(configDir)).toBe(true);
});
test("wallet config is under config dir", () => {
const configDir = getConfigDir();
const walletConfig = getWalletConfigPath();
expect(walletConfig.startsWith(configDir)).toBe(true);
});
});
});