47 Commits

Author SHA1 Message Date
941414b3ee Accept XOInvitationCommit when identifying paricipants 2026-05-19 13:37:01 +02:00
e9bc6186b9 Use flatMap in findSuitableResources 2026-05-19 13:35:22 +02:00
b2ccff5b19 Add random mnemonic generation in seed input screen 2026-05-19 13:31:01 +02:00
b4d82b8b1f Before bliss 2026-05-16 05:59:55 +00:00
a0d9775015 Add variable step 2026-05-11 12:18:47 +00:00
6c01ac1c1b Add currency settings, Settings service, and dialog to select fiat currency. Add support for non Official currencies like DOGE when using rates. 2026-05-11 10:41:41 +00:00
ebe1d8acda Add copy to generate address 2026-05-11 02:52:52 +00:00
c2334b2cdd Document that storage should be removed once engine can provide invitation data. Also documented memory adapter should be moved as its only used in the tests 2026-05-04 12:15:39 +00:00
dec228063b Remove the Logger. Was used temporarily as a remote logger for debugging 2026-05-04 12:15:06 +00:00
3c47ee8a4c Add documentation to the commands for the CLI 2026-05-04 12:14:40 +00:00
8d7856f32e Remove scripts 2026-05-04 12:14:15 +00:00
b8b0a4a1ba Improve output resolution in invitation screen 2026-05-04 11:45:52 +00:00
dedfb69dff Fix history for the 100th time. Fix role resolution in the invitation screen 2026-05-04 11:36:09 +00:00
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
b475b23beb Format with prettier. Use screen mode for invitation import - dialog mode is broken. 2026-03-23 10:15:48 +00:00
7fd89c5663 Fix dialog focus 2026-03-23 03:51:51 +00:00
a28d43a68b Massive changes. I dont know what happens. Rewrote the action wizard again. Fixed several bugs related to the utxo selection. QR codes were added for address. Add support for data results. Experiment with other methods of role extraction 2026-03-22 13:20:46 +00:00
be52f73e64 Add saved-wallets to the import screen. Reads from file 2026-03-16 07:38:22 +00:00
dd275593cd Fix receive and send 2026-03-16 06:48:29 +00:00
9ef1720e1f Large amount of changes. Successfully broadcasts txs 2026-03-08 15:53:50 +00:00
66e9918e04 Save the role as an input when accepting 2026-02-28 08:17:55 +00:00
38a0ac436b Please sync invitatinos 2026-02-23 06:13:12 +00:00
2c547f766a publish to sync server 2026-02-09 12:34:22 +00:00
ef169e76db Invitations screen changes. Scrollable list. Details. And role selection on import 2026-02-09 08:14:52 +00:00
df57f1b9ad Big changes and fixes. Uses action history. Improve role selection. Remove unused logs 2026-02-08 15:41:14 +00:00
da096af0fa Clean up and fixes 2026-02-08 02:32:50 +00:00
eb1bf9020e Mid-rewrite 2026-02-06 13:14:24 +00:00
126 changed files with 25692 additions and 3986 deletions

12
.gitignore vendored
View File

@@ -2,4 +2,14 @@ __sysdb__.sqlite
Electrum.sqlite
XO.sqlite
node_modules/
dist/
dist/
coverage/
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite-journal
resolvedTemplate.json
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": ""
}
]
}
}
]
}

3616
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,11 +3,25 @@
"version": "1.0.0",
"main": "dist/index.js",
"type": "module",
"bin": {
"xo-cli": "./dist/cli/index.js",
"xo-tui": "./dist/index.js",
"xo-complete": "./dist/cli/autocomplete/complete.js"
},
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc",
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
"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",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "vitest --run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
"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": [
"crypto",
@@ -20,21 +34,29 @@
"description": "XO Wallet CLI - Terminal User Interface for XO crypto wallet",
"dependencies": {
"@bitauth/libauth": "^3.0.0",
"@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/state": "file:../state",
"@xo-cash/templates": "file:../templates",
"@xo-cash/types": "file:../types",
"@xo-cash/types": "^0.0.1",
"better-sqlite3": "^12.6.2",
"clipboardy": "^5.1.0",
"ink": "^5.1.0",
"ink-select-input": "^6.0.0",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"react": "^18.3.1"
"ink": "^6.6.0",
"prettier": "^3.8.1",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.0.10",
"@types/react": "^18.3.18",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@vitest/coverage-v8": "^4.1.2",
"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
```

View File

@@ -1,72 +1,45 @@
/**
* Application bootstrap and lifecycle management.
* Coordinates initialization of all CLI components.
* Simplified to render TUI immediately and let it handle AppService creation.
*/
import React from 'react';
import { render, type Instance } from 'ink';
import { App as AppComponent } from './tui/App.js';
import { WalletController } from './controllers/wallet-controller.js';
import { InvitationController } from './controllers/invitation-controller.js';
import { SyncClient } from './services/sync-client.js';
import { join } from "node:path";
import React from "react";
import { render, type Instance } from "ink";
import { App as AppComponent } from "./tui/App.js";
import { getDataDir } from "./utils/paths.js";
/**
* Configuration options for the CLI application.
*/
export interface AppConfig {
/** URL of the sync server (default: http://localhost:3000) */
syncServerUrl?: string;
syncServerUrl: string;
/** Database path for wallet state storage */
databasePath?: string;
databasePath: string;
/** Database filename */
databaseFilename?: string;
databaseFilename: string;
/** Path for invitation storage database */
invitationStoragePath: string;
}
/**
* Main application class that orchestrates all CLI components.
* Main application class that orchestrates the CLI.
* Renders the TUI immediately and passes config for later AppService creation.
*/
export class App {
/** Ink render instance */
private inkInstance: Instance | null = null;
/** Wallet controller for engine operations */
private walletController: WalletController;
/** Invitation controller for collaborative transactions */
private invitationController: InvitationController;
/** HTTP client for sync server communication */
private syncClient: SyncClient;
/** Application configuration */
private config: Required<AppConfig>;
private config: AppConfig;
/**
* Creates a new App instance.
* @param config - Application configuration options
*/
private constructor(config: AppConfig = {}) {
// Set default configuration
this.config = {
syncServerUrl: config.syncServerUrl ?? 'http://localhost:3000',
databasePath: config.databasePath ?? './',
databaseFilename: config.databaseFilename ?? 'xo-wallet',
};
// Initialize sync client
this.syncClient = new SyncClient(this.config.syncServerUrl);
// Initialize wallet controller (engine will be created when seed is provided)
this.walletController = new WalletController({
databasePath: this.config.databasePath,
databaseFilename: this.config.databaseFilename,
});
// Initialize invitation controller
this.invitationController = new InvitationController(
this.walletController,
this.syncClient,
);
private constructor(config: AppConfig) {
this.config = config;
}
/**
@@ -74,61 +47,51 @@ export class App {
* @param config - Application configuration options
* @returns Running App instance
*/
static async create(config: AppConfig = {}): Promise<App> {
const app = new App(config);
static async create(config: Partial<AppConfig> = {}): Promise<App> {
const dataDir = getDataDir();
// Set default configuration
const fullConfig: AppConfig = {
syncServerUrl: config.syncServerUrl ?? "http://localhost:3000",
databasePath: config.databasePath ?? dataDir,
databaseFilename: config.databaseFilename ?? "xo-wallet.db",
invitationStoragePath:
config.invitationStoragePath ?? join(dataDir, "xo-invitations.db"),
};
console.log("Full config:", fullConfig);
const app = new App(fullConfig);
await app.start();
return app;
}
/**
* Starts the application.
* Renders the Ink-based TUI.
* Renders the Ink-based TUI immediately.
*/
async start(): Promise<void> {
// Render the Ink app
// Render the Ink app with config
// TUI will handle AppService creation after seed input
this.inkInstance = render(
React.createElement(AppComponent, {
walletController: this.walletController,
invitationController: this.invitationController,
})
config: this.config,
}),
);
// Wait for the app to exit
await this.inkInstance.waitUntilExit();
process.exit(0);
}
/**
* Stops the application and cleans up resources.
*/
async stop(): Promise<void> {
// Stop the wallet engine if running
await this.walletController.stop();
// Unmount Ink app
if (this.inkInstance) {
this.inkInstance.unmount();
this.inkInstance = null;
}
}
/**
* Gets the wallet controller for external access.
*/
getWalletController(): WalletController {
return this.walletController;
}
/**
* Gets the invitation controller for external access.
*/
getInvitationController(): InvitationController {
return this.invitationController;
}
/**
* Gets the sync client for external access.
*/
getSyncClient(): SyncClient {
return this.syncClient;
}
}

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

@@ -0,0 +1,209 @@
# 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` | JSON settings (`default-mnemonic`, `currency`) |
**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 as
`default-mnemonic` in `~/.config/xo-cli/.wallet`. Later runs can omit `-m`.
`currency` controls the fiat unit used when showing BCH/sats conversions in the TUI.
Mnemonic resolution order:
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) |
| `--currency <code>` | Fiat display currency (e.g. `USD`, `AUD`) |
| `-o`, `--output <filename>` | Output filename (used by `mnemonic create`/`import`) |
| `-v`, `--verbose` | Verbose output |
| `-h`, `--help` | Help |
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
```
### `settings` — Manage Persisted Settings
```bash
xo-cli settings show
xo-cli settings get currency
xo-cli settings get default-mnemonic
xo-cli settings set currency AUD
xo-cli settings set default-mnemonic mnemonic-nuclear
```
### `receive` — Generate a Receiving Address
```bash
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,390 @@
#!/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 } 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 { SettingsService } from "../../services/settings.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 settings = new SettingsService(getWalletConfigPath());
const mnemonicFile = settings.getDefaultMnemonic();
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,311 @@
/**
* 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,
} 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
* - settings.ts: show, get, set
*/
/** 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 settings command */
const SETTINGS_SUBS = ["show", "get", "set"];
/** 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,
settings: SETTINGS_SUBS,
help: [],
completions: COMPLETIONS_SUBS,
} as const;
/** Global option flags available on every command. */
const GLOBAL_OPTIONS = [
"-h",
"--help",
"-v",
"--verbose",
"-m",
"--mnemonic-file",
"--currency",
"-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,240 @@
# ------------------------------------------------------------------------------
# Bash completion template for {{BIN_NAME}}
# ------------------------------------------------------------------------------
# Installation:
# eval "$({{BIN_NAME}} completions bash)"
#
# This file is generated from a template. Placeholders (for example `{{OPTIONS}}`)
# are replaced at build/runtime with concrete command data from the CLI.
# ------------------------------------------------------------------------------
# Prefer a globally-installed helper, but fall back to a helper co-located with
# the CLI binary. This lets completions work in both "installed via PATH" and
# "single extracted directory" workflows.
__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
# @description
# Calls the dynamic completion helper and suppresses helper stderr so the shell
# completion menu stays clean even when the helper is unavailable or errors.
# @param "$@" Arguments forwarded to xo-complete.
__xo_complete() {
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
}
# @description
# Main completion dispatcher invoked by bash's `complete -F`.
# It determines context (command/subcommand/argument position) and then mixes:
# - static completions (known command words)
# - dynamic completions (resolved by xo-complete)
# - filesystem completions (when a subcommand expects file paths)
_{{FUNC_NAME}}_completions() {
local cur prev words cword
# Populates `cur`, `prev`, `words`, and `cword`.
# `_init_completion` is provided by bash-completion.
_init_completion || return
# If the previous token is `-m/--mnemonic-file`, this argument expects a
# mnemonic file alias/path. Ask the helper for mnemonic suggestions.
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
# Option context: show global options when the current token starts with `-`.
if [[ "${cur}" == -* ]]; then
COMPREPLY=($(compgen -W "{{OPTIONS}}" -- "${cur}"))
return 0
fi
# Parse command/subcommand from non-option tokens before the current cursor.
# We track indices so argument-position logic can be computed later.
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 selected yet: complete top-level commands.
if [[ -z "${cmd}" ]]; then
COMPREPLY=($(compgen -W "{{COMMANDS}}" -- "${cur}"))
return 0
fi
# Command-specific completion rules.
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]
# Position is computed relative to the subcommand token:
# 1 => category, 2 => template, 3 => field (inspect only)
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
# Field names depend on both selected category and template.
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>
# We only complete the first positional argument (template) here.
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>
# The available actions depend on the selected template.
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 subcommands expect an invitation identifier as first arg.
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)
# File import path: delegate to bash's built-in 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>
# Suggest known reserved outpoints from the helper.
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
;;
settings)
if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "show get set" -- "${cur}"))
elif [[ "${subcmd}" == "get" || "${subcmd}" == "set" ]]; then
local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then
COMPREPLY=($(compgen -W "currency default-mnemonic" -- "${cur}"))
fi
fi
;;
receive)
# receive <template> [output]
# Template is the first positional argument after `receive`.
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)
# Shell target for generating completion scripts.
if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "bash zsh fish" -- "${cur}"))
fi
;;
esac
}
# Register the completion function for the CLI binary.
complete -F _{{FUNC_NAME}}_completions {{BIN_NAME}}

View File

@@ -0,0 +1,97 @@
# ------------------------------------------------------------------------------
# Fish completion template for {{BIN_NAME}}
# ------------------------------------------------------------------------------
# Installation:
# {{BIN_NAME}} completions fish | source
#
# This file is generated from a template. Placeholders (for example
# `{{TOP_LEVEL_COMMANDS}}`) are replaced with concrete completion definitions.
# ------------------------------------------------------------------------------
# Fish offers file completion by default. Disable that globally first so command
# words are preferred, then selectively re-enable `-F` where paths are expected.
complete -c {{BIN_NAME}} -f
# @description
# Resolves and calls `xo-complete` for dynamic values (templates, invitations,
# fields, etc.). We first try PATH, then a helper next to `{{BIN_NAME}}`.
# @param $argv Arguments forwarded directly to xo-complete.
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 option flags available across top-level command contexts.
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"
complete -c {{BIN_NAME}} -l currency -d "Set fiat display currency"
# Dynamic completion for `-m/--mnemonic-file`.
complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_dynamic mnemonics)'
# Top-level command registrations inserted by template expansion.
{{TOP_LEVEL_COMMANDS}}
# Static subcommand registrations inserted by template expansion.
{{STATIC_SUBCOMMANDS}}
# ---------------------------------------------------------------------------
# Dynamic completions by command/subcommand.
#
# Fish condition notes:
# - `__fish_seen_subcommand_from <name>` checks whether `<name>` exists in the
# current tokenized command line.
# - `count (commandline -opc)` returns how many tokens were entered.
# We use this to infer positional argument index.
# ---------------------------------------------------------------------------
# invitation create <template> <action>
# Position 3 => template argument.
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 <template> <action>
# Position 4 => action argument, filtered by selected template token.
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-id>
# Position 3 => invitation identifier.
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 <path>
# Re-enable default filesystem completion for path argument.
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from import" -F
# template list/inspect <category> <template> [field]
# Position 3 => category, 4 => template, 5 => field (inspect only).
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> <output> <role>
# Position 3 => template argument.
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 <txhash:vout>
# Position 3 => outpoint to unreserve.
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> [output]
# Position 2 => template argument.
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,216 @@
# ------------------------------------------------------------------------------
# Zsh completion template for {{BIN_NAME}}
# ------------------------------------------------------------------------------
# Installation:
# eval "$({{BIN_NAME}} completions zsh)"
#
# This file is generated from a template. Placeholders (for example
# `{{MNEMONIC_SUBS}}`) are replaced with concrete command values.
# ------------------------------------------------------------------------------
# Prefer a helper on PATH; otherwise fall back to helper next to the CLI binary.
# This keeps dynamic completion functional in both installed and portable layouts.
__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
# @description
# Calls the dynamic helper while silencing helper stderr to avoid noisy
# completion menus if helper lookup fails.
# @param "$@" Arguments forwarded to xo-complete.
__xo_complete() {
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
}
# @description
# Main zsh completion dispatcher registered via `compdef`.
# It resolves command context from `$words`/`$CURRENT` and serves:
# - static command words via `compadd`
# - dynamic values from `xo-complete`
# - filesystem completions where file paths are expected
_{{FUNC_NAME}}_completions() {
local -a commands
commands=({{COMMANDS}})
# If previous token is `-m/--mnemonic-file`, complete mnemonic sources.
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
# Option context: if current token starts with `-`, complete known options.
if [[ "${words[${CURRENT}]}" == -* ]]; then
compadd -- {{OPTIONS}}
return
fi
# Find first and second non-option tokens before the cursor.
# `cmd_idx` and `subcmd_idx` are used for positional argument calculations.
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 token yet: offer top-level commands.
if [[ -z "${cmd}" ]]; then
compadd -- ${commands[@]}
return
fi
# Command-specific completion behavior.
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]
# Relative positions from subcommand:
# 1 => category, 2 => template, 3 => field (inspect only)
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
# Field suggestions depend on selected category and template.
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>
# First positional argument is template name.
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)
# invitation create <template> <action>
# Action list is template-specific.
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)
# These subcommands take invitation ID as first argument.
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)
# invitation import <path>: delegate to zsh file completion.
_files
;;
esac
fi
;;
resource)
if [[ -z "${subcmd}" ]]; then
compadd -- {{RESOURCE_SUBS}}
elif [[ "${subcmd}" == "unreserve" ]]; then
# resource unreserve <txhash:vout>
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
;;
settings)
if [[ -z "${subcmd}" ]]; then
compadd -- show get set
elif [[ "${subcmd}" == "get" || "${subcmd}" == "set" ]]; then
local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then
compadd -- currency default-mnemonic
fi
fi
;;
receive)
# receive <template> [output]
local pos=$((CURRENT - cmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
templates=("${(@f)$(__xo_complete templates "${words[CURRENT]}")}")
if [[ ${#templates[@]} -gt 0 ]]; then
compadd -- "${templates[@]}"
fi
fi
;;
completions)
# Shell target for completion generation.
if [[ -z "${subcmd}" ]]; then
compadd -- bash zsh fish
fi
;;
esac
}
# Register completion function for the executable name.
compdef _{{FUNC_NAME}}_completions {{BIN_NAME}}

View File

@@ -0,0 +1,8 @@
export { handleMnemonicCommand, printMnemonicHelp } from "./mnemonic.js";
export { handleSettingsCommand, printSettingsHelp } from "./settings.js";
export { handleTemplateCommand, printTemplateHelp } from "./template.js";
export { handleInvitationCommand, printInvitationHelp } from "./invitation.js";
export { handleReceiveCommand, printReceiveHelp } from "./receive.js";
export { handleResourceCommand, printResourceHelp } from "./resource.js";
export * from "./types.js";

View File

@@ -0,0 +1,910 @@
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"];
// Parse the variables from the options by checking if its starts with "var"
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);
// Get the required sats out with the default fee
const requiredWithFee =
(await invitation.getSatsOut().catch(() => 0n)) + DEFAULT_FEE;
autoSelectGreedyUtxos(selectable, requiredWithFee);
// Get the inputs from the selectable UTXOs
inputs = selectable
.filter((u) => u.selected)
.map((u) => ({
outpointTransactionHash: hexToBin(u.outpointTransactionHash),
outpointIndex: u.outpointIndex,
}));
// If no inputs are found, print a message and return null
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)`);
}
// If the add input option is provided, parse the inputs from the options
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)`,
);
}
// Get the tx hash and vout from the entry
const txHash = entry.substring(0, separatorIndex);
const vout = parseInt(entry.substring(separatorIndex + 1), 10);
// If the tx hash or vout is not a string or isNaN, print a message and throw an error
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;
}
}
}
// Get the template from the engine
const template = await deps.app.engine.getTemplate(
invitation.data.templateIdentifier,
);
// Get the outputs from the template
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,
]),
);
// Sum the total input sats
let totalInputSats = 0n;
// Iterate through the inputs and sum the valueSatoshis
for (const input of inputs) {
// Get the tx hash hex
const txHashHex = binToHex(input.outpointTransactionHash);
// Get the utxo from the utxo map
const utxo = utxoMap.get(`${txHashHex}:${input.outpointIndex}`);
if (!utxo) {
// If the utxo is not found, print a message and return null
deps.io.err(
`UTXO not found: ${txHashHex}:${input.outpointIndex}. Make sure it exists in your wallet.`,
);
return null;
}
// Sum the valueSatoshis
totalInputSats += BigInt(utxo.valueSatoshis);
}
deps.io.verbose(`Total input value: ${totalInputSats} satoshis`);
// Get the required sats out
const requiredSats = await invitation.getSatsOut();
deps.io.verbose(`Required output value: ${requiredSats} satoshis`);
// Get the change amount by subtracting the required sats out from the total input sats and the default fee
const changeAmount = totalInputSats - requiredSats - DEFAULT_FEE;
deps.io.verbose(
`Change amount: ${changeAmount} satoshis (fee: ${DEFAULT_FEE})`,
);
// If the change amount is less than 0, print a message and return null
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 the change amount is greater than or equal to the dust threshold, add the change output
if (changeAmount >= DUST_THRESHOLD) {
outputs.push({ valueSatoshis: changeAmount });
deps.io.out(`Auto-adding change output: ${changeAmount} satoshis`);
}
// If the change amount is greater than 0, print a message
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 there was no subcommand provided, print the help message and throw an error
if (!subCommand) {
deps.io.verbose("No sub-command provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.subcommand.missing",
"No sub-command provided",
);
}
// Switch statement to handle the different subcommands
switch (subCommand) {
case "create": {
// Get the template query and action identifier from the arguments
const templateQuery = args[1];
const actionIdentifier = args[2];
deps.io.verbose(
`Template query: ${templateQuery}, action identifier: ${actionIdentifier}`,
);
// If they didnt provide us with a template query or action identifier, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
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",
);
}
// Resolve the template, this will check both filepath and identifier. Because we are flexible here, we will need to generate the identifier again after
const template = await resolveTemplate(deps, templateQuery);
const templateIdentifier = generateTemplateIdentifier(template);
// Create an XOInvitation. We will convert this into our own invitation instance afterwards
const rawInvitation = await deps.app.engine.createInvitation({
templateIdentifier,
actionIdentifier,
});
deps.io.verbose(`XOInvitation created: ${formatObject(rawInvitation)}`);
// Create our own invitation instance out of the raw XOInvitation. This will also initate the SSE Session
const invitationInstance = await deps.app.createInvitation(rawInvitation);
deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`,
);
// Read the variables that were passed in via `-var-<name> <value>`
const variables = parseVariablesFromOptions(options);
deps.io.verbose(`Variables: ${formatObject(variables)}`);
if (variables.length > 0) {
await invitationInstance.addVariables(variables);
}
// Build the parameters for the append call. This will resolve the inputs and outputs for the invitation.
const params = await buildAppendParams(deps, invitationInstance, options);
if (!params) {
throw new CommandError(
"invitation.create.append_params_failed",
"Failed to build append parameters",
);
}
// Append the inputs and outputs to the invitation
const { inputs, outputs } = params;
if (inputs.length > 0 || outputs.length > 0) {
await invitationInstance.append({ inputs, outputs });
}
// Write the invitation to a file in the working directory
// TODO: Support the -o flag to specify the output path
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})`,
);
// Get the missing requirements for the invitation. This will tell us if we are missing any variables, inputs, outputs, or roles.
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 there are missing requirements, print them out
if (hasMissing) {
deps.io.out(`\n${bold("Remaining requirements:")}`);
deps.io.out(formatObject(missingRequirements));
} else {
// If there are no missing requirements, sign the invitation if the user has requested it
const shouldSign =
options["sign"] === "true" || options["broadcast"] === "true";
const shouldBroadcast = options["broadcast"] === "true";
// Sign the invitation if the user has requested it
if (shouldSign) {
await invitationInstance.sign();
deps.io.out(
`Invitation signed: ${invitationInstance.data.invitationIdentifier}`,
);
}
// Broadcast the transaction if the user has requested it
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 the invitation identifier
return {
invitationIdentifier: invitationInstance.data.invitationIdentifier,
};
}
case "append": {
// Get the invitation identifier from the arguments
const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
// If they didnt provide us with an invitation identifier, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.append.identifier_missing",
"No invitation identifier provided",
);
}
// Find the invitation instance in our list of invitations
const invitation = deps.app.invitations.find(
(inv) => inv.data.invitationIdentifier === invitationIdentifier,
);
// If the invitation is not found, print an error and throw an error
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)}`);
// Parse the variables that were passed in via `-var-<name> <value>`
const variables = parseVariablesFromOptions(options);
deps.io.verbose(`Variables to append: ${formatObject(variables)}`);
if (variables.length > 0) {
await invitation.addVariables(variables);
}
// Build the parameters for the append call. This will resolve the inputs and outputs for the invitation.
const params = await buildAppendParams(deps, invitation, options);
if (!params) {
throw new CommandError(
"invitation.append.params_failed",
"Failed to build append parameters",
);
}
// If there are no variables, inputs, or outputs, print an error and throw an error
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);
}
// Append the inputs and outputs to the invitation
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}`);
// Write the invitation to a file in the working directory
// TODO: Support the -o flag to specify the output path
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})`,
);
// Get the missing requirements for the invitation. This will tell us if we are missing any variables, inputs, outputs, or roles.
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 there are missing requirements, print them out
if (hasMissing) {
deps.io.out(`\n${bold("Remaining requirements:")}`);
deps.io.out(formatObject(missingRequirements));
} else {
// If there are no missing requirements, sign the invitation if the user has requested it
const shouldSign =
options["sign"] === "true" || options["broadcast"] === "true";
const shouldBroadcast = options["broadcast"] === "true";
// Sign the invitation if the user has requested it
if (shouldSign) {
await invitation.sign();
deps.io.out(`Invitation signed: ${invitationIdentifier}`);
}
// Broadcast the transaction if the user has requested it
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": {
// Get the invitation identifier from the arguments
const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
// If they didnt provide us with an invitation identifier, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.sign.identifier_missing",
"No invitation identifier provided",
);
}
// Find the invitation instance in our list of invitations
const invitation = deps.app.invitations.find(
(candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
);
// If the invitation is not found, print an error and throw an error
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)}`);
// Sign the invitation
await invitation.sign();
deps.io.verbose(`Invitation signed: ${formatObject(invitation.data)}`);
deps.io.out(`Invitation signed: ${invitationIdentifier}`);
// Return the invitation identifier
return { invitationIdentifier };
}
case "broadcast": {
// Get the invitation identifier from the arguments
const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
// If they didnt provide us with an invitation identifier, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.broadcast.identifier_missing",
"No invitation identifier provided",
);
}
// Find the invitation instance in our list of invitations
const invitation = deps.app.invitations.find(
(candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
);
// If the invitation is not found, print an error and throw an error
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)}`);
// Broadcast the transaction
const txHash = await invitation.broadcast();
deps.io.verbose(
`Invitation broadcasted: ${formatObject(invitation.data)}`,
);
deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
// Return the invitation identifier and transaction hash
return { invitationIdentifier, txHash };
}
case "requirements": {
// Get the invitation identifier from the arguments
const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
// If they didnt provide us with an invitation identifier, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.requirements.identifier_missing",
"No invitation identifier provided",
);
}
// Find the invitation instance in our list of invitations
const invitation = deps.app.invitations.find(
(candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
);
// If the invitation is not found, print an error and throw an error
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)}`);
// List the requirements for the invitation
const requirements = await deps.app.engine.listRequirements(
invitation.data,
);
deps.io.verbose(`Requirements: ${formatObject(requirements)}`);
deps.io.out(formatObject(requirements));
// Return the invitation identifier
return { invitationIdentifier };
}
case "inspect": {
// Get the invitation file path from the arguments
const invitationFilePath = args[1];
// If they didnt provide us with an invitation file path, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
// Read the invitation file
if (!invitationFilePath) {
deps.io.verbose("No invitation file provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.inspect.file_missing",
"No invitation file provided",
);
}
// Read the invitation file (XOInvitation format, can be passed to the engine directly)
const invitationFile = await readFileSync(invitationFilePath, "utf8");
deps.io.verbose(`Invitation file: ${invitationFile}`);
// Parse the invitation file
const invitation = JSON.parse(invitationFile);
deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
// Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format)
const invitationInstance = await deps.app.createInvitation(invitation);
deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`,
);
// Get the template for the invitation
const template = await deps.app.engine.getTemplate(
invitationInstance.data.templateIdentifier,
);
// Get the action for the invitation
const action =
template?.actions[invitationInstance.data.actionIdentifier];
deps.io.verbose(`Action: ${formatObject(action)}`);
// If the action is not found, print an error and throw an error
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}`,
);
}
// Get the status for the invitation
const status = invitationInstance.status;
deps.io.verbose(`Status: ${status}`);
// Get the entities for the invitation
const entities = Array.from(
new Set(
invitationInstance.data.commits.map(
(commit) => commit.entityIdentifier,
),
),
);
deps.io.verbose(`Entities: ${formatObject(entities)}`);
// Get the entities with roles for the invitation
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),
};
});
// Get the inputs for the invitation
const inputs = invitationInstance.data.commits.flatMap(
(commit) => commit.data.inputs ?? [],
);
deps.io.verbose(`Inputs: ${formatObject(inputs)}`);
// Get the outputs for the invitation
const outputs = invitationInstance.data.commits.flatMap(
(commit) => commit.data.outputs ?? [],
);
deps.io.verbose(`Outputs: ${formatObject(outputs)}`);
// Get the variables for the invitation
const variables = invitationInstance.data.commits.flatMap(
(commit) => commit.data.variables ?? [],
);
deps.io.verbose(`Variables: ${formatObject(variables)}`);
// Return the invitation details
return {
templateName: template?.name ?? "Unknown",
actionIdentifier: invitationInstance.data.actionIdentifier,
status: status,
entities: entitiesWithRoles,
inputs: inputs,
outputs: outputs,
variables: variables,
};
}
case "import": {
// Get the invitation file path from the arguments
const invitationFilePath = args[1];
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
// If they didnt provide us with an invitation file path, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!invitationFilePath) {
deps.io.verbose("No invitation file provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.import.file_missing",
"No invitation file provided",
);
}
// Read the invitation file (XOInvitation format, can be passed to the engine directly)
const invitationFile = await readFileSync(invitationFilePath, "utf8");
deps.io.verbose(`Invitation file: ${invitationFile}`);
// Parse the invitation file
const invitation = JSON.parse(invitationFile);
deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
// "Creates" the invitiation in the engine. This method acts as both creation or import depending on the data that is being passed in
const xoInvitation = await deps.app.engine.createInvitation(invitation);
deps.io.verbose(`XOInvitation: ${formatObject(xoInvitation)}`);
// Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format)
const invitationInstance = await deps.app.createInvitation(xoInvitation);
deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`,
);
// Return the invitation identifier
return {
invitationIdentifier: invitationInstance.data.invitationIdentifier,
};
}
case "list": {
// List all the invitations
const invitations = await Promise.all(
// Iterate over the invitations and compile them into a list of data that we can use to display them with another loop later.
deps.app.invitations.map(async (invitation) => {
// Get the template for the invitation
const template = await deps.app.engine.getTemplate(
invitation.data.templateIdentifier,
);
// Get the role identifier for the invitation
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)}`);
// Format the invitations into a list of strings that we can display to the user
const formattedInvitations = invitations.map(
(invitation) =>
`${bold(invitation.templateName)} ${dim(invitation.status)} ${dim(invitation.invitationIdentifier)} ${dim(invitation.actionIdentifier)} (${dim(invitation.roleIdentifier)})`,
);
// Display the invitations to the user
deps.io.out(formattedInvitations.join("\n"));
// Return the number of invitations
return { count: invitations.length };
}
default:
// If the sub-command is not found, print an error and throw an error
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,151 @@
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 }> => {
// Get the sub-command from the arguments
const subCommand = args[0];
const { mnemonicsDir } = deps.paths;
// If no sub-command is provided, print the help message and throw an error
if (!subCommand) {
deps.io.verbose("No sub-command provided");
printMnemonicHelp(deps.io);
throw new CommandError(
"mnemonic.subcommand.missing",
"No sub-command provided",
);
}
// Handle the sub-command
switch (subCommand) {
case "create": {
// Create a new mnemonic seed
const mnemonicSeed = createMnemonicSeed();
// Create a new mnemonic file
const savedAs = createMnemonicFile(
mnemonicsDir,
mnemonicSeed,
options["output"],
);
// Display the mnemonic file to the user
deps.io.out(`Mnemonic file created: ${savedAs} (${mnemonicSeed})`);
return { savedAs };
}
case "import": {
// Get the mnemonic seed from the arguments
const mnemonicSeed = args.slice(1).join(" ");
// If no mnemonic seed is provided, print the help message and throw an error
if (!mnemonicSeed) {
deps.io.verbose("No mnemonic seed provided");
printMnemonicHelp(deps.io);
throw new CommandError(
"mnemonic.import.seed_missing",
"No mnemonic seed provided",
);
}
// Create a new mnemonic file
deps.io.verbose(`Mnemonic seed: ${mnemonicSeed}`);
const savedAs = createMnemonicFile(
mnemonicsDir,
mnemonicSeed,
options["output"],
);
// Display the mnemonic file to the user
deps.io.out(`Mnemonic file created: ${savedAs}`);
return { savedAs };
}
case "list": {
// List all the mnemonic files
const mnemonicFiles = listMnemonicFiles(mnemonicsDir);
deps.io.out(mnemonicFiles.join("\n"));
// Return the number of mnemonic files
return { count: mnemonicFiles.length };
}
case "expose": {
// Get the mnemonic file from the arguments
const mnemonicFile = args[1];
// If no mnemonic file is provided, print the help message and throw an error
if (!mnemonicFile) {
deps.io.verbose("No mnemonic file provided");
printMnemonicHelp(deps.io);
throw new CommandError(
"mnemonic.expose.file_missing",
"No mnemonic file provided",
);
}
// Try to load the mnemonic file
try {
const mnemonic = loadMnemonic(mnemonicsDir, mnemonicFile);
deps.io.out(mnemonic);
// Return the mnemonic
return { mnemonic };
} catch (error) {
// If the mnemonic file is not found, print an error and throw an error
throw new CommandError(
"mnemonic.expose.file_not_found",
`Mnemonic file not found: ${mnemonicFile}`,
);
}
}
default:
// If the sub-command is not found, print an error and throw an error
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,96 @@
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 }> => {
// Get the template query, output identifier, and role identifier from the arguments
const templateQuery = args[0];
const outputIdentifier = args[1];
const roleIdentifier = args[2];
// Log the receive args
deps.io.verbose(
`Receive args - template: ${templateQuery}, output: ${outputIdentifier}, role: ${roleIdentifier}`,
);
// If no template query or output identifier is provided, print the help message and throw an error
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,234 @@
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 {
// Format the outpoint
const outpoint = bold(
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
);
// Format the value
const value = dim(`${resource.valueSatoshis} sats`);
// Format the output
const output = dim(resource.outputIdentifier);
// Format the height
const height = dim(`(height ${resource.minedAtHeight})`);
// If the resource is reserved, format the reservation info
if (showReserved && resource.reservedBy) {
const inv = dim(`reserved for ${resource.reservedBy}`);
return `${outpoint} ${value} ${output} ${height} ${inv}`;
}
// Otherwise, format the resource without reservation info
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 no sub-command is provided, print the help message and throw an error
if (!subCommand) {
deps.io.verbose("No sub-command provided");
printResourceHelp(deps.io);
throw new CommandError(
"resource.subcommand.missing",
"No sub-command provided",
);
}
// Handle the sub-command
switch (subCommand) {
case "list": {
// Get the qualifier from the arguments - This could be "reserved", "all", or omitted (which defaults to "unreserved")
const qualifier = args[1];
// List all the unspent outputs data
const allResources = await deps.app.engine.listUnspentOutputsData();
let filtered;
// If the qualifier is "reserved", return only the reserved resources
if (qualifier === "reserved") {
filtered = allResources.filter((r) => r.reservedBy);
}
// If the qualifier is "all", return all the resources
else if (qualifier === "all") {
filtered = allResources;
}
// If the qualifier is not "reserved" or "all", return only the unreserved resources
else {
filtered = allResources.filter((r) => !r.reservedBy);
}
// If no resources are found, print a message and return 0
if (filtered.length === 0) {
deps.io.out(dim("No resources found."));
return { count: 0 };
}
// Format the resources into a list of strings that we can display to the user
const showReserved = qualifier === "all" || qualifier === "reserved";
const formattedResources = filtered.map((r) =>
formatResource(r, showReserved),
);
// Display the resources to the user
deps.io.out(formattedResources.join("\n"));
// Display the total satoshis
deps.io.out(
`Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`,
);
// Display the total resources
deps.io.out(`Total resources: ${filtered.length}`);
return { count: filtered.length };
}
case "unreserve": {
// Get the outpoint from the arguments
const outpointArg = args[1];
// If no outpoint is provided, print a message and throw an error
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.",
);
}
// Get the separator index
const separatorIndex = outpointArg.lastIndexOf(":");
if (separatorIndex === -1) {
// If the separator index is -1 (not found), print a message and throw an error
deps.io.err(
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
);
throw new CommandError(
"resource.unreserve.outpoint_invalid",
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
);
}
// Get the tx hash and vout
const txHash = outpointArg.substring(0, separatorIndex);
const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10);
// If the tx hash or vout is not a string or isNaN, print a message and throw an error
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>.`,
);
}
// Gather all of our resources
const allResources = await deps.app.engine.listUnspentOutputsData();
// Find the target resource
const target = allResources.find(
(r) => r.outpointTransactionHash === txHash && r.outpointIndex === vout,
);
// If the target resource is not found, print a message and throw an error
if (!target) {
deps.io.err(`UTXO not found: ${txHash}:${vout}`);
throw new CommandError(
"resource.unreserve.utxo_missing",
`UTXO not found: ${txHash}:${vout}`,
);
}
// If the target resource is not reserved, print a message and return
if (!target.reservedBy) {
deps.io.out(dim("UTXO is not reserved. Nothing to do."));
return {};
}
// Unreserve the resources
await deps.app.engine.unreserveResources(
[{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }],
target.reservedBy,
);
deps.io.out(
`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`,
);
// TODO: What do I want to return here?
return {};
}
case "unreserve-all": {
// Unreserve all the resources
const count = await deps.app.unreserveAllResources();
// If no resources are reserved, print a message and return
if (count === 0) {
deps.io.out(dim("No reserved resources to unreserve."));
}
// If some resources were unreserved, print a message and return the count
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,131 @@
import { SettingsService } from "../../services/settings.js";
import { formatObject } from "../utils.js";
import type { BaseCommandDependencies, CommandIO } from "./types.js";
import { CommandError } from "./types.js";
/**
* Prints help text for the settings command.
*/
export const printSettingsHelp = (io: CommandIO): void => {
io.out(`Settings Command Help:
Commands:
settings show
settings get <currency|default-mnemonic>
settings set <currency|default-mnemonic> <value>
Examples:
xo-cli settings show
xo-cli settings get currency
xo-cli settings set currency AUD
xo-cli settings set default-mnemonic mnemonic-main`);
};
/**
* Supported settings keys exposed on the CLI.
*/
type SettingsKey = "currency" | "default-mnemonic";
/**
* Normalizes user input to one of the supported settings keys.
*/
function parseSettingsKey(input: string | undefined): SettingsKey | null {
if (!input) {
return null;
}
const normalized = input.trim().toLowerCase();
if (normalized === "currency") {
return "currency";
}
if (normalized === "default-mnemonic" || normalized === "defaultMnemonic") {
return "default-mnemonic";
}
return null;
}
/**
* Handles `xo-cli settings` commands.
*
* This command intentionally does not require wallet initialization so users can
* configure currency/default mnemonic without passing `-m`.
*/
export const handleSettingsCommand = async (
deps: BaseCommandDependencies,
args: string[],
options: Record<string, string>,
): Promise<Record<string, unknown>> => {
const settings = new SettingsService(deps.paths.walletConfigPath);
// settings show (default if no subcommand)
const subCommand = args[0] ?? "show";
if (subCommand === "help" || options["help"] === "true") {
printSettingsHelp(deps.io);
return {};
}
switch (subCommand) {
case "show": {
const snapshot = settings.getSettings();
deps.io.out(formatObject(snapshot));
return snapshot;
}
case "get": {
const key = parseSettingsKey(args[1]);
if (!key) {
printSettingsHelp(deps.io);
throw new CommandError(
"settings.get.key_missing",
'Missing or invalid key. Expected "currency" or "default-mnemonic".',
);
}
const value =
key === "currency"
? settings.getCurrency()
: settings.getDefaultMnemonic() ?? "";
deps.io.out(value);
return { key, value };
}
case "set": {
const key = parseSettingsKey(args[1]);
if (!key) {
printSettingsHelp(deps.io);
throw new CommandError(
"settings.set.key_missing",
'Missing or invalid key. Expected "currency" or "default-mnemonic".',
);
}
const rawValue = args.slice(2).join(" ").trim();
if (!rawValue) {
printSettingsHelp(deps.io);
throw new CommandError(
"settings.set.value_missing",
"Missing value for settings set command.",
);
}
if (key === "currency") {
settings.setCurrency(rawValue);
const currency = settings.getCurrency();
deps.io.out(`Updated currency: ${currency}`);
return { key, value: currency };
}
settings.setDefaultMnemonic(rawValue);
const defaultMnemonic = settings.getDefaultMnemonic() ?? "";
deps.io.out(`Updated default-mnemonic: ${defaultMnemonic}`);
return { key, value: defaultMnemonic };
}
default: {
printSettingsHelp(deps.io);
throw new CommandError(
"settings.subcommand.unknown",
`Unknown settings command: ${subCommand}`,
);
}
}
};

View File

@@ -0,0 +1,459 @@
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 }> => {
// Get the template category from the arguments - This could be "action", "transaction", "output", "lockingscript", or "variable"
const templateCategory = args[0];
deps.io.verbose(`Template list category: ${templateCategory}`);
// If no template category is provided, list all the imported templates
if (!templateCategory) {
// List all the imported templates
const templates = await deps.app.engine.listImportedTemplates();
// Format the templates into a list of strings that we can display to the user
const formattedTemplates = templates.map(
(template: XOTemplate) =>
`${bold(generateTemplateIdentifier(template))} - ${dim(template.name)} ${dim(template.description)}`,
);
// Display the templates to the user
deps.io.out(formattedTemplates.join("\n"));
// Return the number of templates
return { count: templates.length };
}
// Get the template identifier from the arguments
const templateIdentifier = args[1];
deps.io.verbose(`Template identifier: ${templateIdentifier}`);
// If no template identifier is provided, print a message and throw an error
if (!templateIdentifier) {
deps.io.err("No template identifier provided");
throw new CommandError(
"template.list.identifier_missing",
"No template identifier provided",
);
}
// Get the raw template from the engine
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
// If the raw template is not found, print a message and throw an error
if (!rawTemplate) {
deps.io.err(`No template found: ${templateIdentifier}`);
throw new CommandError(
"template.list.not_found",
`No template found: ${templateIdentifier}`,
);
}
// Resolve the template deeply - Deeply nested objects instead of shallow objects referencing keys at the top level.
// Reduces the load of having to call multiple lookups just to get some resolved value like the outputIdentifer that comes from calling an action.
const template = await resolveTemplateReferences(rawTemplate);
deps.io.verbose(`Template: ${formatObject(template)}`);
// Handle the template category
switch (templateCategory) {
case "action": {
// Get the actions from the template
const actions = template.actions;
// Format the actions into a list of strings that we can display to the user
const formattedActions = Object.entries(actions).map(
([actionIdentifier, action]) =>
`${bold(actionIdentifier)} ${dim(action.name)} ${dim(action.description)}`,
);
// Display the actions to the user
deps.io.out(formattedActions.join("\n"));
// Return the number of actions
return {};
}
case "transaction": {
// Get the transactions from the template
const transactions = template.transactions;
// Format the transactions into a list of strings that we can display to the user
const formattedTransactions = Object.entries(transactions).map(
([transactionIdentifier, transaction]) =>
`${bold(transactionIdentifier)} ${dim(transaction.name)} ${dim(transaction.description)}`,
);
// Display the transactions to the user
deps.io.out(formattedTransactions.join("\n"));
// Return the number of transactions
return {};
}
case "output": {
// Get the outputs from the template
const outputs = template.outputs;
// Format the outputs into a list of strings that we can display to the user
const formattedOutputs = Object.entries(outputs).map(
([outputIdentifier, output]) =>
`${bold(outputIdentifier)} ${dim(output.name)} ${dim(output.description)}`,
);
// Display the outputs to the user
deps.io.out(formattedOutputs.join("\n"));
// Return the number of outputs
return {};
}
case "lockingscript": {
// Get the lockingscripts from the template
const lockingscripts = template.lockingScripts;
// Format the lockingscripts into a list of strings that we can display to the user
const formattedLockingscripts = Object.entries(lockingscripts).map(
([lockingScriptIdentifier, lockingScript]) =>
`${bold(lockingScriptIdentifier)} ${dim(lockingScript.name)} ${dim(lockingScript.description)}`,
);
// Display the lockingscripts to the user
deps.io.out(formattedLockingscripts.join("\n"));
// Return the number of lockingscripts
return {};
}
case "variable": {
// Get the variables from the template
const variables = template.variables || {};
// Format the variables into a list of strings that we can display to the user
const formattedVariables = Object.entries(variables).map(
([variableIdentifier, variable]) =>
`${bold(variableIdentifier)} ${dim(variable.name)} ${dim(variable.description)}`,
);
// Display the variables to the user
deps.io.out(formattedVariables.join("\n"));
// Return the number of variables
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>> => {
// Get the template category, identifier, and field from the arguments
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 no template category, identifier, or field is provided, print a message and throw an error
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",
);
}
// Resolve the template
const originalTemplate = await resolveTemplate(deps, templateQuery);
deps.io.verbose(`Original Template: ${formatObject(originalTemplate)}`);
// Resolve the template references
const template = await resolveTemplateReferences(originalTemplate);
deps.io.verbose(`Extended Template: ${formatObject(template)}`);
// Handle the template category
switch (templateCategory) {
case "action": {
// Get the action from the template
const action = template.actions[templateField];
// If the action is not found, print a message and throw an error
if (!action) {
deps.io.err(`No action found: ${templateField}`);
throw new CommandError(
"template.inspect.action_missing",
`No action found: ${templateField}`,
);
}
// Display the action to the user
deps.io.out(formatObject(action));
return {};
}
case "transaction": {
// Get the transaction from the template
const transaction = template.transactions?.[templateField];
// If the transaction is not found, print a message and throw an error
if (!transaction) {
deps.io.err(`No transaction found: ${templateField}`);
throw new CommandError(
"template.inspect.transaction_missing",
`No transaction found: ${templateField}`,
);
}
// Display the transaction to the user
deps.io.out(formatObject(transaction));
return {};
}
case "output": {
// Get the output from the template
const output = template.outputs[templateField];
// If the output is not found, print a message and throw an error
if (!output) {
deps.io.err(`No output found: ${templateField}`);
throw new CommandError(
"template.inspect.output_missing",
`No output found: ${templateField}`,
);
}
// Display the output to the user
deps.io.out(formatObject(output));
return {};
}
case "lockingscript": {
// Get the lockingscript from the template
const lockingscript = template.lockingScripts[templateField];
// If the lockingscript is not found, print a message and throw an error
if (!lockingscript) {
deps.io.err(`No lockingscript found: ${templateField}`);
throw new CommandError(
"template.inspect.lockingscript_missing",
`No lockingscript found: ${templateField}`,
);
}
// Display the lockingscript to the user
deps.io.out(formatObject(lockingscript));
return {};
}
case "variable": {
// Get the variable from the template
const variable = template.variables?.[templateField];
// If the variable is not found, print a message and throw an error
if (!variable) {
deps.io.err(`No variable found: ${templateField}`);
throw new CommandError(
"template.inspect.variable_missing",
`No variable found: ${templateField}`,
);
}
// Display the variable to the user
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 }> => {
// Get the sub-command from the arguments
const subCommand = args[0];
// If no sub-command is provided, print a message and throw an error
if (!subCommand) {
deps.io.verbose("No sub-command provided");
printTemplateHelp(deps.io);
throw new CommandError(
"template.subcommand.missing",
"No sub-command provided",
);
}
// Handle the sub-command
switch (subCommand) {
case "import": {
// Get the template file from the arguments
const templateFile = args[1];
// If no template file is provided, print a message and throw an error
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",
);
}
// Resolve the template path
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`);
deps.io.verbose(`Template path: ${templatePath}`);
// If the template file does not exist, print a message and throw an error
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}`,
);
}
// Read the template file
const template = await readFileSync(templatePath, "utf8");
deps.io.verbose(`Importing template: ${templateFile}`);
// Import the template
await deps.app.engine.importTemplate(template);
deps.io.verbose(`Template imported: ${templateFile}`);
// Return the template file
return { templateFile };
}
case "list": {
// Handle the template list command, We offload here as it has lots of arguments and is quite long
return handleTemplateListCommand(deps, args.slice(1));
}
case "inspect": {
// Handle the template inspect command, We offload here as it has lots of arguments and is quite long
return handleTemplateInspectCommand(deps, args.slice(1));
}
case "set-default": {
// Get the template file, output identifier, and role identifier from the arguments
const templateFile = args[1];
const outputIdentifier = args[2];
const roleIdentifier = args[3];
// If no template file, output identifier, or role identifier is provided, print a message and throw an error
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",
);
}
// Set the default locking parameters
deps.io.verbose(
`Template file: ${templateFile}, output identifier: ${outputIdentifier}, role identifier: ${roleIdentifier}`,
);
// Set the default locking parameters
await deps.app.engine.setDefaultLockingParameters(
templateFile,
outputIdentifier,
roleIdentifier,
);
// Return an empty object
return {};
}
default:
// If the sub-command is not found, print a message and throw an error
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;
}
}

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

@@ -0,0 +1,296 @@
#!/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 { join } from "path";
import { AppService } from "../services/app.js";
import { SettingsService } from "../services/settings.js";
import { convertArgsToObject } from "./arguments.js";
import { bold, dim, formatObject } from "./utils.js";
import { listGlobalMnemonicFiles, loadMnemonic } from "./mnemonic.js";
import {
getDataDir,
getMnemonicsDir,
getWalletConfigPath,
} from "../utils/paths.js";
import {
type CommandDependencies,
type CommandIO,
type CommandPaths,
CommandError,
handleMnemonicCommand,
handleSettingsCommand,
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(),
};
const settings = new SettingsService(paths.walletConfigPath);
// 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;
}
}
if (command === "settings") {
try {
await handleSettingsCommand({ io, paths }, subArgs, options);
process.exit(0);
} catch (error) {
if (error instanceof CommandError) {
process.exit(error.code);
}
throw error;
}
}
// Resolve mnemonic file: explicit flag > persisted settings > error.
let mnemonicFile = options["mnemonicFile"];
let didUsePersistedMnemonic = false;
if (!mnemonicFile) {
mnemonicFile = settings.getDefaultMnemonic();
didUsePersistedMnemonic = Boolean(mnemonicFile);
}
if (didUsePersistedMnemonic && mnemonicFile) {
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.
settings.setDefaultMnemonic(mnemonicFile);
if (options["currency"]) {
settings.setCurrency(options["currency"]);
io.verbose(`Using configured currency: ${settings.getCurrency()}`);
}
const mnemonic = loadMnemonic(paths.mnemonicsDir, mnemonicFile);
io.verbose(`Loaded mnemonic from file: ${mnemonicFile} ${mnemonic}`);
// 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"),
}, settings);
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")}
settings ${dim("Manage persisted wallet settings")}
completions ${dim("Generate shell completion scripts (bash, zsh, fish)")}
help ${dim("Show this help message")}
Options:
-h, --help ${dim("Show this help message")}
-m, --mnemonic-file <mnemonic-file> ${dim("Use a specific mnemonic file")}
--currency <currency-code> ${dim("Set fiat display currency (e.g. USD, AUD)")}
-v, --verbose ${dim("Show verbose output")}`,
);
return {};
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

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

@@ -0,0 +1,139 @@
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 => {
// Try to resolve the mnemonic file as an absolute path
if (isAbsolute(mnemonicRef) && existsSync(mnemonicRef)) {
return mnemonicRef;
}
// Try to resolve the mnemonic file relative to the current working directory
const relativeToCwd = resolve(process.cwd(), mnemonicRef);
if (existsSync(relativeToCwd)) {
return relativeToCwd;
}
// Try to resolve the mnemonic file in the mnemonics directory
const inMnemonics = join(mnemonicsDir, basename(mnemonicRef));
if (existsSync(inMnemonics)) {
return inMnemonics;
}
// If the mnemonic file is not found, throw an error
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 => {
// Resolve the mnemonic file path
const resolvedPath = resolveMnemonicFilePath(mnemonicsDir, mnemonicFile);
// Read the mnemonic file
const mnemonicUrl = BCHMnemonicURL.fromURL(
readFileSync(resolvedPath, "utf8"),
);
// Get the entropy from the mnemonic url
const { entropy } = mnemonicUrl.toObject();
// Encode the entropy to a mnemonic
const mnemonic = encodeBip39Mnemonic(entropy);
// If the mnemonic is not a string, throw an error
if (typeof mnemonic === "string") {
throw new Error(`Failed to convert entropy to mnemonic: ${mnemonic}`);
}
// Return the mnemonic phrase
return mnemonic.phrase;
};
/**
* Lists mnemonic files in the given directory.
* @param mnemonicsDir - Directory containing mnemonic files
* @returns Basenames suitable for `-m <name>`
*/
export const listMnemonicFiles = (mnemonicsDir: string): string[] => {
// List the mnemonic files in the given directory
const filenames = readdirSync(mnemonicsDir).filter((f: string) =>
f.startsWith("mnemonic-"),
);
// Return the mnemonic files
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[] => {
// List the mnemonic files in the global mnemonics directory
return listMnemonicFiles(getGlobalMnemonicsDir());
};

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

@@ -0,0 +1,114 @@
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> => {
// Gather all of our imported templates
const templates = await deps.app.engine.listImportedTemplates();
// Create a set to store the matches
const matches = new Set<XOTemplate>();
// Iterate through the templates and check if the identifier matches the query
for (const template of templates) {
if (generateTemplateIdentifier(template) === query) {
// Return early if we got a match since identifiers are always unique by content
return template;
}
}
// Iterate through the templates and check if the name matches the query
for (const template of templates) {
if (template.name === query) {
matches.add(template);
}
}
// If there are multiple matches, throw an error
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 there is one match, return the match
if (matches.size === 1) {
return matches.values().next().value!;
}
// If there are no matches, throw an error
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,293 +0,0 @@
/**
* Invitation Controller - High-level interface for invitation management.
*
* Provides a simplified API for the TUI to interact with invitations,
* wrapping the InvitationFlowManager and coordinating with the WalletController.
*/
import { EventEmitter } from 'events';
import type { XOInvitation } from '@xo-cash/types';
import { InvitationFlowManager, type TrackedInvitation, type InvitationState } from '../services/invitation-flow.js';
import type { WalletController } from './wallet-controller.js';
import type { SyncClient } from '../services/sync-client.js';
/**
* Events emitted by the invitation controller.
*/
export interface InvitationControllerEvents {
'invitation-created': (invitationId: string) => void;
'invitation-updated': (invitationId: string) => void;
'invitation-state-changed': (invitationId: string, state: InvitationState) => void;
'error': (error: Error) => void;
}
/**
* Controller for managing invitations in the TUI.
*/
export class InvitationController extends EventEmitter {
/** Flow manager for invitation lifecycle */
private flowManager: InvitationFlowManager;
/** Wallet controller reference */
private walletController: WalletController;
/** Sync client reference */
private syncClient: SyncClient;
/**
* Creates a new invitation controller.
* @param walletController - Wallet controller instance
* @param syncClient - Sync client instance
*/
constructor(walletController: WalletController, syncClient: SyncClient) {
super();
this.walletController = walletController;
this.syncClient = syncClient;
this.flowManager = new InvitationFlowManager(walletController, syncClient);
// Forward events from flow manager
this.flowManager.on('invitation-created', (invitation: XOInvitation) => {
this.emit('invitation-created', invitation.invitationIdentifier);
});
this.flowManager.on('invitation-updated', (invitationId: string) => {
this.emit('invitation-updated', invitationId);
});
this.flowManager.on('invitation-state-changed', (invitationId: string, state: InvitationState) => {
this.emit('invitation-state-changed', invitationId, state);
});
this.flowManager.on('error', (_invitationId: string, error: Error) => {
this.emit('error', error);
});
}
// ============================================================================
// Invitation Creation Flow
// ============================================================================
/**
* Creates a new invitation from a template action.
* @param templateIdentifier - Template ID
* @param actionIdentifier - Action ID
* @returns Created tracked invitation
*/
async createInvitation(
templateIdentifier: string,
actionIdentifier: string,
): Promise<TrackedInvitation> {
return this.flowManager.createInvitation(templateIdentifier, actionIdentifier);
}
/**
* Publishes an invitation to the sync server and starts listening for updates.
* @param invitationId - Invitation ID to publish
* @returns The invitation ID for sharing
*/
async publishAndSubscribe(invitationId: string): Promise<string> {
// Publish to sync server
await this.flowManager.publishInvitation(invitationId);
// Subscribe to SSE updates
await this.flowManager.subscribeToUpdates(invitationId);
return invitationId;
}
// ============================================================================
// Invitation Import Flow
// ============================================================================
/**
* Imports an invitation by ID from the sync server.
* @param invitationId - Invitation ID to import
* @returns Imported tracked invitation
*/
async importInvitation(invitationId: string): Promise<TrackedInvitation> {
return this.flowManager.importInvitation(invitationId);
}
/**
* Accepts an imported invitation (joins as participant).
* @param invitationId - Invitation ID to accept
* @returns Updated tracked invitation
*/
async acceptInvitation(invitationId: string): Promise<TrackedInvitation> {
return this.flowManager.acceptInvitation(invitationId);
}
// ============================================================================
// Invitation Data Operations
// ============================================================================
/**
* Appends inputs to an invitation.
* @param invitationId - Invitation ID
* @param inputs - Inputs to add
*/
async addInputs(
invitationId: string,
inputs: Array<{
outpointTransactionHash: string;
outpointIndex: number;
sequenceNumber?: number;
inputIdentifier?: string;
}>,
): Promise<TrackedInvitation> {
return this.flowManager.appendToInvitation(invitationId, { inputs });
}
/**
* Appends outputs to an invitation.
* @param invitationId - Invitation ID
* @param outputs - Outputs to add
*/
async addOutputs(
invitationId: string,
outputs: Array<{
valueSatoshis?: bigint;
lockingBytecode?: Uint8Array;
outputIdentifier?: string;
roleIdentifier?: string;
}>,
): Promise<TrackedInvitation> {
return this.flowManager.appendToInvitation(invitationId, { outputs });
}
/**
* Appends variables to an invitation.
* @param invitationId - Invitation ID
* @param variables - Variables to add
*/
async addVariables(
invitationId: string,
variables: Array<{
variableIdentifier: string;
value: bigint | boolean | number | string;
roleIdentifier?: string;
}>,
): Promise<TrackedInvitation> {
return this.flowManager.appendToInvitation(invitationId, { variables });
}
// ============================================================================
// Signing & Broadcasting
// ============================================================================
/**
* Signs an invitation.
* @param invitationId - Invitation ID to sign
* @returns Updated tracked invitation
*/
async signInvitation(invitationId: string): Promise<TrackedInvitation> {
return this.flowManager.signInvitation(invitationId);
}
/**
* Broadcasts the transaction for an invitation.
* @param invitationId - Invitation ID
* @returns Transaction hash
*/
async broadcastTransaction(invitationId: string): Promise<string> {
return this.flowManager.broadcastTransaction(invitationId);
}
// ============================================================================
// Queries
// ============================================================================
/**
* Gets a tracked invitation by ID.
* @param invitationId - Invitation ID
* @returns Tracked invitation or undefined
*/
getInvitation(invitationId: string): TrackedInvitation | undefined {
return this.flowManager.get(invitationId);
}
/**
* Gets all tracked invitations.
* @returns Array of tracked invitations
*/
getAllInvitations(): TrackedInvitation[] {
return this.flowManager.getAll();
}
/**
* Gets the invitation data.
* @param invitationId - Invitation ID
* @returns The XOInvitation or undefined
*/
getInvitationData(invitationId: string): XOInvitation | undefined {
return this.flowManager.get(invitationId)?.invitation;
}
/**
* Gets the state of an invitation.
* @param invitationId - Invitation ID
* @returns Invitation state or undefined
*/
getInvitationState(invitationId: string): InvitationState | undefined {
return this.flowManager.get(invitationId)?.state;
}
/**
* Gets available roles for an invitation.
* @param invitationId - Invitation ID
* @returns Array of available role identifiers
*/
async getAvailableRoles(invitationId: string): Promise<string[]> {
const tracked = this.flowManager.get(invitationId);
if (!tracked) {
throw new Error(`Invitation not found: ${invitationId}`);
}
return this.walletController.getAvailableRoles(tracked.invitation);
}
/**
* Gets missing requirements for an invitation.
* @param invitationId - Invitation ID
* @returns Missing requirements
*/
async getMissingRequirements(invitationId: string) {
const tracked = this.flowManager.get(invitationId);
if (!tracked) {
throw new Error(`Invitation not found: ${invitationId}`);
}
return this.walletController.getMissingRequirements(tracked.invitation);
}
/**
* Gets requirements for an invitation.
* @param invitationId - Invitation ID
* @returns Requirements
*/
async getRequirements(invitationId: string) {
const tracked = this.flowManager.get(invitationId);
if (!tracked) {
throw new Error(`Invitation not found: ${invitationId}`);
}
return this.walletController.getRequirements(tracked.invitation);
}
// ============================================================================
// Cleanup
// ============================================================================
/**
* Stops tracking an invitation.
* @param invitationId - Invitation ID to stop tracking
*/
stopTracking(invitationId: string): void {
this.flowManager.untrack(invitationId);
}
/**
* Cleans up all resources.
*/
destroy(): void {
this.flowManager.destroy();
}
}

View File

@@ -1,408 +0,0 @@
/**
* Wallet Controller - Orchestrates wallet operations via the XO Engine.
*
* Responsibilities:
* - Initializes Engine with user seed
* - Exposes wallet state queries (balances, UTXOs)
* - Delegates template/invitation operations to Engine
* - Emits state change events for UI updates
*/
import { EventEmitter } from 'events';
import { Engine } from '@xo-cash/engine';
import type { XOInvitation, XOTemplate, XOTemplateStartingActions } from '@xo-cash/types';
import type { UnspentOutputData, LockingBytecodeData } from '@xo-cash/state';
import { p2pkhTemplate } from '@xo-cash/templates';
/**
* Configuration options for the wallet controller.
*/
export interface WalletControllerConfig {
/** Path for database storage */
databasePath?: string;
/** Database filename */
databaseFilename?: string;
/** Electrum application identifier */
electrumApplicationIdentifier?: string;
}
/**
* Balance information for display.
*/
export interface WalletBalance {
/** Total satoshis across all UTXOs */
totalSatoshis: bigint;
/** Number of UTXOs */
utxoCount: number;
}
/**
* Events emitted by the wallet controller.
*/
export interface WalletControllerEvents {
'initialized': () => void;
'state-updated': () => void;
'error': (error: Error) => void;
}
/**
* Controller for wallet operations.
*/
export class WalletController extends EventEmitter {
/** The XO Engine instance */
private engine: Engine | null = null;
/** Controller configuration */
private config: WalletControllerConfig;
/** Whether the wallet is initialized */
private initialized: boolean = false;
/**
* Creates a new wallet controller.
* @param config - Controller configuration options
*/
constructor(config: WalletControllerConfig = {}) {
super();
this.config = config;
}
/**
* Checks if the wallet is initialized.
*/
isInitialized(): boolean {
return this.initialized && this.engine !== null;
}
/**
* Initializes the wallet with a seed phrase.
* @param seed - BIP39 seed phrase
*/
async initialize(seed: string): Promise<void> {
try {
// Create the engine with the provided seed
this.engine = await Engine.create(seed, {
databasePath: this.config.databasePath ?? './',
databaseFilename: this.config.databaseFilename ?? 'xo-wallet',
electrumApplicationIdentifier: this.config.electrumApplicationIdentifier ?? 'xo-wallet-cli',
});
// Import the default P2PKH template
await this.engine.importTemplate(p2pkhTemplate);
// Set default locking parameters for P2PKH
await this.engine.setDefaultLockingParameters(
await this.getTemplateIdentifier(p2pkhTemplate),
'receiveOutput',
'receiver',
);
// Generate an initial receiving address
const templateId = await this.getTemplateIdentifier(p2pkhTemplate);
await this.engine.generateLockingBytecode(templateId, 'receiveOutput', 'receiver');
this.initialized = true;
this.emit('initialized');
} catch (error) {
this.emit('error', error instanceof Error ? error : new Error(String(error)));
throw error;
}
}
/**
* Gets the template identifier from a template.
* @param template - The XO template
* @returns The template identifier
*/
private async getTemplateIdentifier(template: XOTemplate): Promise<string> {
// Import the utility to generate template identifier
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
return generateTemplateIdentifier(template);
}
/**
* Stops the wallet engine and cleans up resources.
*/
async stop(): Promise<void> {
if (this.engine) {
await this.engine.stop();
this.engine = null;
this.initialized = false;
}
}
/**
* Gets the engine instance.
* @throws Error if engine is not initialized
*/
getEngine(): Engine {
if (!this.engine) {
throw new Error('Wallet not initialized. Please enter your seed phrase first.');
}
return this.engine;
}
// ============================================================================
// Balance & UTXO Operations
// ============================================================================
/**
* Gets the wallet balance.
* @returns Wallet balance information
*/
async getBalance(): Promise<WalletBalance> {
const engine = this.getEngine();
const utxos = await engine.listUnspentOutputsData();
const totalSatoshis = utxos.reduce(
(sum, utxo) => sum + BigInt(utxo.valueSatoshis),
BigInt(0),
);
return {
totalSatoshis,
utxoCount: utxos.length,
};
}
/**
* Gets all unspent outputs.
* @returns Array of unspent output data
*/
async getUnspentOutputs(): Promise<UnspentOutputData[]> {
const engine = this.getEngine();
return engine.listUnspentOutputsData();
}
/**
* Gets locking bytecodes for a template.
* @param templateIdentifier - Template identifier
* @returns Array of locking bytecode data
*/
async getLockingBytecodes(templateIdentifier: string): Promise<LockingBytecodeData[]> {
const engine = this.getEngine();
return engine.listLockingBytecodesForTemplate(templateIdentifier);
}
// ============================================================================
// Template Operations
// ============================================================================
/**
* Gets all imported templates.
* @returns Array of templates
*/
async getTemplates(): Promise<XOTemplate[]> {
const engine = this.getEngine();
return engine.listImportedTemplates();
}
/**
* Gets a specific template by identifier.
* @param templateIdentifier - Template identifier
* @returns The template or undefined
*/
async getTemplate(templateIdentifier: string): Promise<XOTemplate | undefined> {
const engine = this.getEngine();
return engine.getTemplate(templateIdentifier);
}
/**
* Gets starting actions for a template.
* @param templateIdentifier - Template identifier
* @returns Starting actions
*/
async getStartingActions(templateIdentifier: string): Promise<XOTemplateStartingActions> {
const engine = this.getEngine();
return engine.listStartingActions(templateIdentifier);
}
/**
* Imports a template into the wallet.
* @param template - Template to import (JSON or object)
*/
async importTemplate(template: unknown): Promise<void> {
const engine = this.getEngine();
await engine.importTemplate(template);
this.emit('state-updated');
}
/**
* Generates a new locking bytecode (receiving address).
* @param templateIdentifier - Template identifier
* @param outputIdentifier - Output identifier
* @param roleIdentifier - Role identifier
* @returns Generated locking bytecode as hex
*/
async generateLockingBytecode(
templateIdentifier: string,
outputIdentifier: string,
roleIdentifier?: string,
): Promise<string> {
const engine = this.getEngine();
const lockingBytecode = await engine.generateLockingBytecode(
templateIdentifier,
outputIdentifier,
roleIdentifier,
);
this.emit('state-updated');
return lockingBytecode;
}
// ============================================================================
// Invitation Operations
// ============================================================================
/**
* Creates a new invitation.
* @param templateIdentifier - Template identifier
* @param actionIdentifier - Action identifier
* @returns Created invitation
*/
async createInvitation(
templateIdentifier: string,
actionIdentifier: string,
): Promise<XOInvitation> {
const engine = this.getEngine();
return engine.createInvitation({
templateIdentifier,
actionIdentifier,
});
}
/**
* Accepts an invitation.
* @param invitation - Invitation to accept
* @returns Updated invitation
*/
async acceptInvitation(invitation: XOInvitation): Promise<XOInvitation> {
const engine = this.getEngine();
return engine.acceptInvitation(invitation);
}
/**
* Appends data to an invitation.
* @param invitation - Invitation to append to
* @param params - Data to append
* @returns Updated invitation
*/
async appendInvitation(
invitation: XOInvitation,
params: {
inputs?: Array<{
outpointTransactionHash?: string;
outpointIndex?: number;
sequenceNumber?: number;
mergesWith?: { commitIdentifier: string; index: number };
unlockingBytecode?: Uint8Array;
}>;
outputs?: Array<{
valueSatoshis?: bigint;
lockingBytecode?: Uint8Array;
outputIdentifier?: string;
roleIdentifier?: string;
mergesWith?: { commitIdentifier: string; index: number };
}>;
variables?: Array<{
variableIdentifier: string;
value: bigint | boolean | number | string;
roleIdentifier?: string;
}>;
},
): Promise<XOInvitation> {
const engine = this.getEngine();
// Cast through unknown to handle strict type checking from engine's AppendInvitationParameters
// The engine expects Uint8Array for outpointTransactionHash but we accept string for convenience
return engine.appendInvitation(invitation, params as unknown as Parameters<typeof engine.appendInvitation>[1]);
}
/**
* Signs an invitation.
* @param invitation - Invitation to sign
* @returns Signed invitation
*/
async signInvitation(invitation: XOInvitation): Promise<XOInvitation> {
const engine = this.getEngine();
return engine.signInvitation(invitation);
}
/**
* Validates an invitation.
* @param invitation - Invitation to validate
* @returns Whether the invitation is valid
*/
async isInvitationValid(invitation: XOInvitation): Promise<boolean> {
const engine = this.getEngine();
return engine.isInvitationValid(invitation);
}
/**
* Gets available roles for an invitation.
* @param invitation - Invitation to check
* @returns Array of available role identifiers
*/
async getAvailableRoles(invitation: XOInvitation): Promise<string[]> {
const engine = this.getEngine();
return engine.listAvailableRoles(invitation);
}
/**
* Gets requirements for an invitation.
* @param invitation - Invitation to check
* @returns Requirements information
*/
async getRequirements(invitation: XOInvitation) {
const engine = this.getEngine();
return engine.listRequirements(invitation);
}
/**
* Gets missing requirements for an invitation.
* @param invitation - Invitation to check
* @returns Missing requirements information
*/
async getMissingRequirements(invitation: XOInvitation) {
const engine = this.getEngine();
return engine.listMissingRequirements(invitation);
}
/**
* Finds suitable UTXOs for an invitation.
* @param invitation - Invitation to find resources for
* @param options - Search options
* @returns Suitable unspent outputs
*/
async findSuitableResources(
invitation: XOInvitation,
options: { templateIdentifier: string; outputIdentifier: string },
) {
const engine = this.getEngine();
return engine.findSuitableResources(invitation, options);
}
// ============================================================================
// Transaction Operations
// ============================================================================
/**
* Executes an action (broadcasts transaction).
* @param invitation - Invitation with completed transaction
* @param options - Execution options
* @returns Transaction hash
*/
async executeAction(
invitation: XOInvitation,
options: { broadcastTransaction?: boolean } = { broadcastTransaction: true },
): Promise<string> {
const engine = this.getEngine();
const txHash = await engine.executeAction(invitation, {
broadcastTransaction: options.broadcastTransaction ?? true,
});
if (options.broadcastTransaction) {
this.emit('state-updated');
}
return txHash;
}
}

View File

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

309
src/services/app.ts Normal file
View File

@@ -0,0 +1,309 @@
import {
Engine,
type XOEngineOptions,
// This is temporary. Will likely be moved to where we import templates in the cli. I think that makes more sense as this is a library thing
generateTemplateIdentifier,
} from "@xo-cash/engine";
import type { XOInvitation } from "@xo-cash/types";
import { Invitation } from "./invitation.js";
import { BaseStorage, Storage } from "./storage.js";
import { SyncServer } from "../utils/sync-server.js";
import { HistoryService } from "./history.js";
import { type BlockchainService, ElectrumService } from "./electrum.js";
import { RatesService } from "./rates.js";
import { SettingsService } from "./settings.js";
import { EventEmitter } from "../utils/event-emitter.js";
// TODO: Remove this. Exists to hash the seed for database namespace.
import { createHash } from "crypto";
import { p2pkhTemplate } from "@xo-cash/templates";
import { hexToBin } from "@bitauth/libauth";
import { parseTemplate } from "@xo-cash/engine";
export type AppEventMap = {
"invitation-added": Invitation;
"invitation-removed": Invitation;
"wallet-state-changed": {
reason:
| "invitation-added"
| "invitation-removed"
| "invitation-updated"
| "invitation-status-changed";
invitationIdentifier: string;
};
};
export interface AppConfig {
syncServerUrl: string;
engineConfig: XOEngineOptions;
invitationStoragePath: string;
electrumHost?: string;
electrumApplicationIdentifier?: string;
}
export class AppService extends EventEmitter<AppEventMap> {
public engine: Engine;
public storage: BaseStorage;
public config: AppConfig;
public history: HistoryService;
public electrum: BlockchainService;
public rates: RatesService;
public settings: SettingsService;
public invitations: Invitation[] = [];
private invitationEventCleanup = new Map<
string,
{
onUpdated: (invitation: XOInvitation) => void;
onStatusChanged: (status: string) => void;
}
>();
static async create(
seed: string,
config: AppConfig,
settings: SettingsService = new SettingsService(),
): Promise<AppService> {
// Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app.
// We are going to do this by computing a hash of the seed and prefixing the storage paths with it.
const seedHash = createHash("sha256").update(seed).digest("hex");
// We want to only prefix the file name
const prefixedStoragePath = `${seedHash.slice(0, 8)}-${config.engineConfig.databaseFilename}`;
// Create the engine
const engine = await Engine.create(seed, {
...config.engineConfig,
databaseFilename: prefixedStoragePath,
});
// 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
const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate);
// engine
// .subscribeToLockingBytecodesForTemplate(templateIdentifier)
// .catch((err) =>
// console.error(
// `Error subscribing to locking bytecodes for template ${templateIdentifier}: ${err}`,
// ),
// );
// engine
// .updateUnspentOutputsForTemplate(templateIdentifier)
// .catch((err) =>
// console.error(
// `Error updating unspent outputs for template ${templateIdentifier}: ${err}`,
// ),
// );
// Update all the unspents for every template, and subscribe to the locking bytecodes for changes
// TODO: Remove the above lines that do the same thing. Minimising changes for BLISS.
const updateTemplates = async () => {
const templates = await engine.listImportedTemplates();
templates.forEach(async (template) => {
// engine.updateUnspentOutputsForTemplate(generateTemplateIdentifier(template));
engine.subscribeToLockingBytecodesForTemplate(generateTemplateIdentifier(template));
});
};
updateTemplates();
// Set default locking parameters for P2PKH
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
// TODO: Add discovery for funds in the first index? Or until we return 0 TXs?
await engine.setDefaultLockingParameters(
generateTemplateIdentifier(parseTemplate(p2pkhTemplate)),
"receiveOutput",
"receiver",
);
// Create our own storage for the invitations
const storage = await Storage.create(config.invitationStoragePath);
const walletStorage = await storage.child(seedHash.slice(0, 8));
// Create the app service
const electrum = new ElectrumService({
host: config.electrumHost,
applicationIdentifier: config.electrumApplicationIdentifier,
});
const rates = await RatesService.create(settings);
return new AppService(engine, walletStorage, config, electrum, rates, settings);
}
constructor(
engine: Engine,
storage: BaseStorage,
config: AppConfig,
electrum: BlockchainService,
rates: RatesService,
settings: SettingsService,
) {
super();
this.engine = engine;
this.storage = storage;
this.config = config;
this.electrum = electrum;
this.rates = rates;
this.settings = settings;
this.history = new HistoryService(engine, this.invitations);
}
async createInvitation(
invitation: XOInvitation | string,
): Promise<Invitation> {
// Make sure the engine has the template imported
const invitationStorage = this.storage.child("invitations");
const invitationSyncServer = new SyncServer(
this.config.syncServerUrl,
typeof invitation === "string"
? invitation
: invitation.invitationIdentifier,
);
const deps = {
engine: this.engine,
syncServer: invitationSyncServer,
storage: invitationStorage,
electrum: this.electrum,
};
// Create the invitation
const invitationInstance = await Invitation.create(invitation, deps);
// Add the invitation to the invitations array
await this.addInvitation(invitationInstance);
return invitationInstance;
}
async addInvitation(invitation: Invitation): Promise<void> {
this.attachInvitationListeners(invitation);
// Add the invitation to the invitations array
this.invitations.push(invitation);
// Emit the invitation-added event
this.emit("invitation-added", invitation);
this.emit("wallet-state-changed", {
reason: "invitation-added",
invitationIdentifier: invitation.data.invitationIdentifier,
});
}
async removeInvitation(invitation: Invitation): Promise<void> {
const invitationIdentifier = invitation.data.invitationIdentifier;
this.detachInvitationListeners(invitationIdentifier);
// Remove the invitation from the invitations array while preserving the array reference.
const invitationIndex = this.invitations.indexOf(invitation);
if (invitationIndex >= 0) {
this.invitations.splice(invitationIndex, 1);
}
// Emit the invitation-removed event
this.emit("invitation-removed", invitation);
this.emit("wallet-state-changed", {
reason: "invitation-removed",
invitationIdentifier,
});
}
private attachInvitationListeners(invitation: Invitation): void {
const invitationIdentifier = invitation.data.invitationIdentifier;
if (this.invitationEventCleanup.has(invitationIdentifier)) return;
const onUpdated = () => {
this.emit("wallet-state-changed", {
reason: "invitation-updated",
invitationIdentifier,
});
};
const onStatusChanged = () => {
this.emit("wallet-state-changed", {
reason: "invitation-status-changed",
invitationIdentifier,
});
};
invitation.on("invitation-updated", onUpdated);
invitation.on("invitation-status-changed", onStatusChanged);
this.invitationEventCleanup.set(invitationIdentifier, {
onUpdated,
onStatusChanged,
});
}
private detachInvitationListeners(invitationIdentifier: string): void {
const trackedInvitation = this.invitations.find(
(candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
);
const cleanup = this.invitationEventCleanup.get(invitationIdentifier);
if (!trackedInvitation || !cleanup) return;
trackedInvitation.off("invitation-updated", cleanup.onUpdated);
trackedInvitation.off("invitation-status-changed", cleanup.onStatusChanged);
this.invitationEventCleanup.delete(invitationIdentifier);
}
/**
* Unreserves all reserved UTXOs across every invitation.
* Useful when stale reservations from previous sessions block spending.
*
* @returns The number of UTXOs that were unreserved.
*/
async unreserveAllResources(): Promise<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> {
// 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
const invitationsDb = this.storage.child("invitations");
// Load invitations from storage
const invitations = (await invitationsDb.all()) as {
key: string;
value: XOInvitation;
}[];
await Promise.all(
invitations.map(async ({ key }) => {
await this.createInvitation(key).catch((err) =>
console.error(`Error creating invitation ${key}: ${err}`),
);
}),
);
}
}

57
src/services/electrum.ts Normal file
View File

@@ -0,0 +1,57 @@
import {
fetchTransactionBlockHeight,
initializeElectrumClient,
} from "@electrum-cash/protocol";
export interface ElectrumServiceConfig {
host?: string;
applicationIdentifier?: string;
}
export abstract class BlockchainService {
abstract hasSeenTransaction(transactionHash: string): Promise<boolean>;
}
/**
* Small Electrum adapter used by CLI services.
* Keeps connection logic in one place and exposes a tiny API.
*/
export class ElectrumService {
private readonly host: string;
private readonly applicationIdentifier: string;
private clientPromise?: ReturnType<typeof initializeElectrumClient>;
constructor(config: ElectrumServiceConfig = {}) {
this.host =
config.host ?? process.env["ELECTRUM_HOST"] ?? "bch.imaginary.cash";
this.applicationIdentifier = "xo-cli";
}
private async getClient() {
if (!this.clientPromise) {
this.clientPromise = initializeElectrumClient(
this.applicationIdentifier,
this.host,
);
}
return this.clientPromise;
}
/**
* Returns true when the transaction is known by Electrum
* (confirmed or currently in mempool).
*/
async hasSeenTransaction(transactionHash: string): Promise<boolean> {
try {
const client = await this.getClient();
const height = await fetchTransactionBlockHeight(client, transactionHash);
// Electrum returns numbers for known transactions
// (e.g. >0 confirmed, 0/-1 unconfirmed variants).
return typeof height === "number";
} catch {
return false;
}
}
}

621
src/services/history.ts Normal file
View File

@@ -0,0 +1,621 @@
import { binToHex, hexToBin, sha256 } from "@bitauth/libauth";
import { compileCashAssemblyString, type Engine } from "@xo-cash/engine";
import type { ScriptHashData, UnspentOutputData } from "@xo-cash/state";
import type {
XOInvitation,
XOInvitationCommit,
XOInvitationInput,
XOInvitationOutput,
XOInvitationVariableValue,
XOTemplate,
} from "@xo-cash/types";
import type { Invitation } from "./invitation.js";
export type WalletHistorySource = "invitation" | "utxo";
export type WalletHistoryInput = {
id: string;
commitIdentifier?: string;
inputIdentifier?: string;
role?: string;
description: string;
valueSatoshis?: bigint;
outpoint: {
txid: string;
index: number;
};
scriptHash?: string;
};
export type WalletHistoryOutput = {
id: string;
commitIdentifier?: string;
outputIdentifier?: string;
role?: string;
description: string;
valueSatoshis?: bigint;
outpoint?: {
txid: string;
index: number;
};
lockingBytecode?: string;
scriptHash?: string;
reserved?: boolean;
};
export type WalletHistoryItem = {
id: string;
source: WalletHistorySource;
invitationIdentifier?: string;
createdAtTimestamp?: number;
templateIdentifier: string;
template: string;
action?: string;
roles: string[];
description: string;
valueSatoshis: bigint;
inputs: WalletHistoryInput[];
outputs: WalletHistoryOutput[];
};
export type HistoryItem = WalletHistoryItem;
interface InvitationContext {
invitation: Invitation;
template: XOTemplate | null;
variables: Record<string, XOInvitationVariableValue>;
}
interface UtxoContext {
utxo: UnspentOutputData;
scriptHashData?: ScriptHashData;
template: XOTemplate | null;
}
interface WalletMetadataIndex {
scriptHashDataByScriptHash: Map<string, ScriptHashData>;
}
/*
* This needs a thorough and significant rewrite and design.
* I've tried to fundamental approaches so far:
* - UTXO first
* - Invitation first
*
* The issue is that neither of these end up being simple or effective
* UTXO first makes tracking utxos across invitations extremely difficult. So if you receive a UTXO from an invitation and then spend it on another, you wont even see that old invitation.
* Invitation first makes fitting UTXOs that dont have an invitation (say if someone sent directly to your address) extremely difficult. You end up having to run a UTXO first pass anyway, and then end up with conflicts around resolved roles.
* Inferring roles is also extremely difficult. We cant just say "does this have an output for our P2PKH receiving roll? it does? Ok, we are a receiver" because this would match `true` because of our change outputs.
* If anyone has any idea of how to address this without tying knots of spaghetti, please let me know.
* This has been rewritten multiple times to try and simplify it, but its still extremely hard to follow and understand, while not even providing information that we want.
*/
export class HistoryService {
constructor(
private engine: Engine,
private invitations: Invitation[],
) {}
/**
* I Might swap this over to invitation based history before the event to make it a bit more evident... Really not happy with the UTXO for demo purposes
* But for the actual usage, UTXO is easier to follow - just not good for demo
* Long term, this is intended to be in the Engine, so we will just be a consumer of history state.
*/
async getHistory(): Promise<WalletHistoryItem[]> {
const allUtxos = await this.engine.listUnspentOutputsData();
const metadataIndex = await this.buildWalletMetadataIndex(allUtxos);
const invitationContexts = await this.buildInvitationContextIndex();
const utxoContexts = await Promise.all(
allUtxos.map((utxo) => this.buildUtxoContext(utxo, metadataIndex)),
);
const reservedUtxosByInvitation = new Map<string, UtxoContext[]>();
const standaloneUtxos: UtxoContext[] = [];
for (const context of utxoContexts) {
const invitationIdentifier = context.utxo.reservedBy;
if (invitationIdentifier && invitationContexts.has(invitationIdentifier)) {
const group = reservedUtxosByInvitation.get(invitationIdentifier) ?? [];
group.push(context);
reservedUtxosByInvitation.set(invitationIdentifier, group);
continue;
}
standaloneUtxos.push(context);
}
const invitationItems = [...reservedUtxosByInvitation.entries()].map(
([invitationIdentifier, reservedContexts]) =>
this.projectInvitationHistory(
invitationContexts.get(invitationIdentifier)!,
reservedContexts,
),
);
const standaloneItems = standaloneUtxos.map((context) =>
this.projectStandaloneUtxo(context),
);
return [...standaloneItems, ...invitationItems].sort((a, b) => {
if (a.source !== b.source) return a.source === "utxo" ? -1 : 1;
return (b.createdAtTimestamp ?? 0) - (a.createdAtTimestamp ?? 0);
});
}
private async buildInvitationContextIndex(): Promise<Map<string, InvitationContext>> {
const contexts = new Map<string, InvitationContext>();
for (const invitation of this.invitations) {
const template =
(await this.engine.getTemplate(invitation.data.templateIdentifier)) ?? null;
contexts.set(invitation.data.invitationIdentifier, {
invitation,
template,
variables: this.extractInvitationVariables(invitation.data),
});
}
return contexts;
}
private async buildWalletMetadataIndex(
allUtxos: UnspentOutputData[],
): Promise<WalletMetadataIndex> {
const scriptHashDataByScriptHash = new Map<string, ScriptHashData>();
const templateIdentifiers = new Set<string>();
for (const utxo of allUtxos) {
templateIdentifiers.add(utxo.templateIdentifier);
}
for (const invitation of this.invitations) {
templateIdentifiers.add(invitation.data.templateIdentifier);
}
for (const templateIdentifier of templateIdentifiers) {
const scriptHashDataList = await this.engine.listScriptHashesForTemplate(templateIdentifier);
for (const scriptHashData of scriptHashDataList) {
scriptHashDataByScriptHash.set(scriptHashData.scriptHash, scriptHashData);
}
}
return { scriptHashDataByScriptHash };
}
private async buildUtxoContext(
utxo: UnspentOutputData,
metadataIndex: WalletMetadataIndex,
): Promise<UtxoContext> {
const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get(utxo.scriptHash);
const templateIdentifier = scriptHashData?.templateIdentifier ?? utxo.templateIdentifier;
const template = (await this.engine.getTemplate(templateIdentifier)) ?? null;
return {
utxo,
scriptHashData,
template,
};
}
private projectInvitationHistory(
context: InvitationContext,
reservedContexts: UtxoContext[],
): WalletHistoryItem {
const invitation = context.invitation.data;
const entityRoles = this.deriveInvitationEntityRoles(context);
const inputs = this.projectInvitationInputs(context, reservedContexts, entityRoles);
const inputUtxoIds = this.listInvitationInputUtxoIds(context, reservedContexts);
const outputs = this.projectInvitationOutputs(
context,
reservedContexts,
entityRoles,
inputUtxoIds,
);
const roles = this.deriveRoles(inputs, outputs);
const valueSatoshis = this.calculateValueSatoshis(inputs, outputs);
return {
id: `inv-${invitation.invitationIdentifier}`,
source: "invitation",
invitationIdentifier: invitation.invitationIdentifier,
createdAtTimestamp: invitation.createdAtTimestamp,
templateIdentifier: invitation.templateIdentifier,
template: context.template?.name ?? "UnknownTemplate",
action: invitation.actionIdentifier,
roles,
description: this.describeInvitation(context, roles[0]),
valueSatoshis,
inputs,
outputs,
};
}
private projectInvitationInputs(
context: InvitationContext,
reservedContexts: UtxoContext[],
entityRoles: Map<string, string[]>,
): WalletHistoryInput[] {
const invitation = context.invitation.data;
const inputs: WalletHistoryInput[] = [];
const reservedByOutpoint = new Map(
reservedContexts.map((context) => [
this.getOutpointKey(
context.utxo.outpointTransactionHash,
context.utxo.outpointIndex,
),
context,
]),
);
for (const commit of invitation.commits) {
for (const [index, input] of (commit.data.inputs ?? []).entries()) {
const txid = this.getInputTxid(input);
const outpointIndex = input.outpointIndex;
if (txid === undefined || outpointIndex === undefined) continue;
const utxoContext = reservedByOutpoint.get(this.getOutpointKey(txid, outpointIndex));
// TODO: Remove this reservation-based filter once Engine/library cleanup releases stale invitation reservations internally.
if (!utxoContext) continue;
const inputIdentifier = input.inputIdentifier;
const role =
input.roleIdentifier ??
this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ??
utxoContext.scriptHashData?.roleIdentifier;
inputs.push({
id: `input-${invitation.invitationIdentifier}-${commit.commitIdentifier}-${index}`,
commitIdentifier: commit.commitIdentifier,
inputIdentifier,
role,
description: this.describeInput(inputIdentifier, context),
valueSatoshis: -BigInt(utxoContext.utxo.valueSatoshis),
outpoint: { txid, index: outpointIndex },
scriptHash: utxoContext.utxo.scriptHash,
});
}
}
return inputs;
}
private projectInvitationOutputs(
context: InvitationContext,
reservedContexts: UtxoContext[],
entityRoles: Map<string, string[]>,
inputUtxoIds: Set<string>,
): WalletHistoryOutput[] {
const invitation = context.invitation.data;
const outputs: WalletHistoryOutput[] = [];
const usedUtxoIds = new Set<string>();
for (const commit of invitation.commits) {
for (const [index, output] of (commit.data.outputs ?? []).entries()) {
const matchingContext = this.findReservedOutputContext(
output,
reservedContexts,
usedUtxoIds,
);
// UTXO-first: committed outputs only matter here if they resolve to a wallet UTXO currently reserved by this invitation.
if (!matchingContext) continue;
const lockingBytecode = this.getOutputLockingBytecodeHex(output) ?? matchingContext.scriptHashData?.lockingBytecode;
const outputIdentifier = output.outputIdentifier ?? matchingContext.scriptHashData?.outputIdentifier ?? matchingContext.utxo.outputIdentifier;
const role =
output.roleIdentifier ??
this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ??
matchingContext.scriptHashData?.roleIdentifier;
const valueSatoshis = output.valueSatoshis !== undefined
? BigInt(output.valueSatoshis)
: BigInt(matchingContext.utxo.valueSatoshis);
usedUtxoIds.add(this.getUtxoId(matchingContext.utxo));
outputs.push({
id: `output-${invitation.invitationIdentifier}-${commit.commitIdentifier}-${index}`,
commitIdentifier: commit.commitIdentifier,
outputIdentifier,
role,
description: this.describeOutput(outputIdentifier, context),
valueSatoshis,
outpoint: {
txid: matchingContext.utxo.outpointTransactionHash,
index: matchingContext.utxo.outpointIndex,
},
lockingBytecode,
scriptHash: matchingContext.utxo.scriptHash,
reserved: true,
});
}
}
for (const reservedContext of reservedContexts) {
if (usedUtxoIds.has(this.getUtxoId(reservedContext.utxo))) continue;
if (inputUtxoIds.has(this.getUtxoId(reservedContext.utxo))) continue;
outputs.push(this.projectUtxoOutput(reservedContext));
}
return outputs;
}
private listInvitationInputUtxoIds(
context: InvitationContext,
reservedContexts: UtxoContext[],
): Set<string> {
const invitationInputUtxoIds = new Set<string>();
const reservedByOutpoint = new Map(
reservedContexts.map((context) => [
this.getOutpointKey(
context.utxo.outpointTransactionHash,
context.utxo.outpointIndex,
),
context,
]),
);
for (const commit of context.invitation.data.commits) {
for (const input of commit.data.inputs ?? []) {
const txid = this.getInputTxid(input);
const outpointIndex = input.outpointIndex;
if (txid === undefined || outpointIndex === undefined) continue;
const utxoContext = reservedByOutpoint.get(this.getOutpointKey(txid, outpointIndex));
if (utxoContext) invitationInputUtxoIds.add(this.getUtxoId(utxoContext.utxo));
}
}
return invitationInputUtxoIds;
}
private findReservedOutputContext(
output: XOInvitationOutput,
reservedContexts: UtxoContext[],
usedUtxoIds: Set<string>,
): UtxoContext | undefined {
const lockingBytecode = this.getOutputLockingBytecodeHex(output);
const scriptHash = lockingBytecode
? this.lockingBytecodeToScriptHash(lockingBytecode)
: undefined;
return reservedContexts.find((context) => {
if (usedUtxoIds.has(this.getUtxoId(context.utxo))) return false;
if (scriptHash && context.utxo.scriptHash === scriptHash) return true;
if (lockingBytecode && context.scriptHashData?.lockingBytecode === lockingBytecode) return true;
if (output.outputIdentifier && context.utxo.outputIdentifier === output.outputIdentifier) return true;
return false;
});
}
private projectStandaloneUtxo(context: UtxoContext): WalletHistoryItem {
const output = this.projectUtxoOutput(context);
const templateIdentifier = context.scriptHashData?.templateIdentifier ?? context.utxo.templateIdentifier;
const role = output.role;
return {
id: `utxo-${context.utxo.outpointTransactionHash}:${context.utxo.outpointIndex}`,
source: "utxo",
templateIdentifier,
template: context.template?.name ?? "UnknownTemplate",
roles: role ? [role] : ["unknown"],
description: output.description,
valueSatoshis: output.valueSatoshis ?? 0n,
inputs: [],
outputs: [output],
};
}
private projectUtxoOutput(context: UtxoContext): WalletHistoryOutput {
const outputIdentifier = context.scriptHashData?.outputIdentifier ?? context.utxo.outputIdentifier;
const role = context.scriptHashData?.roleIdentifier;
return {
id: this.getUtxoId(context.utxo),
outputIdentifier,
role,
description: this.describeOutputFromTemplate(outputIdentifier, context.template, {}),
valueSatoshis: BigInt(context.utxo.valueSatoshis),
outpoint: {
txid: context.utxo.outpointTransactionHash,
index: context.utxo.outpointIndex,
},
lockingBytecode: context.scriptHashData?.lockingBytecode,
scriptHash: context.utxo.scriptHash,
reserved: context.utxo.reservedBy !== undefined,
};
}
private deriveInvitationEntityRoles(context: InvitationContext): Map<string, string[]> {
const invitation = context.invitation.data;
const rolesByEntity = new Map<string, Set<string>>();
const allEntities = new Set(invitation.commits.map((commit) => commit.entityIdentifier));
for (const entityIdentifier of allEntities) {
rolesByEntity.set(entityIdentifier, new Set());
}
for (const commit of invitation.commits) {
const roles = rolesByEntity.get(commit.entityIdentifier) ?? new Set<string>();
for (const input of commit.data.inputs ?? []) {
if (input.roleIdentifier) roles.add(input.roleIdentifier);
}
for (const output of commit.data.outputs ?? []) {
if (output.roleIdentifier) roles.add(output.roleIdentifier);
}
for (const variable of commit.data.variables ?? []) {
if (variable.roleIdentifier) roles.add(variable.roleIdentifier);
}
rolesByEntity.set(commit.entityIdentifier, roles);
}
const action = context.template?.actions?.[invitation.actionIdentifier];
const participantRoles = action?.requirements?.participants
?.map((participant) => participant.role)
.filter((role): role is string => typeof role === "string") ?? [];
const explicitlyFilledRoles = new Set<string>();
for (const roles of rolesByEntity.values()) {
for (const role of roles) explicitlyFilledRoles.add(role);
}
const unfilledParticipantRoles = participantRoles.filter(
(role) => !explicitlyFilledRoles.has(role),
);
const entitiesWithoutRoles = [...rolesByEntity.entries()]
.filter(([, roles]) => roles.size === 0)
.map(([entityIdentifier]) => entityIdentifier);
if (unfilledParticipantRoles.length === 1 && entitiesWithoutRoles.length >= 1) {
const inferredRole = unfilledParticipantRoles[0];
if (inferredRole !== undefined) {
for (const entityIdentifier of entitiesWithoutRoles) {
rolesByEntity.get(entityIdentifier)?.add(inferredRole);
}
}
}
return new Map(
[...rolesByEntity.entries()].map(([entityIdentifier, roles]) => [
entityIdentifier,
[...roles],
]),
);
}
private getFirstEntityRole(
entityRoles: Map<string, string[]>,
entityIdentifier: string,
): string | undefined {
return entityRoles.get(entityIdentifier)?.[0];
}
private deriveRoles(
inputs: WalletHistoryInput[],
outputs: WalletHistoryOutput[],
): string[] {
const roles = new Set<string>();
for (const input of inputs) {
if (input.role) roles.add(input.role);
}
for (const output of outputs) {
if (output.role) roles.add(output.role);
}
return roles.size > 0 ? [...roles] : ["unknown"];
}
private calculateValueSatoshis(
inputs: WalletHistoryInput[],
outputs: WalletHistoryOutput[],
): bigint {
const inputTotal = inputs.reduce((total, input) => total + (input.valueSatoshis ?? 0n), 0n);
const outputTotal = outputs.reduce((total, output) => total + (output.valueSatoshis ?? 0n), 0n);
return inputTotal + outputTotal;
}
private describeInvitation(context: InvitationContext, role?: string): string {
const invitation = context.invitation.data;
const template = context.template;
if (!template) return invitation.actionIdentifier;
const action = template.actions?.[invitation.actionIdentifier];
const transaction = action?.transaction
? template.transactions?.[action.transaction]
: undefined;
const roleData = role ? transaction?.roles?.[role] : undefined;
const descriptionTemplate =
roleData?.description ??
transaction?.description ??
roleData?.name ??
transaction?.name ??
action?.description ??
action?.name ??
invitation.actionIdentifier;
return this.compileDescription(descriptionTemplate, context.variables);
}
private describeInput(inputIdentifier: string | undefined, context: InvitationContext): string {
if (!inputIdentifier) return "Input";
const input = context.template?.inputs?.[inputIdentifier];
return this.compileDescription(input?.description ?? input?.name ?? inputIdentifier, context.variables);
}
private describeOutput(outputIdentifier: string | undefined, context: InvitationContext): string {
return this.describeOutputFromTemplate(outputIdentifier, context.template, context.variables);
}
private describeOutputFromTemplate(
outputIdentifier: string | undefined,
template: XOTemplate | null,
variables: Record<string, XOInvitationVariableValue>,
): string {
if (!outputIdentifier) return "Output";
const output = template?.outputs?.[outputIdentifier];
return this.compileDescription(output?.description ?? output?.name ?? outputIdentifier, variables);
}
private compileDescription(
description: string,
variables: Record<string, XOInvitationVariableValue>,
): string {
try {
return compileCashAssemblyString(description, variables);
} catch {
return this.interpolateSimpleCashAssemblyVariables(description, variables);
}
}
private extractInvitationVariables(
invitation: XOInvitation,
): Record<string, XOInvitationVariableValue> {
const committedVariables = invitation.commits.flatMap((c) => c.data.variables ?? []);
return committedVariables.reduce(
(acc, variable) => {
if (!variable.variableIdentifier) return acc;
acc[variable.variableIdentifier] = variable.value;
return acc;
},
{} as Record<string, XOInvitationVariableValue>,
);
}
private getInputTxid(input: XOInvitationInput): string | undefined {
if (!input.outpointTransactionHash) return undefined;
return input.outpointTransactionHash instanceof Uint8Array
? binToHex(input.outpointTransactionHash)
: String(input.outpointTransactionHash);
}
private getOutputLockingBytecodeHex(output: XOInvitationOutput): string | undefined {
if (output.lockingBytecode === undefined) return undefined;
return typeof output.lockingBytecode === "string"
? output.lockingBytecode
: binToHex(output.lockingBytecode);
}
private getOutpointKey(txid: string, index: number): string {
return `${txid}:${index}`;
}
private getUtxoId(utxo: UnspentOutputData): string {
return `utxo-${utxo.outpointTransactionHash}:${utxo.outpointIndex}`;
}
private lockingBytecodeToScriptHash(lockingBytecode: string): string {
const hash = sha256.hash(hexToBin(lockingBytecode));
return binToHex(hash.reverse());
}
private interpolateSimpleCashAssemblyVariables(
text: string,
variables: Record<string, XOInvitationVariableValue>,
): string {
return text.replace(
/\$\(<([^>]+)>\)/g,
(match, variableIdentifier: string) => {
if (!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)) {
return match;
}
return String(variables[variableIdentifier]);
},
);
}
}

View File

@@ -1,435 +0,0 @@
/**
* Invitation Flow Manager - Manages the collaborative invitation lifecycle.
*
* Responsibilities:
* - Coordinates between local Engine and remote sync-server
* - Subscribes to SSE for real-time updates
* - Tracks invitation state machine
*/
import { EventEmitter } from 'events';
import type { XOInvitation } from '@xo-cash/types';
import { SSESession } from '../utils/sse-client.js';
import type { SyncClient } from './sync-client.js';
import type { WalletController } from '../controllers/wallet-controller.js';
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
/**
* States an invitation can be in.
*/
export type InvitationState =
| 'created' // Just created locally
| 'published' // Published to sync server
| 'pending' // Waiting for other party
| 'ready' // All requirements met, ready to sign
| 'signed' // Signed and ready to broadcast
| 'broadcast' // Transaction broadcast
| 'completed' // Transaction confirmed
| 'expired' // Invitation expired
| 'error'; // Error state
/**
* Tracked invitation with state information.
*/
export interface TrackedInvitation {
/** The invitation data */
invitation: XOInvitation;
/** Current state */
state: InvitationState;
/** SSE session for updates (if subscribed) */
sseSession?: SSESession;
/** Timestamp when tracking started */
trackedAt: number;
/** Last update timestamp */
lastUpdatedAt: number;
/** Error message if in error state */
error?: string;
}
/**
* Events emitted by the invitation flow manager.
*/
export interface InvitationFlowEvents {
'invitation-created': (invitation: XOInvitation) => void;
'invitation-updated': (invitationId: string, invitation: XOInvitation) => void;
'invitation-state-changed': (invitationId: string, state: InvitationState) => void;
'error': (invitationId: string, error: Error) => void;
}
/**
* Manages the invitation workflow.
*/
export class InvitationFlowManager extends EventEmitter {
/** Map of tracked invitations by ID */
private trackedInvitations: Map<string, TrackedInvitation> = new Map();
/** Wallet controller reference */
private walletController: WalletController;
/** Sync client reference */
private syncClient: SyncClient;
/**
* Creates a new invitation flow manager.
* @param walletController - Wallet controller instance
* @param syncClient - Sync client instance
*/
constructor(walletController: WalletController, syncClient: SyncClient) {
super();
this.walletController = walletController;
this.syncClient = syncClient;
}
// ============================================================================
// Invitation Tracking
// ============================================================================
/**
* Starts tracking an invitation.
* @param invitation - Invitation to track
* @param initialState - Initial state (default: 'created')
*/
track(invitation: XOInvitation, initialState: InvitationState = 'created'): TrackedInvitation {
const tracked: TrackedInvitation = {
invitation,
state: initialState,
trackedAt: Date.now(),
lastUpdatedAt: Date.now(),
};
this.trackedInvitations.set(invitation.invitationIdentifier, tracked);
return tracked;
}
/**
* Gets a tracked invitation by ID.
* @param invitationId - Invitation ID
* @returns Tracked invitation or undefined
*/
get(invitationId: string): TrackedInvitation | undefined {
return this.trackedInvitations.get(invitationId);
}
/**
* Gets all tracked invitations.
* @returns Array of tracked invitations
*/
getAll(): TrackedInvitation[] {
return Array.from(this.trackedInvitations.values());
}
/**
* Updates the state of a tracked invitation.
* @param invitationId - Invitation ID
* @param state - New state
*/
private updateState(invitationId: string, state: InvitationState): void {
const tracked = this.trackedInvitations.get(invitationId);
if (tracked) {
tracked.state = state;
tracked.lastUpdatedAt = Date.now();
this.emit('invitation-state-changed', invitationId, state);
}
}
/**
* Updates a tracked invitation with new data.
* @param invitation - Updated invitation
*/
private updateInvitation(invitation: XOInvitation): void {
const tracked = this.trackedInvitations.get(invitation.invitationIdentifier);
if (tracked) {
tracked.invitation = invitation;
tracked.lastUpdatedAt = Date.now();
this.emit('invitation-updated', invitation.invitationIdentifier, invitation);
}
}
/**
* Stops tracking an invitation.
* @param invitationId - Invitation ID
*/
untrack(invitationId: string): void {
const tracked = this.trackedInvitations.get(invitationId);
if (tracked?.sseSession) {
tracked.sseSession.close();
}
this.trackedInvitations.delete(invitationId);
}
// ============================================================================
// Flow Operations
// ============================================================================
/**
* Creates a new invitation and starts tracking it.
* @param templateIdentifier - Template ID
* @param actionIdentifier - Action ID
* @returns Created and tracked invitation
*/
async createInvitation(
templateIdentifier: string,
actionIdentifier: string,
): Promise<TrackedInvitation> {
// Create invitation via wallet controller
const invitation = await this.walletController.createInvitation(
templateIdentifier,
actionIdentifier,
);
// Track the invitation
const tracked = this.track(invitation, 'created');
this.emit('invitation-created', invitation);
return tracked;
}
/**
* Publishes an invitation to the sync server.
* @param invitationId - Invitation ID to publish
* @returns Updated tracked invitation
*/
async publishInvitation(invitationId: string): Promise<TrackedInvitation> {
const tracked = this.trackedInvitations.get(invitationId);
if (!tracked) {
throw new Error(`Invitation not found: ${invitationId}`);
}
try {
// Post to sync server
await this.syncClient.postInvitation(tracked.invitation);
// Update state
this.updateState(invitationId, 'published');
return tracked;
} catch (error) {
tracked.state = 'error';
tracked.error = error instanceof Error ? error.message : String(error);
this.emit('error', invitationId, error instanceof Error ? error : new Error(String(error)));
throw error;
}
}
/**
* Subscribes to SSE updates for an invitation.
* @param invitationId - Invitation ID to subscribe to
*/
async subscribeToUpdates(invitationId: string): Promise<void> {
const tracked = this.trackedInvitations.get(invitationId);
if (!tracked) {
throw new Error(`Invitation not found: ${invitationId}`);
}
// Close existing SSE session if any
if (tracked.sseSession) {
tracked.sseSession.close();
}
// Create new SSE session
const sseUrl = this.syncClient.getSSEUrl(invitationId);
tracked.sseSession = await SSESession.from(sseUrl, {
method: 'GET',
headers: {
'Accept': 'text/event-stream',
},
onMessage: (event) => {
this.handleSSEMessage(invitationId, event);
},
onError: (error) => {
console.error(`SSE error for ${invitationId}:`, error);
this.emit('error', invitationId, error instanceof Error ? error : new Error(String(error)));
},
onConnected: () => {
console.log(`SSE connected for invitation: ${invitationId}`);
},
onDisconnected: () => {
console.log(`SSE disconnected for invitation: ${invitationId}`);
},
attemptReconnect: true,
persistent: true,
retryDelay: 3000,
});
// Update state to pending (waiting for updates)
this.updateState(invitationId, 'pending');
}
/**
* Handles an SSE message for an invitation.
* @param invitationId - Invitation ID
* @param event - SSE event
*/
private handleSSEMessage(invitationId: string, event: { data: string; event?: string }): void {
try {
// Parse the event data
const parsed = JSON.parse(event.data) as { topic?: string; data?: unknown };
if (event.event === 'invitation-updated' || parsed.topic === 'invitation-updated') {
// Decode the invitation data (handles ExtJSON)
const invitationData = decodeExtendedJsonObject(parsed.data ?? parsed);
const invitation = invitationData as XOInvitation;
// Update tracked invitation
this.updateInvitation(invitation);
// Check if all requirements are met
this.checkInvitationState(invitationId);
}
} catch (error) {
console.error(`Error parsing SSE message for ${invitationId}:`, error);
}
}
/**
* Checks and updates the state of an invitation based on its data.
* @param invitationId - Invitation ID to check
*/
private async checkInvitationState(invitationId: string): Promise<void> {
const tracked = this.trackedInvitations.get(invitationId);
if (!tracked) return;
try {
// Check missing requirements
const missing = await this.walletController.getMissingRequirements(tracked.invitation);
// If no missing inputs/outputs, it's ready to sign
const hasNoMissingInputs = !missing.inputs || missing.inputs.length === 0;
const hasNoMissingOutputs = !missing.outputs || missing.outputs.length === 0;
if (hasNoMissingInputs && hasNoMissingOutputs) {
this.updateState(invitationId, 'ready');
}
} catch (error) {
// Ignore errors during state check
console.error(`Error checking invitation state: ${error}`);
}
}
/**
* Imports an invitation from the sync server.
* @param invitationId - Invitation ID to import
* @returns Tracked invitation
*/
async importInvitation(invitationId: string): Promise<TrackedInvitation> {
// Fetch from sync server
const invitation = await this.syncClient.getInvitation(invitationId);
if (!invitation) {
throw new Error(`Invitation not found on server: ${invitationId}`);
}
// Track the invitation
const tracked = this.track(invitation, 'pending');
return tracked;
}
/**
* Accepts an invitation (joins as a participant).
* @param invitationId - Invitation ID to accept
* @returns Updated tracked invitation
*/
async acceptInvitation(invitationId: string): Promise<TrackedInvitation> {
const tracked = this.trackedInvitations.get(invitationId);
if (!tracked) {
throw new Error(`Invitation not found: ${invitationId}`);
}
// Accept via wallet controller
const updatedInvitation = await this.walletController.acceptInvitation(tracked.invitation);
this.updateInvitation(updatedInvitation);
return tracked;
}
/**
* Appends data to an invitation.
* @param invitationId - Invitation ID
* @param params - Data to append
* @returns Updated tracked invitation
*/
async appendToInvitation(
invitationId: string,
params: Parameters<WalletController['appendInvitation']>[1],
): Promise<TrackedInvitation> {
const tracked = this.trackedInvitations.get(invitationId);
if (!tracked) {
throw new Error(`Invitation not found: ${invitationId}`);
}
// Append via wallet controller
const updatedInvitation = await this.walletController.appendInvitation(
tracked.invitation,
params,
);
this.updateInvitation(updatedInvitation);
// Publish update to sync server
await this.syncClient.updateInvitation(updatedInvitation);
return tracked;
}
/**
* Signs an invitation.
* @param invitationId - Invitation ID
* @returns Updated tracked invitation
*/
async signInvitation(invitationId: string): Promise<TrackedInvitation> {
const tracked = this.trackedInvitations.get(invitationId);
if (!tracked) {
throw new Error(`Invitation not found: ${invitationId}`);
}
// Sign via wallet controller
const signedInvitation = await this.walletController.signInvitation(tracked.invitation);
this.updateInvitation(signedInvitation);
this.updateState(invitationId, 'signed');
// Publish signed invitation to sync server
await this.syncClient.updateInvitation(signedInvitation);
return tracked;
}
/**
* Broadcasts the transaction for an invitation.
* @param invitationId - Invitation ID
* @returns Transaction hash
*/
async broadcastTransaction(invitationId: string): Promise<string> {
const tracked = this.trackedInvitations.get(invitationId);
if (!tracked) {
throw new Error(`Invitation not found: ${invitationId}`);
}
// Execute action (broadcast)
const txHash = await this.walletController.executeAction(tracked.invitation, {
broadcastTransaction: true,
});
this.updateState(invitationId, 'broadcast');
// Close SSE session since we're done
if (tracked.sseSession) {
tracked.sseSession.close();
delete tracked.sseSession;
}
return txHash;
}
/**
* Cleans up all resources.
*/
destroy(): void {
// Close all SSE sessions
for (const tracked of this.trackedInvitations.values()) {
if (tracked.sseSession) {
tracked.sseSession.close();
}
}
this.trackedInvitations.clear();
}
}

699
src/services/invitation.ts Normal file
View File

@@ -0,0 +1,699 @@
import type {
AcceptInvitationParameters,
AppendInvitationParameters,
Engine,
GetSpendableResourcesParameters,
} from "@xo-cash/engine";
import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
import type {
XOInvitation,
XOInvitationCommit,
XOInvitationInput,
XOInvitationOutput,
XOInvitationVariable,
XOInvitationVariableValue,
} from "@xo-cash/types";
import type { UnspentOutputData } from "@xo-cash/state";
import {
binToHex,
encodeTransaction,
generateTransaction,
hashTransaction,
hexToBin,
} from "@bitauth/libauth";
import type { SSEvent } from "../utils/sse-client.js";
import type { SyncServer } from "../utils/sync-server.js";
import type { BaseStorage } from "./storage.js";
import type { BlockchainService } from "./electrum.js";
import { EventEmitter } from "../utils/event-emitter.js";
import { decodeExtendedJsonObject } from "../utils/ext-json.js";
import { compileCashAssemblyString } from "@xo-cash/engine";
export type InvitationEventMap = {
"invitation-updated": XOInvitation;
"invitation-status-changed": string;
error: Error;
};
export type InvitationDependencies = {
syncServer: SyncServer;
storage: BaseStorage;
engine: Engine;
electrum: BlockchainService;
};
export class Invitation extends EventEmitter<InvitationEventMap> {
/**
* Create an invitation and start the SSE Session required for it.
*/
static async create(
invitation: XOInvitation | string,
dependencies: InvitationDependencies,
): Promise<Invitation> {
// If the invitation is a string, its probably an invitation identifier.
// We will try to find the data then just call the create method again, but this time with the data.
if (typeof invitation === "string") {
// Try to get the invitation from the storage
const invitationFromStorage = await dependencies.storage.get(invitation);
if (invitationFromStorage) {
return this.create(invitationFromStorage, dependencies);
}
// Try to get the invitation from the sync server
const invitationFromSyncServer =
await dependencies.syncServer.getInvitation(invitation);
if (
invitationFromSyncServer &&
invitationFromSyncServer.invitationIdentifier === invitation
) {
return this.create(invitationFromSyncServer, dependencies);
}
// We cant find it. Throw an error.
throw new Error(
`Invitation not found in local or remote storage: ${invitation}`,
);
}
const template = await dependencies.engine.getTemplate(
invitation.templateIdentifier,
);
if (!template) {
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
const invitationInstance = new Invitation(engineInvitation, dependencies);
// Start the invitation and its tracking
await invitationInstance.start();
return invitationInstance;
}
/**
* The invitation data.
*/
public data: XOInvitation = {
invitationIdentifier: "",
commits: [],
createdAtTimestamp: 0,
templateIdentifier: "",
actionIdentifier: "",
};
/**
* The sync server instance.
*/
private syncServer: SyncServer;
/**
* The engine instance.
*/
private engine: Engine;
/**
* The storage instance.
* TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid)
*/
private storage: BaseStorage;
private electrum: BlockchainService;
/**
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
*/
public status: string = "unknown";
/**
* Create an invitation and start the SSE Session required for it.
*/
constructor(invitation: XOInvitation, dependencies: InvitationDependencies) {
super();
this.data = invitation;
this.engine = dependencies.engine;
this.syncServer = dependencies.syncServer;
this.storage = dependencies.storage;
this.electrum = dependencies.electrum;
// Create a listerner for the messages from the SSE Session (sync server)
this.syncServer.on("message", this.handleSSEMessage.bind(this));
}
/**
* Start the invitation - Connect sync server and download latest invitation data.
*/
async start(): Promise<void> {
try {
// Connect to the sync server and get the invitation (in parallel)
const [_, invitation] = await Promise.all([
this.syncServer.connect(),
this.syncServer.getInvitation(this.data.invitationIdentifier),
]);
// There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits
const sseCommits = this.data.commits;
// Merge the commits
const combinedCommits = this.mergeCommits(
sseCommits,
invitation?.commits ?? [],
);
// Set the invitation data with the combined commits
this.data = { ...this.data, ...invitation, commits: combinedCommits };
// Store the invitation in the storage
await this.storage.set(this.data.invitationIdentifier, this.data);
// Publish the invitation to the sync server
this.publishInvitation(this.data);
// Compute and emit initial status
await this.updateStatus();
} catch (err) {
// console.error(`Error starting invitation, could not connect to sync server or get invitation`, err);
// Emit the error event. We might want to throw? but we need a better way of handling errors in the invitation system because we need the invitation to successfully initialize.
this.emit("error", err instanceof Error ? err : new Error(String(err)));
}
}
/**
* Handle an SSE message.
*
* TODO: Invitation should sync up the initial data (top level) then everything after that should be the commits. This makes it easier to merge as we go instead of just having to overwrite the entire invitation.
*/
private handleSSEMessage(event: SSEvent): void {
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
if (data.topic === "invitation-updated") {
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
return;
}
// Filter out commits that already exist (probably a faster way to do this. This is n^2)
const newCommits = this.mergeCommits(
this.data.commits,
invitation.commits,
);
// Set the new commits
this.data = { ...this.data, commits: newCommits };
// Calculate the new status of the invitation (fire-and-forget; handler is sync)
this.updateStatus().catch(() => {});
// Emit the updated event
this.emit("invitation-updated", this.data);
}
}
/**
* 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
* @param initial - The initial commits
* @param additional - The additional commits
* @returns The merged commits
*/
private mergeCommits(
initial: XOInvitationCommit[],
additional: XOInvitationCommit[],
): XOInvitationCommit[] {
// Create a map of the initial commits
const initialMap = new Map<string, XOInvitationCommit>();
for (const commit of initial) {
initialMap.set(commit.commitIdentifier, commit);
}
// Merge the additional commits
// TODO: They are immutable? So, it should be fine to "ovewrite" existing commits as it should be the same data, right?
for (const commit of additional) {
initialMap.set(commit.commitIdentifier, commit);
}
// Return the merged commits
return Array.from(initialMap.values());
}
/**
* Compute the invitation status as a single word: expired | complete | ready | signed | actionable | unknown.
*/
private async computeStatus(): Promise<string> {
try {
return await this.computeStatusInternal();
} catch (err) {
return `error (${err instanceof Error ? err.message : String(err)})`;
}
}
/**
* Internal status computation: returns a single word.
* NOTE: This could be a Enum-like object as well. May be a nice improvement. - DO NOT USE TS ENUM, THEY ARENT NATIVELY SUPPORTED IN NODE.JS
* - complete: we have broadcast this invitation
* - expired: any commit has expired
* - ready: no missing requirements and we have signed (ready to broadcast)
* - signed: we have signed but there are still missing parts (waiting for others)
* - actionable: you can provide data (missing requirements and/or you can sign)
* - unknown: template/action not found or error
*/
private async computeStatusInternal(): Promise<string> {
let missingReqs;
try {
missingReqs = await this.engine.listMissingRequirements(this.data);
} catch {
return "unknown";
}
const hasMissing =
(missingReqs.variables?.length ?? 0) > 0 ||
(missingReqs.inputs?.length ?? 0) > 0 ||
(missingReqs.outputs?.length ?? 0) > 0 ||
(missingReqs.roles !== undefined &&
Object.keys(missingReqs.roles).length > 0);
const hasSignedCommit = this.hasSignedCommitInInvitation();
if (!hasMissing) {
const transactionHash = await this.deriveTransactionHash();
if (
transactionHash &&
(await this.electrum.hasSeenTransaction(transactionHash))
) {
return "complete";
}
}
if (hasInvitationExpired(this.data)) {
return "expired";
}
if (!hasMissing && hasSignedCommit) {
return "ready";
}
if (hasMissing && hasSignedCommit) {
return "signed";
}
return "actionable";
}
private hasSignedCommitInInvitation(): boolean {
for (const commit of this.data.commits) {
for (const input of commit.data.inputs ?? []) {
if (!input.mergesWith) continue;
if (input.unlockingBytecode === undefined) continue;
return true;
}
}
return false;
}
/**
* Build the transaction to get the TX hash, this is so we can check its status on the blockchain.
* TODO: Remove this. This should be part of the engine. The code is virtually identical to `executeAction` except it doesnt throw if the invitation is expired
* @returns txHash or undefined if the transaction could not be built
*/
private async deriveTransactionHash(): Promise<string | undefined> {
try {
const template = await this.engine.getTemplate(
this.data.templateIdentifier,
);
if (!template) return undefined;
const mergedCommit = mergeInvitationCommits(this.data, template);
if (!mergedCommit) return undefined;
const transactionResult = generateTransaction({
version: mergedCommit.transactionVersion,
locktime: mergedCommit.transactionLocktime,
// @ts-expect-error merged inputs include additional invitation metadata.
inputs: mergedCommit.inputs,
// @ts-expect-error merged outputs include additional invitation metadata.
outputs: mergedCommit.outputs,
});
if (!transactionResult.success) return undefined;
const transactionHex = binToHex(
encodeTransaction(transactionResult.transaction),
);
const rawHash: unknown = hashTransaction(hexToBin(transactionHex));
if (typeof rawHash === "string") return rawHash;
if (rawHash instanceof Uint8Array) return binToHex(rawHash);
return undefined;
} catch {
return undefined;
}
}
/**
* Update the status of the invitation and emit the new single-word status.
*/
private async updateStatus(): Promise<void> {
const status = await this.computeStatus();
this.status = status;
this.emit("invitation-status-changed", status);
}
/**
* Accept the invitation
*/
async accept(acceptParams?: AcceptInvitationParameters): Promise<void> {
// Accept the invitation
this.data = await this.engine.acceptInvitation(this.data, acceptParams);
// Sync the invitation to the sync server
this.publishInvitation(this.data);
// Update the status of the invitation
await this.updateStatus();
}
/**
* Sign the invitation
*/
async sign(): Promise<void> {
// Sign the invitation
const signedInvitation = await this.engine.signInvitation(this.data);
// Publish the signed invitation to the sync server
this.publishInvitation(signedInvitation);
// Store the signed invitation in the storage
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
this.data = signedInvitation;
// Update the status of the invitation
await this.updateStatus();
}
/**
* Broadcast the invitation.
* @returns The transaction hash returned by the network after broadcast.
*/
async broadcast(): Promise<string> {
const txHash = await this.engine.executeAction(this.data, {
broadcastTransaction: true,
});
await this.updateStatus();
return String(txHash);
}
// ============================================================================
// Append Operations
// ============================================================================
/**
* Append a commit to the invitation
*/
async append(data: AppendInvitationParameters): Promise<void> {
// Append the commit to the invitation
this.data = await this.engine.appendInvitation(this.data, data);
// Sync the invitation to the sync server
await this.publishInvitation(this.data);
// Store the invitation in the storage
await this.storage.set(this.data.invitationIdentifier, this.data);
// Update the status of the invitation
await this.updateStatus();
}
/**
* Add inputs to the invitation
*/
async addInputs(inputs: XOInvitationInput[]): Promise<void> {
// Append the inputs to the invitation
await this.append({ inputs });
// Sync the invitation to the sync server
await this.publishInvitation(this.data);
}
/**
* Generate the locking bytecode for the invitation
* TODO: Find out if this has side-effects or needs special handling
*/
async generateLockingBytecode(
outputIdentifier: string,
roleIdentifier?: string,
): Promise<string> {
return this.engine.generateLockingBytecode(
this.data.templateIdentifier,
outputIdentifier,
roleIdentifier,
);
}
async addOutputs(outputs: XOInvitationOutput[]): Promise<void> {
// Add the outputs to the invitation
await this.append({ outputs });
// Sync the invitation to the sync server
await this.publishInvitation(this.data);
}
async addVariables(variables: XOInvitationVariable[]): Promise<void> {
// Add the variables to the invitation
await this.append({ variables });
// Sync the invitation to the sync server
await this.publishInvitation(this.data);
}
async findSuitableResources(
options: Partial<GetSpendableResourcesParameters> = {},
): 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 ?? "",
// };
// There are disagreements around whether all spendables should be returned from getSpendableResources.
// I had a fix merged in, but it got overwritten. So, im just going to get all of them manually and go around
// The engine's expectations.
// To do this, we are going to grab all out templates
const templates = await this.engine.listImportedTemplates();
// For each template, we need to create a 2d array of all the outputs
const outputs = templates.flatMap(template => {
return Object.keys(template.outputs).map(output => {
const templateIdentifier = generateTemplateIdentifier(template);
return {
templateIdentifier,
outputIdentifier: output,
};
});
});
// then, for each output, we need to get the spendable resources
const spendableResources = await Promise.all(outputs.map(output => {
return this.engine.getSpendableResources(this.data, {
templateIdentifier: output.templateIdentifier,
outputIdentifier: output.outputIdentifier,
});
}));
const unspentOutputs = spendableResources.flatMap(resource => resource.unspentOutputs);
// Update the status of the invitation
await this.updateStatus();
// Return the suitable resources
return unspentOutputs;
}
// ============================================================================
// Getters and Queries
// ============================================================================
/**
* Get the missing requirements for the invitation
*/
async getMissingRequirements() {
return this.engine.listMissingRequirements(this.data);
}
/**
* Get the requirements for the invitation
*/
async getRequirements() {
return this.engine.listRequirements(this.data);
}
/**
* Get the available roles for the invitation
*/
async getAvailableRoles() {
return this.engine.listAvailableRoles(this.data);
}
/**
* Get the starting actions for the invitation
*/
async getStartingActions() {
return this.engine.listStartingActions(this.data.templateIdentifier);
}
/**
* Get the locking bytecode for the invitation
*/
async getLockingBytecode(
outputIdentifier: string,
roleIdentifier?: string,
): Promise<string> {
return this.engine.generateLockingBytecode(
this.data.templateIdentifier,
outputIdentifier,
roleIdentifier,
);
}
/**
* Get the sats out for the invitation
* TODO: Clean up this function. Why is it so big? Can obviously make it 2 functions instead of recursive, but still...
*/
async getSatsOut(outputIdentifier?: string): Promise<bigint> {
// If an output identifier is provided, find all outputs with that identifier, and its valueSatoshis identifier back to the variables
if (outputIdentifier) {
// Get the valueSatoshis identifier from the template
const template = await this.engine.getTemplate(
this.data.templateIdentifier,
);
if (!template) {
throw new Error(
`Template not found: ${this.data.templateIdentifier} when trying to get sats out for output: ${outputIdentifier}`,
);
}
const output = template.outputs[outputIdentifier];
if (!output) {
throw new Error(
`Output not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`,
);
}
const valueSatoshisIdentifier = output.valueSatoshis;
if (!valueSatoshisIdentifier) {
throw new Error(
`Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`,
);
}
// Create a list of all the variables from the commits
const variables = this.data.commits.flatMap(
(c) => c.data?.variables ?? [],
);
// Create a dictionary of the variables
const formattedVariables = variables.reduce(
(acc, v) => {
acc[v.variableIdentifier ?? ""] = v.value;
return acc;
},
{} as Record<string, XOInvitationVariableValue>,
);
// Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us)
const valueSatoshis = await compileCashAssemblyString(
String(valueSatoshisIdentifier),
formattedVariables,
);
// Return the value satoshis as a bigint
// TODO: Check this of a vulnerability or crash - I assume there might be one if someone made the `valueSatoshis` a malicious expression
return BigInt(valueSatoshis);
}
// If we didnt get an output identifier, go through the action outputs and sum the valueSatoshis
const action = this.data.actionIdentifier;
if (!action) {
throw new Error(
`Action not found: ${this.data.actionIdentifier} when trying to get sats out for output: ${outputIdentifier}`,
);
}
// Get the template
const template = await this.engine.getTemplate(
this.data.templateIdentifier,
);
if (!template) {
throw new Error(
`Template not found: ${this.data.templateIdentifier} when trying to get sats out for action: ${action}`,
);
}
// Get the transaction ID from the action
const transactionID = template.actions[action]?.transaction;
if (!transactionID) {
throw new Error(
`Transactions not found: ${action} in template: ${this.data.templateIdentifier}`,
);
}
// Get the transaction from the template
const transaction = template.transactions?.[transactionID];
if (!transaction) {
throw new Error(
`Transaction not found: ${transactionID} in template: ${this.data.templateIdentifier}`,
);
}
// Get the outputs from the transaction
const outputs = transaction.outputs;
if (!outputs) {
throw new Error(
`Outputs not found: ${transactionID} in template: ${this.data.templateIdentifier}`,
);
}
// Create a value to store the cummulative total of the outputs
let totalSats = 0n;
// Iterate through the outputs and sum the valueSatoshis
for (const output of outputs) {
if (typeof output === "string") {
totalSats += await this.getSatsOut(output);
} else {
totalSats += await this.getSatsOut(output.output);
}
}
return totalSats;
}
}

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

@@ -0,0 +1,217 @@
import { EventEmitter } from '../utils/event-emitter.js';
import {
type RatesEventMap,
} from '../utils/rates/base-rates.js';
import { RatesOracle } from '../utils/rates/rates-oracles.js';
import { SettingsService } from './settings.js';
/**
* Event map emitted by {@link RatesService}.
*/
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 settings: SettingsService;
private readonly ratesByPair = new Map<string, CachedRate>();
private unsubscribeFromAdapter: (() => void) | null = null;
private started = false;
constructor(adapter: RatesAdapter, settings: SettingsService) {
super();
this.adapter = adapter;
this.settings = settings;
}
/**
* Creates a rates service.
*
* If no adapter is passed, this defaults to the Oracle-backed adapter.
*/
public static async create(
settings: SettingsService,
adapter?: RatesAdapter,
): Promise<RatesService> {
const resolvedAdapter = adapter ?? (await RatesOracle.from(undefined, settings));
return new RatesService(resolvedAdapter, settings);
}
/**
* 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());
}
/**
* Lists available market pairs in NUMERATOR/DENOMINATOR format.
*/
public async listPairs(): Promise<Set<string>> {
return this.adapter.listPairs();
}
/**
* Returns the fiat currency currently configured in settings.
*/
public getConfiguredCurrency(): string {
return this.settings.getCurrency();
}
/**
* Handles normalized updates from the underlying adapter.
*/
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()}`;
}
}

194
src/services/settings.ts Normal file
View File

@@ -0,0 +1,194 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { EventEmitter } from "../utils/event-emitter.js";
import { getSettingsPath } from "../utils/paths.js";
/**
* Supported persisted settings keys.
*/
export type SettingsData = {
"default-mnemonic"?: string;
currency: string;
};
/**
* Event payloads emitted by {@link SettingsService}.
*/
export type SettingsServiceEventMap = {
"settings-updated": {
key: keyof SettingsData;
value: string | undefined;
settings: SettingsData;
};
};
/**
* Runtime defaults for settings that should always exist in memory.
*/
const DEFAULT_SETTINGS: SettingsData = {
currency: "USD",
};
/**
* Handles loading, migrating, and persisting wallet settings.
*
* The backing file is `~/.config/xo-cli/.wallet`. Historically it stored a raw
* mnemonic reference string. This service migrates that legacy format to JSON:
* `{ "default-mnemonic": "<value>", "currency": "USD" }`.
*/
export class SettingsService extends EventEmitter<SettingsServiceEventMap> {
private readonly settingsPath: string;
private settings: SettingsData;
/**
* Creates a new settings service instance.
*
* @param settingsPath - Optional custom settings file path (useful for tests)
*/
constructor(settingsPath: string = getSettingsPath()) {
super();
this.settingsPath = settingsPath;
this.settings = this.loadSettings();
}
/**
* Returns the current settings snapshot.
*/
public getSettings(): SettingsData {
return { ...this.settings };
}
/**
* Returns the currently selected default mnemonic reference.
*/
public getDefaultMnemonic(): string | undefined {
return this.settings["default-mnemonic"];
}
/**
* Updates the default mnemonic reference and persists it to disk.
*/
public setDefaultMnemonic(mnemonicRef: string): void {
const normalizedMnemonicRef = mnemonicRef.trim();
if (normalizedMnemonicRef.length === 0) {
throw new Error("default-mnemonic cannot be empty");
}
this.settings["default-mnemonic"] = normalizedMnemonicRef;
this.persistSettings();
this.emit("settings-updated", {
key: "default-mnemonic",
value: normalizedMnemonicRef,
settings: this.getSettings(),
});
}
/**
* Returns the selected fiat currency code (ISO-like uppercase).
*/
public getCurrency(): string {
return this.settings.currency;
}
/**
* Updates the selected fiat currency and persists it to disk.
*/
public setCurrency(currencyCode: string): void {
const normalizedCurrency = this.normalizeCurrency(currencyCode);
if (this.settings.currency === normalizedCurrency) {
return;
}
this.settings.currency = normalizedCurrency;
this.persistSettings();
this.emit("settings-updated", {
key: "currency",
value: normalizedCurrency,
settings: this.getSettings(),
});
}
/**
* Reads and normalizes the settings file from disk.
*
* If the file contains the old legacy format (raw mnemonic string), the
* migrated JSON shape is written back immediately.
*/
private loadSettings(): SettingsData {
if (!existsSync(this.settingsPath)) {
return { ...DEFAULT_SETTINGS };
}
const rawContents = readFileSync(this.settingsPath, "utf8").trim();
if (rawContents.length === 0) {
return { ...DEFAULT_SETTINGS };
}
try {
const parsed = JSON.parse(rawContents);
const normalized = this.normalizeSettings(parsed);
return normalized;
} catch {
const migrated = this.normalizeSettings({
"default-mnemonic": rawContents,
});
this.persistSettings(migrated);
return migrated;
}
}
/**
* Writes the given settings object to disk as pretty JSON.
*
* @param nextSettings - Optional explicit value, defaults to in-memory state
*/
private persistSettings(nextSettings?: SettingsData): void {
if (nextSettings) {
this.settings = nextSettings;
}
writeFileSync(
this.settingsPath,
`${JSON.stringify(this.settings, null, 2)}\n`,
"utf8",
);
}
/**
* Coerces unknown input into a safe settings object.
*/
private normalizeSettings(input: unknown): SettingsData {
const normalized: SettingsData = {
...DEFAULT_SETTINGS,
};
if (!input || typeof input !== "object") {
return normalized;
}
const maybeMnemonic = (input as Record<string, unknown>)["default-mnemonic"];
if (typeof maybeMnemonic === "string" && maybeMnemonic.trim().length > 0) {
normalized["default-mnemonic"] = maybeMnemonic.trim();
}
const maybeCurrency = (input as Record<string, unknown>).currency;
if (typeof maybeCurrency === "string" && maybeCurrency.trim().length > 0) {
normalized.currency = this.normalizeCurrency(maybeCurrency);
}
return normalized;
}
/**
* Ensures currency values stay uppercase and non-empty.
*/
private normalizeCurrency(currencyCode: string): string {
const normalizedCurrency = currencyCode.trim().toUpperCase();
if (normalizedCurrency.length === 0) {
throw new Error("currency cannot be empty");
}
return normalizedCurrency;
}
}

242
src/services/storage.ts Normal file
View File

@@ -0,0 +1,242 @@
import Database from "better-sqlite3";
import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js";
/**
* This is not an actual storage adapter that we want to make use of. This storage adapter is a stop-gap while the engine is under development.
* At the time of writing the storage adapter, the engine provided no way to read data about your currenty invitations, so that is where this is coming in.
* Its providing a Developer facing way to store/read the invitation data and then we can just import them into the engine whenever we want to interact with an invitation.
*/
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;
}
/**
* SQLite Database Storage Adapter.
*/
export class Storage extends BaseStorage {
static async create(dbPath: string): Promise<Storage> {
// Create the database
const database = new Database(dbPath);
// Create the storage table if it doesn't exist
database
.prepare(
"CREATE TABLE IF NOT EXISTS storage (key TEXT PRIMARY KEY, value TEXT)",
)
.run();
return new Storage(database, "");
}
constructor(
private readonly database: Database.Database,
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> {
// Encode the extended json object
const encodedValue = encodeExtendedJson(value);
// Insert or replace the value into the database with full key (including basePath)
const fullKey = this.getFullKey(key);
this.database
.prepare("INSERT OR REPLACE INTO storage (key, value) VALUES (?, ?)")
.run(fullKey, encodedValue);
}
/**
* Get all key-value pairs from this storage namespace (shallow only - no nested children)
*/
async all(): Promise<{ key: string; value: any }[]> {
let query = "SELECT key, value FROM storage";
const params: any[] = [];
if (this.basePath) {
// Filter by basePath prefix
query += " WHERE key LIKE ?";
params.push(`${this.basePath}.%`);
}
// Get all the rows from the database
const rows = (await this.database.prepare(query).all(...params)) as {
key: string;
value: any;
}[];
// Filter for shallow results (only direct children)
const filteredRows = rows.filter((row) => {
const strippedKey = this.stripBasePath(row.key);
// Only include keys that don't have additional dots (no deeper nesting)
return !strippedKey.includes(".");
});
// Decode the extended json objects and strip basePath from keys
return filteredRows.map((row) => ({
key: this.stripBasePath(row.key),
value: decodeExtendedJson(row.value),
}));
}
async get(key: string): Promise<any> {
// Get the row from the database using full key
const fullKey = this.getFullKey(key);
const row = (await this.database
.prepare("SELECT value FROM storage WHERE key = ?")
.get(fullKey)) as { value: any };
// Return null if not found
if (!row) return null;
// Decode the extended json object
return decodeExtendedJson(row.value);
}
async remove(key: string): Promise<void> {
// Delete using full key
const fullKey = this.getFullKey(key);
this.database.prepare("DELETE FROM storage WHERE key = ?").run(fullKey);
}
async clear(): Promise<void> {
if (this.basePath) {
// Clear only items under this namespace
this.database
.prepare("DELETE FROM storage WHERE key LIKE ?")
.run(`${this.basePath}.%`);
} else {
// Clear everything
this.database.prepare("DELETE FROM storage").run();
}
}
child(key: string): Storage {
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.
*
* TODO: Move this somewhere else. There is no reason for this to be in the main codebase. We should put this stricly in the tests beacuse that were its actually being used.
* Ideally, we would provide these kind of generic fills as part of our packages somewhere, but these interfaces dont fit our current design.
*/
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,162 +0,0 @@
/**
* Sync Server Client - HTTP client for sync-server communication.
*
* Handles:
* - Creating/updating invitations on the server
* - Fetching invitations by ID
* - ExtJSON encoding/decoding for data transfer
*/
import type { XOInvitation } from '@xo-cash/types';
import { encodeExtendedJson, decodeExtendedJson } from '../utils/ext-json.js';
/**
* Response from the sync server.
*/
export interface SyncServerResponse<T> {
success: boolean;
data?: T;
error?: string;
}
/**
* HTTP client for sync-server communication.
*/
export class SyncClient {
/** Base URL of the sync server */
private baseUrl: string;
/**
* Creates a new sync client.
* @param baseUrl - Base URL of the sync server (e.g., http://localhost:3000)
*/
constructor(baseUrl: string) {
// Remove trailing slash if present
this.baseUrl = baseUrl.replace(/\/$/, '');
}
/**
* Makes an HTTP request to the sync server.
* @param method - HTTP method
* @param path - Request path
* @param body - Optional request body
* @returns Response data
*/
private async request<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
const options: RequestInit = {
method,
headers,
};
if (body !== undefined) {
// Encode body using ExtJSON for proper BigInt and Uint8Array serialization
options.body = encodeExtendedJson(body);
}
const response = await fetch(url, options);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
const responseText = await response.text();
// Return empty object if no response body
if (!responseText) {
return {} as T;
}
// Decode response using ExtJSON
return decodeExtendedJson(responseText) as T;
}
// ============================================================================
// Invitation Operations
// ============================================================================
/**
* Posts an invitation to the sync server (create or update).
* @param invitation - Invitation to post
* @returns The stored invitation
*/
async postInvitation(invitation: XOInvitation): Promise<XOInvitation> {
return this.request<XOInvitation>('POST', '/invitations', invitation);
}
/**
* Gets an invitation from the sync server.
* @param invitationIdentifier - Invitation ID to fetch
* @returns The invitation or undefined if not found
*/
async getInvitation(invitationIdentifier: string): Promise<XOInvitation | undefined> {
try {
// Use query parameter for GET request (can't have body)
const response = await this.request<XOInvitation>(
'GET',
`/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`
);
return response;
} catch (error) {
// Return undefined if not found (404)
if (error instanceof Error && error.message.includes('404')) {
return undefined;
}
throw error;
}
}
/**
* Updates an invitation on the sync server.
* @param invitation - Updated invitation
* @returns The updated invitation
*/
async updateInvitation(invitation: XOInvitation): Promise<XOInvitation> {
// Uses the same POST endpoint which handles both create and update
return this.postInvitation(invitation);
}
// ============================================================================
// Health Check
// ============================================================================
/**
* Checks if the sync server is healthy.
* @returns True if server is healthy
*/
async isHealthy(): Promise<boolean> {
try {
const response = await this.request<{ status: string }>('GET', '/health');
return response.status === 'ok';
} catch {
return false;
}
}
/**
* Gets the base URL of the sync server.
*/
getBaseUrl(): string {
return this.baseUrl;
}
/**
* Gets the SSE endpoint URL for an invitation.
* @param invitationId - Invitation ID to subscribe to
* @returns SSE endpoint URL
*/
getSSEUrl(invitationIdentifier: string): string {
return `${this.baseUrl}/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`;
}
}

View File

@@ -4,27 +4,28 @@
*/
import React from 'react';
import { Box, Text, useApp, useInput } from 'ink';
import { Box, Text, useApp } from 'ink';
import { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
import type { WalletController } from '../controllers/wallet-controller.js';
import type { InvitationController } from '../controllers/invitation-controller.js';
import { InputLayerProvider, useBlockableInput } from './hooks/useInputLayer.js';
import type { AppConfig } from '../app.js';
import { colors, logoSmall } from './theme.js';
// Screen imports (will be created)
// Screen imports
import { SeedInputScreen } from './screens/SeedInput.js';
import { WalletStateScreen } from './screens/WalletState.js';
import { TemplateListScreen } from './screens/TemplateList.js';
import { ActionWizardScreen } from './screens/ActionWizard.js';
import { InvitationScreen } from './screens/Invitation.js';
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
import { InvitationScreen } from './screens/invitations/InvitationScreen.js';
import { TransactionScreen } from './screens/Transaction.js';
import { MessageDialog } from './components/Dialog.js';
/**
* Props for the App component.
*/
interface AppProps {
walletController: WalletController;
invitationController: InvitationController;
config: AppConfig;
}
/**
@@ -78,21 +79,7 @@ function StatusBar(): React.ReactElement {
* Dialog overlay component for modals.
*/
function DialogOverlay(): React.ReactElement | null {
const { dialog, setDialog } = useDialog();
useInput((input, key) => {
if (!dialog?.visible) return;
if (key.return || input === 'y' || input === 'Y') {
if (dialog.type === 'confirm' && dialog.onConfirm) {
dialog.onConfirm();
} else {
dialog.onCancel?.();
}
} else if (key.escape || input === 'n' || input === 'N') {
dialog.onCancel?.();
}
}, { isActive: dialog?.visible ?? false });
const { dialog } = useDialog();
if (!dialog?.visible) return null;
@@ -109,28 +96,14 @@ function DialogOverlay(): React.ReactElement | null {
width="100%"
height="100%"
>
<Box
flexDirection="column"
borderStyle="double"
borderColor={borderColor}
paddingX={2}
paddingY={1}
width={60}
>
<Text color={borderColor} bold>
{dialog.type === 'error' ? '✗ Error' :
dialog.type === 'confirm' ? '? Confirm' :
' Info'}
</Text>
<Box marginY={1}>
<Text wrap="wrap">{dialog.message}</Text>
</Box>
<Text color={colors.textMuted}>
{dialog.type === 'confirm'
? 'Press Y to confirm, N or ESC to cancel'
: 'Press Enter or ESC to close'}
</Text>
</Box>
<MessageDialog
title={dialog.type === 'error' ? '✗ Error' :
dialog.type === 'confirm' ? '? Confirm' :
' Info'}
message={dialog.message}
onClose={dialog.onCancel ?? (() => {})}
type={dialog.type as 'error' | 'info' | 'success'}
/>
</Box>
);
}
@@ -141,16 +114,13 @@ function DialogOverlay(): React.ReactElement | null {
function MainContent(): React.ReactElement {
const { exit } = useApp();
const { goBack, canGoBack } = useNavigation();
const { dialog } = useDialog();
const { screen } = useNavigation();
const appContext = useAppContext();
// Global keybindings (disabled when dialog is shown)
useInput((input, key) => {
// Don't handle global keys when dialog is shown
if (dialog?.visible) return;
// Quit on 'q' or Ctrl+C
if (input === 'q' || (key.ctrl && input === 'c')) {
// Global keybindings — auto-blocked when any dialog/overlay is capturing input.
useBlockableInput((input, key) => {
// Quit on Ctrl+C
if (key.ctrl && input === 'c') {
appContext.exit();
exit();
}
@@ -158,6 +128,14 @@ function MainContent(): React.ReactElement {
// Go back on Escape
if (key.escape && canGoBack) {
goBack();
// If we went back to the seed input screen, remove the current engine
// TODO: This was to support going back to seed input then re-opening your seed, but there is a bug in the engine which prevents it from closing the current
// storage instance, giving us an error about the database already being opened.
if (screen === 'seed-input') {
appContext.appService?.engine.stop();
appContext.appService = null;
}
}
});
@@ -181,24 +159,24 @@ function MainContent(): React.ReactElement {
* Main App component.
* Sets up providers and renders the main content.
*/
export function App({ walletController, invitationController }: AppProps): React.ReactElement {
export function App({ config }: AppProps): React.ReactElement {
const { exit } = useApp();
// Cleanup will be handled by React when components unmount
const handleExit = () => {
// Cleanup controllers if needed
walletController.stop();
exit();
};
return (
<AppProvider
walletController={walletController}
invitationController={invitationController}
config={config}
onExit={handleExit}
>
<NavigationProvider initialScreen="seed-input">
<MainContent />
</NavigationProvider>
<InputLayerProvider>
<NavigationProvider initialScreen="seed-input">
<MainContent />
</NavigationProvider>
</InputLayerProvider>
</AppProvider>
);
}

View File

@@ -0,0 +1,188 @@
import React, { useEffect, useId, useMemo, useState } from "react";
import { Box, Text } from "ink";
import { ScrollableList, type ListItemData } from "./List.js";
import TextInput from "./TextInput.js";
import { DialogWrapper } from "./Dialog.js";
import { useInputLayer, useLayeredInput } from "../hooks/useInputLayer.js";
import { colors } from "../theme.js";
/**
* Props for the currency selection dialog.
*/
interface CurrencySelectionDialogProps {
/** Current wallet currency from persisted settings. */
currentCurrency: string;
/** Available fiat numerator symbols that can be paired with BCH. */
currencies: string[];
/** True while the dialog is loading available pairs. */
isLoading: boolean;
/** Optional loading/error message for pair discovery. */
errorMessage: string | null;
/** Called when the user chooses a currency and confirms. */
onSelectCurrency: (currencyCode: string) => void;
/** Called when the dialog should close without applying changes. */
onCancel: () => void;
}
/**
* Currency picker dialog.
*
* UX requirements:
* - Arrow keys move the highlighted item.
* - Typing immediately filters results.
* - Enter applies current selection.
* - Escape closes without saving.
*/
export function CurrencySelectionDialog({
currentCurrency,
currencies,
isLoading,
errorMessage,
onSelectCurrency,
onCancel,
}: CurrencySelectionDialogProps): React.ReactElement {
const layerId = useId();
const [filterText, setFilterText] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
// Mount this as a capturing input layer so background screens stop handling keys.
useInputLayer(layerId);
/**
* Applies the currently selected filtered result.
*/
const applySelection = (): void => {
const selectedCurrency = filteredCurrencies[selectedIndex];
if (!selectedCurrency) {
return;
}
onSelectCurrency(selectedCurrency);
};
useLayeredInput(layerId, (_input, key) => {
if (key.escape) {
onCancel();
return;
}
if (key.upArrow) {
setSelectedIndex((prev) =>
prev <= 0 ? Math.max(filteredCurrencies.length - 1, 0) : prev - 1,
);
return;
}
if (key.downArrow) {
setSelectedIndex((prev) =>
filteredCurrencies.length === 0
? 0
: prev >= filteredCurrencies.length - 1
? 0
: prev + 1,
);
return;
}
});
/**
* Filter currencies as the user types.
*/
const filteredCurrencies = useMemo(() => {
const normalizedFilter = filterText.trim().toUpperCase();
if (!normalizedFilter) {
return currencies;
}
return currencies.filter((currencyCode) =>
currencyCode.toUpperCase().includes(normalizedFilter),
);
}, [currencies, filterText]);
/**
* Keep selected index valid whenever filtering shrinks the result set.
*/
useEffect(() => {
if (filteredCurrencies.length === 0) {
setSelectedIndex(0);
return;
}
if (selectedIndex >= filteredCurrencies.length) {
setSelectedIndex(filteredCurrencies.length - 1);
}
}, [filteredCurrencies, selectedIndex]);
/**
* When the dialog opens or the list updates, default to current currency.
*/
useEffect(() => {
if (filterText.trim().length > 0) {
return;
}
const currentIndex = filteredCurrencies.findIndex(
(currencyCode) => currencyCode.toUpperCase() === currentCurrency.toUpperCase(),
);
if (currentIndex >= 0) {
setSelectedIndex(currentIndex);
}
}, [filteredCurrencies, currentCurrency, filterText]);
const listItems: ListItemData<string>[] = filteredCurrencies.map(
(currencyCode) => ({
key: currencyCode,
label: currencyCode,
description:
currencyCode.toUpperCase() === currentCurrency.toUpperCase()
? "(current)"
: undefined,
value: currencyCode,
}),
);
return (
<DialogWrapper title="Select Fiat Currency" borderColor={colors.info} width={64}>
<Text color={colors.textMuted}>
Available BCH quote pairs are loaded from the live rates adapter.
</Text>
<Box marginTop={1}>
<Text color={colors.primary}>Filter:</Text>
</Box>
<Box borderStyle="single" borderColor={colors.focus} paddingX={1}>
<TextInput
value={filterText}
onChange={setFilterText}
onSubmit={() => applySelection()}
placeholder="Type currency code (e.g. USD, AUD)..."
focus
/>
</Box>
<Box marginTop={1} flexDirection="column">
{isLoading ? (
<Text color={colors.textMuted}>Loading available pairs...</Text>
) : errorMessage ? (
<Text color={colors.error}>{errorMessage}</Text>
) : (
<ScrollableList
items={listItems}
selectedIndex={selectedIndex}
onSelect={setSelectedIndex}
onActivate={() => applySelection()}
focus={false}
maxVisible={8}
emptyMessage="No BCH quote pairs match this filter."
/>
)}
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted}>
Type to filter navigate Enter apply Esc cancel
</Text>
</Box>
</DialogWrapper>
);
}

View File

@@ -2,10 +2,11 @@
* Dialog components for modals, confirmations, and input dialogs.
*/
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import React, { useId, useRef, useState } from 'react';
import { Box, Text, measureElement } from 'ink';
import TextInput from './TextInput.js';
import { colors } from '../theme.js';
import { useInputLayer, useLayeredInput } from '../hooks/useInputLayer.js';
/**
* Base dialog wrapper props.
@@ -19,30 +20,69 @@ interface DialogWrapperProps {
children: React.ReactNode;
/** Dialog width */
width?: number;
/** Dialog Background Color */
backgroundColor?: string;
}
/**
* Base dialog wrapper component.
*/
function DialogWrapper({
export function DialogWrapper({
title,
borderColor = colors.primary,
children,
width = 60,
backgroundColor = colors.bg,
}: DialogWrapperProps): React.ReactElement {
const ref = useRef<any>(null);
const [height, setHeight] = useState<number | null>(null);
// measure after render
React.useLayoutEffect(() => {
if (ref.current) {
const { height } = measureElement(ref.current);
setHeight(height);
}
}, [children, title, width]);
return (
<Box
flexDirection="column"
borderStyle="double"
borderColor={borderColor}
paddingX={2}
paddingY={1}
width={width}
>
<Text color={borderColor} bold>{title}</Text>
<Box marginY={1} flexDirection="column">
{children}
<Box flexDirection="column">
{/* Opaque backing layer */}
{height !== null && (
<Box
position="absolute"
flexDirection="column"
width={width}
height={height}
backgroundColor={backgroundColor}
>
{Array.from({ length: height }).map((_, i) => (
<Text key={i} backgroundColor={backgroundColor}>
{' '.repeat(width)}
</Text>
))}
</Box>
)}
{/* Actual dialog */}
<Box
ref={ref}
flexDirection="column"
borderStyle="double"
borderColor={borderColor}
paddingX={2}
paddingY={1}
width={width}
backgroundColor={backgroundColor}
>
<Text color={borderColor} bold>
{title}
</Text>
<Box marginY={1} flexDirection="column">
{children}
</Box>
</Box>
</Box>
);
}
@@ -79,15 +119,17 @@ export function InputDialog({
onCancel,
isActive = true,
}: InputDialogProps): React.ReactElement {
const layerId = useId();
const [value, setValue] = useState(initialValue);
useInput((input, key) => {
if (!isActive) return;
// Auto-capture input when this dialog is mounted.
useInputLayer(layerId);
useLayeredInput(layerId, (_input, key) => {
if (key.escape) {
onCancel();
}
}, { isActive });
});
const handleSubmit = (val: string) => {
onSubmit(val);
@@ -144,11 +186,13 @@ export function ConfirmDialog({
confirmLabel = 'Yes',
cancelLabel = 'No',
}: ConfirmDialogProps): React.ReactElement {
const layerId = useId();
const [selected, setSelected] = useState<'confirm' | 'cancel'>('confirm');
useInput((input, key) => {
if (!isActive) return;
// Auto-capture input when this dialog is mounted.
useInputLayer(layerId);
useLayeredInput(layerId, (input, key) => {
if (key.leftArrow || key.rightArrow || key.tab) {
setSelected(prev => prev === 'confirm' ? 'cancel' : 'confirm');
} else if (key.return) {
@@ -162,7 +206,7 @@ export function ConfirmDialog({
} else if (input === 'y' || input === 'Y') {
onConfirm();
}
}, { isActive });
});
return (
<DialogWrapper title={title} borderColor={colors.warning}>
@@ -216,13 +260,16 @@ export function MessageDialog({
type = 'info',
isActive = true,
}: MessageDialogProps): React.ReactElement {
useInput((input, key) => {
if (!isActive) return;
const layerId = useId();
// Auto-capture input when this dialog is mounted.
useInputLayer(layerId);
useLayeredInput(layerId, (_input, key) => {
if (key.return || key.escape) {
onClose();
}
}, { isActive });
});
const borderColor = type === 'error' ? colors.error :
type === 'success' ? colors.success :

View File

@@ -4,7 +4,7 @@
import React from 'react';
import { Box, Text } from 'ink';
import TextInput from 'ink-text-input';
import TextInput from './TextInput.js';
import { colors } from '../theme.js';
/**

View File

@@ -1,13 +1,462 @@
/**
* Selectable list component with keyboard navigation.
* List components with keyboard navigation.
*
* Provides:
* - ScrollableList: Full-featured list with grouping, filtering, and custom rendering
* - List: Basic selectable list (legacy, kept for backward compatibility)
* - SimpleList: Non-selectable list for display only
*/
import React from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from './TextInput.js';
import { colors } from '../theme.js';
// =============================================================================
// Types
// =============================================================================
/**
* List item type.
* Base list item data interface.
* Used by ScrollableList for item data.
*/
export interface ListItemData<T = unknown> {
/** Unique key for the item */
key: string;
/** Display label */
label: string;
/** Optional secondary text/description */
description?: string;
/** Optional value associated with item */
value?: T;
/** Whether item is disabled (can't be activated) */
disabled?: boolean;
/** Whether item should be hidden (not rendered, skipped in navigation) */
hidden?: boolean;
/** Custom color name for the item (semantic: 'info', 'warning', 'success', 'error', 'muted') */
color?: string;
/** Group identifier for grouping items */
group?: string;
}
/**
* Group definition for organizing list items.
*/
export interface ListGroup {
/** Unique group identifier */
id: string;
/** Optional header text to display above group */
label?: string;
/** Whether to show a separator after this group */
separator?: boolean;
}
/**
* Props for ScrollableList component.
*/
export interface ScrollableListProps<T> {
/** Array of list items */
items: ListItemData<T>[];
/** Currently selected index */
selectedIndex: number;
/** Handler called when selection changes */
onSelect: (index: number) => void;
/** Handler called when item is activated (Enter key) */
onActivate?: (item: ListItemData<T>, index: number) => void;
/** Whether the list is focused for keyboard input */
focus?: boolean;
/** Maximum number of visible items (enables scrolling). Default: 10 */
maxVisible?: number;
/** Whether to show a border around the list */
border?: boolean;
/** Optional label/title for the list */
label?: string;
/** Message to show when list is empty */
emptyMessage?: string;
/** Group definitions for organizing items */
groups?: ListGroup[];
/** Whether to enable filtering/search */
filterable?: boolean;
/** Placeholder text for filter input */
filterPlaceholder?: string;
/** Handler called when filter text changes */
onFilterChange?: (filter: string) => void;
/** Custom render function for items */
renderItem?: (item: ListItemData<T>, isSelected: boolean, isFocused: boolean) => React.ReactNode;
/** Whether to wrap around when navigating past ends */
wrapNavigation?: boolean;
/** Whether to show the scroll position indicator (e.g., "1-5 of 10"). Default: true */
showScrollIndicator?: boolean;
}
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Map semantic color names to theme colors.
*/
function getColorFromName(colorName: string | undefined): string {
switch (colorName) {
case 'info':
return colors.info as string;
case 'warning':
return colors.warning as string;
case 'success':
return colors.success as string;
case 'error':
return colors.error as string;
case 'muted':
return colors.textMuted as string;
case 'accent':
return colors.accent as string;
case 'primary':
return colors.primary as string;
default:
return colors.text as string;
}
}
/**
* Find the next valid (non-hidden) index in a direction.
*
* @param items - Array of items
* @param currentIndex - Current index
* @param direction - Direction to search (1 for down, -1 for up)
* @param wrap - Whether to wrap around at ends
* @returns Next valid index, or current if none found
*/
function findNextValidIndex<T>(
items: ListItemData<T>[],
currentIndex: number,
direction: 1 | -1,
wrap: boolean = false
): number {
if (items.length === 0) return 0;
// Count visible items
const visibleIndices = items
.map((item, idx) => ({ item, idx }))
.filter(({ item }) => !item.hidden)
.map(({ idx }) => idx);
if (visibleIndices.length === 0) return currentIndex;
// Find current position in visible indices
const currentVisiblePos = visibleIndices.indexOf(currentIndex);
if (currentVisiblePos === -1) {
// Current index is hidden, find nearest visible
return visibleIndices[0] ?? 0;
}
// Calculate next position
let nextVisiblePos = currentVisiblePos + direction;
if (wrap) {
if (nextVisiblePos < 0) nextVisiblePos = visibleIndices.length - 1;
if (nextVisiblePos >= visibleIndices.length) nextVisiblePos = 0;
} else {
nextVisiblePos = Math.max(0, Math.min(visibleIndices.length - 1, nextVisiblePos));
}
return visibleIndices[nextVisiblePos] ?? 0;
}
/**
* Calculate scroll window for visible items.
*/
function calculateScrollWindow(
selectedIndex: number,
totalItems: number,
maxVisible: number
): { startIndex: number; endIndex: number } {
const halfWindow = Math.floor(maxVisible / 2);
let startIndex = Math.max(0, selectedIndex - halfWindow);
let endIndex = Math.min(totalItems, startIndex + maxVisible);
// Adjust start if we're near the end
if (endIndex - startIndex < maxVisible) {
startIndex = Math.max(0, endIndex - maxVisible);
}
return { startIndex, endIndex };
}
// =============================================================================
// ScrollableList Component
// =============================================================================
/**
* Full-featured scrollable list with grouping, filtering, and custom rendering.
*/
export function ScrollableList<T>({
items,
selectedIndex,
onSelect,
onActivate,
focus = true,
maxVisible = 10,
border = false,
label,
emptyMessage = 'No items',
groups,
filterable = false,
filterPlaceholder = 'Filter...',
onFilterChange,
renderItem,
wrapNavigation = false,
showScrollIndicator = true,
}: ScrollableListProps<T>): React.ReactElement {
// Filter state
const [filterText, setFilterText] = useState('');
const [isFiltering, setIsFiltering] = useState(false);
// Filter items based on filter text
const filteredItems = useMemo(() => {
if (!filterText.trim()) return items;
const lowerFilter = filterText.toLowerCase();
return items.map(item => ({
...item,
hidden: item.hidden || !item.label.toLowerCase().includes(lowerFilter),
}));
}, [items, filterText]);
// Get visible (non-hidden) items count
const visibleCount = useMemo(() =>
filteredItems.filter(item => !item.hidden).length,
[filteredItems]
);
// Handle keyboard navigation
useInput((input, key) => {
if (!focus) return;
// Toggle filter mode with '/'
if (filterable && input === '/' && !isFiltering) {
setIsFiltering(true);
return;
}
// Exit filter mode with Escape
if (isFiltering && key.escape) {
setIsFiltering(false);
setFilterText('');
onFilterChange?.('');
return;
}
// Don't process navigation when filtering
if (isFiltering) return;
// Navigation
if (key.upArrow || input === 'k') {
const newIndex = findNextValidIndex(filteredItems, selectedIndex, -1, wrapNavigation);
onSelect(newIndex);
} else if (key.downArrow || input === 'j') {
const newIndex = findNextValidIndex(filteredItems, selectedIndex, 1, wrapNavigation);
onSelect(newIndex);
} else if (key.return && onActivate) {
const item = filteredItems[selectedIndex];
if (item && !item.disabled && !item.hidden) {
onActivate(item, selectedIndex);
}
}
}, { isActive: focus && !isFiltering });
// Handle filter text change
const handleFilterChange = useCallback((value: string) => {
setFilterText(value);
onFilterChange?.(value);
}, [onFilterChange]);
// Handle filter submit (Enter in filter mode)
const handleFilterSubmit = useCallback(() => {
setIsFiltering(false);
}, []);
// Render a single item
const renderListItem = (item: ListItemData<T>, index: number) => {
if (item.hidden) return null;
const isSelected = index === selectedIndex;
const isFocused = focus && isSelected;
// Use custom render if provided
if (renderItem) {
return (
<Box key={item.key}>
{renderItem(item, isSelected, isFocused)}
</Box>
);
}
// Default rendering
const itemColor = isFocused
? colors.focus
: item.disabled
? colors.textMuted
: getColorFromName(item.color);
return (
<Box key={item.key}>
<Text
color={itemColor as string}
bold={isSelected}
dimColor={item.disabled}
>
{isFocused ? '▸ ' : ' '}
{item.label}
</Text>
{item.description && (
<Text color={colors.textMuted} dimColor> {item.description}</Text>
)}
</Box>
);
};
// Get all visible (non-hidden) items with their original indices
const visibleItemsWithIndices = useMemo(() => {
return filteredItems
.map((item, idx) => ({ item, idx }))
.filter(({ item }) => !item.hidden);
}, [filteredItems]);
// Calculate scroll window based on visible items only
const scrollWindow = useMemo(() => {
// Find position of selected index in visible items
const selectedVisiblePos = visibleItemsWithIndices.findIndex(({ idx }) => idx === selectedIndex);
const effectivePos = selectedVisiblePos >= 0 ? selectedVisiblePos : 0;
const halfWindow = Math.floor(maxVisible / 2);
let start = Math.max(0, effectivePos - halfWindow);
let end = Math.min(visibleItemsWithIndices.length, start + maxVisible);
// Adjust start if we're near the end
if (end - start < maxVisible) {
start = Math.max(0, end - maxVisible);
}
return { start, end };
}, [visibleItemsWithIndices, selectedIndex, maxVisible]);
// Get the slice of visible items to display
const displayItems = useMemo(() => {
return visibleItemsWithIndices.slice(scrollWindow.start, scrollWindow.end);
}, [visibleItemsWithIndices, scrollWindow]);
// Render content based on grouping
const renderContent = () => {
// Show empty message if no visible items
if (visibleCount === 0) {
return <Text color={colors.textMuted} dimColor>{emptyMessage}</Text>;
}
// If groups are defined, render grouped (but still respect maxVisible)
if (groups && groups.length > 0) {
// Get display item indices for quick lookup
const displayIndices = new Set(displayItems.map(({ idx }) => idx));
return (
<Box flexDirection="column">
{groups.map((group, groupIndex) => {
// Filter to only items that are in this group AND in the display window
const groupItems = displayItems.filter(({ item }) => item.group === group.id);
if (groupItems.length === 0) return null;
return (
<Box key={group.id} flexDirection="column">
{/* Group label */}
{group.label && (
<Text color={colors.textMuted} bold>{group.label}</Text>
)}
{/* Group items */}
{groupItems.map(({ item, idx }) => renderListItem(item, idx))}
{/* Separator - only show if there are more groups with items after this */}
{group.separator && groupIndex < groups.length - 1 && (
<Box marginY={1}>
<Text color={colors.textMuted}></Text>
</Box>
)}
</Box>
);
})}
</Box>
);
}
// No grouping - render with scroll window
return (
<Box flexDirection="column">
{displayItems.map(({ item, idx }) => renderListItem(item, idx))}
</Box>
);
};
const borderColor = focus ? colors.focus : colors.border;
const content = (
<Box flexDirection="column">
{/* Filter input */}
{filterable && isFiltering && (
<Box marginBottom={1}>
<Text color={colors.info}>Filter: </Text>
<TextInput
value={filterText}
onChange={handleFilterChange}
onSubmit={handleFilterSubmit}
placeholder={filterPlaceholder}
focus={isFiltering}
/>
</Box>
)}
{/* List content */}
{renderContent()}
{/* Scroll indicator */}
{showScrollIndicator && visibleCount > maxVisible && (
<Text color={colors.textMuted} dimColor>
{scrollWindow.start + 1}-{scrollWindow.end} of {visibleCount}
</Text>
)}
{/* Filter hint */}
{filterable && !isFiltering && (
<Text color={colors.textMuted} dimColor>
Press '/' to filter
</Text>
)}
</Box>
);
return (
<Box flexDirection="column">
{label && <Text color={colors.primary} bold>{label}</Text>}
{border ? (
<Box
borderStyle="single"
borderColor={borderColor}
paddingX={1}
flexDirection="column"
>
{content}
</Box>
) : content}
</Box>
);
}
// =============================================================================
// Legacy List Component (kept for backward compatibility)
// =============================================================================
/**
* Legacy list item type.
* @deprecated Use ListItemData instead
*/
export interface ListItem<T = unknown> {
/** Unique key for the item */
@@ -23,7 +472,8 @@ export interface ListItem<T = unknown> {
}
/**
* Props for the List component.
* Props for the legacy List component.
* @deprecated Use ScrollableListProps instead
*/
interface ListProps<T> {
/** List items */
@@ -46,6 +496,7 @@ interface ListProps<T> {
/**
* Selectable list with keyboard navigation.
* @deprecated Use ScrollableList instead
*/
export function List<T>({
items,
@@ -132,8 +583,12 @@ export function List<T>({
);
}
// =============================================================================
// SimpleList Component
// =============================================================================
/**
* Simple inline list for displaying items without selection.
* Props for SimpleList component.
*/
interface SimpleListProps {
items: string[];
@@ -141,6 +596,9 @@ interface SimpleListProps {
bullet?: string;
}
/**
* Simple inline list for displaying items without selection.
*/
export function SimpleList({
items,
label,

View File

@@ -116,9 +116,15 @@ interface LoadingProps {
}
export function Loading({ message = 'Loading...' }: LoadingProps): React.ReactElement {
// Simple spinner using Ink's spinner component
const Spinner = require('ink-spinner').default;
// Was using ink-spinner, but its not updated for react 19.
// Just putting nothing here for now
const Spinner = (props: any) => {
return (
<></>
);
};
return (
<Box>
<Text color={colors.primary}>

View File

@@ -0,0 +1,202 @@
/**
* QR Code component for displaying scannable QR codes in the terminal.
*
* Uses the lower-half-block character (▄) exclusively for rendering. The top
* half of each cell is controlled via backgroundColor and the bottom half via
* the foreground color. This avoids the sub-pixel seams that occur when mixing
* different Unicode block characters (█, ▀, ▄, space) across adjacent rows.
*/
import React, { useMemo } from 'react';
import { Box, Text } from 'ink';
import QRCodeLib from 'qrcode';
import { DialogWrapper } from './Dialog.js';
import { colors } from '../theme.js';
/** Color used for light (background) QR modules. */
const LIGHT = 'white';
/** Color used for dark (data) QR modules. Must match the dialog/terminal bg. */
const DARK = colors.bg as string;
/** Default quiet zone size in modules (QR spec recommends 4, 2 is usually sufficient). */
const QUIET_ZONE = 2;
/**
* A run of consecutive characters in a rendered QR row that share the
* same foreground/background color pair.
*/
interface ColorSpan {
/** The repeated ▄ characters for this span. */
chars: string;
/** Foreground color (controls the bottom half of each cell). */
fg: string;
/** Background color (controls the top half of each cell). */
bg: string;
}
/**
* Props for the QRCode component.
*/
interface QRCodeProps {
/** The data to encode in the QR code. */
value: string;
/** Whether to wrap the QR code in a DialogWrapper. */
dialog?: boolean;
/** Dialog title (only used when dialog is true). Defaults to "QR Code". */
dialogTitle?: string;
/** Whether to display the raw encoded value as copyable text above the QR code. */
showValue?: boolean;
/** Optional subtitle to display below the QR code. */
subtitle?: React.ReactNode;
}
/**
* Generates the QR code module matrix with a quiet zone border.
*
* @param value - The string to encode.
* @param quietZone - Number of light-module rows/columns to add around the QR data.
* @returns A 2D array where `true` means dark module and `false` means light module.
*/
function generateMatrix(value: string, quietZone: number = QUIET_ZONE): boolean[][] {
const qr = QRCodeLib.create(value, { errorCorrectionLevel: 'M' });
const { size, data } = qr.modules;
const totalSize = size + quietZone * 2;
const matrix: boolean[][] = [];
for (let row = 0; row < totalSize; row++) {
const matrixRow: boolean[] = [];
for (let col = 0; col < totalSize; col++) {
const qrRow = row - quietZone;
const qrCol = col - quietZone;
const insideData = qrRow >= 0 && qrRow < size && qrCol >= 0 && qrCol < size;
// Quiet zone modules are always light (false).
matrixRow.push(insideData ? data[qrRow * size + qrCol] === 1 : false);
}
matrix.push(matrixRow);
}
return matrix;
}
/**
* Converts a pair of module rows into an array of {@link ColorSpan}s.
*
* Every cell uses the `▄` (lower half block) character. The foreground color
* paints the bottom half and the backgroundColor paints the top half, giving
* us artifact-free rendering with a single glyph.
*
* Consecutive cells that share the same color pair are merged into one span
* to keep the element count low.
*
* @param matrix - The full module matrix.
* @param row - The index of the top row in the pair (the bottom row is row + 1).
* @returns An array of color spans for this terminal line.
*/
function buildRowSpans(matrix: boolean[][], row: number): ColorSpan[] {
const width = matrix[0]?.length ?? 0;
const spans: ColorSpan[] = [];
for (let col = 0; col < width; col++) {
const topDark = matrix[row]?.[col] ?? false;
const bottomDark = matrix[row + 1]?.[col] ?? false;
// ▄ lower-half block: foreground = bottom color, backgroundColor = top color
const fg = bottomDark ? DARK : LIGHT;
const bg = topDark ? DARK : LIGHT;
const last = spans[spans.length - 1];
if (last && last.fg === fg && last.bg === bg) {
last.chars += '▄';
} else {
spans.push({ chars: '▄', fg, bg });
}
}
return spans;
}
/**
* Renders the full module matrix into an array of span-arrays, one per
* terminal row (each covering two QR module rows).
*
* @param matrix - The 2D dark/light module matrix from {@link generateMatrix}.
*/
function renderMatrix(matrix: boolean[][]): ColorSpan[][] {
const rows: ColorSpan[][] = [];
const height = matrix.length;
for (let row = 0; row < height; row += 2) {
rows.push(buildRowSpans(matrix, row));
}
return rows;
}
/**
* Displays a scannable QR code in the terminal.
*
* Supports optional dialog wrapping via the `dialog` prop and an optional
* copyable text display of the encoded value via `showValue`.
*
* @example
* ```tsx
* // Minimal usage
* <QRCode value="bitcoincash:qr..." />
*
* // Inside a dialog with the raw value shown
* <QRCode value="bitcoincash:qr..." dialog dialogTitle="Receive Address" showValue />
* ```
*/
export function QRCode({
value,
dialog = false,
dialogTitle = 'QR Code',
showValue = false,
subtitle = null,
}: QRCodeProps): React.ReactElement {
const { rows, moduleCount } = useMemo(() => {
const matrix = generateMatrix(value);
return {
rows: renderMatrix(matrix),
moduleCount: matrix[0]?.length ?? 0,
};
}, [value]);
const qrContent = (
<Box flexDirection="column" alignItems="center">
{showValue && (
<Box marginBottom={1} width={moduleCount}>
<Text color={colors.textMuted} wrap="wrap">{value}</Text>
</Box>
)}
<Box flexDirection="column">
{rows.map((spans, i) => (
<Text key={i}>
{spans.map((span, j) => (
<Text key={j} color={span.fg} backgroundColor={span.bg}>
{span.chars}
</Text>
))}
</Text>
))}
</Box>
</Box>
);
if (dialog) {
const dialogWidth = Math.max(moduleCount + 8, 40);
return (
<DialogWrapper title={dialogTitle} borderColor={colors.primary} width={dialogWidth}>
{qrContent}
{subtitle}
</DialogWrapper>
);
}
return qrContent;
}

View File

@@ -0,0 +1,216 @@
import React, {useState, useEffect} from 'react';
import {Text, useInput} from 'ink';
import chalk from 'chalk';
import type {Except} from 'type-fest';
export type Props = {
/**
* Text to display when `value` is empty.
*/
readonly placeholder?: string;
/**
* Listen to user's input. Useful in case there are multiple input components
* at the same time and input must be "routed" to a specific component.
*/
readonly focus?: boolean; // eslint-disable-line react/boolean-prop-naming
/**
* Replace all chars and mask the value. Useful for password inputs.
*/
readonly mask?: string;
/**
* Whether to show cursor and allow navigation inside text input with arrow keys.
*/
readonly showCursor?: boolean; // eslint-disable-line react/boolean-prop-naming
/**
* Highlight pasted text
*/
readonly highlightPastedText?: boolean; // eslint-disable-line react/boolean-prop-naming
/**
* Value to display in a text input.
*/
readonly value: string;
/**
* Function to call when value updates.
*/
readonly onChange: (value: string) => void;
/**
* Function to call when `Enter` is pressed, where first argument is a value of the input.
*/
readonly onSubmit?: (value: string) => void;
};
function TextInput({
value: originalValue,
placeholder = '',
focus = true,
mask,
highlightPastedText = false,
showCursor = true,
onChange,
onSubmit,
}: Props) {
const [state, setState] = useState({
cursorOffset: (originalValue || '').length,
cursorWidth: 0,
});
const {cursorOffset, cursorWidth} = state;
useEffect(() => {
setState(previousState => {
if (!focus || !showCursor) {
return previousState;
}
const newValue = originalValue || '';
if (previousState.cursorOffset > newValue.length - 1) {
return {
cursorOffset: newValue.length,
cursorWidth: 0,
};
}
return previousState;
});
}, [originalValue, focus, showCursor]);
const cursorActualWidth = highlightPastedText ? cursorWidth : 0;
const value = mask ? mask.repeat(originalValue.length) : originalValue;
let renderedValue = value;
let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined;
// Fake mouse cursor, because it's too inconvenient to deal with actual cursor and ansi escapes
if (showCursor && focus) {
renderedPlaceholder =
placeholder.length > 0
? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1))
: chalk.inverse(' ');
renderedValue = value.length > 0 ? '' : chalk.inverse(' ');
let i = 0;
for (const char of value) {
renderedValue +=
i >= cursorOffset - cursorActualWidth && i <= cursorOffset
? chalk.inverse(char)
: char;
i++;
}
if (value.length > 0 && cursorOffset === value.length) {
renderedValue += chalk.inverse(' ');
}
}
useInput(
(input, key) => {
if (
key.upArrow ||
key.downArrow ||
(key.ctrl && input === 'c') ||
key.tab ||
(key.shift && key.tab)
) {
return;
}
if (key.return) {
if (onSubmit) {
onSubmit(originalValue);
}
return;
}
let nextCursorOffset = cursorOffset;
let nextValue = originalValue;
let nextCursorWidth = 0;
if (key.leftArrow) {
if (showCursor) {
nextCursorOffset--;
}
} else if (key.rightArrow) {
if (showCursor) {
nextCursorOffset++;
}
} else if (key.backspace || key.delete) {
if (cursorOffset > 0) {
nextValue =
originalValue.slice(0, cursorOffset - 1) +
originalValue.slice(cursorOffset, originalValue.length);
nextCursorOffset--;
}
} else {
nextValue =
originalValue.slice(0, cursorOffset) +
input +
originalValue.slice(cursorOffset, originalValue.length);
nextCursorOffset += input.length;
if (input.length > 1) {
nextCursorWidth = input.length;
}
}
if (cursorOffset < 0) {
nextCursorOffset = 0;
}
if (cursorOffset > originalValue.length) {
nextCursorOffset = originalValue.length;
}
setState({
cursorOffset: nextCursorOffset,
cursorWidth: nextCursorWidth,
});
if (nextValue !== originalValue) {
onChange(nextValue);
}
},
{isActive: focus},
);
return (
<Text>
{placeholder
? value.length > 0
? renderedValue
: renderedPlaceholder
: renderedValue}
</Text>
);
}
export default TextInput;
type UncontrolledProps = {
/**
* Initial value.
*/
readonly initialValue?: string;
} & Except<Props, 'value' | 'onChange'>;
export function UncontrolledTextInput({
initialValue = '',
...props
}: UncontrolledProps) {
const [value, setValue] = useState(initialValue);
return <TextInput {...props} value={value} onChange={setValue} />;
}

View File

@@ -0,0 +1,144 @@
import React, { useMemo } from "react";
import { Box, Text } from "ink";
import TextInput from "./TextInput.js";
import { useSatoshisConversion } from "../hooks/useSatoshisConversion.js";
interface VariableInputFieldProps {
variable: {
id: string;
name: string;
type: string;
hint?: string;
value: string;
};
index: number;
isFocused: boolean;
onChange: (index: number, value: string) => void;
onSubmit: () => void;
borderColor: 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({
variable,
index,
isFocused,
onChange,
onSubmit,
borderColor,
focusColor,
}: VariableInputFieldProps): React.ReactElement {
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
useSatoshisConversion();
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 (
<Box flexDirection="column" marginBottom={1}>
<Text color={focusColor}>{variable.name}</Text>
{variable.hint && (
<Text color={borderColor} dimColor>
({variable.hint})
</Text>
)}
<Box
borderStyle="single"
borderColor={isFocused ? focusColor : borderColor}
paddingX={1}
marginTop={1}
gap={1}
>
<TextInput
value={variable.value}
onChange={(value) => onChange(index, value)}
onSubmit={onSubmit}
focus={isFocused}
placeholder={`Enter ${variable.name}...`}
/>
{/* TODO: this may need to be conditional. Need to play around with other templates though */}
<Text color={borderColor} dimColor>{variable.hint}</Text>
</Box>
{shouldShowSatoshisConversion && (
<Box flexDirection="column">
{formattedBch ? (
<>
<Text color={borderColor} dimColor>
{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>
);
}

View File

@@ -2,9 +2,23 @@
* Export all shared components.
*/
export { Screen } from './Screen.js';
export { Input, TextDisplay } from './Input.js';
export { Button, ButtonRow } from './Button.js';
export { List, SimpleList, type ListItem } from './List.js';
export { InputDialog, ConfirmDialog, MessageDialog } from './Dialog.js';
export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js';
export { Screen } from "./Screen.js";
export { Input, TextDisplay } from "./Input.js";
export { Button, ButtonRow } from "./Button.js";
export {
List,
SimpleList,
ScrollableList,
type ListItem,
type ListItemData,
type ListGroup,
type ScrollableListProps,
} from "./List.js";
export { InputDialog, ConfirmDialog, MessageDialog } from "./Dialog.js";
export {
ProgressBar,
StepIndicator,
Loading,
type Step,
} from "./ProgressBar.js";
export { QRCode } from "./QRCode.js";

View File

@@ -2,5 +2,26 @@
* Export all hooks.
*/
export { NavigationProvider, useNavigation } from './useNavigation.js';
export { AppProvider, useAppContext, useDialog, useStatus } from './useAppContext.js';
export { NavigationProvider, useNavigation } from "./useNavigation.js";
export {
AppProvider,
useAppContext,
useDialog,
useStatus,
} from "./useAppContext.js";
export {
useInvitations,
useInvitation,
useInvitationData,
useCreateInvitation,
useInvitationIds,
} from "./useInvitations.js";
export {
InputLayerProvider,
useInputLayer,
useLayeredInput,
useBlockableInput,
useIsInputCaptured,
} from "./useInputLayer.js";
export { useRate, useBchToFiatRate } from "./useRates.js";
export { useSatoshisConversion } from "./useSatoshisConversion.js";

View File

@@ -1,10 +1,10 @@
/**
* App context hook for accessing controllers and app-level functions.
* App context hook for accessing AppService and app-level functions.
*/
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import type { WalletController } from '../../controllers/wallet-controller.js';
import type { InvitationController } from '../../controllers/invitation-controller.js';
import { AppService } from '../../services/app.js';
import type { AppConfig } from '../../app.js';
import type { AppContextType, DialogState } from '../types.js';
/**
@@ -37,27 +37,50 @@ const StatusContext = createContext<StatusContextType | null>(null);
*/
interface AppProviderProps {
children: ReactNode;
walletController: WalletController;
invitationController: InvitationController;
config: AppConfig;
onExit: () => void;
}
/**
* App provider component.
* Provides controllers, dialog management, and app-level functions to children.
* Provides AppService, dialog management, and app-level functions to children.
*/
export function AppProvider({
children,
walletController,
invitationController,
config,
onExit,
}: AppProviderProps): React.ReactElement {
const [appService, setAppService] = useState<AppService | null>(null);
const [dialog, setDialog] = useState<DialogState | null>(null);
const [status, setStatusState] = useState<string>('Ready');
const [isWalletInitialized, setWalletInitialized] = useState(false);
// Promise resolver for confirm dialogs
const [confirmResolver, setConfirmResolver] = useState<((value: boolean) => void) | null>(null);
/**
* Initialize wallet with seed phrase and create AppService.
*/
const initializeWallet = useCallback(async (seed: string) => {
try {
// Create the AppService with the seed
const service = await AppService.create(seed, {
syncServerUrl: config.syncServerUrl,
engineConfig: {
databasePath: config.databasePath,
databaseFilename: config.databaseFilename,
},
invitationStoragePath: config.invitationStoragePath,
});
// Start the AppService (loads existing invitations)
await service.start();
// Set the service and mark as initialized
setAppService(service);
setWalletInitialized(true);
} catch (error) {
// Re-throw the error so the caller can handle it
throw error;
}
}, [config]);
/**
* Show an error dialog.
@@ -88,7 +111,6 @@ export function AppProvider({
*/
const confirm = useCallback((message: string): Promise<boolean> => {
return new Promise((resolve) => {
setConfirmResolver(() => resolve);
setDialog({
visible: true,
type: 'confirm',
@@ -113,15 +135,15 @@ export function AppProvider({
}, []);
const appValue: AppContextType = {
walletController,
invitationController,
appService,
initializeWallet,
isWalletInitialized,
config,
showError,
showInfo,
confirm,
exit: onExit,
setStatus,
isWalletInitialized,
setWalletInitialized,
};
const dialogValue: DialogContextType = {

View File

@@ -0,0 +1,169 @@
/**
* Input Layer System — stack-based keyboard input capture for dialogs and overlays.
*
* Only "capturing" components (dialogs, overlays, import flows) register layers.
* When any layer exists on the stack, all non-capturing input handlers are blocked.
*
* Hooks:
* - `useInputLayer(id)` — push a capturing layer (dialogs/overlays).
* - `useLayeredInput(id, …)` — handle input for a specific capturing layer.
* - `useBlockableInput(…)` — handle input for screens / global keys; auto-blocked
* when any capturing layer is on the stack.
* - `useIsInputCaptured()` — returns true when a capturing layer is present
* (useful for disabling `focus` on child components).
*/
import React, {
createContext,
useContext,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { useInput } from 'ink';
// ── Context ──────────────────────────────────────────────────────────────────
interface InputLayerContextType {
/** Push a capturing layer. Returns a cleanup that pops it. */
push: (layerId: string) => () => void;
/** True when `layerId` is the topmost entry in the stack. */
isTop: (layerId: string) => boolean;
/** True when the stack has no entries (no dialog/overlay is capturing). */
isStackEmpty: () => boolean;
/** Monotonic counter — bumped on every push/pop so consumers re-render. */
version: number;
}
const InputLayerContext = createContext<InputLayerContextType | null>(null);
// ── Provider ─────────────────────────────────────────────────────────────────
interface InputLayerProviderProps {
children: ReactNode;
}
/**
* Wraps the component tree and provides the input-layer stack.
*
* Place this inside your outermost providers but above any component
* that calls the input-layer hooks.
*/
export function InputLayerProvider({ children }: InputLayerProviderProps): React.ReactElement {
const stackRef = useRef<string[]>([]);
const [version, setVersion] = useState(0);
const bump = useCallback(() => setVersion((v) => v + 1), []);
const push = useCallback(
(layerId: string): (() => void) => {
stackRef.current = [...stackRef.current, layerId];
bump();
return () => {
stackRef.current = stackRef.current.filter((id) => id !== layerId);
bump();
};
},
[bump],
);
const isTop = useCallback(
(layerId: string): boolean => {
const s = stackRef.current;
return s.length > 0 && s[s.length - 1] === layerId;
},
[],
);
const isStackEmpty = useCallback(
(): boolean => stackRef.current.length === 0,
[],
);
const value = useMemo<InputLayerContextType>(
() => ({ push, isTop, isStackEmpty, version }),
[push, isTop, isStackEmpty, version],
);
return (
<InputLayerContext.Provider value={value}>
{children}
</InputLayerContext.Provider>
);
}
// ── Hooks ────────────────────────────────────────────────────────────────────
/**
* Register a **capturing** layer (dialog / overlay / import flow).
*
* Pushes on mount, pops on unmount. While this layer is present every
* `useBlockableInput` handler in the tree is automatically disabled.
*
* @returns `{ isActive }` — true only when this layer is the topmost.
*/
export function useInputLayer(layerId: string): { isActive: boolean } {
const ctx = useContext(InputLayerContext);
if (!ctx) {
throw new Error('useInputLayer must be used within an InputLayerProvider');
}
const { push } = ctx;
useEffect(() => {
const pop = push(layerId);
return pop;
}, [push, layerId]);
return { isActive: ctx.isTop(layerId) };
}
/**
* Input handler for a **capturing** layer.
*
* Only fires when `layerId` is the topmost entry in the stack.
*/
export function useLayeredInput(
layerId: string,
handler: (input: string, key: any) => void,
options?: { isActive?: boolean },
): void {
const ctx = useContext(InputLayerContext);
if (!ctx) {
throw new Error('useLayeredInput must be used within an InputLayerProvider');
}
const isTopLayer = ctx.isTop(layerId);
const externalActive = options?.isActive !== false;
useInput(handler, { isActive: isTopLayer && externalActive });
}
/**
* Input handler for **non-capturing** components (screens, global keys).
*
* Fires only when the capture stack is empty (no dialog/overlay is open).
* This is the hook screens should use instead of raw `useInput`.
*/
export function useBlockableInput(
handler: (input: string, key: any) => void,
options?: { isActive?: boolean },
): void {
const ctx = useContext(InputLayerContext);
const nothingCapturing = ctx ? ctx.isStackEmpty() : true;
const externalActive = options?.isActive !== false;
useInput(handler, { isActive: nothingCapturing && externalActive });
}
/**
* Returns `true` when any capturing layer is on the stack.
*
* Use this to disable `focus` props on child components (e.g. ScrollableList)
* so their internal `useInput` handlers don't fire while a dialog is open.
*/
export function useIsInputCaptured(): boolean {
const ctx = useContext(InputLayerContext);
return ctx ? !ctx.isStackEmpty() : false;
}

View File

@@ -0,0 +1,144 @@
/**
* Performance-optimized invitation hooks.
* Uses useSyncExternalStore for fine-grained reactivity.
*/
import { useSyncExternalStore, useMemo, useCallback } from 'react';
import type { Invitation } from '../../services/invitation.js';
import type { XOInvitation } from '@xo-cash/types';
import { useAppContext } from './useAppContext.js';
/**
* Get all invitations reactively.
* Re-renders when invitations are added or removed.
*/
export function useInvitations(): Invitation[] {
const { appService } = useAppContext();
const subscribe = useCallback(
(callback: () => void) => {
if (!appService) {
return () => {};
}
// Subscribe to invitation list changes
const onAdded = () => callback();
const onRemoved = () => callback();
appService.on('invitation-added', onAdded);
appService.on('invitation-removed', onRemoved);
return () => {
appService.off('invitation-added', onAdded);
appService.off('invitation-removed', onRemoved);
};
},
[appService]
);
const getSnapshot = useCallback(() => {
return appService?.invitations ?? [];
}, [appService]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
/**
* Get a single invitation by ID with selective re-rendering.
* Only re-renders when the specific invitation is updated.
*/
export function useInvitation(invitationId: string | null): Invitation | null {
const { appService } = useAppContext();
const subscribe = useCallback(
(callback: () => void) => {
if (!appService || !invitationId) {
return () => {};
}
// Find the invitation instance
const invitation = appService.invitations.find(
(inv) => inv.data.invitationIdentifier === invitationId
);
if (!invitation) {
return () => {};
}
// Subscribe to this specific invitation's updates
const onUpdated = () => callback();
const onStatusChanged = () => callback();
invitation.on('invitation-updated', onUpdated);
invitation.on('invitation-status-changed', onStatusChanged);
// Also subscribe to list changes in case the invitation is removed
const onRemoved = () => callback();
appService.on('invitation-removed', onRemoved);
return () => {
invitation.off('invitation-updated', onUpdated);
invitation.off('invitation-status-changed', onStatusChanged);
appService.off('invitation-removed', onRemoved);
};
},
[appService, invitationId]
);
const getSnapshot = useCallback(() => {
if (!appService || !invitationId) {
return null;
}
return (
appService.invitations.find(
(inv) => inv.data.invitationIdentifier === invitationId
) ?? null
);
}, [appService, invitationId]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
/**
* Get invitation data with memoization.
* Returns stable references to prevent unnecessary re-renders.
*/
export function useInvitationData(invitationId: string | null): XOInvitation | null {
const invitation = useInvitation(invitationId);
return useMemo(() => {
return invitation?.data ?? null;
}, [invitation?.data.invitationIdentifier, invitation?.data.commits?.length]);
}
/**
* Hook to create invitations.
* Returns a memoized function to create invitations.
*/
export function useCreateInvitation() {
const { appService } = useAppContext();
return useCallback(
async (invitation: XOInvitation | string): Promise<Invitation> => {
if (!appService) {
throw new Error('AppService not initialized');
}
return await appService.createInvitation(invitation);
},
[appService]
);
}
/**
* Hook to get all invitations with their IDs.
* Useful for lists where you only need IDs (prevents re-renders on data changes).
*/
export function useInvitationIds(): string[] {
const invitations = useInvitations();
return useMemo(() => {
return invitations.map((inv) => inv.data.invitationIdentifier);
}, [invitations]);
}

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,73 @@
import { useCallback, useMemo, useSyncExternalStore } 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) {
const { appService } = useAppContext();
const subscribeToCurrency = useCallback(
(callback: () => void) => {
if (!appService || targetCurrency) {
return () => {};
}
return appService.settings.on('settings-updated', (event) => {
if (event.key === 'currency') {
callback();
}
});
},
[appService, targetCurrency],
);
const getCurrencySnapshot = useCallback(() => {
if (targetCurrency) {
return targetCurrency.toUpperCase();
}
if (!appService) {
return 'USD';
}
return appService.settings.getCurrency();
}, [appService, targetCurrency]);
const currencyCode = useSyncExternalStore(
subscribeToCurrency,
getCurrencySnapshot,
getCurrencySnapshot,
);
const fiatPerBchRate = useBchToFiatRate(currencyCode);
const formattedFiatPerBchRate = useMemo(() => {
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

@@ -1,885 +0,0 @@
/**
* Action Wizard Screen - Step-by-step walkthrough for template actions.
*
* Guides users through:
* - Reviewing action requirements
* - Entering variables (e.g., requestedSatoshis)
* - Selecting inputs (UTXOs) for funding
* - Reviewing outputs and change
* - Creating and publishing invitation
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
/**
* Isolated Variable Input Component.
* This component handles its own input without interference from parent useInput hooks.
*/
interface VariableInputFieldProps {
variable: { id: string; name: string; type: string; hint?: string; value: string };
index: number;
isFocused: boolean;
onChange: (index: number, value: string) => void;
onSubmit: () => void;
borderColor: string;
focusColor: string;
}
function VariableInputField({
variable,
index,
isFocused,
onChange,
onSubmit,
borderColor,
focusColor,
}: VariableInputFieldProps): React.ReactElement {
return (
<Box flexDirection="column" marginBottom={1}>
<Text color={focusColor}>{variable.name}</Text>
{variable.hint && (
<Text color={borderColor} dimColor>({variable.hint})</Text>
)}
<Box
borderStyle="single"
borderColor={isFocused ? focusColor : borderColor}
paddingX={1}
marginTop={1}
>
<TextInput
value={variable.value}
onChange={value => onChange(index, value)}
onSubmit={onSubmit}
focus={isFocused}
placeholder={`Enter ${variable.name}...`}
/>
</Box>
</Box>
);
}
import { StepIndicator, type Step } from '../components/ProgressBar.js';
import { Button } from '../components/Button.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
import { copyToClipboard } from '../utils/clipboard.js';
import type { XOTemplate, XOInvitation } from '@xo-cash/types';
/**
* Wizard step types.
*/
type StepType = 'info' | 'variables' | 'inputs' | 'review' | 'publish';
/**
* Wizard step definition.
*/
interface WizardStep {
name: string;
type: StepType;
}
/**
* Variable input state.
*/
interface VariableInput {
id: string;
name: string;
type: string;
hint?: string;
value: string;
}
/**
* UTXO for selection.
*/
interface SelectableUTXO {
outpointTransactionHash: string;
outpointIndex: number;
valueSatoshis: bigint;
lockingBytecode?: string;
selected: boolean;
}
/**
* Action Wizard Screen Component.
*/
export function ActionWizardScreen(): React.ReactElement {
const { navigate, goBack, data: navData } = useNavigation();
const { walletController, invitationController, showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
// Extract navigation data
const templateIdentifier = navData.templateIdentifier as string | undefined;
const actionIdentifier = navData.actionIdentifier as string | undefined;
const roleIdentifier = navData.roleIdentifier as string | undefined;
const template = navData.template as XOTemplate | undefined;
// Wizard state
const [steps, setSteps] = useState<WizardStep[]>([]);
const [currentStep, setCurrentStep] = useState(0);
// Variable inputs
const [variables, setVariables] = useState<VariableInput[]>([]);
// UTXO selection
const [availableUtxos, setAvailableUtxos] = useState<SelectableUTXO[]>([]);
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
const [requiredAmount, setRequiredAmount] = useState<bigint>(0n);
const [fee, setFee] = useState<bigint>(500n); // Default fee estimate
// Invitation state
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
const [invitationId, setInvitationId] = useState<string | null>(null);
// UI state
const [focusedInput, setFocusedInput] = useState(0);
const [focusedButton, setFocusedButton] = useState<'back' | 'cancel' | 'next'>('next');
const [focusArea, setFocusArea] = useState<'content' | 'buttons'>('content');
const [isProcessing, setIsProcessing] = useState(false);
/**
* Initialize wizard on mount.
*/
useEffect(() => {
if (!template || !actionIdentifier || !roleIdentifier) {
showError('Missing wizard data');
goBack();
return;
}
// Build steps based on template
const action = template.actions?.[actionIdentifier];
const role = action?.roles?.[roleIdentifier];
const requirements = role?.requirements;
const wizardSteps: WizardStep[] = [
{ name: 'Welcome', type: 'info' },
];
// Add variables step if needed
if (requirements?.variables && requirements.variables.length > 0) {
wizardSteps.push({ name: 'Variables', type: 'variables' });
// Initialize variable inputs
const varInputs = requirements.variables.map(varId => {
const varDef = template.variables?.[varId];
return {
id: varId,
name: varDef?.name || varId,
type: varDef?.type || 'string',
hint: varDef?.hint,
value: '',
};
});
setVariables(varInputs);
}
// Add inputs step if role requires slots (funding inputs)
// Slots indicate the role needs to provide transaction inputs/outputs
if (requirements?.slots && requirements.slots.min > 0) {
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
}
wizardSteps.push({ name: 'Review', type: 'review' });
wizardSteps.push({ name: 'Publish', type: 'publish' });
setSteps(wizardSteps);
setStatus(`${actionIdentifier}/${roleIdentifier}`);
}, [template, actionIdentifier, roleIdentifier, showError, goBack, setStatus]);
/**
* Get current step data.
*/
const currentStepData = steps[currentStep];
/**
* Calculate selected amount.
*/
const selectedAmount = availableUtxos
.filter(u => u.selected)
.reduce((sum, u) => sum + u.valueSatoshis, 0n);
/**
* Calculate change amount.
*/
const changeAmount = selectedAmount - requiredAmount - fee;
/**
* Load available UTXOs for the inputs step.
*/
const loadAvailableUtxos = useCallback(async () => {
if (!invitation || !templateIdentifier) return;
try {
setIsProcessing(true);
setStatus('Finding suitable UTXOs...');
// First, get the required amount from variables (e.g., requestedSatoshis)
const requestedVar = variables.find(v =>
v.id.toLowerCase().includes('satoshi') ||
v.id.toLowerCase().includes('amount')
);
const requested = requestedVar ? BigInt(requestedVar.value || '0') : 0n;
setRequiredAmount(requested);
// Find suitable resources
const resources = await walletController.findSuitableResources(invitation, {
templateIdentifier,
outputIdentifier: 'receiveOutput', // Common output identifier
});
// Convert to selectable UTXOs
const utxos: SelectableUTXO[] = (resources.unspentOutputs || []).map((utxo: any) => ({
outpointTransactionHash: utxo.outpointTransactionHash,
outpointIndex: utxo.outpointIndex,
valueSatoshis: BigInt(utxo.valueSatoshis),
lockingBytecode: utxo.lockingBytecode
? typeof utxo.lockingBytecode === 'string'
? utxo.lockingBytecode
: Buffer.from(utxo.lockingBytecode).toString('hex')
: undefined,
selected: false,
}));
// Auto-select UTXOs to cover required amount + fee
let accumulated = 0n;
const seenLockingBytecodes = new Set<string>();
for (const utxo of utxos) {
// Ensure lockingBytecode uniqueness
if (utxo.lockingBytecode && seenLockingBytecodes.has(utxo.lockingBytecode)) {
continue;
}
if (utxo.lockingBytecode) {
seenLockingBytecodes.add(utxo.lockingBytecode);
}
utxo.selected = true;
accumulated += utxo.valueSatoshis;
if (accumulated >= requested + fee) {
break;
}
}
setAvailableUtxos(utxos);
setStatus('Ready');
} catch (error) {
showError(`Failed to load UTXOs: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsProcessing(false);
}
}, [invitation, templateIdentifier, variables, walletController, showError, setStatus]);
/**
* Toggle UTXO selection.
*/
const toggleUtxoSelection = useCallback((index: number) => {
setAvailableUtxos(prev => {
const updated = [...prev];
const utxo = updated[index];
if (utxo) {
updated[index] = { ...utxo, selected: !utxo.selected };
}
return updated;
});
}, []);
/**
* Navigate to next step.
*/
const nextStep = useCallback(async () => {
if (currentStep >= steps.length - 1) return;
const stepType = currentStepData?.type;
// Handle step-specific logic
if (stepType === 'variables') {
// Validate that all required variables have values
const emptyVars = variables.filter(v => !v.value || v.value.trim() === '');
if (emptyVars.length > 0) {
showError(`Please enter values for: ${emptyVars.map(v => v.name).join(', ')}`);
return;
}
// Create invitation and add variables
await createInvitationWithVariables();
return;
}
if (stepType === 'inputs') {
// Add selected inputs and outputs to invitation
await addInputsAndOutputs();
return;
}
if (stepType === 'review') {
// Publish invitation
await publishInvitation();
return;
}
setCurrentStep(prev => prev + 1);
setFocusArea('content');
setFocusedInput(0);
}, [currentStep, steps.length, currentStepData, variables, showError]);
/**
* Create invitation and add variables.
*/
const createInvitationWithVariables = useCallback(async () => {
if (!templateIdentifier || !actionIdentifier || !roleIdentifier || !template) return;
setIsProcessing(true);
setStatus('Creating invitation...');
try {
// Create invitation
const tracked = await invitationController.createInvitation(
templateIdentifier,
actionIdentifier,
);
let inv = tracked.invitation;
const invId = inv.invitationIdentifier;
setInvitationId(invId);
// Add variables if any
if (variables.length > 0) {
const variableData = variables.map(v => {
// Determine if this is a numeric type that should be BigInt
// Template types include: 'integer', 'number', 'satoshis'
// Hints include: 'satoshis', 'amount'
const isNumeric = ['integer', 'number', 'satoshis'].includes(v.type) ||
(v.hint && ['satoshis', 'amount'].includes(v.hint));
return {
variableIdentifier: v.id,
roleIdentifier: roleIdentifier,
value: isNumeric ? BigInt(v.value || '0') : v.value,
};
});
const updated = await invitationController.addVariables(invId, variableData);
inv = updated.invitation;
}
// Add template-required outputs for the current role
// This is critical - the template defines which outputs the initiator must create
const action = template.actions?.[actionIdentifier];
const transaction = action?.transaction ? template.transactions?.[action.transaction] : null;
if (transaction?.outputs && transaction.outputs.length > 0) {
setStatus('Adding required outputs...');
// Add each required output with just its identifier
// IMPORTANT: Do NOT pass roleIdentifier here - if roleIdentifier is set,
// the engine skips generating the lockingBytecode (see engine.ts appendInvitation)
// The engine will automatically generate the locking bytecode based on the template
const outputsToAdd = transaction.outputs.map((outputId: string) => ({
outputIdentifier: outputId,
// Note: roleIdentifier intentionally omitted to trigger lockingBytecode generation
}));
const updated = await invitationController.addOutputs(invId, outputsToAdd);
inv = updated.invitation;
}
setInvitation(inv);
// Check if next step is inputs
const nextStepType = steps[currentStep + 1]?.type;
if (nextStepType === 'inputs') {
setCurrentStep(prev => prev + 1);
// Load UTXOs after step change
setTimeout(() => loadAvailableUtxos(), 100);
} else {
setCurrentStep(prev => prev + 1);
}
setStatus('Invitation created');
} catch (error) {
showError(`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsProcessing(false);
}
}, [templateIdentifier, actionIdentifier, roleIdentifier, template, variables, invitationController, steps, currentStep, showError, setStatus, loadAvailableUtxos]);
/**
* Add selected inputs and change output to invitation.
*/
const addInputsAndOutputs = useCallback(async () => {
if (!invitationId || !invitation) return;
const selectedUtxos = availableUtxos.filter(u => u.selected);
if (selectedUtxos.length === 0) {
showError('Please select at least one UTXO');
return;
}
if (selectedAmount < requiredAmount + fee) {
showError(`Insufficient funds. Need ${formatSatoshis(requiredAmount + fee)}, selected ${formatSatoshis(selectedAmount)}`);
return;
}
if (changeAmount < 546n) { // Dust threshold
showError(`Change amount (${changeAmount}) is below dust threshold (546 sats)`);
return;
}
setIsProcessing(true);
setStatus('Adding inputs and outputs...');
try {
// Add inputs
const inputs = selectedUtxos.map(utxo => ({
outpointTransactionHash: utxo.outpointTransactionHash,
outpointIndex: utxo.outpointIndex,
}));
await invitationController.addInputs(invitationId, inputs);
// Add change output
const outputs = [{
valueSatoshis: changeAmount,
// The engine will automatically generate the locking bytecode for change
}];
await invitationController.addOutputs(invitationId, outputs);
// Add transaction metadata
// Note: This would be done via appendInvitation but we don't have direct access here
// The engine should handle defaults
setCurrentStep(prev => prev + 1);
setStatus('Inputs and outputs added');
} catch (error) {
showError(`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsProcessing(false);
}
}, [invitationId, invitation, availableUtxos, selectedAmount, requiredAmount, fee, changeAmount, invitationController, showError, setStatus]);
/**
* Publish invitation.
*/
const publishInvitation = useCallback(async () => {
if (!invitationId) return;
setIsProcessing(true);
setStatus('Publishing invitation...');
try {
await invitationController.publishAndSubscribe(invitationId);
setCurrentStep(prev => prev + 1);
setStatus('Invitation published');
} catch (error) {
showError(`Failed to publish: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsProcessing(false);
}
}, [invitationId, invitationController, showError, setStatus]);
/**
* Navigate to previous step.
*/
const previousStep = useCallback(() => {
if (currentStep <= 0) {
goBack();
return;
}
setCurrentStep(prev => prev - 1);
setFocusArea('content');
setFocusedInput(0);
}, [currentStep, goBack]);
/**
* Cancel wizard.
*/
const cancel = useCallback(() => {
goBack();
}, [goBack]);
/**
* Copy invitation ID to clipboard.
*/
const copyId = useCallback(async () => {
if (!invitationId) return;
try {
await copyToClipboard(invitationId);
showInfo(`Copied to clipboard!\n\n${invitationId}`);
} catch (error) {
showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`);
}
}, [invitationId, showInfo, showError]);
/**
* Update variable value.
*/
const updateVariable = useCallback((index: number, value: string) => {
setVariables(prev => {
const updated = [...prev];
const variable = updated[index];
if (variable) {
updated[index] = { ...variable, value };
}
return updated;
});
}, []);
// Check if TextInput should have exclusive focus (variables step with content focus)
const textInputHasFocus = currentStepData?.type === 'variables' && focusArea === 'content';
/**
* Handle TextInput submit (Enter key) - moves to next variable or buttons.
*/
const handleTextInputSubmit = useCallback(() => {
if (focusedInput < variables.length - 1) {
setFocusedInput(prev => prev + 1);
} else {
setFocusArea('buttons');
setFocusedButton('next');
}
}, [focusedInput, variables.length]);
// Keyboard handler - COMPLETELY DISABLED when TextInput has focus
// This allows TextInput to receive character input without interference
// When TextInput is focused, use Enter to navigate (handled by onSubmit callback)
useInput((input, key) => {
// Tab to switch between content and buttons
if (key.tab) {
if (focusArea === 'content') {
// Handle tab based on current step type
if (currentStepData?.type === 'inputs' && availableUtxos.length > 0) {
if (selectedUtxoIndex < availableUtxos.length - 1) {
setSelectedUtxoIndex(prev => prev + 1);
return;
}
}
setFocusArea('buttons');
setFocusedButton('next');
} else {
if (focusedButton === 'back') {
setFocusedButton('cancel');
} else if (focusedButton === 'cancel') {
setFocusedButton('next');
} else {
setFocusArea('content');
setFocusedInput(0);
setSelectedUtxoIndex(0);
}
}
return;
}
// Arrow keys for UTXO selection
if (focusArea === 'content' && currentStepData?.type === 'inputs') {
if (key.upArrow) {
setSelectedUtxoIndex(prev => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedUtxoIndex(prev => Math.min(availableUtxos.length - 1, prev + 1));
} else if (key.return || input === ' ') {
toggleUtxoSelection(selectedUtxoIndex);
}
return;
}
// Arrow keys in buttons area
if (focusArea === 'buttons') {
if (key.leftArrow) {
setFocusedButton(prev =>
prev === 'next' ? 'cancel' : prev === 'cancel' ? 'back' : 'back'
);
} else if (key.rightArrow) {
setFocusedButton(prev =>
prev === 'back' ? 'cancel' : prev === 'cancel' ? 'next' : 'next'
);
}
}
// Enter on buttons
if (key.return && focusArea === 'buttons') {
if (focusedButton === 'back') previousStep();
else if (focusedButton === 'cancel') cancel();
else if (focusedButton === 'next') nextStep();
}
// 'c' to copy on publish step
if (input === 'c' && currentStepData?.type === 'publish' && invitationId) {
copyId();
}
// 'a' to select all UTXOs
if (input === 'a' && currentStepData?.type === 'inputs') {
setAvailableUtxos(prev => prev.map(u => ({ ...u, selected: true })));
}
// 'n' to deselect all UTXOs
if (input === 'n' && currentStepData?.type === 'inputs') {
setAvailableUtxos(prev => prev.map(u => ({ ...u, selected: false })));
}
}, { isActive: !textInputHasFocus });
// Get action details
const action = template?.actions?.[actionIdentifier ?? ''];
const actionName = action?.name || actionIdentifier || 'Unknown';
// Render step content
const renderStepContent = () => {
if (!currentStepData) return null;
switch (currentStepData.type) {
case 'info':
return (
<Box flexDirection="column">
<Text color={colors.primary} bold>Action: {actionName}</Text>
<Text color={colors.textMuted}>{action?.description || 'No description'}</Text>
<Box marginTop={1}>
<Text color={colors.text}>Your Role: </Text>
<Text color={colors.accent}>{roleIdentifier}</Text>
</Box>
{action?.roles?.[roleIdentifier ?? '']?.requirements && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Requirements:</Text>
{action.roles[roleIdentifier ?? '']?.requirements?.variables?.map(v => (
<Text key={v} color={colors.textMuted}> Variable: {v}</Text>
))}
{action.roles[roleIdentifier ?? '']?.requirements?.slots && (
<Text color={colors.textMuted}> Slots: {action.roles[roleIdentifier ?? '']?.requirements?.slots?.min} min (UTXO selection required)</Text>
)}
</Box>
)}
</Box>
);
case 'variables':
return (
<Box flexDirection="column">
<Text color={colors.text} bold>Enter required values:</Text>
<Box marginTop={1} flexDirection="column">
{variables.map((variable, index) => (
<VariableInputField
key={variable.id}
variable={variable}
index={index}
isFocused={focusArea === 'content' && focusedInput === index}
onChange={updateVariable}
onSubmit={handleTextInputSubmit}
borderColor={colors.border as string}
focusColor={colors.primary as string}
/>
))}
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
Type your value, then press Enter to continue
</Text>
</Box>
</Box>
);
case 'inputs':
return (
<Box flexDirection="column">
<Text color={colors.text} bold>Select UTXOs to fund the transaction:</Text>
<Box marginTop={1} flexDirection="column">
<Text color={colors.textMuted}>
Required: {formatSatoshis(requiredAmount)} + {formatSatoshis(fee)} fee
</Text>
<Text color={selectedAmount >= requiredAmount + fee ? colors.success : colors.warning}>
Selected: {formatSatoshis(selectedAmount)}
</Text>
{selectedAmount > requiredAmount + fee && (
<Text color={colors.info}>
Change: {formatSatoshis(changeAmount)}
</Text>
)}
</Box>
<Box marginTop={1} flexDirection="column" borderStyle="single" borderColor={colors.border} paddingX={1}>
{availableUtxos.length === 0 ? (
<Text color={colors.textMuted}>No UTXOs available</Text>
) : (
availableUtxos.map((utxo, index) => (
<Box key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}>
<Text
color={selectedUtxoIndex === index && focusArea === 'content' ? colors.focus : colors.text}
bold={selectedUtxoIndex === index && focusArea === 'content'}
>
{selectedUtxoIndex === index && focusArea === 'content' ? '▸ ' : ' '}
[{utxo.selected ? 'X' : ' '}] {formatSatoshis(utxo.valueSatoshis)} - {formatHex(utxo.outpointTransactionHash, 12)}:{utxo.outpointIndex}
</Text>
</Box>
))
)}
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
Space/Enter: Toggle a: Select all n: Deselect all
</Text>
</Box>
</Box>
);
case 'review':
const selectedUtxos = availableUtxos.filter(u => u.selected);
return (
<Box flexDirection="column">
<Text color={colors.text} bold>Review your invitation:</Text>
<Box marginTop={1} flexDirection="column">
<Text color={colors.textMuted}>Template: {template?.name}</Text>
<Text color={colors.textMuted}>Action: {actionName}</Text>
<Text color={colors.textMuted}>Role: {roleIdentifier}</Text>
</Box>
{variables.length > 0 && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Variables:</Text>
{variables.map(v => (
<Text key={v.id} color={colors.textMuted}>
{' '}{v.name}: {v.value || '(empty)'}
</Text>
))}
</Box>
)}
{selectedUtxos.length > 0 && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Inputs ({selectedUtxos.length}):</Text>
{selectedUtxos.slice(0, 3).map(u => (
<Text key={`${u.outpointTransactionHash}:${u.outpointIndex}`} color={colors.textMuted}>
{' '}{formatSatoshis(u.valueSatoshis)}
</Text>
))}
{selectedUtxos.length > 3 && (
<Text color={colors.textMuted}> ...and {selectedUtxos.length - 3} more</Text>
)}
</Box>
)}
{changeAmount > 0 && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Outputs:</Text>
<Text color={colors.textMuted}> Change: {formatSatoshis(changeAmount)}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={colors.warning}>
Press Next to create and publish the invitation.
</Text>
</Box>
</Box>
);
case 'publish':
return (
<Box flexDirection="column">
<Text color={colors.success} bold> Invitation Created & Published!</Text>
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Invitation ID:</Text>
<Box
borderStyle="single"
borderColor={colors.primary}
paddingX={1}
marginTop={1}
>
<Text color={colors.accent}>{invitationId}</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted}>
Share this ID with the other party to complete the transaction.
</Text>
</Box>
<Box marginTop={1}>
<Text color={colors.warning}>Press 'c' to copy ID to clipboard</Text>
</Box>
</Box>
);
default:
return null;
}
};
// Convert steps to StepIndicator format
const stepIndicatorSteps: Step[] = steps.map(s => ({ label: s.name }));
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1} flexDirection="column">
<Text color={colors.primary} bold>{logoSmall} - Action Wizard</Text>
<Text color={colors.textMuted}>
{template?.name} {'>'} {actionName} (as {roleIdentifier})
</Text>
</Box>
{/* Progress indicator */}
<Box marginTop={1} paddingX={1}>
<StepIndicator steps={stepIndicatorSteps} currentStep={currentStep} />
</Box>
{/* Content area */}
<Box
borderStyle="single"
borderColor={focusArea === 'content' ? colors.focus : colors.primary}
flexDirection="column"
paddingX={1}
paddingY={1}
marginTop={1}
marginX={1}
flexGrow={1}
>
<Text color={colors.primary} bold>
{' '}{currentStepData?.name} ({currentStep + 1}/{steps.length}){' '}
</Text>
<Box marginTop={1}>
{isProcessing ? (
<Text color={colors.info}>Processing...</Text>
) : (
renderStepContent()
)}
</Box>
</Box>
{/* Buttons */}
<Box marginTop={1} marginX={1} justifyContent="space-between">
<Box gap={1}>
<Button
label="Back"
focused={focusArea === 'buttons' && focusedButton === 'back'}
disabled={currentStepData?.type === 'publish'}
/>
<Button
label="Cancel"
focused={focusArea === 'buttons' && focusedButton === 'cancel'}
/>
</Box>
<Button
label={currentStepData?.type === 'publish' ? 'Done' : 'Next'}
focused={focusArea === 'buttons' && focusedButton === 'next'}
disabled={isProcessing}
/>
</Box>
{/* Help text */}
<Box marginTop={1} marginX={1}>
<Text color={colors.textMuted} dimColor>
Tab: Navigate Enter: Select Esc: Back
{currentStepData?.type === 'publish' ? ' • c: Copy ID' : ''}
</Text>
</Box>
</Box>
);
}

View File

@@ -1,633 +0,0 @@
/**
* Invitation Screen - Manages invitations (create, import, view, monitor).
*
* Provides:
* - Import invitation by ID
* - View active invitations
* - Monitor invitation updates via SSE
* - Fill missing requirements
* - Sign and complete invitations
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import { InputDialog } from '../components/Dialog.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { colors, logoSmall, formatHex, formatSatoshis } from '../theme.js';
import { copyToClipboard } from '../utils/clipboard.js';
import type { TrackedInvitation, InvitationState } from '../../services/invitation-flow.js';
/**
* Get color for invitation state.
*/
function getStateColor(state: InvitationState): string {
switch (state) {
case 'created':
case 'published':
return colors.info as string;
case 'pending':
return colors.warning as string;
case 'ready':
case 'signed':
return colors.success as string;
case 'broadcast':
case 'completed':
return colors.success as string;
case 'expired':
case 'error':
return colors.error as string;
default:
return colors.textMuted as string;
}
}
/**
* Action menu items.
*/
const actionItems = [
{ label: 'Import Invitation', value: 'import' },
{ label: 'Accept & Join', value: 'accept' },
{ label: 'Fill Requirements', value: 'fill' },
{ label: 'Sign Transaction', value: 'sign' },
{ label: 'View Transaction', value: 'transaction' },
{ label: 'Copy Invitation ID', value: 'copy' },
{ label: 'Refresh', value: 'refresh' },
];
/**
* Invitation Screen Component.
*/
export function InvitationScreen(): React.ReactElement {
const { navigate, data: navData } = useNavigation();
const { walletController, invitationController, showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
// State
const [invitations, setInvitations] = useState<TrackedInvitation[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
const [focusedPanel, setFocusedPanel] = useState<'list' | 'details' | 'actions'>('list');
const [showImportDialog, setShowImportDialog] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// Check if we should open import dialog on mount
const initialMode = navData.mode as string | undefined;
/**
* Load invitations.
*/
const loadInvitations = useCallback(() => {
const tracked = invitationController.getAllInvitations();
setInvitations(tracked);
}, [invitationController]);
/**
* Set up event listeners and initial load.
*/
useEffect(() => {
loadInvitations();
// Listen for updates
const handleUpdate = () => {
loadInvitations();
};
invitationController.on('invitation-updated', handleUpdate);
invitationController.on('invitation-state-changed', handleUpdate);
// Show import dialog if mode is 'import'
if (initialMode === 'import') {
setShowImportDialog(true);
}
return () => {
invitationController.off('invitation-updated', handleUpdate);
invitationController.off('invitation-state-changed', handleUpdate);
};
}, [invitationController, loadInvitations, initialMode]);
// Get selected invitation
const selectedInvitation = invitations[selectedIndex];
/**
* Import invitation by ID.
*/
const importInvitation = useCallback(async (invitationId: string) => {
setShowImportDialog(false);
if (!invitationId.trim()) return;
try {
setIsLoading(true);
setStatus('Importing invitation...');
const tracked = await invitationController.importInvitation(invitationId);
await invitationController.publishAndSubscribe(tracked.invitation.invitationIdentifier);
loadInvitations();
showInfo(`Invitation imported!\n\nTemplate: ${tracked.invitation.templateIdentifier}\nAction: ${tracked.invitation.actionIdentifier}`);
setStatus('Ready');
} catch (error) {
showError(`Failed to import: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsLoading(false);
}
}, [invitationController, loadInvitations, showInfo, showError, setStatus]);
/**
* Accept selected invitation.
*/
const acceptInvitation = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
setIsLoading(true);
setStatus('Accepting invitation...');
await invitationController.acceptInvitation(selectedInvitation.invitation.invitationIdentifier);
loadInvitations();
showInfo('Invitation accepted! You are now a participant.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
setStatus('Ready');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
// Check if already accepted
if (errorMsg.toLowerCase().includes('already') || errorMsg.toLowerCase().includes('participant')) {
showInfo('You have already accepted this invitation.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
} else {
showError(`Failed to accept: ${errorMsg}`);
}
} finally {
setIsLoading(false);
}
}, [selectedInvitation, invitationController, loadInvitations, showInfo, showError, setStatus]);
/**
* Sign selected invitation.
*/
const signInvitation = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
setIsLoading(true);
setStatus('Signing invitation...');
await invitationController.signInvitation(selectedInvitation.invitation.invitationIdentifier);
loadInvitations();
showInfo('Invitation signed!');
setStatus('Ready');
} catch (error) {
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsLoading(false);
}
}, [selectedInvitation, invitationController, loadInvitations, showInfo, showError, setStatus]);
/**
* Copy invitation ID.
*/
const copyId = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
await copyToClipboard(selectedInvitation.invitation.invitationIdentifier);
showInfo(`Copied!\n\n${selectedInvitation.invitation.invitationIdentifier}`);
} catch (error) {
showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`);
}
}, [selectedInvitation, showInfo, showError]);
/**
* Fill requirements for selected invitation.
* This automatically:
* 1. Accepts the invitation (if not already)
* 2. Finds suitable UTXOs
* 3. Selects UTXOs to cover the required amount
* 4. Appends inputs and change output to the invitation
*/
const fillRequirements = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
const invId = selectedInvitation.invitation.invitationIdentifier;
try {
setIsLoading(true);
// Step 1: Check available roles
setStatus('Checking available roles...');
const availableRoles = await invitationController.getAvailableRoles(invId);
if (availableRoles.length === 0) {
// Already participating, check if we can add inputs
showInfo('You are already participating in this invitation. Checking if inputs are needed...');
} else {
// Need to accept a role first
// TODO: Let user pick role if multiple available
// For now, auto-select the first available role
const roleToTake = availableRoles[0];
showInfo(`Accepting invitation as role: ${roleToTake}`);
setStatus(`Accepting as ${roleToTake}...`);
try {
await invitationController.acceptInvitation(invId);
} catch (e) {
showError(`Failed to accept role: ${e instanceof Error ? e.message : String(e)}`);
setStatus('Ready');
return;
}
}
// Step 2: Check if invitation already has inputs or needs funding
setStatus('Analyzing invitation...');
// Get the tracked invitation with updated state
const tracked = invitationController.getInvitation(invId);
if (!tracked) {
throw new Error('Invitation not found after accepting');
}
// Calculate how much we need
// Look for a requestedSatoshis variable in the invitation
let requiredAmount = 0n;
const commits = tracked.invitation.commits || [];
for (const commit of commits) {
const variables = commit.data?.variables || [];
for (const variable of variables) {
if (variable.variableIdentifier?.toLowerCase().includes('satoshi')) {
requiredAmount = BigInt(variable.value?.toString() || '0');
break;
}
}
if (requiredAmount > 0n) break;
}
const fee = 500n; // Estimated fee
const dust = 546n; // Dust threshold
const totalNeeded = requiredAmount + fee + dust;
// Find resources - use a common output identifier
const resources = await walletController.findSuitableResources(
tracked.invitation,
{
templateIdentifier: tracked.invitation.templateIdentifier,
outputIdentifier: 'receiveOutput', // Try common identifier
}
);
const utxos = (resources as any)?.unspentOutputs || [];
if (utxos.length === 0) {
showError('No suitable UTXOs found. Make sure your wallet has funds.');
setStatus('Ready');
return;
}
// Step 5: Select UTXOs (auto-select to cover the amount)
setStatus('Selecting UTXOs...');
const selectedUtxos: Array<{
outpointTransactionHash: string;
outpointIndex: number;
valueSatoshis: bigint;
}> = [];
let accumulated = 0n;
const seenLockingBytecodes = new Set<string>();
for (const utxo of utxos) {
// Check lockingBytecode uniqueness
const lockingBytecodeHex = utxo.lockingBytecode
? typeof utxo.lockingBytecode === 'string'
? utxo.lockingBytecode
: Buffer.from(utxo.lockingBytecode).toString('hex')
: undefined;
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) {
continue;
}
if (lockingBytecodeHex) {
seenLockingBytecodes.add(lockingBytecodeHex);
}
selectedUtxos.push({
outpointTransactionHash: utxo.outpointTransactionHash,
outpointIndex: utxo.outpointIndex,
valueSatoshis: BigInt(utxo.valueSatoshis),
});
accumulated += BigInt(utxo.valueSatoshis);
if (accumulated >= totalNeeded) {
break;
}
}
if (accumulated < totalNeeded) {
showError(`Insufficient funds. Need ${formatSatoshis(totalNeeded)}, have ${formatSatoshis(accumulated)}`);
setStatus('Ready');
return;
}
const changeAmount = accumulated - requiredAmount - fee;
// Step 6: Add inputs to the invitation
setStatus('Adding inputs...');
await invitationController.addInputs(
invId,
selectedUtxos.map(u => ({
outpointTransactionHash: u.outpointTransactionHash,
outpointIndex: u.outpointIndex,
}))
);
// Step 7: Add change output
if (changeAmount >= dust) {
setStatus('Adding change output...');
await invitationController.addOutputs(invId, [{
valueSatoshis: changeAmount,
}]);
}
// Reload and show success
loadInvitations();
showInfo(
`Requirements filled!\n\n` +
`• Selected ${selectedUtxos.length} UTXO(s)\n` +
`• Total: ${formatSatoshis(accumulated)}\n` +
`• Required: ${formatSatoshis(requiredAmount)}\n` +
`• Fee: ${formatSatoshis(fee)}\n` +
`• Change: ${formatSatoshis(changeAmount)}\n\n` +
`Now use "Sign Transaction" to complete.`
);
setStatus('Ready');
} catch (error) {
showError(`Failed to fill requirements: ${error instanceof Error ? error.message : String(error)}`);
setStatus('Ready');
} finally {
setIsLoading(false);
}
}, [selectedInvitation, invitationController, walletController, loadInvitations, showInfo, showError, setStatus]);
/**
* Handle action selection.
*/
const handleAction = useCallback((action: string) => {
switch (action) {
case 'import':
setShowImportDialog(true);
break;
case 'copy':
copyId();
break;
case 'accept':
acceptInvitation();
break;
case 'fill':
fillRequirements();
break;
case 'sign':
signInvitation();
break;
case 'transaction':
if (selectedInvitation) {
navigate('transaction', { invitationId: selectedInvitation.invitation.invitationIdentifier });
}
break;
case 'refresh':
loadInvitations();
break;
}
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate, loadInvitations]);
// Handle keyboard navigation
useInput((input, key) => {
// Don't handle input while dialog is open
if (showImportDialog) return;
// Tab to switch panels
if (key.tab) {
setFocusedPanel(prev => {
if (prev === 'list') return 'details';
if (prev === 'details') return 'actions';
return 'list';
});
return;
}
// Up/Down navigation
if (key.upArrow || input === 'k') {
if (focusedPanel === 'list') {
setSelectedIndex(prev => Math.max(0, prev - 1));
} else if (focusedPanel === 'actions') {
setSelectedActionIndex(prev => Math.max(0, prev - 1));
}
} else if (key.downArrow || input === 'j') {
if (focusedPanel === 'list') {
setSelectedIndex(prev => Math.min(invitations.length - 1, prev + 1));
} else if (focusedPanel === 'actions') {
setSelectedActionIndex(prev => Math.min(actionItems.length - 1, prev + 1));
}
}
// Enter to select action
if (key.return && focusedPanel === 'actions') {
const action = actionItems[selectedActionIndex];
if (action) {
handleAction(action.value);
}
}
// 'c' to copy
if (input === 'c') {
copyId();
}
// 'i' to import
if (input === 'i') {
setShowImportDialog(true);
}
}, { isActive: !showImportDialog });
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
<Text color={colors.primary} bold>{logoSmall} - Invitations</Text>
</Box>
{/* Main content - three columns */}
<Box flexDirection="row" marginTop={1} flexGrow={1}>
{/* Left column: Invitation list */}
<Box flexDirection="column" width="40%" paddingRight={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'list' ? colors.focus : colors.primary}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Active Invitations </Text>
<Box marginTop={1} flexDirection="column">
{invitations.length === 0 ? (
<Text color={colors.textMuted}>No invitations</Text>
) : (
invitations.map((inv, index) => (
<Text
key={inv.invitation.invitationIdentifier}
color={index === selectedIndex ? colors.focus : colors.text}
bold={index === selectedIndex}
>
{index === selectedIndex && focusedPanel === 'list' ? '▸ ' : ' '}
<Text color={getStateColor(inv.state)}>[{inv.state}]</Text>
{' '}{formatHex(inv.invitation.invitationIdentifier, 12)}
</Text>
))
)}
</Box>
</Box>
</Box>
{/* Middle column: Details */}
<Box flexDirection="column" width="40%" paddingX={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'details' ? colors.focus : colors.border}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Details </Text>
<Box marginTop={1} flexDirection="column">
{selectedInvitation ? (
<>
<Text color={colors.text}>ID: {formatHex(selectedInvitation.invitation.invitationIdentifier, 20)}</Text>
<Text color={colors.text}>
State: <Text color={getStateColor(selectedInvitation.state)}>{selectedInvitation.state}</Text>
</Text>
<Text color={colors.textMuted}>
Template: {selectedInvitation.invitation.templateIdentifier?.slice(0, 20)}...
</Text>
<Text color={colors.textMuted}>
Action: {selectedInvitation.invitation.actionIdentifier}
</Text>
<Text color={colors.textMuted}>
Commits: {selectedInvitation.invitation.commits?.length ?? 0}
</Text>
{/* State-specific guidance */}
<Box marginTop={1} flexDirection="column">
{selectedInvitation.state === 'created' && (
<Text color={colors.info}> Share this ID with the other party</Text>
)}
{selectedInvitation.state === 'published' && (
<Text color={colors.info}> Waiting for other party to join...</Text>
)}
{selectedInvitation.state === 'pending' && (
<>
<Text color={colors.warning}> Action needed!</Text>
<Text color={colors.warning}> Use "Fill Requirements" to add</Text>
<Text color={colors.warning}> your UTXOs and complete your part</Text>
</>
)}
{selectedInvitation.state === 'ready' && (
<>
<Text color={colors.success}> Ready to sign!</Text>
<Text color={colors.success}> Use "Sign Transaction"</Text>
</>
)}
{selectedInvitation.state === 'signed' && (
<>
<Text color={colors.success}> Signed!</Text>
<Text color={colors.success}> View Transaction to broadcast</Text>
</>
)}
{selectedInvitation.state === 'broadcast' && (
<Text color={colors.success}> Transaction broadcast! Waiting for confirmation...</Text>
)}
{selectedInvitation.state === 'completed' && (
<Text color={colors.success}> Transaction completed!</Text>
)}
{selectedInvitation.state === 'error' && (
<Text color={colors.error}> Error - check logs</Text>
)}
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
</Box>
</>
) : (
<Text color={colors.textMuted}>Select an invitation</Text>
)}
</Box>
</Box>
</Box>
{/* Right column: Actions */}
<Box flexDirection="column" width="20%" paddingLeft={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Actions </Text>
<Box marginTop={1} flexDirection="column">
{actionItems.map((item, index) => (
<Text
key={item.value}
color={index === selectedActionIndex && focusedPanel === 'actions' ? colors.focus : colors.text}
bold={index === selectedActionIndex && focusedPanel === 'actions'}
>
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
{item.label}
</Text>
))}
</Box>
</Box>
</Box>
</Box>
{/* Help text */}
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
Tab: Switch panel : Navigate Enter: Select i: Import c: Copy ID Esc: Back
</Text>
</Box>
{/* Import dialog */}
{showImportDialog && (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
height="100%"
>
<InputDialog
title="Import Invitation"
prompt="Enter Invitation ID:"
placeholder="Paste invitation ID..."
onSubmit={importInvitation}
onCancel={() => setShowImportDialog(false)}
isActive={showImportDialog}
/>
</Box>
)}
</Box>
);
}

View File

@@ -1,39 +1,125 @@
/**
* Seed Input Screen - Initial screen for wallet seed phrase entry.
*
* Allows users to enter their BIP39 seed phrase to initialize the wallet.
* Allows users to enter their BIP39 seed phrase to initialize the wallet,
* or select from previously saved mnemonic files on disk.
*/
import React, { useState, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import { Screen } from '../components/Screen.js';
import { Button, ButtonRow } from '../components/Button.js';
import React, { useState, useCallback, useEffect } from 'react';
import { Box, Text } from 'ink';
import TextInput from '../components/TextInput.js';
import { Button } from '../components/Button.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { useBlockableInput } from '../hooks/useInputLayer.js';
import { colors, logo } from '../theme.js';
import fs from 'fs';
import path from 'path';
import { createMnemonicFile } from '../../cli/mnemonic.js';
import { getMnemonicsDir } from '../../utils/paths.js';
import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js';
import { encodeBip39Mnemonic, generateBip39Mnemonic } from '@bitauth/libauth';
/**
* Status message type.
*/
type StatusType = 'idle' | 'loading' | 'error' | 'success';
/**
* Parsed mnemonic file entry with the derived seed phrase ready for wallet init.
*/
interface MnemonicFileEntry {
filename: string;
/** Friendly label derived from filename or the URL comment field. */
label: string;
/** The BIP39 mnemonic phrase derived from the file's entropy. */
mnemonic: string;
}
/**
* Focus sections the user can tab between.
* When saved wallets exist the file list is shown first.
*/
type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'generateRandomSeed' | 'button';
/**
* Reads mnemonic-* files from ~/.config/xo-cli/mnemonics/ (same as xo-cli),
* then from cwd for legacy installs. Parses each as a BCHMnemonicURL.
*/
function loadMnemonicFiles(): MnemonicFileEntry[] {
const dirs = [getMnemonicsDir(), process.cwd()];
const seenBasenames = new Set<string>();
const entries: MnemonicFileEntry[] = [];
for (const dir of dirs) {
if (!fs.existsSync(dir)) continue;
const filenames = fs
.readdirSync(dir)
.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);
if (typeof mnemonicResult === 'string') continue;
/** Use the URL comment as the label, falling back to a cleaned-up filename. */
const label = raw.comment
?? filename.replace(/^mnemonic-/, '').replace(/\.[^.]+$/, '');
entries.push({ filename, label, mnemonic: mnemonicResult.phrase });
seenBasenames.add(filename);
} catch {
// Skip files that can't be parsed
}
}
}
return entries;
}
/**
* Seed Input Screen Component.
* Provides seed phrase entry for wallet initialization.
* Provides seed phrase entry for wallet initialization and a selectable
* list of previously saved mnemonic files.
*/
export function SeedInputScreen(): React.ReactElement {
const { navigate } = useNavigation();
const { walletController, showError, setWalletInitialized } = useAppContext();
const { initializeWallet } = useAppContext();
const { setStatus } = useStatus();
// State
const [seedPhrase, setSeedPhrase] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [statusType, setStatusType] = useState<StatusType>('idle');
const [focusedElement, setFocusedElement] = useState<'input' | 'button'>('input');
const [isSubmitting, setIsSubmitting] = useState(false);
// Mnemonic file list state
const [mnemonicFiles, setMnemonicFiles] = useState<MnemonicFileEntry[]>([]);
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.
const [focusedSection, setFocusedSection] = useState<FocusSection>('input');
useEffect(() => {
const entries = loadMnemonicFiles();
setMnemonicFiles(entries);
if (entries.length > 0) setFocusedSection('files');
}, []);
/**
* The ordered list of focusable sections (files section only when entries exist).
*/
const focusSections: FocusSection[] = mnemonicFiles.length > 0
? ['files', 'input', 'generateRandomSeed', 'saveCheckbox', 'button']
: ['input', 'generateRandomSeed', 'saveCheckbox', 'button'];
/**
* Shows a status message with the given type.
*/
@@ -43,12 +129,55 @@ export function SeedInputScreen(): React.ReactElement {
}, []);
/**
* Handles seed phrase submission.
* Shared wallet initialization handler used by both manual entry and file selection.
*/
const doInitialize = useCallback(
async (seed: string, options?: { saveMnemonic?: boolean }) => {
showStatus('Initializing wallet...', 'loading');
setStatus('Initializing wallet...');
setIsSubmitting(true);
try {
await initializeWallet(seed);
let statusText = 'Wallet initialized successfully!';
if (options?.saveMnemonic) {
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}`;
}
}
showStatus(statusText, 'success');
setStatus('Wallet ready');
setSeedPhrase('');
setSaveMnemonicChecked(false);
setTimeout(() => {
navigate('wallet');
}, 500);
} catch (error) {
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.
*/
const handleSubmit = useCallback(async () => {
const seed = seedPhrase.trim();
// Basic validation
if (!seed) {
showStatus('Please enter your seed phrase', 'error');
return;
@@ -60,64 +189,89 @@ export function SeedInputScreen(): React.ReactElement {
return;
}
// Show loading status
showStatus('Initializing wallet...', 'loading');
setStatus('Initializing wallet...');
setIsSubmitting(true);
await doInitialize(seed, { saveMnemonic: saveMnemonicChecked });
}, [seedPhrase, saveMnemonicChecked, doInitialize, showStatus]);
try {
// Initialize wallet via controller
await walletController.initialize(seed);
/**
* Handles selecting a mnemonic file from the list.
*/
const handleFileSelect = useCallback(async (index: number) => {
const entry = mnemonicFiles[index];
if (!entry) return;
await doInitialize(entry.mnemonic);
}, [mnemonicFiles, doInitialize]);
showStatus('Wallet initialized successfully!', 'success');
setStatus('Wallet ready');
setWalletInitialized(true);
// Clear sensitive data before navigating
setSeedPhrase('');
// Navigate to wallet state screen
setTimeout(() => {
navigate('wallet');
}, 500);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to initialize wallet';
showStatus(message, 'error');
setStatus('Initialization failed');
setIsSubmitting(false);
}
}, [seedPhrase, walletController, navigate, showStatus, setStatus, setWalletInitialized]);
// Handle keyboard navigation
useInput((input, key) => {
// Keyboard navigation
useBlockableInput((input, key) => {
if (isSubmitting) return;
// Tab to switch focus
// Tab / Shift-Tab to cycle focus sections
if (key.tab) {
setFocusedElement(prev => prev === 'input' ? 'button' : 'input');
setFocusedSection((prev) => {
const idx = focusSections.indexOf(prev);
const next = key.shift
? (idx - 1 + focusSections.length) % focusSections.length
: (idx + 1) % focusSections.length;
return focusSections[next]!;
});
return;
}
// Enter on button submits
if (key.return && focusedElement === 'button') {
// 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
if (focusedSection === 'files' && mnemonicFiles.length > 0) {
if (key.upArrow) {
setSelectedFileIndex((prev) => Math.max(0, prev - 1));
return;
}
if (key.downArrow) {
setSelectedFileIndex((prev) => Math.min(mnemonicFiles.length - 1, prev + 1));
return;
}
if (key.return) {
handleFileSelect(selectedFileIndex);
return;
}
}
// Ctrl-R generates a random seed phrase and fills it in the input
if (key.ctrl && input === 'r') {
setSeedPhrase(generateBip39Mnemonic());
return;
}
// If pressing enter while the generate random seed section is focused, generate a random seed and fill it in the input
if (key.return && focusedSection === 'generateRandomSeed') {
setSeedPhrase(generateBip39Mnemonic());
return;
}
// Enter on button submits manual seed
if (key.return && focusedSection === 'button') {
handleSubmit();
}
});
// Get status color
// Derived style helpers
const statusColor = statusType === 'error' ? colors.error :
statusType === 'success' ? colors.success :
statusType === 'loading' ? colors.info :
colors.textMuted;
// Get border color based on status
const inputBorderColor = statusType === 'error' ? colors.error :
statusType === 'success' ? colors.success :
focusedElement === 'input' ? colors.focus :
colors.border;
focusedSection === 'input' ? colors.focus :
colors.borderMuted;
return (
<Box flexDirection="column" alignItems="center" paddingY={1}>
<Box flexDirection='column' alignItems='center' paddingY={1}>
{/* Logo */}
<Box marginBottom={1}>
<Text color={colors.primary}>{logo}</Text>
@@ -125,16 +279,84 @@ export function SeedInputScreen(): React.ReactElement {
{/* Title */}
<Text color={colors.text} bold>Welcome to XO Wallet CLI</Text>
<Text color={colors.textMuted}>Enter your seed phrase to get started</Text>
<Text color={colors.textMuted}>Enter your seed phrase or select a saved wallet</Text>
{/* Spacer */}
<Box marginY={1} />
{/* Input section */}
<Box flexDirection="column" width={64}>
<Text color={colors.text} bold>Seed Phrase (12 or 24 words):</Text>
<Box
borderStyle="single"
<Box flexDirection='column' width={64}>
{/* ── Saved Wallets ─────────────────────────────────── */}
{mnemonicFiles.length > 0 && (
<Box flexDirection='column' marginBottom={1}>
<Box marginBottom={1}>
<Text color={colors.primary} bold>
{'▸ '}Saved Wallets
</Text>
<Text color={colors.textMuted}> ({mnemonicFiles.length})</Text>
</Box>
<Box
flexDirection='column'
borderStyle='single'
borderColor={focusedSection === 'files' ? colors.focus : colors.borderMuted}
paddingX={1}
>
{mnemonicFiles.map((entry, idx) => {
const isHighlighted = focusedSection === 'files' && idx === selectedFileIndex;
return (
<Box key={entry.filename} paddingY={0}>
<Text
color={isHighlighted ? colors.bg : colors.textMuted}
backgroundColor={isHighlighted ? colors.focus : undefined}
bold={isHighlighted}
>
{isHighlighted ? ' ▶ ' : ' '}
</Text>
<Text
color={isHighlighted ? colors.bg : colors.text}
backgroundColor={isHighlighted ? colors.focus : undefined}
bold={isHighlighted}
>
{` ${entry.label} `}
</Text>
<Text
color={isHighlighted ? colors.bg : colors.textMuted}
backgroundColor={isHighlighted ? colors.focus : undefined}
dimColor={!isHighlighted}
>
{` (${entry.filename})`}
</Text>
</Box>
);
})}
</Box>
{focusedSection === 'files' && (
<Box marginTop={0} paddingX={1}>
<Text color={colors.textMuted} dimColor>
navigate Enter: load wallet
</Text>
</Box>
)}
</Box>
)}
{/* ── Divider between sections ──────────────────────── */}
{mnemonicFiles.length > 0 && (
<Box marginBottom={1} justifyContent='center'>
<Text color={colors.borderMuted}>{'─'.repeat(20)} or {'─'.repeat(20)}</Text>
</Box>
)}
{/* ── Manual Seed Entry ─────────────────────────────── */}
<Text color={colors.primary} bold>
{'▸ '}Manual Entry
</Text>
<Box marginTop={1}>
<Text color={colors.text}>Seed Phrase (12 or 24 words):</Text>
</Box>
<Box
borderStyle='single'
borderColor={inputBorderColor}
paddingX={1}
marginTop={1}
@@ -143,11 +365,50 @@ export function SeedInputScreen(): React.ReactElement {
value={seedPhrase}
onChange={setSeedPhrase}
onSubmit={handleSubmit}
placeholder="Enter your seed phrase..."
focus={focusedElement === 'input' && !isSubmitting}
placeholder='Enter your seed phrase...'
focus={focusedSection === 'input' && !isSubmitting}
/>
</Box>
{/* Generate random seed phrase and fill in the input */}
<Box marginTop={1}>
<Box
paddingX={1}
paddingY={0}
backgroundColor={focusedSection === 'generateRandomSeed' ? colors.focus : colors.bgSelected}
>
<Text color={focusedSection === 'generateRandomSeed' ? colors.bg : colors.text} bold>Generate Random Seed</Text>
</Box>
<Text color={colors.textMuted}> (Ctrl-R)</Text>
</Box>
{/* Save mnemonic checkbox (manual entry only; applies on Continue) */}
<Box
marginTop={1}
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 */}
<Box marginTop={1} height={1}>
{statusMessage && (
@@ -161,12 +422,12 @@ export function SeedInputScreen(): React.ReactElement {
</Box>
{/* Submit button */}
<Box justifyContent="center" marginTop={1}>
<Box justifyContent='center' marginTop={1}>
<Button
label="Continue"
focused={focusedElement === 'button'}
label='Continue'
focused={focusedSection === 'button'}
disabled={isSubmitting}
shortcut="Enter"
shortcut='Enter'
/>
</Box>
</Box>
@@ -174,7 +435,7 @@ export function SeedInputScreen(): React.ReactElement {
{/* Help text */}
<Box marginTop={2}>
<Text color={colors.textMuted} dimColor>
Tab: navigate Enter: submit q: quit
Tab: navigate Enter: submit, load wallet, or toggle save Space: toggle save Esc: back
</Text>
</Box>
</Box>

View File

@@ -6,13 +6,24 @@
* - Select a template and action to start a new transaction
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import { Screen } from '../components/Screen.js';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Box, Text } from 'ink';
import { ScrollableList, type ListItemData } from '../components/List.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { useBlockableInput } from '../hooks/useInputLayer.js';
import { colors, logoSmall } from '../theme.js';
import type { XOTemplate, XOTemplateStartingActions } from '@xo-cash/types';
// XO Imports
import { generateTemplateIdentifier } from '@xo-cash/engine';
import type { XOTemplate } from '@xo-cash/types';
// Import utility functions
import {
formatTemplateListItem,
formatActionListItem,
getTemplateRoles,
} from '../../utils/template-utils.js';
/**
* Template item with metadata.
@@ -20,7 +31,25 @@ import type { XOTemplate, XOTemplateStartingActions } from '@xo-cash/types';
interface TemplateItem {
template: XOTemplate;
templateIdentifier: string;
startingActions: XOTemplateStartingActions;
availableActions: TemplateActionItem[];
}
/**
* Template list item with TemplateItem value.
*/
type TemplateListItem = ListItemData<TemplateItem>;
/**
* Action list item with available action value.
*/
type ActionListItem = ListItemData<TemplateActionItem>;
interface TemplateActionItem {
actionIdentifier: string;
name: string;
description?: string;
roles: string[];
source: 'starting' | 'next' | 'starting+next';
}
/**
@@ -29,7 +58,7 @@ interface TemplateItem {
*/
export function TemplateListScreen(): React.ReactElement {
const { navigate } = useNavigation();
const { walletController, showError } = useAppContext();
const { appService, showError } = useAppContext();
const { setStatus } = useStatus();
// State
@@ -40,21 +69,104 @@ export function TemplateListScreen(): React.ReactElement {
const [isLoading, setIsLoading] = useState(true);
/**
* Loads templates from the wallet controller.
* Loads templates from the engine.
*/
const loadTemplates = useCallback(async () => {
if (!appService) {
showError('AppService not initialized');
return;
}
try {
setIsLoading(true);
setStatus('Loading templates...');
const templateList = await walletController.getTemplates();
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
const templateList = await appService.engine.listImportedTemplates();
const allUtxos = await appService.engine.listUnspentOutputsData();
const ownedOutputsByTemplate = new Map<string, Set<string>>();
for (const utxo of allUtxos) {
const existing = ownedOutputsByTemplate.get(utxo.templateIdentifier) ?? new Set<string>();
existing.add(utxo.outputIdentifier);
ownedOutputsByTemplate.set(utxo.templateIdentifier, existing);
}
const loadedTemplates = await Promise.all(
templateList.map(async (template) => {
const templateIdentifier = generateTemplateIdentifier(template);
const startingActions = await walletController.getStartingActions(templateIdentifier);
return { template, templateIdentifier, startingActions };
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
const actionMap = new Map<string, TemplateActionItem>();
for (const startingAction of rawStartingActions) {
const existing = actionMap.get(startingAction.action);
if (existing) {
if (!existing.roles.includes(startingAction.role ?? '')) {
existing.roles.push(startingAction.role ?? '');
}
continue;
}
const actionDef = template.actions?.[startingAction.action];
actionMap.set(startingAction.action, {
actionIdentifier: startingAction.action,
name: actionDef?.name || startingAction.action,
description: actionDef?.description,
roles: [startingAction.role ?? ''],
source: 'starting',
});
}
const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>();
for (const outputIdentifier of ownedOutputIdentifiers) {
const outputDef = template.outputs?.[outputIdentifier];
if (!outputDef || typeof outputDef.lockingScript !== 'string') continue;
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockingScript] as
| { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> }
| undefined;
if (!lockingScriptDefinition?.roles) continue;
for (const [lockscriptRoleId, lockscriptRoleDef] of Object.entries(lockingScriptDefinition.roles)) {
for (const actionSpec of lockscriptRoleDef.actions ?? []) {
const actionIdentifier = typeof actionSpec === 'string'
? actionSpec
: actionSpec.action;
if (!actionIdentifier) continue;
const roleIdentifier = typeof actionSpec === 'string'
? lockscriptRoleId
: (actionSpec.role ?? lockscriptRoleId);
const existing = actionMap.get(actionIdentifier);
if (existing) {
if (!existing.roles.includes(roleIdentifier)) {
existing.roles.push(roleIdentifier);
}
if (existing.source === 'starting') {
existing.source = 'starting+next';
}
continue;
}
const actionDef = template.actions?.[actionIdentifier];
actionMap.set(actionIdentifier, {
actionIdentifier,
name: actionDef?.name || actionIdentifier,
description: actionDef?.description,
roles: [roleIdentifier],
source: 'next',
});
}
}
}
const availableActions = Array.from(actionMap.values()).sort((a, b) => a.name.localeCompare(b.name));
return {
template,
templateIdentifier,
availableActions,
};
})
);
@@ -67,7 +179,7 @@ export function TemplateListScreen(): React.ReactElement {
showError(`Failed to load templates: ${error instanceof Error ? error.message : String(error)}`);
setIsLoading(false);
}
}, [walletController, setStatus, showError]);
}, [appService, setStatus, showError]);
// Load templates on mount
useEffect(() => {
@@ -76,57 +188,118 @@ export function TemplateListScreen(): React.ReactElement {
// Get current template and its actions
const currentTemplate = templates[selectedTemplateIndex];
const currentActions = currentTemplate?.startingActions ?? [];
const currentActions = currentTemplate?.availableActions ?? [];
/**
* Build template list items for ScrollableList.
*/
const templateListItems = useMemo((): TemplateListItem[] => {
return templates.map((item, index) => {
const formatted = formatTemplateListItem(item.template, index);
return {
key: item.templateIdentifier,
label: formatted.label,
description: formatted.description,
value: item,
hidden: !formatted.isValid,
};
});
}, [templates]);
/**
* Build action list items for ScrollableList.
*/
const actionListItems = useMemo((): ActionListItem[] => {
return currentActions.map((action, index) => {
const formatted = formatActionListItem(
action.actionIdentifier,
currentTemplate?.template?.actions?.[action.actionIdentifier],
action.roles.length,
index
);
return {
key: action.actionIdentifier,
label: `${formatted.label}`,
description: formatted.description,
value: action,
hidden: !formatted.isValid,
};
});
}, [currentActions, currentTemplate]);
/**
* Handle template selection change.
*/
const handleTemplateSelect = useCallback((index: number) => {
setSelectedTemplateIndex(index);
setSelectedActionIndex(0); // Reset action selection when template changes
}, []);
/**
* Handles action selection.
* Navigates to the Action Wizard where the user will choose their role.
*/
const handleActionSelect = useCallback(() => {
if (!currentTemplate || currentActions.length === 0) return;
const handleActionActivate = useCallback((item: ActionListItem, index: number) => {
if (!currentTemplate || !item.value) return;
const action = currentActions[selectedActionIndex];
if (!action) return;
const action = item.value;
// Navigate to action wizard with selected template and action
// Navigate to the Action Wizard — role selection happens there
navigate('wizard', {
templateIdentifier: currentTemplate.templateIdentifier,
actionIdentifier: action.action,
roleIdentifier: action.role,
actionIdentifier: action.actionIdentifier,
actionRoles: action.roles,
template: currentTemplate.template,
});
}, [currentTemplate, currentActions, selectedActionIndex, navigate]);
}, [currentTemplate, navigate]);
// Handle keyboard navigation
useInput((input, key) => {
// Tab to switch panels
useBlockableInput((_input, key) => {
if (key.tab) {
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
return;
}
// Up/Down navigation
if (key.upArrow || input === 'k') {
if (focusedPanel === 'templates') {
setSelectedTemplateIndex(prev => Math.max(0, prev - 1));
setSelectedActionIndex(0); // Reset action selection when template changes
} else {
setSelectedActionIndex(prev => Math.max(0, prev - 1));
}
} else if (key.downArrow || input === 'j') {
if (focusedPanel === 'templates') {
setSelectedTemplateIndex(prev => Math.min(templates.length - 1, prev + 1));
setSelectedActionIndex(0); // Reset action selection when template changes
} else {
setSelectedActionIndex(prev => Math.min(currentActions.length - 1, prev + 1));
}
}
// Enter to select action
if (key.return && focusedPanel === 'actions') {
handleActionSelect();
}
});
/**
* Render custom template list item.
*/
const renderTemplateItem = useCallback((
item: TemplateListItem,
isSelected: boolean,
isFocused: boolean
): React.ReactNode => {
return (
<Text
color={isFocused ? colors.focus : colors.text}
bold={isSelected}
>
{isFocused ? '▸ ' : ' '}
{item.label}
</Text>
);
}, []);
/**
* Render custom action list item.
*/
const renderActionItem = useCallback((
item: ActionListItem,
isSelected: boolean,
isFocused: boolean
): React.ReactNode => {
return (
<Text
color={isFocused ? colors.focus : colors.text}
bold={isSelected}
>
{isFocused ? '▸ ' : ' '}
{item.label}
</Text>
);
}, []);
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
@@ -146,24 +319,20 @@ export function TemplateListScreen(): React.ReactElement {
flexGrow={1}
>
<Text color={colors.primary} bold> Templates </Text>
<Box marginTop={1} flexDirection="column">
{isLoading ? (
{isLoading ? (
<Box marginTop={1}>
<Text color={colors.textMuted}>Loading...</Text>
) : templates.length === 0 ? (
<Text color={colors.textMuted}>No templates imported</Text>
) : (
templates.map((item, index) => (
<Text
key={item.templateIdentifier}
color={index === selectedTemplateIndex ? colors.focus : colors.text}
bold={index === selectedTemplateIndex}
>
{index === selectedTemplateIndex && focusedPanel === 'templates' ? '▸ ' : ' '}
{index + 1}. {item.template.name || 'Unnamed Template'}
</Text>
))
)}
</Box>
</Box>
) : (
<ScrollableList
items={templateListItems}
selectedIndex={selectedTemplateIndex}
onSelect={handleTemplateSelect}
focus={focusedPanel === 'templates'}
emptyMessage="No templates imported"
renderItem={renderTemplateItem}
/>
)}
</Box>
</Box>
@@ -176,29 +345,26 @@ export function TemplateListScreen(): React.ReactElement {
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Starting Actions </Text>
<Box marginTop={1} flexDirection="column">
{!currentTemplate ? (
<Text color={colors.primary} bold> Available Actions </Text>
{isLoading ? (
<Box marginTop={1}>
<Text color={colors.textMuted}>Loading...</Text>
</Box>
) : !currentTemplate ? (
<Box marginTop={1}>
<Text color={colors.textMuted}>Select a template...</Text>
) : currentActions.length === 0 ? (
<Text color={colors.textMuted}>No starting actions available</Text>
) : (
currentActions.map((action, index) => {
const actionDef = currentTemplate.template.actions?.[action.action];
const name = actionDef?.name || action.action;
return (
<Text
key={`${action.action}-${action.role}`}
color={index === selectedActionIndex ? colors.focus : colors.text}
bold={index === selectedActionIndex}
>
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
{index + 1}. {name} (as {action.role})
</Text>
);
})
)}
</Box>
</Box>
) : (
<ScrollableList
items={actionListItems}
selectedIndex={selectedActionIndex}
onSelect={setSelectedActionIndex}
onActivate={handleActionActivate}
focus={focusedPanel === 'actions'}
emptyMessage="No actions available"
renderItem={renderActionItem}
/>
)}
</Box>
</Box>
</Box>
@@ -214,7 +380,9 @@ export function TemplateListScreen(): React.ReactElement {
width="100%"
>
<Text color={colors.primary} bold> Description </Text>
{currentTemplate ? (
{/* Show template description when templates panel is focused */}
{focusedPanel === 'templates' && currentTemplate ? (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text} bold>
{currentTemplate.template.name || 'Unnamed Template'}
@@ -230,17 +398,63 @@ export function TemplateListScreen(): React.ReactElement {
{currentTemplate.template.roles && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Roles:</Text>
{Object.entries(currentTemplate.template.roles).map(([roleId, role]) => (
<Text key={roleId} color={colors.textMuted}>
{' '}- {role.name || roleId}: {role.description || 'No description'}
{getTemplateRoles(currentTemplate.template).map((role) => (
<Text key={role.roleId} color={colors.textMuted}>
{' '}- {role.name}: {role.description || 'No description'}
</Text>
))}
</Box>
)}
</Box>
) : (
) : focusedPanel === 'templates' && !currentTemplate ? (
<Text color={colors.textMuted}>Select a template to see details</Text>
)}
) : null}
{/* Show action description when actions panel is focused */}
{focusedPanel === 'actions' && currentTemplate && currentActions.length > 0 ? (
<Box marginTop={1} flexDirection="column">
{(() => {
const action = currentActions[selectedActionIndex];
if (!action) return null;
return (
<>
<Text color={colors.text} bold>
{action.name}
</Text>
<Text color={colors.textMuted}>
{action.description || 'No description available'}
</Text>
{/* List roles available for this action in current context */}
{action.roles.length > 0 && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Available Roles:</Text>
{action.roles.map((roleId) => {
const roleDef = currentTemplate.template.roles?.[roleId];
const roleName = typeof roleDef === 'object' ? roleDef?.name ?? roleId : roleId;
const roleDescription = typeof roleDef === 'object' ? roleDef?.description : undefined;
return (
<Text key={roleId} color={colors.textMuted}>
{' '}- {roleName}
{roleDescription ? `: ${roleDescription}` : ''}
</Text>
);
})}
<Text color={colors.textMuted}>
{' '}Source: {action.source}
</Text>
</Box>
)}
</>
);
})()}
</Box>
) : focusedPanel === 'actions' && !currentTemplate ? (
<Text color={colors.textMuted}>Select a template first</Text>
) : focusedPanel === 'actions' && currentActions.length === 0 ? (
<Text color={colors.textMuted}>No actions available</Text>
) : null}
</Box>
</Box>

View File

@@ -9,13 +9,14 @@
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { ConfirmDialog } from '../components/Dialog.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { useBlockableInput } from '../hooks/useInputLayer.js';
import { useInvitation } from '../hooks/useInvitations.js';
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
import { copyToClipboard } from '../utils/clipboard.js';
import type { XOInvitation } from '@xo-cash/types';
/**
* Action menu items.
@@ -32,59 +33,51 @@ const actionItems = [
*/
export function TransactionScreen(): React.ReactElement {
const { navigate, goBack, data: navData } = useNavigation();
const { invitationController, showError, showInfo, confirm } = useAppContext();
const { showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
// Extract invitation ID from navigation data
const invitationId = navData.invitationId as string | undefined;
// Use hook to get invitation reactively
const invitationInstance = useInvitation(invitationId ?? null);
// State
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
const [focusedPanel, setFocusedPanel] = useState<'inputs' | 'outputs' | 'actions'>('actions');
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [showBroadcastConfirm, setShowBroadcastConfirm] = useState(false);
/**
* Load invitation data.
*/
const loadInvitation = useCallback(() => {
// Check if invitation exists
useEffect(() => {
if (!invitationId) {
showError('No invitation ID provided');
goBack();
return;
}
const tracked = invitationController.getInvitation(invitationId);
if (!tracked) {
if (invitationId && !invitationInstance) {
showError('Invitation not found');
goBack();
return;
}
}, [invitationId, invitationInstance, showError, goBack]);
setInvitation(tracked.invitation);
}, [invitationId, invitationController, showError, goBack]);
// Load on mount
useEffect(() => {
loadInvitation();
}, [loadInvitation]);
const invitation = invitationInstance?.data ?? null;
/**
* Broadcast transaction.
*/
const broadcastTransaction = useCallback(async () => {
if (!invitationId) return;
if (!invitationInstance) return;
setShowBroadcastConfirm(false);
setIsLoading(true);
setStatus('Broadcasting transaction...');
try {
const txHash = await invitationController.broadcastTransaction(invitationId);
await invitationInstance.broadcast();
showInfo(
`Transaction Broadcast Successful!\n\n` +
`Transaction Hash:\n${txHash}\n\n` +
`The transaction has been submitted to the network.`
);
navigate('wallet');
@@ -94,20 +87,19 @@ export function TransactionScreen(): React.ReactElement {
setIsLoading(false);
setStatus('Ready');
}
}, [invitationId, invitationController, showInfo, showError, navigate, setStatus]);
}, [invitationInstance, showInfo, showError, navigate, setStatus]);
/**
* Sign transaction.
*/
const signTransaction = useCallback(async () => {
if (!invitationId) return;
if (!invitationInstance) return;
setIsLoading(true);
setStatus('Signing transaction...');
try {
await invitationController.signInvitation(invitationId);
loadInvitation();
await invitationInstance.sign();
showInfo('Transaction signed successfully!');
} catch (error) {
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
@@ -115,7 +107,7 @@ export function TransactionScreen(): React.ReactElement {
setIsLoading(false);
setStatus('Ready');
}
}, [invitationId, invitationController, loadInvitation, showInfo, showError, setStatus]);
}, [invitationInstance, showInfo, showError, setStatus]);
/**
* Copy transaction hex.
@@ -155,10 +147,8 @@ export function TransactionScreen(): React.ReactElement {
}
}, [signTransaction, copyTransactionHex, goBack]);
// Handle keyboard navigation
useInput((input, key) => {
if (showBroadcastConfirm) return;
// Handle keyboard navigation — automatically blocked when the confirm dialog is open.
useBlockableInput((input, key) => {
// Tab to switch panels
if (key.tab) {
setFocusedPanel(prev => {
@@ -185,7 +175,7 @@ export function TransactionScreen(): React.ReactElement {
handleAction(action.value);
}
}
}, { isActive: !showBroadcastConfirm });
});
// Extract transaction data from invitation
const commits = invitation?.commits ?? [];
@@ -273,24 +263,24 @@ export function TransactionScreen(): React.ReactElement {
const hasUnresolvedInputs = inputs.length > 0; // Input values are always unknown from commit data
return (
<Box flexDirection="column" flexGrow={1}>
<Box flexDirection='column' flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
<Box borderStyle='single' borderColor={colors.secondary} paddingX={1}>
<Text color={colors.primary} bold>{logoSmall} - Transaction Review</Text>
</Box>
{/* Summary box */}
<Box
borderStyle="single"
borderStyle='single'
borderColor={colors.primary}
marginTop={1}
marginX={1}
paddingX={1}
flexDirection="column"
flexDirection='column'
>
<Text color={colors.primary} bold> Transaction Summary </Text>
{invitation ? (
<Box flexDirection="column" marginTop={1}>
<Box flexDirection='column' marginTop={1}>
<Text color={colors.text}>Inputs: {inputs.length} | Outputs: {resolvedOutputs.length} | Commits: {commits.length}</Text>
{hasUnresolvedInputs && (
<Text color={colors.textMuted}>Total In: (requires UTXO lookup)</Text>
@@ -308,22 +298,22 @@ export function TransactionScreen(): React.ReactElement {
</Box>
{/* Inputs and Outputs */}
<Box flexDirection="row" marginTop={1} marginX={1} flexGrow={1}>
<Box flexDirection='row' marginTop={1} marginX={1} flexGrow={1}>
{/* Inputs */}
<Box
borderStyle="single"
borderStyle='single'
borderColor={focusedPanel === 'inputs' ? colors.focus : colors.border}
width="50%"
flexDirection="column"
width='50%'
flexDirection='column'
paddingX={1}
>
<Text color={colors.primary} bold> Inputs </Text>
<Box flexDirection="column" marginTop={1}>
<Box flexDirection='column' marginTop={1}>
{inputs.length === 0 ? (
<Text color={colors.textMuted}>No inputs</Text>
) : (
inputs.map((input, index) => (
<Box key={`${input.txid}-${input.index}`} flexDirection="column" marginBottom={1}>
<Box key={`${input.txid}-${input.index}`} flexDirection='column' marginBottom={1}>
<Text color={colors.text}>
{index + 1}. {formatHex(input.txid, 12)}:{input.index}
</Text>
@@ -338,20 +328,20 @@ export function TransactionScreen(): React.ReactElement {
{/* Outputs */}
<Box
borderStyle="single"
borderStyle='single'
borderColor={focusedPanel === 'outputs' ? colors.focus : colors.border}
width="50%"
flexDirection="column"
width='50%'
flexDirection='column'
paddingX={1}
marginLeft={1}
>
<Text color={colors.primary} bold> Outputs </Text>
<Box flexDirection="column" marginTop={1}>
<Box flexDirection='column' marginTop={1}>
{resolvedOutputs.length === 0 ? (
<Text color={colors.textMuted}>No outputs</Text>
) : (
resolvedOutputs.map((output, index) => (
<Box key={index} flexDirection="column" marginBottom={1}>
<Box key={index} flexDirection='column' marginBottom={1}>
<Text color={colors.text}>
{index + 1}. {output.value !== undefined ? formatSatoshis(output.value) : '(pending)'}
{output.outputIdentifier && (
@@ -371,15 +361,15 @@ export function TransactionScreen(): React.ReactElement {
{/* Actions */}
<Box
borderStyle="single"
borderStyle='single'
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
marginTop={1}
marginX={1}
paddingX={1}
flexDirection="column"
flexDirection='column'
>
<Text color={colors.primary} bold> Actions </Text>
<Box flexDirection="column" marginTop={1}>
<Box flexDirection='column' marginTop={1}>
{actionItems.map((item, index) => (
<Text
key={item.value}
@@ -403,19 +393,18 @@ export function TransactionScreen(): React.ReactElement {
{/* Broadcast confirmation dialog */}
{showBroadcastConfirm && (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
height="100%"
position='absolute'
flexDirection='column'
alignItems='center'
justifyContent='center'
width='100%'
height='100%'
>
<ConfirmDialog
title="Broadcast Transaction"
message="Are you sure you want to broadcast this transaction? This action cannot be undone."
title='Broadcast Transaction'
message='Are you sure you want to broadcast this transaction? This action cannot be undone.'
onConfirm={broadcastTransaction}
onCancel={() => setShowBroadcastConfirm(false)}
isActive={showBroadcastConfirm}
/>
</Box>
)}

View File

@@ -1,83 +1,176 @@
/**
* Wallet State Screen - Displays wallet balances and UTXOs.
* Wallet State Screen - Displays wallet balances and history.
*
* Shows:
* - Total balance
* - List of unspent outputs
* - Wallet history (invitations, reservations)
* - Navigation to other actions
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import SelectInput from 'ink-select-input';
import { Screen } from '../components/Screen.js';
import { List, type ListItem } from '../components/List.js';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Box, Text } from 'ink';
import { ScrollableList, type ListItemData } from '../components/List.js';
import { QRCode } from '../components/QRCode.js';
import { CurrencySelectionDialog } from '../components/CurrencySelectionDialog.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { useSatoshisConversion } from '../hooks/useSatoshisConversion.js';
import { useInputLayer, useLayeredInput, useBlockableInput, useIsInputCaptured } from '../hooks/useInputLayer.js';
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
import type { HistoryItem } from '../../services/history.js';
import { generateTemplateIdentifier } from '@xo-cash/engine';
import { hexToBin, lockingBytecodeToCashAddress } from '@bitauth/libauth';
// Import utility functions
import {
buildHistoryDisplayRows,
getHistoryItemColorName,
formatHistoryDate,
type HistoryDisplayRow,
type HistoryColorName,
} from '../../utils/history-utils.js';
import { copyToClipboard } from '../utils/clipboard.js';
/**
* Map history color name to theme color.
*/
function getHistoryColor(colorName: HistoryColorName): string {
switch (colorName) {
case 'info':
return colors.info as string;
case 'warning':
return colors.warning as string;
case 'success':
return colors.success as string;
case 'error':
return colors.error as string;
case 'muted':
return colors.textMuted as string;
case 'text':
default:
return colors.text as string;
}
}
/**
* Menu action items.
*/
const menuItems = [
{ label: 'New Transaction (from template)', value: 'new-tx' },
{ label: 'Import Invitation', value: 'import' },
{ label: 'View Invitations', value: 'invitations' },
{ label: 'Generate New Address', value: 'new-address' },
{ label: 'Refresh', value: 'refresh' },
const menuItems: ListItemData<string>[] = [
{ key: 'new-tx', label: 'New Transaction (from template)', value: 'new-tx' },
{ key: 'import', label: 'Import Invitation', value: 'import' },
{ key: 'invitations', label: 'View Invitations', value: 'invitations' },
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' },
{ key: 'set-currency', label: 'Set Fiat Currency', value: 'set-currency' },
{ key: 'unreserve-all', label: 'Unreserve All Resources', value: 'unreserve-all' },
{ key: 'refresh', label: 'Refresh', value: 'refresh' },
];
/**
* UTXO display item.
* History list item with display row value.
*/
interface UTXOItem {
key: string;
satoshis: bigint;
txid: string;
index: number;
reserved: boolean;
type HistoryListItem = ListItemData<HistoryDisplayRow>;
/**
* QR code dialog overlay — auto-captures input via the layer system.
* Rendered only while a QR address is visible; closes on Enter/Esc.
*/
function QRDialogOverlay({ address, onClose }: { address: string; onClose: () => void }): React.ReactElement {
useInputLayer('qr-dialog');
useLayeredInput('qr-dialog', (input, key) => {
if (input === 'c' || input === 'C') {
copyToClipboard(address);
}
if (key.escape || key.return) {
onClose();
}
});
return (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
width="100%"
>
<QRCode
value={address}
dialog
dialogTitle="Receive Address"
showValue
subtitle={
<Box flexDirection="column" justifyContent="center" marginTop={1}>
<Text color={colors.textMuted}>Press C to copy to clipboard</Text>
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
</Box>
}
/>
</Box>
);
}
/**
* Wallet State Screen Component.
* Displays wallet balance, UTXOs, and action menu.
* Displays wallet balance, history, and action menu.
*/
export function WalletStateScreen(): React.ReactElement {
const { navigate } = useNavigation();
const { walletController, showError, showInfo } = useAppContext();
const { appService, showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
const {
currencyCode,
fiatPerBchRate,
formattedFiatPerBchRate,
formatSatoshisToFiat,
} = useSatoshisConversion();
// State
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
const [utxos, setUtxos] = useState<UTXOItem[]>([]);
const [focusedPanel, setFocusedPanel] = useState<'menu' | 'utxos'>('menu');
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
const [history, setHistory] = useState<HistoryItem[]>([]);
const [focusedPanel, setFocusedPanel] = useState<'menu' | 'history'>('menu');
const [selectedMenuIndex, setSelectedMenuIndex] = useState(0);
const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0);
const [isLoading, setIsLoading] = useState(true);
/** Cash address to display in the QR code dialog (null when dialog is hidden). */
const [qrAddress, setQrAddress] = useState<string | null>(null);
/** Whether the fiat currency selection dialog is open. */
const [isCurrencyDialogOpen, setCurrencyDialogOpen] = useState(false);
/** Loading state for rates pair discovery. */
const [isLoadingCurrencyPairs, setLoadingCurrencyPairs] = useState(false);
/** Optional error message shown in the currency dialog. */
const [currencyPairsError, setCurrencyPairsError] = useState<string | null>(null);
/** Available fiat currencies derived from rates pairs in X/BCH format. */
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([]);
/**
* Refreshes wallet state.
*/
const refresh = useCallback(async () => {
if (!appService) {
showError('AppService not initialized');
return;
}
try {
setIsLoading(true);
setStatus('Loading wallet state...');
// Get balance
const balanceData = await walletController.getBalance();
// Get UTXOs for balance calculation
const utxoData = await appService.engine.listUnspentOutputsData();
// Calculate balance
const selectableUtxos = utxoData.filter(utxo => utxo.selectable);
const balanceData = selectableUtxos.reduce((acc, utxo) => acc + BigInt(utxo.valueSatoshis), BigInt(0));
setBalance({
totalSatoshis: balanceData.totalSatoshis,
utxoCount: balanceData.utxoCount,
totalSatoshis: balanceData,
utxoCount: selectableUtxos.length,
});
// Get UTXOs
const utxoData = await walletController.getUnspentOutputs();
setUtxos(utxoData.map((utxo) => ({
key: `${utxo.outpointTransactionHash}:${utxo.outpointIndex}`,
satoshis: BigInt(utxo.valueSatoshis),
txid: utxo.outpointTransactionHash,
index: utxo.outpointIndex,
reserved: utxo.reserved ?? false,
})));
// Get wallet history from the history service
const historyData = await appService.history.getHistory();
setHistory(historyData);
setStatus('Wallet ready');
setIsLoading(false);
@@ -85,22 +178,42 @@ export function WalletStateScreen(): React.ReactElement {
showError(`Failed to load wallet state: ${error instanceof Error ? error.message : String(error)}`);
setIsLoading(false);
}
}, [walletController, setStatus, showError]);
}, [appService, setStatus, showError]);
// Load wallet state on mount
useEffect(() => {
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.
* Generates a new receiving address and displays it as a QR code.
*/
const generateNewAddress = useCallback(async () => {
if (!appService) {
showError('AppService not initialized');
return;
}
try {
setStatus('Generating new address...');
// Get the default P2PKH template
const templates = await walletController.getTemplates();
const templates = await appService.engine.listImportedTemplates();
const p2pkhTemplate = templates.find(t => t.name?.includes('P2PKH'));
if (!p2pkhTemplate) {
@@ -108,30 +221,143 @@ export function WalletStateScreen(): React.ReactElement {
return;
}
// Generate a new locking bytecode
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
// Generate the template identifier
const templateId = generateTemplateIdentifier(p2pkhTemplate);
const lockingBytecode = await walletController.generateLockingBytecode(
// Generate the locking bytecode (returned as a hex string)
const lockingBytecodeHex = await appService.engine.generateLockingBytecode(
templateId,
'receiveOutput',
'receiver',
);
showInfo(`New address generated!\n\nLocking bytecode:\n${formatHex(lockingBytecode, 40)}`);
// Convert the locking bytecode to a BCH cash address for display and QR encoding.
const result = lockingBytecodeToCashAddress({ bytecode: hexToBin(lockingBytecodeHex), prefix: 'bitcoincash' });
if (typeof result === 'string') {
showError(`Failed to encode address: ${result}`);
return;
}
console.log(result);
setQrAddress(result.address);
setStatus('Address generated');
// Refresh to show updated state
await refresh();
} catch (error) {
showError(`Failed to generate address: ${error instanceof Error ? error.message : String(error)}`);
}
}, [walletController, setStatus, showInfo, showError, refresh]);
}, [appService, setStatus, showError, refresh]);
/**
* Handles menu selection.
* Unreserves all reserved UTXOs and refreshes the wallet state.
*/
const handleMenuSelect = useCallback((item: { value: string }) => {
switch (item.value) {
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]);
/**
* Loads all available rates pairs, then extracts fiat numerator symbols from
* pairs shaped like X/BCH.
*
* We retry briefly because rates startup is asynchronous and metadata can take
* a moment to hydrate right after wallet initialization.
*/
const loadAvailableCurrencies = useCallback(async (): Promise<void> => {
if (!appService) {
setCurrencyPairsError("AppService not initialized");
return;
}
setLoadingCurrencyPairs(true);
setCurrencyPairsError(null);
try {
let pairs = new Set<string>();
// Retry a few times so we can catch late metadata initialization.
for (let attempt = 0; attempt < 4; attempt += 1) {
pairs = await appService.rates.listPairs();
if (pairs.size > 0) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
const currencies = Array.from(pairs)
.map((pair) => pair.toUpperCase())
.filter((pair) => pair.endsWith("/BCH"))
.map((pair) => pair.split("/")[0] ?? "")
.filter((currency) => currency.length > 0)
.sort((a, b) => a.localeCompare(b));
const uniqueCurrencies = Array.from(new Set(currencies));
setAvailableCurrencies(uniqueCurrencies);
if (uniqueCurrencies.length === 0) {
setCurrencyPairsError(
"No X/BCH rates are currently available. Try again in a moment.",
);
}
} catch (error) {
setCurrencyPairsError(
`Failed to load currency pairs: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
setLoadingCurrencyPairs(false);
}
}, [appService]);
/**
* Opens the fiat currency dialog and triggers pair discovery.
*/
const openCurrencyDialog = useCallback(() => {
setCurrencyDialogOpen(true);
void loadAvailableCurrencies();
}, [loadAvailableCurrencies]);
/**
* Applies the selected fiat currency to persisted settings.
*/
const applyCurrencySelection = useCallback(
(currencyCode: string) => {
if (!appService) {
showError("AppService not initialized");
return;
}
try {
appService.settings.setCurrency(currencyCode);
setStatus(`Fiat currency updated to ${currencyCode}`);
setCurrencyDialogOpen(false);
} catch (error) {
showError(
`Failed to update currency: ${error instanceof Error ? error.message : String(error)}`,
);
}
},
[appService, setStatus, showError],
);
/**
* Handles menu action.
*/
const handleMenuAction = useCallback((action: string) => {
switch (action) {
case 'new-tx':
navigate('templates');
break;
@@ -144,25 +370,150 @@ export function WalletStateScreen(): React.ReactElement {
case 'new-address':
generateNewAddress();
break;
case 'set-currency':
openCurrencyDialog();
break;
case 'unreserve-all':
unreserveAll();
break;
case 'refresh':
refresh();
break;
}
}, [navigate, generateNewAddress, refresh]);
}, [navigate, generateNewAddress, openCurrencyDialog, unreserveAll, refresh]);
// Handle keyboard navigation between panels
useInput((input, key) => {
/**
* Handle menu item activation.
*/
const handleMenuItemActivate = useCallback((item: ListItemData<string>, index: number) => {
if (item.value) {
handleMenuAction(item.value);
}
}, [handleMenuAction]);
/**
* Build history list items for ScrollableList.
*/
const historyListItems = useMemo((): HistoryListItem[] => {
return buildHistoryDisplayRows(history).map(row => {
return {
key: row.id,
label: row.label,
description: row.description,
value: row,
color: getHistoryItemColorName(row, false),
hidden: false,
};
});
}, [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.
const isCaptured = useIsInputCaptured();
useBlockableInput((_input, key) => {
if (key.tab) {
setFocusedPanel(prev => prev === 'menu' ? 'utxos' : 'menu');
setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu');
}
});
// Convert UTXOs to list items
const utxoListItems: ListItem[] = utxos.map((utxo, index) => ({
key: utxo.key,
label: `${formatSatoshis(utxo.satoshis)} | ${formatHex(utxo.txid, 16)}:${utxo.index}`,
description: utxo.reserved ? '[Reserved]' : undefined,
}));
/**
* Render custom history list item.
*/
const renderHistoryItem = useCallback((
item: HistoryListItem,
isSelected: boolean,
isFocused: boolean
): React.ReactNode => {
const row = item.value;
if (!row) return null;
const colorName = getHistoryItemColorName(row, isFocused);
const itemColor = isFocused ? colors.focus : getHistoryColor(colorName);
const dateStr = formatHistoryDate(row.timestamp);
const indicator = isFocused ? '▸ ' : ' ';
const groupingPrefix = row.isNested ? ' -> ' : '';
if (row.type === 'history_item') {
const sats = row.valueSatoshis ?? 0n;
const fiatSuffix = getFiatSuffix(sats);
return (
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row">
<Text color={itemColor}>
{indicator}{formatSatoshis(sats)}{fiatSuffix}
</Text>
<Text color={colors.textMuted}> {row.label}</Text>
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
}
if (row.type === 'history_input') {
const sats = row.valueSatoshis ?? 0n;
return (
<Box flexDirection="row" justifyContent="space-between">
<Box>
<Text color={itemColor}>
{indicator}{groupingPrefix}[Input] {formatSatoshis(sats)}
{getFiatSuffix(sats)}
</Text>
<Text color={colors.textMuted}> {row.label}</Text>
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
}
if (row.type === 'history_output') {
const sats = row.valueSatoshis ?? 0n;
const reservedTag = row.reserved ? ' [Reserved]' : '';
return (
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row">
<Text color={itemColor}>
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
{getFiatSuffix(sats)}
</Text>
<Text color={colors.textMuted}> {row.label}{reservedTag}</Text>
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
}
// Fallback for other types
return (
<Box flexDirection="row" justifyContent="space-between">
<Text color={itemColor}>
{indicator}{row.label}
</Text>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
}, [getFiatSuffix]);
return (
<Box flexDirection="column" flexGrow={1}>
@@ -194,6 +545,20 @@ export function WalletStateScreen(): React.ReactElement {
<Text color={colors.success} bold>
{formatSatoshis(balance.totalSatoshis)}
</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}>
UTXOs: {balance.utxoCount}
</Text>
@@ -218,59 +583,45 @@ export function WalletStateScreen(): React.ReactElement {
paddingX={1}
>
<Text color={colors.primary} bold> Actions </Text>
<Box marginTop={1}>
<SelectInput
items={menuItems}
onSelect={handleMenuSelect}
isFocused={focusedPanel === 'menu'}
indicatorComponent={({ isSelected }) => (
<Text color={isSelected ? colors.focus : colors.text}>
{isSelected ? '▸ ' : ' '}
</Text>
)}
itemComponent={({ isSelected, label }) => (
<Text
color={isSelected ? colors.text : colors.textMuted}
bold={isSelected}
>
{label}
</Text>
)}
/>
</Box>
<ScrollableList
items={menuItems}
selectedIndex={selectedMenuIndex}
onSelect={setSelectedMenuIndex}
onActivate={handleMenuItemActivate}
focus={focusedPanel === 'menu' && !isCaptured}
emptyMessage="No actions"
/>
</Box>
</Box>
</Box>
{/* UTXO list */}
{/* Wallet History */}
<Box marginTop={1} flexGrow={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'utxos' ? colors.focus : colors.border}
borderColor={focusedPanel === 'history' ? colors.focus : colors.border}
flexDirection="column"
paddingX={1}
width="100%"
height={14}
overflow="hidden"
>
<Text color={colors.primary} bold> Unspent Outputs (UTXOs) </Text>
<Box marginTop={1} flexDirection="column">
{isLoading ? (
<Text color={colors.primary} bold> Wallet History {historyListItems.length > 0 ? `(${selectedHistoryIndex + 1}/${historyListItems.length})` : ''}</Text>
{isLoading ? (
<Box marginTop={1}>
<Text color={colors.textMuted}>Loading...</Text>
) : utxoListItems.length === 0 ? (
<Text color={colors.textMuted}>No unspent outputs found</Text>
) : (
utxoListItems.map((item, index) => (
<Box key={item.key}>
<Text color={index === selectedUtxoIndex && focusedPanel === 'utxos' ? colors.focus : colors.text}>
{index === selectedUtxoIndex && focusedPanel === 'utxos' ? '▸ ' : ' '}
{index + 1}. {item.label}
</Text>
{item.description && (
<Text color={colors.warning}> {item.description}</Text>
)}
</Box>
))
)}
</Box>
</Box>
) : (
<ScrollableList
items={historyListItems}
selectedIndex={selectedHistoryIndex}
onSelect={setSelectedHistoryIndex}
focus={focusedPanel === 'history' && !isCaptured}
maxVisible={10}
emptyMessage="No history found"
renderItem={renderHistoryItem}
/>
)}
</Box>
</Box>
@@ -280,6 +631,35 @@ export function WalletStateScreen(): React.ReactElement {
Tab: Switch focus Enter: Select : Navigate Esc: Back
</Text>
</Box>
{/* QR Code dialog overlay for generated addresses */}
{qrAddress && (
<QRDialogOverlay
address={qrAddress}
onClose={() => setQrAddress(null)}
/>
)}
{/* Fiat currency selection dialog overlay */}
{isCurrencyDialogOpen && (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
height="100%"
>
<CurrencySelectionDialog
currentCurrency={currencyCode}
currencies={availableCurrencies}
isLoading={isLoadingCurrencyPairs}
errorMessage={currencyPairsError}
onSelectCurrency={applyCurrencySelection}
onCancel={() => setCurrencyDialogOpen(false)}
/>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,184 @@
import React from 'react';
import { Box, Text } from 'ink';
import { StepIndicator, type Step } from '../../components/ProgressBar.js';
import { Button } from '../../components/Button.js';
import { colors, logoSmall } from '../../theme.js';
import { useActionWizard } from './hooks/useActionWizard.js';
import { useWizardKeyboard } from './hooks/useWizardKeyboard.js';
// Steps
import { RoleSelectStep } from './steps/RoleSelectStep.js';
import { VariablesStep } from './steps/VariablesStep.js';
import { InputsStep } from './steps/InputsStep.js';
import { ReviewStep } from './steps/ReviewStep.js';
import { PublishStep } from './steps/PublishStep.js';
import { DataResultStep } from './steps/DataResultStep.js';
export function ActionWizardScreen(): React.ReactElement {
const wizard = useActionWizard();
useWizardKeyboard(wizard);
// ── Step router ────────────────────────────────────────────────
const renderStep = () => {
if (wizard.isProcessing) {
return <Text color={colors.info}>Processing...</Text>;
}
switch (wizard.currentStepData?.type) {
case 'role-select':
return (
<RoleSelectStep
template={wizard.template!}
actionIdentifier={wizard.actionIdentifier!}
availableRoles={wizard.availableRoles}
selectedRoleIndex={wizard.selectedRoleIndex}
focusArea={wizard.focusArea}
/>
);
case 'variables':
return (
<VariablesStep
variables={wizard.variables}
updateVariable={wizard.updateVariable}
handleTextInputSubmit={wizard.handleTextInputSubmit}
focusArea={wizard.focusArea}
focusedInput={wizard.focusedInput}
/>
);
case 'inputs':
return (
<InputsStep
availableUtxos={wizard.availableUtxos}
selectedUtxoIndex={wizard.selectedUtxoIndex}
requiredAmount={wizard.requiredAmount}
fee={wizard.fee}
selectedAmount={wizard.selectedAmount}
changeAmount={wizard.changeAmount}
focusArea={wizard.focusArea}
/>
);
case 'review':
return (
<ReviewStep
template={wizard.template!}
actionName={wizard.actionName}
roleIdentifier={wizard.roleIdentifier!}
variables={wizard.variables}
availableUtxos={wizard.availableUtxos}
changeAmount={wizard.changeAmount}
/>
);
case 'publish':
return (
<PublishStep
invitationId={wizard.invitationId}
requirementsComplete={wizard.requirementsComplete}
hasSignedAndBroadcasted={wizard.hasSignedAndBroadcasted}
/>
);
case 'result':
return (
<DataResultStep
actionName={wizard.actionName}
variables={wizard.variables}
dataResults={wizard.dataResults}
/>
);
default:
return null;
}
};
// ── Layout ─────────────────────────────────────────────────────
const stepIndicatorSteps: Step[] = wizard.steps.map((s) => ({
label: s.name,
}));
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box
borderStyle="single"
borderColor={colors.secondary}
paddingX={1}
flexDirection="column"
>
<Text color={colors.primary} bold>
{logoSmall} - Action Wizard
</Text>
<Text color={colors.textMuted}>
{wizard.template?.name} {">"} {wizard.actionName}
{wizard.roleIdentifier ? ` (as ${wizard.roleIdentifier})` : ''}
</Text>
</Box>
{/* Progress indicator */}
<Box marginTop={1} paddingX={1}>
<StepIndicator
steps={stepIndicatorSteps}
currentStep={wizard.currentStep}
/>
</Box>
{/* Content area */}
<Box
borderStyle="single"
borderColor={
wizard.focusArea === "content" ? colors.focus : colors.primary
}
flexDirection="column"
paddingX={1}
paddingY={1}
marginTop={1}
marginX={1}
flexGrow={1}
>
<Text color={colors.primary} bold>
{" "}
{wizard.currentStepData?.name} ({wizard.currentStep + 1}/
{wizard.steps.length}){" "}
</Text>
<Box marginTop={1}>{renderStep()}</Box>
</Box>
{/* Buttons */}
<Box marginTop={1} marginX={1} justifyContent="space-between">
<Box gap={1}>
<Button
label="Back"
focused={
wizard.focusArea === "buttons" &&
wizard.focusedButton === "back"
}
disabled={wizard.isLastStep}
/>
<Button
label="Cancel"
focused={
wizard.focusArea === "buttons" &&
wizard.focusedButton === "cancel"
}
/>
</Box>
<Button
label={wizard.nextButtonLabel}
focused={
wizard.focusArea === "buttons" &&
wizard.focusedButton === "next"
}
disabled={wizard.isProcessing}
/>
</Box>
{/* Help text */}
<Box marginTop={1} marginX={1}>
<Text color={colors.textMuted} dimColor>
Tab: Navigate Enter: Select Esc: Back
{wizard.currentStepData?.type === "publish"
? " • c: Copy ID"
: ""}
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,40 @@
import type { FlowContext, StepType } from "../types.js";
import { WizardFlow } from "./WizardFlow.js";
/**
* Flow strategy for data-only actions (e.g. sign, verify).
*
* These actions produce computed data rather than a transaction.
* No invitation, UTXOs, or fees are involved — just variables in,
* data result out.
*
* NOTE: Engine-level data action execution is not yet implemented.
* The result step is currently stubbed.
*/
export class DataWizardFlow extends WizardFlow {
readonly type = "data" as const;
/** The data field identifiers this action produces (from action.data). */
readonly dataOutputs: string[];
constructor(dataOutputs: string[]) {
super();
this.dataOutputs = dataOutputs;
}
getStepTypes(context: FlowContext): StepType[] {
const steps: StepType[] = [];
if (context.availableRoles.length > 1) steps.push("role-select");
if (context.hasVariables) steps.push("variables");
steps.push("result");
return steps;
}
canFinalize(): boolean {
return false;
}
getFinalActionLabel(): string {
return "Done";
}
}

View File

@@ -0,0 +1,36 @@
import type { FlowContext, StepType } from "../types.js";
import { WizardFlow } from "./WizardFlow.js";
/**
* Flow strategy for transaction-based actions.
*
* Handles both single-role actions (sendSatoshis, burn) where the
* creator provides inputs and signs locally, and multi-role actions
* (receive, request) where the creator publishes an invitation for
* another party to complete.
*/
export class TransactionWizardFlow extends WizardFlow {
readonly type = "transaction" as const;
getStepTypes(context: FlowContext): StepType[] {
const steps: StepType[] = [];
if (context.availableRoles.length > 1) steps.push("role-select");
if (context.hasVariables) steps.push("variables");
if (context.shouldCollectInputs) steps.push("inputs");
steps.push("review");
steps.push("publish");
return steps;
}
canFinalize(context: FlowContext): boolean {
return (
context.requirementsComplete &&
context.wizardCollectedInputs &&
!context.hasSignedAndBroadcasted
);
}
getFinalActionLabel(context: FlowContext): string {
return this.canFinalize(context) ? "Sign & Broadcast" : "Done";
}
}

View File

@@ -0,0 +1,22 @@
import type { FlowContext, StepType } from "../types.js";
/**
* Abstract strategy that defines the shape of a wizard flow.
*
* Subclasses declare which steps are needed, whether the action can be
* finalized, and what the final button should say. They hold no React
* state — the orchestrator hook wires domain hooks to the step configs
* produced from these methods.
*/
export abstract class WizardFlow {
abstract readonly type: "transaction" | "data";
/** Determine which step types this flow needs given the current context. */
abstract getStepTypes(context: FlowContext): StepType[];
/** Whether the action can be finalized (e.g. signed & broadcast). */
abstract canFinalize(context: FlowContext): boolean;
/** Label for the primary action button on the final step. */
abstract getFinalActionLabel(context: FlowContext): string;
}

View File

@@ -0,0 +1,21 @@
import type { XOTemplateAction } from "@xo-cash/types";
import { TransactionWizardFlow } from "./TransactionWizardFlow.js";
import { DataWizardFlow } from "./DataWizardFlow.js";
import type { WizardFlow } from "./WizardFlow.js";
export { WizardFlow } from "./WizardFlow.js";
export { TransactionWizardFlow } from "./TransactionWizardFlow.js";
export { DataWizardFlow } from "./DataWizardFlow.js";
/**
* Inspect a template action and return the appropriate wizard flow strategy.
*
* Actions with `data` fields and no `transaction` are data-only flows.
* Everything else uses the transaction flow.
*/
export function createWizardFlow(action: XOTemplateAction): WizardFlow {
if (action.data?.length && !action.transaction) {
return new DataWizardFlow([action.data]);
}
return new TransactionWizardFlow();
}

View File

@@ -0,0 +1,534 @@
import { useState, useMemo, useCallback, useEffect } from "react";
import { useNavigation } from "../../../hooks/useNavigation.js";
import { useAppContext, useStatus } from "../../../hooks/useAppContext.js";
import { copyToClipboard } from "../../../utils/clipboard.js";
import { roleRequiresInputs } from "../../../../utils/invitation-flow.js";
import type { XOTemplate } from "@xo-cash/types";
import type { StepConfig, FlowContext, DataResult } from "../types.js";
import {
createWizardFlow,
type WizardFlow,
DataWizardFlow,
} from "../flows/index.js";
import { useRoleSelection } from "./useRoleSelection.js";
import { useVariableInputs } from "./useVariableInputs.js";
import { useUtxoSelection } from "./useUtxoSelection.js";
import { useInvitationManager } from "./useInvitationManager.js";
import { useWizardFocus } from "./useWizardFocus.js";
import { useWizardSteps } from "./useWizardSteps.js";
/**
* Thin orchestrator that composes domain hooks and wires them
* to step configs produced by the WizardFlow strategy.
*
* This replaces the original 861-line god-hook.
*/
export function useActionWizard() {
const { goBack, data: navData } = useNavigation();
const { appService, showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
if (!appService) {
throw new Error("AppService not initialized");
}
// ── Navigation data ───────────────────────────────────────────
const templateIdentifier = navData.templateIdentifier as string | undefined;
const actionIdentifier = navData.actionIdentifier as string | undefined;
const template = navData.template as XOTemplate | undefined;
const actionRolesFromNavigation = navData.actionRoles as string[] | undefined;
// ── Derived template data ─────────────────────────────────────
const action = template?.actions?.[actionIdentifier ?? ""];
const actionName = action?.name || actionIdentifier || "Unknown";
// ── Flow strategy ─────────────────────────────────────────────
const flow = useMemo<WizardFlow>(() => {
// Create a default action if no action is found
if (!action) {
return createWizardFlow({ name: "", description: "" });
}
// Create the flow from the action
return createWizardFlow(action);
}, [action]);
// ── Domain hooks ──────────────────────────────────────────────
const roleSelection = useRoleSelection(
template,
actionIdentifier,
actionRolesFromNavigation,
);
const variableInputs = useVariableInputs();
const utxoSelection = useUtxoSelection();
const invitationManager = useInvitationManager({
appService,
showError,
showInfo,
setStatus,
});
const focus = useWizardFocus();
// ── Data results (data-only flows) ────────────────────────────
const [dataResults, setDataResults] = useState<DataResult[]>([]);
// ── Initialize variables when role becomes available ──────────
useEffect(() => {
if (template && actionIdentifier && roleSelection.effectiveRole) {
const act = template.actions?.[actionIdentifier];
const role = act?.roles?.[roleSelection.effectiveRole];
const varIds = role?.requirements?.variables;
if (varIds && varIds.length > 0) {
variableInputs.initFromTemplate(
template,
actionIdentifier,
roleSelection.effectiveRole,
);
}
}
}, [
template,
actionIdentifier,
roleSelection.effectiveRole,
variableInputs.initFromTemplate,
]);
// ── Determine whether creator should provide inputs ───────────
const shouldCollectInputs = useMemo(() => {
if (flow.type !== "transaction") return false;
if (!template || !actionIdentifier || !roleSelection.effectiveRole)
return false;
const act = template.actions?.[actionIdentifier];
const totalActionRoles = Object.keys(act?.roles ?? {}).length;
const isSingleRoleAction = totalActionRoles <= 1;
return (
isSingleRoleAction &&
roleRequiresInputs(
template,
actionIdentifier,
roleSelection.effectiveRole,
)
);
}, [flow.type, template, actionIdentifier, roleSelection.effectiveRole]);
// ── Build flow context for strategy methods ───────────────────
const flowContext = useMemo<FlowContext>(
() => ({
availableRoles: roleSelection.availableRoles,
hasVariables: variableInputs.variables.length > 0,
shouldCollectInputs,
requirementsComplete: invitationManager.requirementsComplete,
wizardCollectedInputs: shouldCollectInputs,
hasSignedAndBroadcasted: invitationManager.hasSignedAndBroadcasted,
}),
[
roleSelection.availableRoles,
variableInputs.variables.length,
shouldCollectInputs,
invitationManager.requirementsComplete,
invitationManager.hasSignedAndBroadcasted,
],
);
// ── Handle Enter inside a TextInput ───────────────────────────
const handleTextInputSubmit = useCallback(() => {
if (focus.focusedInput < variableInputs.variables.length - 1) {
focus.setFocusedInput((prev) => prev + 1);
} else {
focus.moveToButtons();
}
}, [
focus.focusedInput,
variableInputs.variables.length,
focus.setFocusedInput,
focus.moveToButtons,
]);
// ── Copy invitation ID to clipboard ───────────────────────────
const copyId = useCallback(async () => {
if (!invitationManager.invitationId) return;
try {
await copyToClipboard(invitationManager.invitationId);
showInfo(`Copied to clipboard!\n\n${invitationManager.invitationId}`);
} catch (error) {
showError(
`Failed to copy: ${error instanceof Error ? error.message : String(error)}`,
);
}
}, [invitationManager.invitationId, showInfo, showError]);
// ── Helper: create invitation if it doesn't exist yet ─────────
const ensureInvitation = useCallback(
async (roleId?: string): Promise<string | null> => {
if (invitationManager.invitationId) return invitationManager.invitationId;
const role = roleId ?? roleSelection.effectiveRole;
if (!templateIdentifier || !actionIdentifier || !role || !template)
return null;
return invitationManager.createWithVariables(
templateIdentifier,
actionIdentifier,
role,
template,
variableInputs.variables,
);
},
[
invitationManager.invitationId,
invitationManager.createWithVariables,
roleSelection.effectiveRole,
templateIdentifier,
actionIdentifier,
template,
variableInputs.variables,
],
);
// ── Helper: load UTXOs after invitation is created ────────────
const loadUtxosForInvitation = useCallback(
async (invId: string) => {
if (!appService || !templateIdentifier) return;
const instance = appService.invitations.find(
(inv) => inv.data.invitationIdentifier === invId,
);
if (instance) {
invitationManager.setIsProcessing(true);
try {
await utxoSelection.loadUtxos(
instance,
templateIdentifier,
variableInputs.variables,
setStatus,
);
} finally {
invitationManager.setIsProcessing(false);
}
}
},
[
appService,
templateIdentifier,
variableInputs.variables,
utxoSelection.loadUtxos,
invitationManager.setIsProcessing,
setStatus,
],
);
// ── Build step configs from flow strategy ─────────────────────
const stepConfigs = useMemo<StepConfig[]>(() => {
const stepTypes = flow.getStepTypes(flowContext);
return stepTypes.map((type): StepConfig => {
switch (type) {
case "role-select":
return {
type,
name: "Select Role",
validate: () => {
const selectedRole =
roleSelection.availableRoles[roleSelection.selectedRoleIndex];
return selectedRole ? null : "Please select a role";
},
onNext: async () => {
const selectedRole =
roleSelection.availableRoles[roleSelection.selectedRoleIndex];
if (!selectedRole) return false;
// Initialize variables for this role immediately
if (template && actionIdentifier) {
const act = template.actions?.[actionIdentifier];
const role = act?.roles?.[selectedRole];
const hasVars =
(role?.requirements?.variables?.length ?? 0) > 0;
if (hasVars) {
variableInputs.initFromTemplate(
template,
actionIdentifier,
selectedRole,
);
}
// If no variables step follows, create the invitation now (transaction flows only)
if (!hasVars && flow.type === "transaction") {
if (templateIdentifier && template) {
const invId = await invitationManager.createWithVariables(
templateIdentifier,
actionIdentifier,
selectedRole,
template,
[],
);
if (!invId) return false;
// Pre-load UTXOs if the inputs step follows
const totalRoles = Object.keys(act?.roles ?? {}).length;
const needsInputs =
totalRoles <= 1 &&
roleRequiresInputs(
template,
actionIdentifier,
selectedRole,
);
if (needsInputs) {
await loadUtxosForInvitation(invId);
}
}
}
}
roleSelection.setRoleIdentifier(selectedRole);
focus.resetToContent();
return true;
},
};
case "variables":
return {
type,
name: "Variables",
validate: () => variableInputs.validate(),
onNext: async () => {
if (flow.type === "transaction") {
if (
!templateIdentifier ||
!actionIdentifier ||
!template ||
!roleSelection.effectiveRole
)
return false;
const invId = await invitationManager.createWithVariables(
templateIdentifier,
actionIdentifier,
roleSelection.effectiveRole,
template,
variableInputs.variables,
);
if (!invId) return false;
// Pre-load UTXOs if the inputs step follows
if (shouldCollectInputs) {
await loadUtxosForInvitation(invId);
}
}
// For data flows, just advance — variables are used in the result step
focus.resetToContent();
return true;
},
};
case "inputs":
return {
type,
name: "Select UTXOs",
validate: () => utxoSelection.validate(),
onNext: async () => {
const selectedUtxos = utxoSelection.availableUtxos.filter(
(u) => u.selected,
);
const success = await invitationManager.addInputsAndOutputs(
selectedUtxos,
utxoSelection.changeAmount,
);
if (success) focus.resetToContent();
return success;
},
};
case "review":
return {
type,
name: "Review",
validate: () => null,
onNext: async () => {
// Ensure invitation exists (covers the case where no prior step created it)
const invId = await ensureInvitation();
if (!invId) return false;
await invitationManager.refreshRequirements(invId);
focus.resetToContent();
return true;
},
};
case "publish":
return {
type,
name: "Publish",
validate: () => null,
onNext: async () => {
if (flow.canFinalize(flowContext)) {
await invitationManager.signAndBroadcast();
// Stay on publish step (it's the last step, stepper won't advance)
return true;
}
goBack();
return true;
},
};
case "result":
return {
type,
name: "Result",
validate: () => null,
onNext: async () => {
// Data-only flows: populate stubbed results, then exit
if (flow instanceof DataWizardFlow) {
const results: DataResult[] = flow.dataOutputs.map((dataId) => {
const dataDef = template?.data?.[dataId];
return {
id: dataId,
name: dataDef?.hint ?? dataId,
type: dataDef?.type ?? "unknown",
hint: dataDef?.hint,
value: null, // Engine-level data execution not yet implemented
};
});
setDataResults(results);
}
goBack();
return true;
},
};
default:
return {
type,
name: type,
validate: () => null,
onNext: async () => true,
};
}
});
}, [
flow,
flowContext,
roleSelection,
variableInputs,
utxoSelection,
invitationManager,
focus,
template,
templateIdentifier,
actionIdentifier,
shouldCollectInputs,
ensureInvitation,
loadUtxosForInvitation,
goBack,
setStatus,
]);
// ── Step navigation ───────────────────────────────────────────
const stepper = useWizardSteps(stepConfigs, goBack, showError);
// ── Set initial status ────────────────────────────────────────
useEffect(() => {
if (!template || !actionIdentifier) {
showError("Missing wizard data");
goBack();
return;
}
setStatus(
roleSelection.effectiveRole
? `${actionIdentifier}/${roleSelection.effectiveRole}`
: actionIdentifier,
);
}, [
template,
actionIdentifier,
roleSelection.effectiveRole,
showError,
goBack,
setStatus,
]);
// ── Convenience derived values ────────────────────────────────
const textInputHasFocus =
stepper.currentStepData?.type === "variables" &&
focus.focusArea === "content";
const canSignAndBroadcast = flow.canFinalize(flowContext);
const isLastStep = stepper.currentStep >= stepper.steps.length - 1;
const lastStepType = stepper.currentStepData?.type;
const nextButtonLabel =
lastStepType === "publish"
? flow.getFinalActionLabel(flowContext)
: lastStepType === "result"
? "Done"
: "Next";
// ── Public API ────────────────────────────────────────────────
return {
// Meta
template,
templateIdentifier,
actionIdentifier,
roleIdentifier: roleSelection.effectiveRole,
action,
actionName,
flow,
flowContext,
// Role selection
availableRoles: roleSelection.availableRoles,
selectedRoleIndex: roleSelection.selectedRoleIndex,
setSelectedRoleIndex: roleSelection.setSelectedRoleIndex,
// Steps
steps: stepper.steps,
currentStep: stepper.currentStep,
currentStepData: stepper.currentStepData,
// Variables
variables: variableInputs.variables,
updateVariable: variableInputs.updateVariable,
handleTextInputSubmit,
// UTXOs
availableUtxos: utxoSelection.availableUtxos,
selectedUtxoIndex: utxoSelection.selectedUtxoIndex,
setSelectedUtxoIndex: utxoSelection.setSelectedUtxoIndex,
requiredAmount: utxoSelection.requiredAmount,
fee: utxoSelection.fee,
selectedAmount: utxoSelection.selectedAmount,
changeAmount: utxoSelection.changeAmount,
toggleUtxoSelection: utxoSelection.toggleSelection,
selectAll: utxoSelection.selectAll,
deselectAll: utxoSelection.deselectAll,
// Invitation
invitation: invitationManager.invitation,
invitationId: invitationManager.invitationId,
requirementsComplete: invitationManager.requirementsComplete,
hasSignedAndBroadcasted: invitationManager.hasSignedAndBroadcasted,
canSignAndBroadcast,
// Data results
dataResults,
// UI focus
focusedInput: focus.focusedInput,
setFocusedInput: focus.setFocusedInput,
focusedButton: focus.focusedButton,
setFocusedButton: focus.setFocusedButton,
focusArea: focus.focusArea,
setFocusArea: focus.setFocusArea,
isProcessing: invitationManager.isProcessing,
textInputHasFocus,
nextButtonLabel,
isLastStep,
// Actions
nextStep: stepper.nextStep,
previousStep: stepper.previousStep,
cancel: stepper.cancel,
copyId,
} as const;
}
/** Convenience type so other files can type the return value. */
export type ActionWizardState = ReturnType<typeof useActionWizard>;

View File

@@ -0,0 +1,295 @@
import { useState, useCallback } from "react";
import type {
XOTemplate,
XOInvitation,
XOTemplateTransactionOutput,
} from "@xo-cash/types";
import type { VariableInput, SelectableUTXO } from "../types.js";
import {
getTransactionOutputIdentifier,
isInvitationRequirementsComplete,
resolveProvidedLockingBytecodeHex,
} from "../../../../utils/invitation-flow.js";
import type { AppService } from "../../../../services/app.js";
interface InvitationManagerDeps {
appService: AppService;
showError: (msg: string) => void;
showInfo: (msg: string) => void;
setStatus: (msg: string) => void;
}
/**
* Manages the full invitation lifecycle for transaction-based actions:
* creation, variable persistence, output generation, input addition,
* signing, and broadcasting.
*
* Only relevant for TransactionWizardFlow — data flows bypass this entirely.
*/
export function useInvitationManager(deps: InvitationManagerDeps) {
const { appService, showError, showInfo, setStatus } = deps;
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
const [invitationId, setInvitationId] = useState<string | null>(null);
const [requirementsComplete, setRequirementsComplete] = useState(false);
const [hasSignedAndBroadcasted, setHasSignedAndBroadcasted] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
/** Re-check whether all invitation requirements are satisfied. */
const refreshRequirements = useCallback(
async (identifier: string | null = invitationId): Promise<boolean> => {
if (!identifier || !appService) {
setRequirementsComplete(false);
return false;
}
const instance = appService.invitations.find(
(inv: any) => inv.data.invitationIdentifier === identifier,
);
if (!instance) {
setRequirementsComplete(false);
return false;
}
const complete = await isInvitationRequirementsComplete(instance);
setRequirementsComplete(complete);
return complete;
},
[appService, invitationId],
);
/**
* Create an invitation, persist variable values, and add
* template-required transaction outputs.
*
* @returns The invitation identifier on success, or null on failure.
*/
const createWithVariables = useCallback(
async (
templateIdentifier: string,
actionIdentifier: string,
roleIdentifier: string,
template: XOTemplate,
variables: VariableInput[],
): Promise<string | null> => {
if (!appService) return null;
setIsProcessing(true);
setStatus("Creating invitation...");
try {
// Create via the engine
const xoInvitation = await appService.engine.createInvitation({
templateIdentifier,
actionIdentifier,
});
// Wrap and track
const invitationInstance =
await appService.createInvitation(xoInvitation);
let inv = invitationInstance.data;
const invId = inv.invitationIdentifier;
setInvitationId(invId);
// Persist variable values
if (variables.length > 0) {
setStatus("Adding variables...");
const variableData = variables.map((v) => {
const isNumeric =
["integer", "number", "satoshis"].includes(v.type) ||
(v.hint && ["satoshis", "amount"].includes(v.hint));
return {
variableIdentifier: v.id,
roleIdentifier,
value: isNumeric ? BigInt(v.value || "0") : v.value,
};
});
await invitationInstance.addVariables(variableData);
inv = invitationInstance.data;
}
// Build variable values lookup for output resolution
const variableValuesByIdentifier = variables.reduce(
(acc, variable) => {
if (
typeof variable.value === "string" &&
variable.value.trim().length > 0
) {
acc[variable.id] = variable.value;
}
return acc;
},
{} as Record<string, string>,
);
// Add template-required transaction outputs
const act = template.actions?.[actionIdentifier];
const transaction = act?.transaction
? template.transactions?.[act.transaction]
: null;
if (transaction?.outputs && transaction.outputs.length > 0) {
setStatus("Adding required outputs...");
const outputsToAdd = await Promise.all(
transaction.outputs.map(
async (output: XOTemplateTransactionOutput) => {
const outputIdentifier = getTransactionOutputIdentifier(output);
if (!outputIdentifier) {
throw new Error("Invalid transaction output definition");
}
const providedHex = resolveProvidedLockingBytecodeHex(
template,
outputIdentifier,
variableValuesByIdentifier,
);
const lockingBytecodeHex =
providedHex ??
(await invitationInstance.generateLockingBytecode(
outputIdentifier,
roleIdentifier,
));
return {
outputIdentifier,
lockingBytecode: lockingBytecodeHex,
};
},
),
);
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOutputs accept a hex string. 3. Have addOutputs handle lockscript generation.
await invitationInstance.addOutputs(
outputsToAdd.map((output) => ({
outputIdentifier: output.outputIdentifier,
lockingBytecode: new Uint8Array(
Buffer.from(output.lockingBytecode, "hex"),
),
})),
);
inv = invitationInstance.data;
}
setInvitation(inv);
await refreshRequirements(invId);
setStatus("Invitation created");
return invId;
} catch (error) {
showError(
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
} finally {
setIsProcessing(false);
}
},
[appService, showError, setStatus, refreshRequirements],
);
/**
* Add the selected UTXOs as inputs and a change output to the invitation.
*
* @returns true on success, false on failure.
*/
const addInputsAndOutputs = useCallback(
async (
selectedUtxos: SelectableUTXO[],
changeAmount: bigint,
): Promise<boolean> => {
if (!invitationId || !appService) return false;
setIsProcessing(true);
setStatus("Adding inputs and outputs...");
try {
const instance = appService.invitations.find(
(inv: any) => inv.data.invitationIdentifier === invitationId,
);
if (!instance) throw new Error("Invitation not found");
const inputs = selectedUtxos.map((utxo) => ({
outpointTransactionHash: new Uint8Array(
Buffer.from(utxo.outpointTransactionHash, "hex"),
),
outpointIndex: utxo.outpointIndex,
}));
await instance.addInputs(inputs);
await instance.addOutputs([{ valueSatoshis: changeAmount }]);
await refreshRequirements(invitationId);
setStatus("Inputs and outputs added");
return true;
} catch (error) {
showError(
`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
} finally {
setIsProcessing(false);
}
},
[invitationId, appService, showError, setStatus, refreshRequirements],
);
/** Sign the invitation and broadcast the transaction. */
const signAndBroadcast = useCallback(async (): Promise<boolean> => {
if (!invitationId || !appService) return false;
setIsProcessing(true);
setStatus("Signing invitation...");
try {
const instance = appService.invitations.find(
(inv: any) => inv.data.invitationIdentifier === invitationId,
);
if (!instance) throw new Error("Invitation not found");
const complete = await refreshRequirements(invitationId);
if (!complete) {
showError("Invitation requirements are not complete yet.");
return false;
}
await instance.sign();
setStatus("Broadcasting transaction...");
await instance.broadcast();
setHasSignedAndBroadcasted(true);
setStatus("Transaction signed and broadcasted");
showInfo("Transaction signed and broadcasted.");
await refreshRequirements(invitationId);
return true;
} catch (error) {
showError(
`Failed to sign and broadcast: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
} finally {
setIsProcessing(false);
}
}, [
invitationId,
appService,
setStatus,
showError,
showInfo,
refreshRequirements,
]);
return {
invitation,
invitationId,
requirementsComplete,
hasSignedAndBroadcasted,
isProcessing,
setIsProcessing,
refreshRequirements,
createWithVariables,
addInputsAndOutputs,
signAndBroadcast,
} as const;
}
export type InvitationManagerState = ReturnType<typeof useInvitationManager>;

View File

@@ -0,0 +1,50 @@
import { useState, useEffect, useMemo } from "react";
import type { XOTemplate } from "@xo-cash/types";
import { resolveActionRoles } from "../../../../utils/invitation-flow.js";
/**
* Manages role selection state for the wizard.
*
* Derives the list of available roles from the template and auto-selects
* when only one role exists for the action.
*/
export function useRoleSelection(
template: XOTemplate | undefined,
actionIdentifier: string | undefined,
actionRolesFromNavigation: string[] | undefined,
) {
const [roleIdentifier, setRoleIdentifier] = useState<string | undefined>();
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
/** Roles that can start this action, derived from template start entries. */
const availableRoles = useMemo(() => {
return resolveActionRoles(
template,
actionIdentifier,
actionRolesFromNavigation,
);
}, [template, actionIdentifier, actionRolesFromNavigation]);
/** The role to use for the flow — either explicitly selected or auto-selected when only one exists. */
const effectiveRole =
roleIdentifier ??
(availableRoles.length === 1 ? availableRoles[0] : undefined);
// Auto-select when only one role exists.
useEffect(() => {
if (!roleIdentifier && availableRoles.length === 1) {
setRoleIdentifier(availableRoles[0]);
}
}, [roleIdentifier, availableRoles]);
return {
roleIdentifier,
setRoleIdentifier,
selectedRoleIndex,
setSelectedRoleIndex,
availableRoles,
effectiveRole,
} as const;
}
export type RoleSelectionState = ReturnType<typeof useRoleSelection>;

View File

@@ -0,0 +1,123 @@
import { useState, useCallback, useMemo } from "react";
import type { SelectableUTXO, VariableInput } from "../types.js";
import type { Invitation } from "../../../../services/invitation.js";
import { formatSatoshis } from "../../../theme.js";
import {
autoSelectGreedyUtxos,
mapUnspentOutputsToSelectable,
} from "../../../../utils/invitation-flow.js";
/**
* Manages UTXO selection state for the wizard's inputs step.
*
* Only active for transaction flows that require the creator
* to provide funding inputs.
*/
export function useUtxoSelection() {
const [availableUtxos, setAvailableUtxos] = useState<SelectableUTXO[]>([]);
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
const [requiredAmount, setRequiredAmount] = useState<bigint>(0n);
const [fee, setFee] = useState<bigint>(500n);
const selectedAmount = useMemo(
() =>
availableUtxos
.filter((u) => u.selected)
.reduce((sum, u) => sum + u.valueSatoshis, 0n),
[availableUtxos],
);
const changeAmount = useMemo(
() => selectedAmount - requiredAmount - fee,
[selectedAmount, requiredAmount, fee],
);
/** Toggle the selected state of a single UTXO. */
const toggleSelection = useCallback((index: number) => {
setAvailableUtxos((prev) => {
const updated = [...prev];
const utxo = updated[index];
if (utxo) {
updated[index] = { ...utxo, selected: !utxo.selected };
}
return updated;
});
}, []);
/** Select all available UTXOs. */
const selectAll = useCallback(() => {
setAvailableUtxos((prev) => prev.map((u) => ({ ...u, selected: true })));
}, []);
/** Deselect all UTXOs. */
const deselectAll = useCallback(() => {
setAvailableUtxos((prev) => prev.map((u) => ({ ...u, selected: false })));
}, []);
/**
* Query the invitation instance for suitable UTXOs and auto-select
* greedily to meet the required amount.
*/
const loadUtxos = useCallback(
async (
invitationInstance: Invitation,
templateIdentifier: string,
variables: VariableInput[],
setStatus: (msg: string) => void,
): Promise<void> => {
setStatus("Finding suitable UTXOs...");
// Derive required amount from variables that look like satoshi/amount fields.
const requestedVar = variables.find(
(v) =>
v.id.toLowerCase().includes("satoshi") ||
v.id.toLowerCase().includes("amount"),
);
const requested = requestedVar ? BigInt(requestedVar.value || "0") : 0n;
setRequiredAmount(requested);
const unspentOutputs = await invitationInstance.findSuitableResources({
templateIdentifier,
});
const mapped = mapUnspentOutputsToSelectable(unspentOutputs);
const autoSelected = autoSelectGreedyUtxos(mapped, requested + fee);
setAvailableUtxos(autoSelected as SelectableUTXO[]);
setStatus("Ready");
},
[fee],
);
/** Validate that the selection meets the required amounts. */
const validate = useCallback((): string | null => {
const selected = availableUtxos.filter((u) => u.selected);
if (selected.length === 0) {
return "Please select at least one UTXO";
}
if (selectedAmount < requiredAmount + fee) {
return `Insufficient funds. Need ${formatSatoshis(requiredAmount + fee)}, selected ${formatSatoshis(selectedAmount)}`;
}
if (changeAmount < 546n) {
return `Change amount (${changeAmount}) is below dust threshold (546 sats)`;
}
return null;
}, [availableUtxos, selectedAmount, requiredAmount, fee, changeAmount]);
return {
availableUtxos,
setAvailableUtxos,
selectedUtxoIndex,
setSelectedUtxoIndex,
requiredAmount,
fee,
selectedAmount,
changeAmount,
toggleSelection,
selectAll,
deselectAll,
loadUtxos,
validate,
} as const;
}
export type UtxoSelectionState = ReturnType<typeof useUtxoSelection>;

View File

@@ -0,0 +1,75 @@
import { useState, useCallback } from "react";
import type { XOTemplate } from "@xo-cash/types";
import type { VariableInput } from "../types.js";
/**
* Manages the variable input state for the wizard's variables step.
*
* Populates variables from the template's action/role requirements
* and provides validation + update helpers.
*/
export function useVariableInputs() {
const [variables, setVariables] = useState<VariableInput[]>([]);
/**
* Populate the variable list from the template's role requirements.
* Calling this again replaces the current variables entirely.
*/
const initFromTemplate = useCallback(
(
template: XOTemplate,
actionIdentifier: string,
roleIdentifier: string,
) => {
const action = template.actions?.[actionIdentifier];
const role = action?.roles?.[roleIdentifier];
const varIds = role?.requirements?.variables ?? [];
const varInputs: VariableInput[] = varIds.map((varId) => {
const varDef = template.variables?.[varId];
return {
id: varId,
name: varDef?.name || varId,
type: varDef?.type || "string",
hint: varDef?.hint,
value: "",
};
});
setVariables(varInputs);
},
[],
);
/** Update a single variable's value by index. */
const updateVariable = useCallback((index: number, value: string) => {
setVariables((prev) => {
const updated = [...prev];
const variable = updated[index];
if (variable) {
updated[index] = { ...variable, value };
}
return updated;
});
}, []);
/** Returns an error message if any required variable is empty, or null if valid. */
const validate = useCallback((): string | null => {
const emptyVars = variables.filter(
(v) => !v.value || v.value.trim() === "",
);
if (emptyVars.length > 0) {
return `Please enter values for: ${emptyVars.map((v) => v.name).join(", ")}`;
}
return null;
}, [variables]);
return {
variables,
setVariables,
initFromTemplate,
updateVariable,
validate,
} as const;
}
export type VariableInputsState = ReturnType<typeof useVariableInputs>;

View File

@@ -0,0 +1,37 @@
import { useState, useCallback } from "react";
import type { FocusArea, ButtonFocus } from "../types.js";
/**
* Manages which area of the wizard UI has keyboard focus and
* which specific element within that area is highlighted.
*/
export function useWizardFocus() {
const [focusArea, setFocusArea] = useState<FocusArea>("content");
const [focusedButton, setFocusedButton] = useState<ButtonFocus>("next");
const [focusedInput, setFocusedInput] = useState(0);
/** Reset focus to the content area at the first element. */
const resetToContent = useCallback(() => {
setFocusArea("content");
setFocusedInput(0);
}, []);
/** Move focus to the button bar. */
const moveToButtons = useCallback((button: ButtonFocus = "next") => {
setFocusArea("buttons");
setFocusedButton(button);
}, []);
return {
focusArea,
setFocusArea,
focusedButton,
setFocusedButton,
focusedInput,
setFocusedInput,
resetToContent,
moveToButtons,
} as const;
}
export type WizardFocusState = ReturnType<typeof useWizardFocus>;

View File

@@ -0,0 +1,153 @@
import { useBlockableInput } from "../../../hooks/useInputLayer.js";
import type { ActionWizardState } from "./useActionWizard.js";
/**
* Keyboard input handler for the action wizard.
*
* Dispatches key presses to step-specific handlers based on the
* current step type and focus area. Extracted from the screen
* component to keep it purely presentational.
*/
export function useWizardKeyboard(wizard: ActionWizardState): void {
useBlockableInput(
(input, key) => {
// ── Tab: cycle through content and button bar ─────────
if (key.tab) {
handleTab(wizard);
return;
}
// ── Content-area: step-specific input handling ────────
if (wizard.focusArea === "content") {
if (wizard.currentStepData?.type === "role-select") {
handleRoleSelectInput(wizard, input, key);
return;
}
if (wizard.currentStepData?.type === "inputs") {
handleInputsStepInput(wizard, input, key);
return;
}
}
// ── Button bar navigation + activation ────────────────
if (wizard.focusArea === "buttons") {
handleButtonBarInput(wizard, key);
}
// ── Global shortcuts ──────────────────────────────────
if (
input === "c" &&
wizard.currentStepData?.type === "publish" &&
wizard.invitationId
) {
wizard.copyId();
}
if (input === "a" && wizard.currentStepData?.type === "inputs") {
wizard.selectAll();
}
if (input === "n" && wizard.currentStepData?.type === "inputs") {
wizard.deselectAll();
}
},
{ isActive: !wizard.textInputHasFocus },
);
}
// ── Tab cycling ─────────────────────────────────────────────────
function handleTab(wizard: ActionWizardState): void {
if (wizard.focusArea === "content") {
// Within role-select, tab through roles before moving to buttons
if (
wizard.currentStepData?.type === "role-select" &&
wizard.availableRoles.length > 0 &&
wizard.selectedRoleIndex < wizard.availableRoles.length - 1
) {
wizard.setSelectedRoleIndex((prev) => prev + 1);
return;
}
// Within inputs, tab through UTXOs before moving to buttons
if (
wizard.currentStepData?.type === "inputs" &&
wizard.availableUtxos.length > 0 &&
wizard.selectedUtxoIndex < wizard.availableUtxos.length - 1
) {
wizard.setSelectedUtxoIndex((prev) => prev + 1);
return;
}
// Move to button bar
wizard.setFocusArea("buttons");
wizard.setFocusedButton("next");
} else {
// Cycle through buttons, then wrap back to content
if (wizard.focusedButton === "back") {
wizard.setFocusedButton("cancel");
} else if (wizard.focusedButton === "cancel") {
wizard.setFocusedButton("next");
} else {
wizard.setFocusArea("content");
wizard.setFocusedInput(0);
wizard.setSelectedUtxoIndex(0);
wizard.setSelectedRoleIndex(0);
}
}
}
// ── Role-select step ────────────────────────────────────────────
function handleRoleSelectInput(
wizard: ActionWizardState,
_input: string,
key: { upArrow: boolean; downArrow: boolean },
): void {
if (key.upArrow) {
wizard.setSelectedRoleIndex((p) => Math.max(0, p - 1));
} else if (key.downArrow) {
wizard.setSelectedRoleIndex((p) =>
Math.min(wizard.availableRoles.length - 1, p + 1),
);
}
}
// ── Inputs step (UTXO selection) ────────────────────────────────
function handleInputsStepInput(
wizard: ActionWizardState,
input: string,
key: { upArrow: boolean; downArrow: boolean; return: boolean },
): void {
if (key.upArrow) {
wizard.setSelectedUtxoIndex((p) => Math.max(0, p - 1));
} else if (key.downArrow) {
wizard.setSelectedUtxoIndex((p) =>
Math.min(wizard.availableUtxos.length - 1, p + 1),
);
} else if (key.return || input === " ") {
wizard.toggleUtxoSelection(wizard.selectedUtxoIndex);
}
}
// ── Button bar ──────────────────────────────────────────────────
function handleButtonBarInput(
wizard: ActionWizardState,
key: { leftArrow: boolean; rightArrow: boolean; return: boolean },
): void {
if (key.leftArrow) {
wizard.setFocusedButton((p) =>
p === "next" ? "cancel" : p === "cancel" ? "back" : "back",
);
} else if (key.rightArrow) {
wizard.setFocusedButton((p) =>
p === "back" ? "cancel" : p === "cancel" ? "next" : "next",
);
}
if (key.return) {
if (wizard.focusedButton === "back") wizard.previousStep();
else if (wizard.focusedButton === "cancel") wizard.cancel();
else if (wizard.focusedButton === "next") wizard.nextStep();
}
}

View File

@@ -0,0 +1,73 @@
import { useState, useCallback, useMemo } from "react";
import type { StepConfig, WizardStep } from "../types.js";
/**
* Generic step navigation driven by an array of StepConfig objects.
*
* The orchestrator builds the StepConfig[] from the flow strategy
* and domain hooks; this hook just manages the step index and
* delegates validate/onNext to the current config.
*/
export function useWizardSteps(
stepConfigs: StepConfig[],
onCancel: () => void,
showError: (msg: string) => void,
) {
const [currentStep, setCurrentStep] = useState(0);
/** Flat step descriptors for the progress indicator. */
const steps: WizardStep[] = useMemo(
() => stepConfigs.map((c) => ({ name: c.name, type: c.type })),
[stepConfigs],
);
const currentStepData = steps[currentStep];
const currentConfig = stepConfigs[currentStep];
/** Validate the current step, run its onNext, then advance if not the last step. */
const nextStep = useCallback(async () => {
const config = stepConfigs[currentStep];
if (!config) return;
const error = config.validate();
if (error) {
showError(error);
return;
}
const success = await config.onNext();
if (!success) return;
// Don't advance past the last step — the final step's onNext handles exit.
if (currentStep < stepConfigs.length - 1) {
setCurrentStep((prev) => prev + 1);
}
}, [currentStep, stepConfigs, showError]);
/** Go back one step, or cancel the wizard if already at the first step. */
const previousStep = useCallback(() => {
if (currentStep <= 0) {
onCancel();
return;
}
setCurrentStep((prev) => prev - 1);
}, [currentStep, onCancel]);
/** Cancel the wizard entirely. */
const cancel = useCallback(() => {
onCancel();
}, [onCancel]);
return {
steps,
currentStep,
setCurrentStep,
currentStepData,
currentConfig,
nextStep,
previousStep,
cancel,
} as const;
}
export type WizardStepsState = ReturnType<typeof useWizardSteps>;

View File

@@ -0,0 +1,5 @@
export * from "./ActionWizardScreen.js";
export * from "./hooks/useActionWizard.js";
export * from "./types.js";
export * from "./steps/index.js";
export * from "./flows/index.js";

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../../theme.js';
import type { VariableInput, DataResult } from '../types.js';
interface DataResultStepProps {
actionName: string;
variables: VariableInput[];
dataResults: DataResult[];
}
/**
* Displays the result of a data-only action (e.g. sign, verify).
*
* NOTE: Engine-level data action execution is not yet implemented.
* The computed values are stubbed until the engine supports evaluating
* CashASM data expressions outside of a transaction context.
*/
export function DataResultStep({
actionName,
variables,
dataResults,
}: DataResultStepProps): React.ReactElement {
return (
<Box flexDirection="column">
<Text color={colors.primary} bold>
{actionName} Result
</Text>
{/* Variables that were provided */}
{variables.length > 0 && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Provided values:</Text>
{variables.map((v) => (
<Text key={v.id} color={colors.textMuted}>
{' '}{v.name}: {v.value || '(empty)'}
</Text>
))}
</Box>
)}
{/* Computed data results */}
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Output:</Text>
{dataResults.length === 0 ? (
<Text color={colors.warning}>
{' '}Engine support for data actions is not yet implemented.
</Text>
) : (
dataResults.map((result) => (
<Box key={result.id} flexDirection="column" marginTop={0}>
<Text color={colors.textMuted}>
{' '}{result.name} ({result.type}):
</Text>
{result.value !== null ? (
<Box
borderStyle="single"
borderColor={colors.primary}
paddingX={1}
marginLeft={2}
>
<Text color={colors.accent}>{result.value}</Text>
</Box>
) : (
<Text color={colors.warning} dimColor>
{' '}Pending engine data execution not yet available
</Text>
)}
</Box>
))
)}
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
Press Done to exit.
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,112 @@
import React from 'react';
import { Box, Text } from 'ink';
import { colors, formatSatoshis, formatHex } from '../../../theme.js';
import { useSatoshisConversion } from '../../../hooks/useSatoshisConversion.js';
import type { SelectableUTXO, FocusArea } from '../types.js';
interface Props {
availableUtxos: SelectableUTXO[];
selectedUtxoIndex: number;
requiredAmount: bigint;
fee: bigint;
selectedAmount: bigint;
changeAmount: bigint;
focusArea: FocusArea;
}
export function InputsStep({
availableUtxos,
selectedUtxoIndex,
requiredAmount,
fee,
selectedAmount,
changeAmount,
focusArea,
}: Props): React.ReactElement {
const { formatSatoshisToFiat } = useSatoshisConversion();
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
};
return (
<Box flexDirection='column'>
<Text color={colors.text} bold>
Select UTXOs to fund the transaction:
</Text>
<Box marginTop={1} flexDirection='column'>
<Text color={colors.textMuted}>
Required: {formatSatoshis(requiredAmount)} +{' '}
{formatSatoshis(fee)} fee
{getFiatSuffix(requiredAmount + fee)}
</Text>
<Text
color={
selectedAmount >= requiredAmount + fee
? colors.success
: colors.warning
}
>
Selected: {formatSatoshis(selectedAmount)}
{getFiatSuffix(selectedAmount)}
</Text>
{selectedAmount > requiredAmount + fee && (
<Text color={colors.info}>
Change: {formatSatoshis(changeAmount)}
{getFiatSuffix(changeAmount)}
</Text>
)}
</Box>
<Box
marginTop={1}
flexDirection='column'
borderStyle='single'
borderColor={colors.border}
paddingX={1}
>
{availableUtxos.length === 0 ? (
<Text color={colors.textMuted}>No UTXOs available</Text>
) : (
availableUtxos.map((utxo, index) => {
const isCursor =
selectedUtxoIndex === index && focusArea === 'content';
return (
<Box
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
flexDirection='column'
>
<Text
color={isCursor ? colors.focus : colors.text}
bold={isCursor}
>
{isCursor ? '▸ ' : ' '}[{utxo.selected ? 'X' : ' '}]{' '}
{formatSatoshis(utxo.valueSatoshis)} -{' '}
{formatHex(utxo.outpointTransactionHash, 12)}:
{utxo.outpointIndex}
</Text>
{(() => {
const fiatValue = formatSatoshisToFiat(utxo.valueSatoshis);
if (!fiatValue) return null;
return (
<Text color={colors.textMuted}>
{' '} {fiatValue}
</Text>
);
})()}
</Box>
);
})
)}
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
Space/Enter: Toggle a: Select all n: Deselect all
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../../theme.js';
interface PublishStepProps {
invitationId: string | null;
requirementsComplete: boolean;
hasSignedAndBroadcasted: boolean;
}
export function PublishStep({
invitationId,
requirementsComplete,
hasSignedAndBroadcasted,
}: PublishStepProps): React.ReactElement {
return (
<Box flexDirection='column'>
<Text color={colors.success} bold>
Invitation Ready
</Text>
<Box marginTop={1} flexDirection='column'>
<Text color={colors.text}>Invitation ID:</Text>
<Box
borderStyle='single'
borderColor={colors.primary}
paddingX={1}
marginTop={1}
>
<Text color={colors.accent}>
{invitationId ?? '(unknown)'}
</Text>
</Box>
</Box>
<Box marginTop={1}>
{hasSignedAndBroadcasted ? (
<Text color={colors.success}>
Transaction signed and broadcasted.
</Text>
) : requirementsComplete ? (
<Text color={colors.textMuted}>
Requirements are complete. Use the Sign & Broadcast button to finalize.
</Text>
) : (
<Text color={colors.warning}>
Requirements are incomplete. Complete missing requirements before signing.
</Text>
)}
</Box>
<Box marginTop={1}>
<Text color={colors.warning}>
Press 'c' to copy ID to clipboard
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,123 @@
import React from 'react';
import { Box, Text } from 'ink';
import { colors, formatSatoshis } from '../../../theme.js';
import { useSatoshisConversion } from '../../../hooks/useSatoshisConversion.js';
import type { VariableInput, SelectableUTXO } from '../types.js';
import type { XOTemplate } from '@xo-cash/types';
interface ReviewStepProps {
template: XOTemplate;
actionName: string;
roleIdentifier: string;
variables: VariableInput[];
availableUtxos: SelectableUTXO[];
changeAmount: bigint;
}
export function ReviewStep({
template,
actionName,
roleIdentifier,
variables,
availableUtxos,
changeAmount,
}: ReviewStepProps): React.ReactElement {
const selectedUtxos = availableUtxos.filter((u) => u.selected);
const { formatSatoshisToFiat } = useSatoshisConversion();
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 (
<Box flexDirection='column'>
<Text color={colors.text} bold>
Review your invitation:
</Text>
{/* Summary */}
<Box marginTop={1} flexDirection='column'>
<Text color={colors.textMuted}>Template: {template?.name}</Text>
<Text color={colors.textMuted}>Action: {actionName}</Text>
<Text color={colors.textMuted}>Role: {roleIdentifier}</Text>
</Box>
{/* Variables */}
{variables.length > 0 && (
<Box marginTop={1} flexDirection='column'>
<Text color={colors.text}>Variables:</Text>
{variables.map((v) => (
<Text key={v.id} color={colors.textMuted}>
{' '}
{v.name}: {v.value || '(empty)'}
{v.value ? getVariableFiatSuffix(v) : ''}
</Text>
))}
</Box>
)}
{/* Inputs */}
{selectedUtxos.length > 0 && (
<Box marginTop={1} flexDirection='column'>
<Text color={colors.text}>
Inputs ({selectedUtxos.length}):
</Text>
{selectedUtxos.slice(0, 3).map((u) => (
<Text
key={`${u.outpointTransactionHash}:${u.outpointIndex}`}
color={colors.textMuted}
>
{' '}
{formatSatoshis(u.valueSatoshis)}
{getFiatSuffix(u.valueSatoshis)}
</Text>
))}
{selectedUtxos.length > 3 && (
<Text color={colors.textMuted}>
{' '}...and {selectedUtxos.length - 3} more
</Text>
)}
</Box>
)}
{/* Outputs */}
{changeAmount > 0n && (
<Box marginTop={1} flexDirection='column'>
<Text color={colors.text}>Outputs:</Text>
<Text color={colors.textMuted}>
{' '}Change: {formatSatoshis(changeAmount)}
{getFiatSuffix(changeAmount)}
</Text>
</Box>
)}
{/* Confirmation prompt */}
<Box marginTop={1}>
<Text color={colors.warning}>
Press Next to create and publish the invitation.
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,123 @@
/**
* Role Selection Step - Allows the user to choose which role they want
* to take for the selected action.
*/
import React from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../../theme.js';
import type { XOTemplate } from '@xo-cash/types';
import type { FocusArea } from '../types.js';
interface RoleSelectStepProps {
/** The loaded template definition. */
template: XOTemplate;
/** The selected action identifier. */
actionIdentifier: string;
/** Role identifiers available for this action. */
availableRoles: string[];
/** The currently focused role index. */
selectedRoleIndex: number;
/** Whether the content area or button bar is focused. */
focusArea: FocusArea;
}
/**
* Displays the available roles for the selected action and
* lets the user navigate between them with arrow keys.
*/
export function RoleSelectStep({
template,
actionIdentifier,
availableRoles,
selectedRoleIndex,
focusArea,
}: RoleSelectStepProps): React.ReactElement {
const action = template.actions?.[actionIdentifier];
return (
<Box flexDirection="column">
<Text color={colors.text} bold>
Select your role for this action:
</Text>
{/* Action info */}
{action && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.textMuted}>
{action.description || 'No description available'}
</Text>
</Box>
)}
{/* Role list */}
<Box
marginTop={1}
flexDirection="column"
borderStyle="single"
borderColor={colors.border}
paddingX={1}
>
{availableRoles.length === 0 ? (
<Text color={colors.textMuted}>No roles available</Text>
) : (
availableRoles.map((roleId: string, index: number) => {
const isCursor =
selectedRoleIndex === index && focusArea === 'content';
const roleDef = template.roles?.[roleId];
const actionRole = action?.roles?.[roleId];
const requirements = actionRole?.requirements;
const actionRequirements = action?.requirements;
const actionRoleRequirements = actionRole && actionRequirements && actionRequirements.participants?.find((participant) => participant.role === roleId);
return (
<Box key={roleId} flexDirection="column" marginY={0}>
<Text
color={isCursor ? colors.focus : colors.text}
bold={isCursor}
>
{isCursor ? '▸ ' : ' '}
{roleDef?.name || roleId}
</Text>
{/* Show role description indented below the name */}
{roleDef?.description && (
<Text color={colors.textMuted}>
{' '}
{roleDef.description}
</Text>
)}
{/* Show a brief summary of requirements */}
{requirements && (
<Box flexDirection="row" paddingLeft={4}>
{requirements.variables && requirements.variables.length > 0 && (
<Text color={colors.textMuted} dimColor>
{requirements.variables.length} variable
{requirements.variables.length !== 1 ? 's' : ''}
{' '}
</Text>
)}
{actionRoleRequirements && actionRoleRequirements.slots && actionRoleRequirements.slots.min > 0 && (
<Text color={colors.textMuted} dimColor>
{actionRoleRequirements.slots.min} input slot
{actionRoleRequirements.slots.min !== 1 ? 's' : ''}
</Text>
)}
</Box>
)}
</Box>
);
})
)}
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
: Navigate Next: Confirm selection
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../../theme.js';
import { VariableInputField } from '../../../components/VariableInputField.js';
import type { VariableInput, FocusArea } from '../types.js';
interface Props {
variables: VariableInput[];
updateVariable: (index: number, value: string) => void;
handleTextInputSubmit: () => void;
focusArea: FocusArea;
focusedInput: number;
}
export function VariablesStep({
variables,
updateVariable,
handleTextInputSubmit,
focusArea,
focusedInput,
}: Props): React.ReactElement {
return (
<Box flexDirection='column'>
<Text color={colors.text} bold>
Enter required values:
</Text>
<Box marginTop={1} flexDirection='column'>
{variables.map((variable, index) => (
<VariableInputField
key={variable.id}
variable={variable}
index={index}
isFocused={focusArea === 'content' && focusedInput === index}
onChange={updateVariable}
onSubmit={handleTextInputSubmit}
borderColor={colors.border as string}
focusColor={colors.primary as string}
/>
))}
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
Type your value, then press Enter to continue
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,6 @@
export * from "./RoleSelectStep.js";
export * from "./VariablesStep.js";
export * from "./InputsStep.js";
export * from "./ReviewStep.js";
export * from "./PublishStep.js";
export * from "./DataResultStep.js";

View File

@@ -0,0 +1,79 @@
/**
* Shared types for the action wizard.
*/
/** Supported step types in the wizard. */
export type StepType =
| "role-select"
| "variables"
| "inputs"
| "review"
| "publish"
| "result";
/** A step displayed in the wizard's progress indicator. */
export interface WizardStep {
name: string;
type: StepType;
}
/**
* Configuration for a single wizard step.
* The flow strategy determines which steps exist; the orchestrator
* wires validate/onNext to the appropriate domain hooks.
*/
export interface StepConfig {
type: StepType;
name: string;
/** Return an error message if the step is invalid, or null if OK to proceed. */
validate: () => string | null;
/** Execute transition logic. Return true on success, false to stay on this step. */
onNext: () => Promise<boolean>;
}
/**
* Context passed to WizardFlow strategy methods so they can
* determine steps and finalization state without holding React state.
*/
export interface FlowContext {
availableRoles: string[];
hasVariables: boolean;
shouldCollectInputs: boolean;
requirementsComplete: boolean;
wizardCollectedInputs: boolean;
hasSignedAndBroadcasted: boolean;
}
/** Variable input state for the variables step. */
export interface VariableInput {
id: string;
name: string;
type: string;
hint?: string;
value: string;
}
/** A UTXO that can be toggled for transaction funding. */
export interface SelectableUTXO {
outpointTransactionHash: string;
outpointIndex: number;
valueSatoshis: bigint;
lockingBytecode?: string;
selected: boolean;
}
/** Which area of the wizard UI currently has keyboard focus. */
export type FocusArea = "content" | "buttons";
/** Which button in the bottom bar is focused. */
export type ButtonFocus = "back" | "cancel" | "next";
/** A computed data result from a data-only action. */
export interface DataResult {
id: string;
name: string;
type: string;
hint?: string;
/** null when the engine hasn't computed the value yet. */
value: string | null;
}

View File

@@ -2,9 +2,9 @@
* Export all screen components.
*/
export * from './action-wizard/index.js';
export { SeedInputScreen } from './SeedInput.js';
export { WalletStateScreen } from './WalletState.js';
export { TemplateListScreen } from './TemplateList.js';
export { ActionWizardScreen } from './ActionWizard.js';
export { InvitationScreen } from './Invitation.js';
export { InvitationScreen } from './invitations/InvitationScreen.js';
export { TransactionScreen } from './Transaction.js';

View File

@@ -0,0 +1,895 @@
/**
* Invitation Screen - Manages invitations (create, import, view, monitor).
*
* Provides:
* - Import invitation by ID with multi-step import flow
* - View active invitations with detailed information
* - Monitor invitation updates via SSE
* - Fill missing requirements
* - Sign and complete invitations
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Box, Text } from 'ink';
import { InputDialog } from '../../components/Dialog.js';
import { ScrollableList, type ListItemData, type ListGroup } from '../../components/List.js';
import { useNavigation } from '../../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
import { useBlockableInput, useIsInputCaptured } from '../../hooks/useInputLayer.js';
import { useInvitations } from '../../hooks/useInvitations.js';
import { useSatoshisConversion } from '../../hooks/useSatoshisConversion.js';
import { colors, logoSmall, formatSatoshis } from '../../theme.js';
import { copyToClipboard } from '../../utils/clipboard.js';
import type { Invitation } from '../../../services/invitation.js';
import type { XOInvitationCommit, XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
import {
getInvitationState,
getStateColorName,
getInvitationInputs,
getInvitationOutputs,
getInvitationVariables,
formatInvitationListItem,
formatInvitationId,
} from '../../../utils/invitation-utils.js';
import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js';
import { compileCashAssemblyString } from '@xo-cash/engine';
/**
* Map state color name to theme color.
*/
function getStateColor(state: string): string {
const colorName = getStateColorName(state);
switch (colorName) {
case 'info':
return colors.info as string;
case 'warning':
return colors.warning as string;
case 'success':
return colors.success as string;
case 'error':
return colors.error as string;
case 'muted':
default:
return colors.textMuted as string;
}
}
/**
* Action menu items.
*/
const actionItems: ListItemData<string>[] = [
{ key: 'accept', label: 'Accept & Join', value: 'accept' },
{ key: 'fill', label: 'Fill Requirements', value: 'fill' },
{ key: 'sign', label: 'Sign Transaction', value: 'sign' },
{ key: 'transaction', label: 'View Transaction', value: 'transaction' },
{ key: 'copy', label: 'Copy Invitation ID', value: 'copy' },
];
/**
* Invitation list item with invitation value or null for import action.
*/
type InvitationListItem = ListItemData<Invitation | null>;
/**
* Groups for the invitation list.
*/
const invitationListGroups: ListGroup[] = [
{ id: 'actions' },
{ id: 'invitations', separator: true },
];
type OwnInvitationContext = {
entityIdentifier: string | null;
roleIdentifier: string | null;
};
function getRoleIdentifierFromCommits(commits: XOInvitationCommit[]): string | null {
for (const commit of commits) {
for (const input of commit.data.inputs ?? []) {
if (input.roleIdentifier) return input.roleIdentifier;
}
for (const output of commit.data.outputs ?? []) {
if (output.roleIdentifier) return output.roleIdentifier;
}
for (const variable of commit.data.variables ?? []) {
if (variable.roleIdentifier) return variable.roleIdentifier;
}
}
return null;
}
/**
* Invitation Screen Component.
*/
export function InvitationScreen(): React.ReactElement {
const { navigate, data: navData } = useNavigation();
const { appService, showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
const invitations = useInvitations();
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
useSatoshisConversion();
// ── UI state ─────────────────────────────────────────────────────────────
const [selectedIndex, setSelectedIndex] = useState(0);
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
const [focusedPanel, setFocusedPanel] = useState<'list' | 'actions'>('list');
const [isLoading, setIsLoading] = useState(false);
// ── Import state ─────────────────────────────────────────────────────────
// Two phases: first the ID input dialog, then the multi-step import flow.
const [showIdDialog, setShowIdDialog] = useState(false);
const [importingId, setImportingId] = useState<string | null>(null);
const [pendingImportedInvitationId, setPendingImportedInvitationId] = useState<string | null>(null);
// ── Template cache ───────────────────────────────────────────────────────
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null);
const [ownInvitationContext, setOwnInvitationContext] = useState<OwnInvitationContext>({
entityIdentifier: null,
roleIdentifier: null,
});
// Check if we should open import dialog on mount
const initialMode = navData.mode as string | undefined;
useEffect(() => {
if (initialMode === 'import') {
setShowIdDialog(true);
}
}, [initialMode]);
/**
* Load templates for all invitations (for list display).
*/
useEffect(() => {
if (!appService) return;
invitations.forEach(inv => {
const templateId = inv.data.templateIdentifier;
if (!templateCache.has(templateId)) {
appService.engine.getTemplate(templateId).then(template => {
if (template) {
setTemplateCache(prev => new Map(prev).set(templateId, template));
}
});
}
});
}, [invitations, appService, templateCache]);
/**
* Build list items for ScrollableList.
*/
const listItems = useMemo((): InvitationListItem[] => {
const importItem: InvitationListItem = {
key: 'import',
label: '+ Import Invitation',
value: null,
group: 'actions',
color: 'info',
};
const invitationItems: InvitationListItem[] = invitations.map(inv => {
const template = templateCache.get(inv.data.templateIdentifier);
const formatted = formatInvitationListItem(inv, template);
return {
key: inv.data.invitationIdentifier,
label: formatted.label,
value: inv,
group: 'invitations',
color: formatted.statusColor,
hidden: !formatted.isValid,
};
});
return [importItem, ...invitationItems];
}, [invitations.length, templateCache]);
const selectedItem = listItems[selectedIndex];
const selectedInvitation = selectedItem?.value ?? null;
/**
* Load template for selected invitation.
*/
useEffect(() => {
if (!selectedInvitation || !appService) {
setSelectedTemplate(null);
return;
}
appService.engine.getTemplate(selectedInvitation.data.templateIdentifier)
.then(template => setSelectedTemplate(template ?? null));
}, [selectedInvitation, appService]);
/**
* Load the current engine entity's commits for the selected invitation.
*/
useEffect(() => {
if (!selectedInvitation || !appService) {
setOwnInvitationContext({
entityIdentifier: null,
roleIdentifier: null,
});
return;
}
let isCurrent = true;
appService.engine.getOwnCommits(selectedInvitation.data)
.then((ownCommits) => {
if (!isCurrent) return;
setOwnInvitationContext({
entityIdentifier: ownCommits[0]?.entityIdentifier ?? null,
roleIdentifier: getRoleIdentifierFromCommits(ownCommits),
});
})
.catch(() => {
if (!isCurrent) return;
setOwnInvitationContext({
entityIdentifier: null,
roleIdentifier: null,
});
});
return () => {
isCurrent = false;
};
}, [selectedInvitation, appService]);
// ── Import flow callbacks ──────────────────────────────────────────────
/**
* ID dialog submitted — transition to the multi-step import flow.
*/
const handleImportIdSubmit = useCallback((invitationId: string) => {
if (!invitationId.trim()) {
setShowIdDialog(false);
return;
}
setShowIdDialog(false);
setImportingId(invitationId.trim());
}, []);
/**
* Import flow closed (completed or cancelled).
*/
const handleImportFlowClose = useCallback((importedInvitationId?: string) => {
if (importedInvitationId) {
setPendingImportedInvitationId(importedInvitationId);
}
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 ────────────────────────────────────────────────────
const acceptInvitation = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
setIsLoading(true);
setStatus('Accepting invitation...');
await selectedInvitation.accept();
showInfo('Invitation accepted! You are now a participant.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
setStatus('Ready');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
if (errorMsg.toLowerCase().includes('already') || errorMsg.toLowerCase().includes('participant')) {
showInfo('You have already accepted this invitation.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
} else {
showError(`Failed to accept: ${errorMsg}`);
}
} finally {
setIsLoading(false);
}
}, [selectedInvitation, showInfo, showError, setStatus]);
const signInvitation = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
setIsLoading(true);
setStatus('Signing invitation...');
await selectedInvitation.sign();
showInfo('Invitation signed!');
setStatus('Ready');
} catch (error) {
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsLoading(false);
}
}, [selectedInvitation, showInfo, showError, setStatus]);
const copyId = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
await copyToClipboard(selectedInvitation.data.invitationIdentifier);
showInfo(`Copied!\n\n${selectedInvitation.data.invitationIdentifier}`);
} catch (error) {
showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`);
}
}, [selectedInvitation, showInfo, showError]);
const fillRequirements = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
setIsLoading(true);
setStatus('Checking available roles...');
const roles = await selectedInvitation.getAvailableRoles();
if (roles.length === 0) {
showInfo('You are already participating in this invitation. Checking if inputs are needed...');
} else {
const roleToTake = roles[0];
showInfo(`Accepting invitation as role: ${roleToTake}`);
setStatus(`Accepting as ${roleToTake}...`);
try {
await selectedInvitation.accept();
} catch (e) {
showError(`Failed to accept role: ${e instanceof Error ? e.message : String(e)}`);
setStatus('Ready');
return;
}
}
setStatus('Analyzing invitation...');
let requiredAmount = 0n;
const commits = selectedInvitation.data.commits || [];
for (const commit of commits) {
const variables = commit.data?.variables || [];
for (const variable of variables) {
if (variable.variableIdentifier?.toLowerCase().includes('satoshi')) {
requiredAmount = BigInt(variable.value?.toString() || '0');
break;
}
}
if (requiredAmount > 0n) break;
}
const fee = 500n;
const dust = 546n;
const totalNeeded = requiredAmount + fee + dust;
const utxos = await selectedInvitation.findSuitableResources({
templateIdentifier: selectedInvitation.data.templateIdentifier,
outputIdentifier: 'receiveOutput',
});
if (utxos.length === 0) {
showError('No suitable UTXOs found. Make sure your wallet has funds.');
setStatus('Ready');
return;
}
setStatus('Selecting UTXOs...');
const selectedUtxos: Array<{
outpointTransactionHash: string;
outpointIndex: number;
valueSatoshis: bigint;
}> = [];
let accumulated = 0n;
const seenLockingBytecodes = new Set<string>();
for (const utxo of utxos) {
const lockingBytecodeHex = utxo.scriptHash
? typeof utxo.scriptHash === 'string'
? utxo.scriptHash
: Buffer.from(utxo.scriptHash).toString('hex')
: undefined;
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue;
if (lockingBytecodeHex) seenLockingBytecodes.add(lockingBytecodeHex);
selectedUtxos.push({
outpointTransactionHash: utxo.outpointTransactionHash,
outpointIndex: utxo.outpointIndex,
valueSatoshis: BigInt(utxo.valueSatoshis),
});
accumulated += BigInt(utxo.valueSatoshis);
if (accumulated >= totalNeeded) break;
}
if (accumulated < totalNeeded) {
showError(`Insufficient funds. Need ${formatSatoshis(totalNeeded)}, have ${formatSatoshis(accumulated)}`);
setStatus('Ready');
return;
}
const changeAmount = accumulated - requiredAmount - fee;
setStatus('Adding inputs...');
await selectedInvitation.addInputs(
selectedUtxos.map(u => ({
outpointTransactionHash: new Uint8Array(Buffer.from(u.outpointTransactionHash, 'hex')),
outpointIndex: u.outpointIndex,
}))
);
if (changeAmount >= dust) {
setStatus('Adding change output...');
await selectedInvitation.addOutputs([{
valueSatoshis: changeAmount,
}]);
}
showInfo(
`Requirements filled!\n\n` +
`• Selected ${selectedUtxos.length} UTXO(s)\n` +
`• Total: ${formatSatoshis(accumulated)}\n` +
`• Required: ${formatSatoshis(requiredAmount)}\n` +
`• Fee: ${formatSatoshis(fee)}\n` +
`• Change: ${formatSatoshis(changeAmount)}\n\n` +
`Now use "Sign Transaction" to complete.`
);
setStatus('Ready');
} catch (error) {
showError(`Failed to fill requirements: ${error instanceof Error ? error.message : String(error)}`);
setStatus('Ready');
} finally {
setIsLoading(false);
}
}, [selectedInvitation, showInfo, showError, setStatus]);
const handleAction = useCallback((action: string) => {
switch (action) {
case 'copy':
copyId();
break;
case 'accept':
acceptInvitation();
break;
case 'fill':
fillRequirements();
break;
case 'sign':
signInvitation();
break;
case 'transaction':
if (selectedInvitation) {
navigate('transaction', { invitationId: selectedInvitation.data.invitationIdentifier });
}
break;
}
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
const handleListItemActivate = useCallback((item: InvitationListItem, _index: number) => {
if (item.key === 'import') {
setShowIdDialog(true);
}
}, []);
const handleActionItemActivate = useCallback((item: ListItemData<string>, _index: number) => {
if (item.value) {
handleAction(item.value);
}
}, [handleAction]);
// ── Keyboard navigation ──────────────────────────────────────────────────
// Automatically blocked when any dialog/overlay is capturing input.
const isCaptured = useIsInputCaptured();
useBlockableInput((input, key) => {
if (key.tab) {
setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list');
return;
}
if (input === 'c' && selectedInvitation) {
copyId();
}
if (input === 'i') {
setShowIdDialog(true);
}
});
// ── Render helpers ───────────────────────────────────────────────────────
const renderInvitationListItem = useCallback((
item: InvitationListItem,
isSelected: boolean,
isFocused: boolean
): React.ReactNode => {
if (item.key === 'import') {
return (
<Text
color={isFocused ? colors.focus : colors.info}
bold={isSelected}
>
{isFocused ? '▸ ' : ' '}
{item.label}
</Text>
);
}
const inv = item.value;
if (!inv) return null;
const state = getInvitationState(inv);
const template = templateCache.get(inv.data.templateIdentifier);
const templateName = template?.name ?? 'Unknown';
return (
<Text
color={isFocused ? colors.focus : colors.text}
bold={isSelected}
>
{isFocused ? '▸ ' : ' '}
<Text color={getStateColor(state)}>[{state}]</Text>
{' '}{templateName}-{inv.data.actionIdentifier} ({formatInvitationId(inv.data.invitationIdentifier, 8)})
</Text>
);
}, [templateCache]);
const renderDetails = () => {
if (!selectedInvitation) {
return <Text color={colors.textMuted}>Select an invitation to view details</Text>;
}
const state = getInvitationState(selectedInvitation);
const action = selectedTemplate?.actions?.[selectedInvitation.data.actionIdentifier];
const inputs = getInvitationInputs(selectedInvitation);
const outputs = getInvitationOutputs(selectedInvitation);
const variables = getInvitationVariables(selectedInvitation);
const userEntityId = ownInvitationContext.entityIdentifier;
const userRole = ownInvitationContext.roleIdentifier;
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
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 (
<Box flexDirection="column">
{/* Type & Status */}
<Box flexDirection="row" marginBottom={1}>
<Box width="50%">
<Box flexDirection="column">
<Text color={colors.primary} bold>Type: </Text>
<Text color={colors.text}>{selectedTemplate?.name ?? 'Unknown Template'}</Text>
<Text color={colors.textMuted} dimColor>
{selectedTemplate?.description ?? 'No description available'}
</Text>
</Box>
</Box>
<Box width="50%">
<Box flexDirection="column">
<Text color={colors.primary} bold>Status: </Text>
<Text color={getStateColor(state)}>{state}</Text>
<Text color={colors.textMuted}>
Action: {action?.name ?? selectedInvitation.data.actionIdentifier}
</Text>
{formattedFiatPerBchRate && (
<Text color={colors.textMuted}>
1 BCH = {formattedFiatPerBchRate}
</Text>
)}
{action?.description && (
<Text color={colors.textMuted} dimColor>{action.description}</Text>
)}
</Box>
</Box>
</Box>
{/* Your Role */}
{userRole && (
<Box marginBottom={1} flexDirection="column">
<Text color={colors.primary} bold>Your Role: </Text>
<Text color={colors.success}>{roleInfo?.name ?? userRole}</Text>
{roleInfo?.description && (
<Text color={colors.textMuted} dimColor>{roleInfo.description}</Text>
)}
</Box>
)}
{/* Inputs & Outputs */}
<Box flexDirection="row" marginBottom={1}>
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
{inputs.length === 0 ? (
<Text color={colors.textMuted}> No inputs yet</Text>
) : (
inputs.map((input, idx) => {
const isUserInput = input.entityIdentifier === userEntityId;
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
const inputSatoshis = (
'valueSatoshis' in input && input.valueSatoshis !== undefined
)
? parseNumberishToBigInt(input.valueSatoshis)
: null;
return (
<Text
key={`input-${idx}`}
color={isUserInput ? colors.success : colors.text}
>
{/* Indicator for whether this is the user's input */}
{' '}{isUserInput ? '• ' : '○ '}
{/* TODO: Why doesnt this stuff work? It just cant resolve inputs? */}
{/* Input name */}
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{/* Input role */}
{input.roleIdentifier && ` (${input.roleIdentifier})`}
{/* Input value */}
{inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`}
</Text>
);
})
)}
</Box>
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
{outputs.length === 0 ? (
<Text color={colors.textMuted}> No outputs yet</Text>
) : (
outputs.map((output, idx) => {
const isUserOutput = output.entityIdentifier === userEntityId;
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
const outputSatoshis = output.valueSatoshis !== undefined
? parseNumberishToBigInt(output.valueSatoshis)
: null;
return (
<Text
key={`output-${idx}`}
color={isUserOutput ? colors.success : colors.text}
>
{/* Indicator for whether this is the user's output */}
{' '}{isUserOutput ? '• ' : '○ '}
{/* Output name */}
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{/* Output description */}
{outputTemplate?.description && ' - ' + compileCashAssemblyString(outputTemplate?.description ?? '', variables.reduce((acc, variable) => {
acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue;
return acc;
}, {} as Record<string, XOInvitationVariableValue>))}
{/* Output value */}
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}
</Text>
);
})
)}
</Box>
</Box>
{/* Variables */}
<Box flexDirection="column">
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
{variables.length === 0 ? (
<Text color={colors.textMuted}> No variables set</Text>
) : (
variables.map((variable, idx) => {
const isUserVariable = variable.entityIdentifier === userEntityId;
const varTemplate = selectedTemplate?.variables?.[variable.variableIdentifier];
const displayValue = typeof variable.value === 'bigint'
? variable.value.toString()
: String(variable.value);
const parsedVariableSatoshis = isSatoshisVariable(variable.variableIdentifier)
? parseNumberishToBigInt(variable.value)
: null;
return (
<Text
key={`var-${idx}`}
color={isUserVariable ? colors.success : colors.text}
>
{' '}{isUserVariable ? '• ' : '○ '}
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
{parsedVariableSatoshis !== null &&
` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`}
{varTemplate?.description && (
<Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text>
)}
</Text>
);
})
)}
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
</Box>
</Box>
);
};
// ── Main render ──────────────────────────────────────────────────────────
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
<Text color={colors.primary} bold>{logoSmall} - Invitations</Text>
</Box>
{/* Top row: List + Actions */}
<Box flexDirection="row" marginTop={1} height={12}>
{/* Left column: Invitation list */}
<Box flexDirection="column" width="70%" paddingRight={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'list' ? colors.focus : colors.primary}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Invitations </Text>
<ScrollableList
items={listItems}
selectedIndex={selectedIndex}
onSelect={setSelectedIndex}
onActivate={handleListItemActivate}
focus={focusedPanel === 'list' && !isCaptured}
maxVisible={6}
groups={invitationListGroups}
emptyMessage="No invitations yet"
renderItem={renderInvitationListItem}
/>
</Box>
</Box>
{/* Right column: Actions */}
<Box flexDirection="column" width="30%" paddingLeft={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Actions </Text>
<ScrollableList
items={actionItems}
selectedIndex={selectedActionIndex}
onSelect={setSelectedActionIndex}
onActivate={handleActionItemActivate}
focus={focusedPanel === 'actions' && !isCaptured}
emptyMessage="No actions"
/>
</Box>
</Box>
</Box>
{/* Bottom row: Details */}
<Box flexDirection="column" marginTop={1} flexGrow={1}>
<Box
borderStyle="single"
borderColor={colors.border}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Details </Text>
<Box marginTop={1} flexDirection="column">
{renderDetails()}
</Box>
</Box>
</Box>
{/* Help text */}
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
Tab: Switch panel : Navigate Enter: Select i: Import c: Copy ID Esc: Back
</Text>
</Box>
{/* Import ID dialog */}
{showIdDialog && (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
height="100%"
>
<InputDialog
title="Import Invitation"
prompt="Enter Invitation ID:"
placeholder="Paste invitation ID..."
onSubmit={handleImportIdSubmit}
onCancel={() => setShowIdDialog(false)}
isActive={true}
/>
</Box>
)}
{/* Multi-step import flow */}
{importingId && appService && (
<InvitationImportFlow
invitationId={importingId}
mode="screen"
appService={appService}
onClose={handleImportFlowClose}
showError={showError}
showInfo={showInfo}
setStatus={setStatus}
/>
)}
</Box>
);
}

View File

@@ -0,0 +1,421 @@
/**
* InvitationImportFlow — orchestrates the multi-step invitation import.
*
* Manages the step state machine, accumulates data from each step, and
* injects it into the next step via props (dependency injection).
*
* Supports two display modes:
* - `'dialog'`: renders as an absolute-positioned overlay (used when called from InvitationScreen)
* - `'screen'`: renders as a full-screen component with header, step indicator, and button bar
*/
import React, { useState, useCallback } from 'react';
import { Box, Text } from 'ink';
import { colors, logoSmall } from '../../../theme.js';
import { StepIndicator, type Step } from '../../../components/ProgressBar.js';
import { FetchInvitationStep } from './steps/FetchInvitationStep.js';
import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js';
import { RoleSelectStep } from './steps/RoleSelectStep.js';
import { VariablesStep } from './steps/VariablesStep.js';
import { InputsSelectStep } from './steps/InputsSelectStep.js';
import { ReviewStep } from './steps/ReviewStep.js';
import { IMPORT_STEPS, type ImportFlowProps, type ImportStepType, type ImportVariableInput, type SelectableUTXO } from './types.js';
import type { Invitation } from '../../../../services/invitation.js';
import type { XOTemplate } from '@xo-cash/types';
import { DialogWrapper } from '../../../components/Dialog.js';
import { useInputLayer, useLayeredInput } from '../../../hooks/useInputLayer.js';
import { hexToBin } from '@bitauth/libauth';
/** Default fee estimate in satoshis. */
const DEFAULT_FEE = 500n;
/** Dust threshold — outputs below this are unspendable. */
const DUST_THRESHOLD = 546n;
/**
* Resolve the fixed index of a flow step from `IMPORT_STEPS`.
* We centralize this so step transitions do not rely on magic numbers.
*/
function getStepIndex(type: ImportStepType): number {
const index = IMPORT_STEPS.findIndex((step) => step.type === type);
if (index === -1) {
throw new Error(`Import step not found: ${type}`);
}
return index;
}
const PREVIEW_STEP_INDEX = getStepIndex('preview');
const ROLE_SELECT_STEP_INDEX = getStepIndex('role-select');
const VARIABLES_STEP_INDEX = getStepIndex('variables');
const INPUTS_SELECT_STEP_INDEX = getStepIndex('inputs-select');
const REVIEW_STEP_INDEX = getStepIndex('review');
export function InvitationImportFlow({
invitationId,
mode,
appService,
onClose,
showError,
showInfo,
setStatus,
}: ImportFlowProps): React.ReactElement {
// ── Accumulated state ────────────────────────────────────────────────────
const [currentStep, setCurrentStep] = useState(0);
const [invitation, setInvitation] = useState<Invitation | null>(null);
const [template, setTemplate] = useState<XOTemplate | null>(null);
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
const [selectedRole, setSelectedRole] = useState<string | null>(null);
const [variableInputs, setVariableInputs] = useState<ImportVariableInput[]>([]);
const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]);
const [changeAmount, setChangeAmount] = useState(0n);
const [requiredAmount, setRequiredAmount] = useState(0n);
// ── Cancel handler ───────────────────────────────────────────────────────
/**
* Cleans up (removes the invitation if it was fetched) and signals the parent.
*/
const handleCancel = useCallback(async () => {
if (invitation && appService) {
try {
await appService.removeInvitation(invitation);
} catch {
// Best-effort removal — don't block close on failure
}
}
onClose();
}, [invitation, appService, onClose]);
// ── Step completion callbacks ────────────────────────────────────────────
/**
* FetchStep completed — invitation and template are now available.
* Also pre-fetches available roles for the next steps.
*/
const handleFetchComplete = useCallback(async (inv: Invitation, tmpl: XOTemplate | null) => {
setInvitation(inv);
setTemplate(tmpl);
try {
const roles = await inv.getAvailableRoles();
setAvailableRoles(roles);
} catch (err) {
showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`);
}
setCurrentStep(PREVIEW_STEP_INDEX); // → Preview
}, [showError]);
/** PreviewStep completed — user reviewed the invitation state and wants to proceed. */
const handlePreviewComplete = useCallback(() => {
setCurrentStep(ROLE_SELECT_STEP_INDEX); // → Role Select
}, []);
/** RoleSelectStep completed — user picked a role. */
const handleRoleComplete = useCallback((role: string) => {
setSelectedRole(role);
const action = template?.actions?.[invitation?.data.actionIdentifier ?? ""];
const roleRequirements = action?.roles?.[role]?.requirements?.variables ?? [];
const hasRequiredVariables = roleRequirements.length > 0;
if (!hasRequiredVariables) {
setVariableInputs([]);
setCurrentStep(INPUTS_SELECT_STEP_INDEX); // → Inputs Select
return;
}
const initializedVariables: ImportVariableInput[] = roleRequirements.map((variableId) => {
const variableDefinition = template?.variables?.[variableId];
return {
id: variableId,
name: variableDefinition?.name ?? variableId,
type: variableDefinition?.type ?? 'string',
hint: variableDefinition?.hint,
value: '',
};
});
setVariableInputs(initializedVariables);
setCurrentStep(VARIABLES_STEP_INDEX); // → Variables
}, [template, invitation]);
/** VariablesStep edited a field value. */
const handleVariableUpdate = useCallback((index: number, value: string) => {
setVariableInputs((previous) => {
const updated = [...previous];
const current = updated[index];
if (current) {
updated[index] = { ...current, value };
}
return updated;
});
}, []);
/**
* Convert variable input value to its invitation payload representation.
* Numeric variables are persisted as bigint so they match action wizard behavior.
*/
const parseVariableValue = useCallback((variable: ImportVariableInput) => {
const variableHint = variable.hint?.toLowerCase();
const isNumeric =
['integer', 'number', 'satoshis'].includes(variable.type) ||
(variableHint !== undefined && ['satoshis', 'amount'].includes(variableHint));
if (!isNumeric) {
return variable.value;
}
return BigInt(variable.value || '0');
}, []);
/** VariablesStep completed — persist variables then continue to input selection. */
const handleVariablesComplete = useCallback(async () => {
if (!invitation || !selectedRole) return;
const emptyVariables = variableInputs.filter((variable) => variable.value.trim() === '');
if (emptyVariables.length > 0) {
showError(`Please enter values for: ${emptyVariables.map((variable) => variable.name).join(', ')}`);
return;
}
try {
await invitation.addVariables(
variableInputs.map((variable) => ({
variableIdentifier: variable.id,
roleIdentifier: selectedRole,
value: parseVariableValue(variable),
})),
);
setCurrentStep(INPUTS_SELECT_STEP_INDEX); // → Inputs Select
} catch (error) {
showError(
`Failed to add variables: ${error instanceof Error ? error.message : String(error)}`,
);
}
}, [invitation, selectedRole, variableInputs, parseVariableValue, showError]);
/** InputsSelectStep completed — user selected UTXOs. */
const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => {
setSelectedInputs(inputs);
await invitation?.addInputs(inputs.map(input => ({
outpointTransactionHash: hexToBin(input.outpointTransactionHash),
outpointIndex: input.outpointIndex,
})));
// Compute totals from selected inputs
const totalSelected = inputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
// Determine required amount from invitation variables
const requiredSats = await invitation?.getSatsOut() ?? 0n;
setRequiredAmount(requiredSats);
// Set the change amount for the review step
const changeAmountSats = totalSelected - requiredSats - DEFAULT_FEE;
setChangeAmount(changeAmountSats);
// Add the change output if it exceeds the dust threshold
if (changeAmountSats >= DUST_THRESHOLD) {
await invitation?.addOutputs([{
valueSatoshis: changeAmountSats,
}]);
}
setCurrentStep(REVIEW_STEP_INDEX); // → Review
}, [invitation]);
/** ReviewStep completed — invitation import is done. */
const handleReviewComplete = useCallback(() => {
const roleName = (() => {
if (!selectedRole || !template) return selectedRole ?? '';
const raw = template.roles?.[selectedRole];
return (raw && typeof raw === 'object' && 'name' in raw) ? String(raw.name) : selectedRole;
})();
showInfo(
`Invitation imported and accepted!\n\n` +
`Role: ${roleName}\n` +
`Template: ${template?.name ?? invitation?.data.templateIdentifier ?? 'Unknown'}\n` +
`Action: ${invitation?.data.actionIdentifier ?? 'Unknown'}`
);
setStatus('Ready');
onClose(invitation?.data.invitationIdentifier);
}, [selectedRole, template, invitation, showInfo, setStatus, onClose]);
// ── Keyboard handling ────────────────────────────────────────────────────
// The import flow registers its own layer so it captures input above the
// parent screen. Individual steps also register sub-layers when needed.
useInputLayer('import-flow');
useLayeredInput('import-flow', (_input, key) => {
if (currentStep !== 0) return;
// Enter retries, Esc cancels — handled within FetchStep rendering,
// but we also catch Esc here for safety.
if (key.escape) handleCancel();
});
// ── Step router ──────────────────────────────────────────────────────────
const renderStep = (): React.ReactNode => {
const stepDef = IMPORT_STEPS[currentStep];
if (!stepDef) return null;
switch (stepDef.type) {
case 'fetch':
return (
<FetchInvitationStep
invitationId={invitationId}
appService={appService}
onComplete={handleFetchComplete}
onCancel={handleCancel}
isActive={true}
/>
);
case 'preview':
if (!invitation) return null;
return (
<PreviewInvitationStep
invitation={invitation}
template={template}
onComplete={handlePreviewComplete}
onCancel={handleCancel}
isActive={true}
/>
);
case 'role-select':
if (!invitation) return null;
return (
<RoleSelectStep
invitation={invitation}
template={template}
availableRoles={availableRoles}
onComplete={handleRoleComplete}
onCancel={handleCancel}
isActive={true}
/>
);
case 'variables':
return (
<VariablesStep
variables={variableInputs}
onUpdateVariable={handleVariableUpdate}
onComplete={handleVariablesComplete}
onCancel={handleCancel}
isActive={true}
/>
);
case 'inputs-select':
if (!invitation || !selectedRole) return null;
return (
<InputsSelectStep
invitation={invitation}
template={template}
selectedRole={selectedRole}
appService={appService}
onComplete={handleInputsComplete}
onCancel={handleCancel}
isActive={true}
/>
);
case 'review':
if (!invitation || !selectedRole) return null;
return (
<ReviewStep
invitation={invitation}
template={template}
selectedRole={selectedRole}
selectedInputs={selectedInputs}
changeAmount={changeAmount}
requiredAmount={requiredAmount}
appService={appService}
onComplete={handleReviewComplete}
onCancel={handleCancel}
isActive={true}
/>
);
default:
return null;
}
};
// ── Step indicator data ──────────────────────────────────────────────────
const indicatorSteps: Step[] = IMPORT_STEPS.map(s => ({ label: s.name }));
// ── Layout: dialog mode ──────────────────────────────────────────────────
if (mode === 'dialog') {
return (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
height="100%"
>
<DialogWrapper title="Import Invitation" borderColor={colors.primary}>
{/* Step indicator (compact) */}
<Box marginTop={1}>
<StepIndicator steps={indicatorSteps} currentStep={currentStep} />
</Box>
{/* Step content */}
<Box marginTop={1} flexDirection="column">
{renderStep()}
</Box>
</DialogWrapper>
</Box>
);
}
// ── Layout: screen mode ──────────────────────────────────────────────────
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1} flexDirection="column">
<Text color={colors.primary} bold>{logoSmall} - Import Invitation</Text>
<Text color={colors.textMuted}>
{template?.name ?? 'Loading...'}
{selectedRole ? ` (as ${selectedRole})` : ''}
</Text>
</Box>
{/* Step indicator */}
<Box marginTop={1} paddingX={1}>
<StepIndicator steps={indicatorSteps} currentStep={currentStep} />
</Box>
{/* Step content */}
<Box
borderStyle="single"
borderColor={colors.primary}
flexDirection="column"
paddingX={1}
paddingY={1}
marginTop={1}
marginX={1}
flexGrow={1}
>
<Text color={colors.primary} bold>
{IMPORT_STEPS[currentStep]?.name ?? 'Unknown'} ({currentStep + 1}/{IMPORT_STEPS.length})
</Text>
<Box marginTop={1} flexDirection="column">
{renderStep()}
</Box>
</Box>
{/* Help text */}
<Box marginTop={1} marginX={1}>
<Text color={colors.textMuted} dimColor>
Esc: Cancel import
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,73 @@
/**
* FetchInvitationStep — first step in the import flow.
*
* Receives an invitation ID, fetches the invitation from the sync server,
* resolves its template, and auto-advances once loaded.
* Shows a loading spinner while fetching and an error state with retry/cancel.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../../../theme.js';
import type { FetchStepProps } from '../types.js';
export function FetchInvitationStep({
invitationId,
appService,
onComplete,
isActive,
}: FetchStepProps): React.ReactElement {
const [status, setStatus] = useState<'loading' | 'error'>('loading');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
/**
* Fetch the invitation and its template, then auto-advance.
*/
const fetchInvitation = useCallback(async () => {
setStatus('loading');
setErrorMessage(null);
try {
// Create/fetch the invitation instance (fetches from sync server if needed)
const invitation = await appService.createInvitation(invitationId);
// Resolve the template for display in later steps
const template = await appService.engine.getTemplate(invitation.data.templateIdentifier);
// Auto-advance — hand the loaded data to the flow controller
onComplete(invitation, template ?? null);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
setStatus('error');
}
}, [invitationId, appService, onComplete]);
// Kick off the fetch on mount
useEffect(() => {
if (isActive) {
fetchInvitation();
}
}, [isActive, fetchInvitation]);
return (
<Box flexDirection="column">
{status === 'loading' && (
<Box flexDirection="column">
<Text color={colors.info}>Fetching invitation...</Text>
<Text color={colors.textMuted} dimColor>ID: {invitationId}</Text>
</Box>
)}
{status === 'error' && (
<Box flexDirection="column">
<Text color={colors.error} bold>Failed to fetch invitation</Text>
<Text color={colors.textMuted} wrap="wrap">{errorMessage}</Text>
<Box marginTop={1}>
<Text color={colors.textMuted}>Press Enter to retry or Esc to cancel</Text>
</Box>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,267 @@
/**
* InputsSelectStep — lets the user select UTXOs to fund the invitation.
*
* On mount, queries for suitable resources via the invitation's `findSuitableResources`.
* Auto-selects greedily, then lets the user toggle individual UTXOs.
* Shows required, selected, and change amounts.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Box, Text } from 'ink';
import { colors, formatSatoshis } from '../../../../theme.js';
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js';
import type { UnspentOutputData } from '@xo-cash/state';
/** Default fee estimate in satoshis. */
const DEFAULT_FEE = 500n;
/** Dust threshold — outputs below this are unspendable. */
const DUST_THRESHOLD = 546n;
export function InputsSelectStep({
invitation,
appService,
onComplete,
onCancel,
isActive,
}: InputsSelectStepProps): React.ReactElement {
const [utxos, setUtxos] = useState<SelectableUTXO[]>([]);
const [focusedIndex, setFocusedIndex] = useState(0);
const [requiredAmount, setRequiredAmount] = useState(0n);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { formatSatoshisToFiat } = useSatoshisConversion();
const fee = DEFAULT_FEE;
// Derived totals
const selectedAmount = utxos
.filter(u => u.selected)
.reduce((sum, u) => sum + u.valueSatoshis, 0n);
const changeAmount = 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.
*/
const computeRequiredAmount = useCallback(async (): Promise<bigint> => {
return await invitation.getSatsOut() ?? 0n;
}, [invitation]);
/**
* Fetch suitable UTXOs from the engine and auto-select greedily.
*/
const loadUtxos = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const required = await computeRequiredAmount();
setRequiredAmount(required);
const template = await appService.engine.getTemplate(invitation.data.templateIdentifier);
if (!template) {
throw new Error('Template not found');
}
// Get the action that we are calling from the template
const action = template.actions[invitation.data.actionIdentifier];
if (!action) {
throw new Error('Action not found');
}
if (!action.transaction) {
throw new Error('Action does not have a transaction');
}
// Get the transaction that the action is creating
const transaction = template.transactions?.[action.transaction];
if (!transaction) {
throw new Error(`Transaction not found for action: ${action.transaction}`);
}
if (!transaction.outputs) {
throw new Error(`Transaction does not have outputs`);
}
// Create a set to store all the output identifiers
const outputIdentifiers = new Set<string>();
for (const output of transaction.outputs) {
outputIdentifiers.add(output.output);
}
// Create a map of the utxoID to suitable resource
const utxoIdToSuitableResource = new Map<string, UnspentOutputData>();
for (const outputIdentifier of outputIdentifiers) {
const suitableResources = await invitation.findSuitableResources();
for (const suitableResource of suitableResources) {
utxoIdToSuitableResource.set(suitableResource.outpointTransactionHash + ':' + suitableResource.outpointIndex, suitableResource);
}
}
const selectable = mapUnspentOutputsToSelectable(Array.from(utxoIdToSuitableResource.values()));
const autoSelected = autoSelectGreedyUtxos(selectable, required + fee);
setUtxos(autoSelected as SelectableUTXO[]);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setIsLoading(false);
}
}, [invitation, computeRequiredAmount, fee]);
// Load UTXOs once on mount. We use a ref guard to prevent re-firing when
// `loadUtxos` identity changes due to parent re-renders — each re-fire
// flashes the loading state, causing the visible flicker bug.
const hasLoadedRef = useRef(false);
useEffect(() => {
if (isActive && !hasLoadedRef.current) {
hasLoadedRef.current = true;
loadUtxos();
}
}, [isActive, loadUtxos]);
/**
* Toggle the selection of a UTXO at the given index.
*/
const toggleSelection = useCallback((index: number) => {
setUtxos(prev => {
const updated = [...prev];
const utxo = updated[index];
if (utxo) updated[index] = { ...utxo, selected: !utxo.selected };
return updated;
});
}, []);
// Keyboard handling — gated by the import-flow layer so dialogs on top block input.
useLayeredInput('import-flow', (input, key) => {
if (key.upArrow || input === 'k') {
setFocusedIndex(prev => Math.max(0, prev - 1));
} else if (key.downArrow || input === 'j') {
setFocusedIndex(prev => Math.min(utxos.length - 1, prev + 1));
} else if (input === ' ') {
if (utxos.length > 0) toggleSelection(focusedIndex);
} else if (input === 'a') {
setUtxos(prev => prev.map(u => ({ ...u, selected: true })));
} else if (input === 'n') {
setUtxos(prev => prev.map(u => ({ ...u, selected: false })));
} else if (key.return) {
if (hasEnough) {
onComplete(utxos.filter(u => u.selected));
}
} else if (key.escape) {
onCancel();
}
}, { isActive });
// Loading state
if (isLoading) {
return (
<Box flexDirection="column">
<Text color={colors.info}>Finding suitable UTXOs...</Text>
</Box>
);
}
// Error state
if (error) {
return (
<Box flexDirection="column">
<Text color={colors.error} bold>Failed to load UTXOs</Text>
<Text color={colors.textMuted}>{error}</Text>
<Box marginTop={1}>
<Text color={colors.textMuted}>Esc: Cancel</Text>
</Box>
</Box>
);
}
// No UTXOs found
if (utxos.length === 0) {
return (
<Box flexDirection="column">
<Text color={colors.warning}>No suitable UTXOs found. Make sure your wallet has funds.</Text>
<Box marginTop={1}>
<Text color={colors.textMuted}>Esc: Cancel</Text>
</Box>
</Box>
);
}
return (
<Box flexDirection="column">
{/* Summary bar */}
<Box flexDirection="row" marginBottom={1}>
<Text color={colors.primary} bold>Required: </Text>
<Text color={colors.text}>
{formatSatoshis(requiredAmount + fee)}
{getFiatSuffix(requiredAmount + fee)}
</Text>
<Text color={colors.textMuted}> (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)})</Text>
</Box>
<Box flexDirection="row" marginBottom={1}>
<Text color={colors.primary} bold>Selected: </Text>
<Text color={hasEnough ? colors.success : colors.error}>
{formatSatoshis(selectedAmount)}
{getFiatSuffix(selectedAmount)}
</Text>
{hasEnough && changeAmount >= DUST_THRESHOLD && (
<Text color={colors.textMuted}>
{' '}
(change: {formatSatoshis(changeAmount)}
{getFiatSuffix(changeAmount)})
</Text>
)}
{!hasEnough && (
<Text color={colors.error}>
{' '}
need {formatSatoshis(requiredAmount + fee - selectedAmount)}
{getFiatSuffix(requiredAmount + fee - selectedAmount)} more
</Text>
)}
</Box>
{/* UTXO list */}
<Text color={colors.primary} bold>UTXOs ({utxos.length}):</Text>
{utxos.map((utxo, index) => {
const isFocused = index === focusedIndex;
const checkMark = utxo.selected ? '☑' : '☐';
const txShort = utxo.outpointTransactionHash.slice(0, 8);
return (
<Box
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
flexDirection="column"
>
<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>
);
})}
{/* Navigation hint */}
<Box marginTop={1}>
<Text color={colors.textMuted}>
: Navigate Space: Toggle a: All n: None return: Confirm Esc: Cancel
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,226 @@
/**
* PreviewInvitationStep — displays the current state of a fetched invitation.
*
* Shows which roles, inputs, outputs, and variables have already been filled
* so the user can understand what they're joining before proceeding.
* Press Enter to continue, Esc to cancel.
*/
import React from 'react';
import { Box, Text } from 'ink';
import { colors, formatSatoshis } from '../../../../theme.js';
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
import {
getInvitationState,
getStateColorName,
getInvitationInputs,
getInvitationOutputs,
getInvitationVariables,
} from '../../../../../utils/invitation-utils.js';
import type { PreviewStepProps } from '../types.js';
/**
* Map a semantic color name to an actual theme color value.
*/
function stateColor(state: string): string {
const name = getStateColorName(state);
switch (name) {
case 'info': return colors.info as string;
case 'warning': return colors.warning as string;
case 'success': return colors.success as string;
case 'error': return colors.error as string;
case 'muted':
default: return colors.textMuted as string;
}
}
export function PreviewInvitationStep({
invitation,
template,
onComplete,
onCancel,
isActive,
}: PreviewStepProps): React.ReactElement {
const { formatSatoshisToFiat } = useSatoshisConversion();
useLayeredInput('import-flow', (_input, key) => {
if (key.return) onComplete();
if (key.escape) onCancel();
}, { isActive });
const state = getInvitationState(invitation);
const action = template?.actions?.[invitation.data.actionIdentifier];
const inputs = getInvitationInputs(invitation);
const outputs = getInvitationOutputs(invitation);
const variables = getInvitationVariables(invitation);
// Collect role identifiers that appear across all commits
const filledRoles = new Set<string>();
for (const commit of invitation.data.commits ?? []) {
for (const input of commit.data?.inputs ?? []) {
if (input.roleIdentifier) filledRoles.add(input.roleIdentifier);
}
}
return (
<Box flexDirection="column">
{/* Template info */}
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color={colors.primary} bold>Template:</Text>
</Box>
<Box>
<Text color={colors.text}>{template?.name ?? invitation.data.templateIdentifier}</Text>
</Box>
{template?.description && (
<Box>
<Text color={colors.textMuted} dimColor>{template.description}</Text>
</Box>
)}
</Box>
{/* Action info */}
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color={colors.primary} bold>Action:</Text>
</Box>
<Box>
<Text color={colors.text}>{action?.name ?? invitation.data.actionIdentifier}</Text>
</Box>
{action?.description && (
<Box>
<Text color={colors.textMuted} dimColor>{action.description}</Text>
</Box>
)}
</Box>
{/* Status */}
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color={colors.primary} bold>Status:</Text>
</Box>
<Box>
<Text color={stateColor(state)}>{state}</Text>
</Box>
</Box>
{/* Roles already filled */}
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color={colors.primary} bold>Roles Filled ({filledRoles.size}):</Text>
</Box>
<Box marginLeft={1}>
{filledRoles.size === 0 ? (
<Box>
<Text color={colors.textMuted}> None yet</Text>
</Box>
) : (
Array.from(filledRoles).map(role => {
const roleInfoRaw = template?.roles?.[role];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
return (
<Box key={role}>
<Text color={colors.text}> {roleInfo?.name ?? role}</Text>
</Box>
);
})
)}
</Box>
</Box>
{/* Inputs */}
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
</Box>
<Box marginLeft={1}>
{inputs.length === 0 ? (
<Box>
<Text color={colors.textMuted}> None yet</Text>
</Box>
) : (
inputs.map((input, idx) => {
const inputTemplate = template?.inputs?.[input.inputIdentifier ?? ''];
return (
<Box key={`input-${idx}`}>
<Text color={colors.text}>
{' '} {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{input.roleIdentifier && ` (${input.roleIdentifier})`}
</Text>
</Box>
);
})
)}
</Box>
</Box>
{/* Outputs */}
<Box flexDirection="column" marginBottom={1} marginLeft={1}>
<Box>
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
</Box>
<Box marginLeft={1}>
{outputs.length === 0 ? (
<Box>
<Text color={colors.textMuted}>None yet</Text>
</Box>
) : (
outputs.map((output, idx) => {
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
const fiatValue = output.valueSatoshis !== undefined
? formatSatoshisToFiat(output.valueSatoshis)
: null;
return (
<Box key={`output-${idx}`}>
<Text color={colors.text}>
{' '} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
{fiatValue && ` (~${fiatValue})`}
</Text>
</Box>
);
})
)}
</Box>
</Box>
{/* Variables */}
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
</Box>
<Box marginLeft={1}>
{variables.length === 0 ? (
<Box>
<Text color={colors.textMuted}> None set</Text>
</Box>
) : (
variables.map((variable, idx) => {
const varTemplate = template?.variables?.[variable.variableIdentifier];
const displayValue = typeof variable.value === 'bigint'
? variable.value.toString()
: String(variable.value);
return (
<Box key={`var-${idx}`}>
<Text color={colors.text}>
{' '} {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
</Text>
</Box>
);
})
)}
</Box>
</Box>
{/* Navigation hint */}
<Box marginTop={1}>
<Text color={colors.textMuted}>Enter: Continue Esc: Cancel</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,140 @@
/**
* ReviewStep — final step that summarizes the import and executes it.
*
* Displays the accumulated selections (role, inputs, amounts) and on confirmation:
* 1. Adds inputs (with the selected role identifier) to the invitation.
* 2. Optionally adds a change output if the change exceeds the dust threshold.
* 3. Calls `onComplete()` to signal the flow is finished.
*/
import React, { useState, useCallback } from 'react';
import { Box, Text } from 'ink';
import { colors, formatSatoshis } from '../../../../theme.js';
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
import type { ReviewStepProps, SelectableUTXO } from '../types.js';
/** Default fee estimate in satoshis. */
const DEFAULT_FEE = 500n;
/** Dust threshold — outputs below this are unspendable. */
const DUST_THRESHOLD = 546n;
export function ReviewStep({
invitation,
template,
selectedRole,
selectedInputs,
requiredAmount,
changeAmount,
onComplete,
onCancel,
isActive,
}: ReviewStepProps): React.ReactElement {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { formatSatoshisToFiat } = useSatoshisConversion();
const fee = DEFAULT_FEE;
const action = template?.actions?.[invitation.data.actionIdentifier];
// Compute totals from selected inputs
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.
*/
const submit = useCallback(async () => {
setIsSubmitting(true);
setError(null);
try {
onComplete();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setIsSubmitting(false);
}
}, [invitation, selectedRole, selectedInputs, onComplete]);
// Keyboard handling — gated by the import-flow layer.
useLayeredInput('import-flow', (_input, key) => {
if (isSubmitting) return;
if (key.return) {
submit();
} else if (key.escape) {
onCancel();
}
}, { isActive });
// Resolve role display name
const roleInfoRaw = template?.roles?.[selectedRole];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
return (
<Box flexDirection="column">
<Text color={colors.primary} bold>Review Import</Text>
{/* Template & action */}
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Template: {template?.name ?? invitation.data.templateIdentifier}</Text>
<Text color={colors.text}>Action: {action?.name ?? invitation.data.actionIdentifier}</Text>
<Text color={colors.text}>Role: {roleInfo?.name ?? selectedRole}</Text>
</Box>
{/* Funding summary */}
<Box marginTop={1} flexDirection="column">
<Text color={colors.primary} bold>Funding:</Text>
<Text color={colors.text}> UTXOs: {selectedInputs.length}</Text>
<Text color={colors.text}> Total: {formatSatoshis(totalSelected)}{getFiatSuffix(totalSelected)}</Text>
<Text color={colors.text}> Required: {formatSatoshis(requiredAmount)}{getFiatSuffix(requiredAmount)}</Text>
<Text color={colors.text}> Fee: {formatSatoshis(fee)}{getFiatSuffix(fee)}</Text>
{changeAmount >= DUST_THRESHOLD && (
<Text color={colors.text}> Change: {formatSatoshis(changeAmount)}{getFiatSuffix(changeAmount)}</Text>
)}
</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 && (
<Box marginTop={1}>
<Text color={colors.error} bold>Error: {error}</Text>
</Box>
)}
{/* Status / hint */}
<Box marginTop={1}>
{isSubmitting ? (
<Text color={colors.info}>Submitting...</Text>
) : (
<Text color={colors.textMuted}>Enter: Confirm & Import Esc: Cancel</Text>
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,87 @@
/**
* RoleSelectStep — lets the user choose which role to take in the invitation.
*
* Displays available roles with their template-level and action-level descriptions.
* Arrow keys to navigate, Enter to select, Esc to cancel.
*/
import React, { useState } from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../../../theme.js';
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
import type { RoleSelectStepProps } from '../types.js';
export function RoleSelectStep({
invitation,
template,
availableRoles,
onComplete,
onCancel,
isActive,
}: RoleSelectStepProps): React.ReactElement {
const [selectedIndex, setSelectedIndex] = useState(0);
useLayeredInput('import-flow', (input, key) => {
if (key.upArrow || input === 'k') {
setSelectedIndex(prev => Math.max(0, prev - 1));
} else if (key.downArrow || input === 'j') {
setSelectedIndex(prev => Math.min(availableRoles.length - 1, prev + 1));
} else if (key.return) {
const role = availableRoles[selectedIndex];
if (role) onComplete(role);
} else if (key.escape) {
onCancel();
}
}, { isActive });
const action = template?.actions?.[invitation.data.actionIdentifier];
return (
<Box flexDirection="column">
{/* Context header */}
<Box marginBottom={1} flexDirection="column">
<Text color={colors.text}>Template: {template?.name ?? 'Unknown'}</Text>
<Text color={colors.text}>Action: {action?.name ?? invitation.data.actionIdentifier}</Text>
</Box>
{/* Role list */}
<Box flexDirection="column">
<Text color={colors.primary} bold>Available Roles:</Text>
{availableRoles.length === 0 ? (
<Text color={colors.warning}>No roles available (you may have already joined)</Text>
) : (
availableRoles.map((role, index) => {
const roleInfoRaw = template?.roles?.[role];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
const actionRoleRaw = action?.roles?.[role];
const actionRole = actionRoleRaw && typeof actionRoleRaw === 'object' ? actionRoleRaw : null;
const isFocused = index === selectedIndex;
return (
<Box key={role} flexDirection="column">
<Text
color={isFocused ? colors.focus : colors.text}
bold={isFocused}
>
{isFocused ? '▸ ' : ' '}
{roleInfo?.name ?? role}
</Text>
{(roleInfo?.description || actionRole?.description) && (
<Text color={colors.textMuted} dimColor>
{' '}{actionRole?.description ?? roleInfo?.description}
</Text>
)}
</Box>
);
})
)}
</Box>
{/* Navigation hint */}
<Box marginTop={1}>
<Text color={colors.textMuted}>: Select role Enter: Accept Esc: Cancel</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,113 @@
/**
* VariablesStep — collects all required variable values for invitation import.
*
* This runs after role selection and before input selection so cashasm
* expressions can resolve required variables during `getSatsOut()`.
*/
import React, { useMemo, useState, useCallback } from "react";
import { Box, Text } from "ink";
import { colors } from "../../../../theme.js";
import { useLayeredInput } from "../../../../hooks/useInputLayer.js";
import { VariableInputField } from "../../../../components/VariableInputField.js";
import type { VariablesStepProps } from "../types.js";
/**
* Build a user-facing validation error for empty required fields.
*/
function validateVariables(
variables: VariablesStepProps["variables"],
): string | null {
const empty = variables.filter((v) => v.value.trim() === "");
if (empty.length === 0) return null;
return `Please enter values for: ${empty.map((v) => v.name).join(", ")}`;
}
export function VariablesStep({
variables,
onUpdateVariable,
onComplete,
onCancel,
isActive,
}: VariablesStepProps): React.ReactElement {
const [focusedInput, setFocusedInput] = useState(0);
const [validationError, setValidationError] = useState<string | null>(null);
const helpText = useMemo(() => {
if (variables.length === 0) {
return "No variables required for this role.";
}
return "Enter a value for each variable, then press Enter on the last field to continue.";
}, [variables.length]);
/**
* Move focus to next input, or finish the step if this is the last one.
*/
const handleInputSubmit = useCallback(() => {
if (variables.length === 0) {
onComplete();
return;
}
if (focusedInput < variables.length - 1) {
setFocusedInput((prev) => prev + 1);
return;
}
const validation = validateVariables(variables);
setValidationError(validation);
if (!validation) {
onComplete();
}
}, [variables, focusedInput, onComplete]);
// Keyboard navigation for non-text actions.
useLayeredInput(
"import-flow",
(input, key) => {
if (key.upArrow || input === "k") {
setFocusedInput((prev) => Math.max(0, prev - 1));
} else if (key.downArrow || input === "j") {
setFocusedInput((prev) => Math.min(variables.length - 1, prev + 1));
} else if (key.escape) {
onCancel();
}
},
{ isActive },
);
return (
<Box flexDirection="column">
<Text color={colors.primary} bold>
Required Variables
</Text>
<Box marginTop={1} flexDirection="column">
{variables.map((variable, index) => (
<VariableInputField
key={variable.id}
variable={variable}
index={index}
isFocused={focusedInput === index}
onChange={onUpdateVariable}
onSubmit={handleInputSubmit}
borderColor={colors.border as string}
focusColor={colors.primary as string}
/>
))}
</Box>
{validationError && (
<Box marginTop={1}>
<Text color={colors.error}>{validationError}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={colors.textMuted}>
{helpText} : Change field Esc: Cancel
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,151 @@
/**
* Shared types for the invitation import flow.
*
* Each step in the flow receives only what it needs via props (dependency injection).
* The flow controller (`InvitationImportFlow`) accumulates data and passes it forward.
*/
import type { Invitation } from "../../../../services/invitation.js";
import type { AppService } from "../../../../services/app.js";
import type { XOTemplate } from "@xo-cash/types";
// ── Step definitions ─────────────────────────────────────────────────────────
/** Identifies each step in the import flow. */
export type ImportStepType =
| "fetch"
| "preview"
| "role-select"
| "variables"
| "inputs-select"
| "review";
/** A single step descriptor used by the flow controller and step indicator. */
export interface ImportStep {
name: string;
type: ImportStepType;
}
/** The ordered list of steps in the import flow. */
export const IMPORT_STEPS: ImportStep[] = [
{ name: "Fetch", type: "fetch" },
{ name: "Preview", type: "preview" },
{ name: "Select Role", type: "role-select" },
{ name: "Variables", type: "variables" },
{ name: "Select Inputs", type: "inputs-select" },
{ name: "Review", type: "review" },
];
// ── Display mode ─────────────────────────────────────────────────────────────
/** Controls whether the import flow renders as a dialog overlay or a full screen. */
export type ImportFlowMode = "dialog" | "screen";
// ── UTXO selection ───────────────────────────────────────────────────────────
/** A UTXO that the user can toggle on/off during the inputs step. */
export interface SelectableUTXO {
outpointTransactionHash: string;
outpointIndex: number;
valueSatoshis: bigint;
lockingBytecode?: string;
selected: boolean;
}
// ── Step props ───────────────────────────────────────────────────────────────
// Each step receives exactly the data and callbacks it needs.
/** Props for FetchInvitationStep — loads the invitation from an ID. */
export interface FetchStepProps {
invitationId: string;
appService: AppService;
onComplete: (invitation: Invitation, template: XOTemplate | null) => void;
onCancel: () => void;
isActive: boolean;
}
/** Props for PreviewInvitationStep — displays invitation state. */
export interface PreviewStepProps {
invitation: Invitation;
template: XOTemplate | null;
onComplete: () => void;
onCancel: () => void;
isActive: boolean;
}
/** Props for RoleSelectStep — lets user pick a role. */
export interface RoleSelectStepProps {
invitation: Invitation;
template: XOTemplate | null;
availableRoles: string[];
onComplete: (selectedRole: string) => void;
onCancel: () => void;
isActive: boolean;
}
/** A single variable input required by the selected action role. */
export interface ImportVariableInput {
id: string;
name: string;
type: string;
hint?: string;
value: string;
}
/** Props for VariablesStep — collects required role/action variable values. */
export interface VariablesStepProps {
variables: ImportVariableInput[];
onUpdateVariable: (index: number, value: string) => void;
onComplete: () => void;
onCancel: () => void;
isActive: boolean;
}
/** Props for InputsSelectStep — lets user pick UTXOs to fund the invitation. */
export interface InputsSelectStepProps {
invitation: Invitation;
template: XOTemplate | null;
selectedRole: string;
appService: AppService;
onComplete: (inputs: SelectableUTXO[]) => void;
onCancel: () => void;
isActive: boolean;
}
/** Props for ReviewStep — summarizes and executes the import. */
export interface ReviewStepProps {
invitation: Invitation;
template: XOTemplate | null;
selectedRole: string;
selectedInputs: SelectableUTXO[];
changeAmount: bigint;
requiredAmount: bigint;
appService: AppService;
onComplete: () => void;
onCancel: () => void;
isActive: boolean;
}
// ── Flow controller props ────────────────────────────────────────────────────
/** Props for the top-level InvitationImportFlow component. */
export interface ImportFlowProps {
/** The invitation ID to import (already entered by the user in InvitationScreen). */
invitationId: string;
/** Whether to render as a dialog overlay or a full screen. */
mode: ImportFlowMode;
/** The application service — injected, not pulled from context. */
appService: AppService;
/**
* 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. */
showError: (message: string) => void;
/** Display an info message to the user. */
showInfo: (message: string) => void;
/** Update the global status bar. */
setStatus: (message: string) => void;
}

View File

@@ -3,12 +3,12 @@
* Defines colors, styles, and visual constants used throughout the application.
*/
import type { TextProps } from 'ink';
import type { TextProps } from "ink";
/**
* Color type - supports Ink color names.
*/
export type Color = TextProps['color'];
export type Color = TextProps["color"];
/**
* Color palette for the application.
@@ -16,33 +16,33 @@ export type Color = TextProps['color'];
*/
export const colors = {
// Primary colors
primary: 'cyan' as Color,
secondary: 'blue' as Color,
accent: 'magenta' as Color,
primary: "cyan" as Color,
secondary: "blue" as Color,
accent: "magenta" as Color,
// Status colors
success: 'green' as Color,
warning: 'yellow' as Color,
error: 'red' as Color,
info: 'cyan' as Color,
success: "green" as Color,
warning: "yellow" as Color,
error: "red" as Color,
info: "cyan" as Color,
// Text colors
text: 'white' as Color,
textMuted: 'gray' as Color,
textHighlight: 'whiteBright' as Color,
text: "white" as Color,
textMuted: "gray" as Color,
textHighlight: "whiteBright" as Color,
// Background colors
bg: 'black' as Color,
bgSelected: 'blue' as Color,
bgHover: 'gray' as Color,
bg: "black" as Color,
bgSelected: "blue" as Color,
bgHover: "gray" as Color,
// Border colors
border: 'cyan' as Color,
borderFocused: 'yellowBright' as Color,
borderMuted: 'gray' as Color,
border: "cyan" as Color,
borderFocused: "yellowBright" as Color,
borderMuted: "gray" as Color,
// Focus highlight color (very visible)
focus: 'yellowBright' as Color,
focus: "yellowBright" as Color,
} as const;
/**
@@ -76,7 +76,7 @@ export const logo = `
/**
* Small logo for status bar.
*/
export const logoSmall = 'XO Wallet';
export const logoSmall = "XO Wallet";
/**
* Helper to format satoshis for display.
@@ -84,7 +84,7 @@ export const logoSmall = 'XO Wallet';
* @returns Formatted string with BCH amount
*/
export function formatSatoshis(satoshis: bigint | number): string {
const value = typeof satoshis === 'bigint' ? satoshis : BigInt(satoshis);
const value = typeof satoshis === "bigint" ? satoshis : BigInt(satoshis);
const bch = Number(value) / 100_000_000;
return `${bch.toFixed(8)} BCH (${value.toLocaleString()} sats)`;
}
@@ -97,7 +97,7 @@ export function formatSatoshis(satoshis: bigint | number): string {
*/
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 3) + '...';
return str.slice(0, maxLength - 3) + "...";
}
/**

View File

@@ -2,19 +2,19 @@
* Shared types for the CLI TUI.
*/
import type { WalletController } from '../controllers/wallet-controller.js';
import type { InvitationController } from '../controllers/invitation-controller.js';
import type { AppService } from "../services/app.js";
import type { AppConfig } from "../app.js";
/**
* Screen names for navigation.
*/
export type ScreenName =
| 'seed-input'
| 'wallet'
| 'templates'
| 'wizard'
| 'invitations'
| 'transaction';
export type ScreenName =
| "seed-input"
| "wallet"
| "templates"
| "wizard"
| "invitations"
| "transaction";
/**
* Navigation context data that can be passed between screens.
@@ -51,13 +51,17 @@ export interface NavigationContextType {
}
/**
* App context interface - provides access to controllers and app-level functions.
* App context interface - provides access to AppService and app-level functions.
*/
export interface AppContextType {
/** Wallet controller for wallet operations */
walletController: WalletController;
/** Invitation controller for invitation operations */
invitationController: InvitationController;
/** AppService instance (null before wallet initialization) */
appService: AppService | null;
/** Initialize wallet with seed phrase and create AppService */
initializeWallet: (seed: string) => Promise<void>;
/** Whether the wallet is initialized */
isWalletInitialized: boolean;
/** Application configuration */
config: AppConfig;
/** Show an error message dialog */
showError: (message: string) => void;
/** Show an info message dialog */
@@ -68,10 +72,6 @@ export interface AppContextType {
exit: () => void;
/** Update status bar message */
setStatus: (message: string) => void;
/** Whether the wallet is initialized */
isWalletInitialized: boolean;
/** Set wallet initialized state */
setWalletInitialized: (initialized: boolean) => void;
}
/**
@@ -81,7 +81,7 @@ export interface DialogState {
/** Whether dialog is visible */
visible: boolean;
/** Dialog type */
type: 'error' | 'info' | 'confirm';
type: "error" | "info" | "confirm";
/** Dialog message */
message: string;
/** Callback for confirm dialog */

View File

@@ -2,61 +2,74 @@
* Cross-platform clipboard utility with multiple fallback methods.
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import clipboardy from "clipboardy";
import { exec } from "child_process";
import { promisify } from "util";
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.
* Tries native commands first (most reliable), then clipboardy as fallback.
*
*
* @param text - The text to copy to clipboard
* @returns Promise that resolves on success, rejects with error message on failure
*/
export async function copyToClipboard(text: string): Promise<void> {
const platform = process.platform;
// Escape the text for shell commands
const escapedText = text.replace(/'/g, "'\\''");
// Try native commands first - they're more reliable
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
}
const availableMethods = Object.values(clipboardMethods).filter(method => method.platform(platform));
const errors: Error[] = [];
for (const method of availableMethods) {
try {
if (method.platform(platform)) {
await method.command(escapedText);
} else {
continue;
}
} else if (platform === 'win32') {
// Windows - use clip.exe
await execAsync(`echo|set /p="${text}" | clip`);
return;
} catch(error) {
if (error instanceof Error) {
errors.push(error);
}
}
} catch {
// Native command failed, try clipboardy
}
// Fallback to clipboardy
try {
const clipboard = await import('clipboardy');
await clipboard.default.write(text);
return;
} catch {
// clipboardy also 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

@@ -0,0 +1,183 @@
/*
* Handles BCH Mnemonic parsing to/from URL form.
* Pulled directly from the old stack package.
*/
import { z } from "zod";
import { decodeBip39Mnemonic } from "@bitauth/libauth";
export type BCHMnemonicURLRaw = {
entropy: Uint8Array;
passphrase?: string;
language?: (typeof BCHMnemonicURL.SUPPORTED_LANGUAGES)[number];
comment?: string;
path?: string;
startHeight?: number;
};
/**
* Handles BCHMnemonic URLs
*/
export class BCHMnemonicURL {
static PROTOCOL = "bch-mnemonic";
/**
* Check if a URL is a valid wallet backup URL
*
* @param url The URL to check
* @returns True if the URL is a valid wallet backup URL, false otherwise
*/
public static canHandle(urlStr: string): boolean {
try {
BCHMnemonicURL.fromURL(urlStr);
return true;
} catch (_error) {
return false;
}
}
/**
* Creates a BCHMnemonic from a URL-encoded string
* @param urlStr - The URL-encoded mnemonic string
* @returns A new BCHMnemonic instance
* @throws Error if the URL format is invalid or entropy is invalid
*/
static fromURL(urlStr: string): BCHMnemonicURL {
const url = new URL(urlStr);
if (url.protocol !== `${BCHMnemonicURL.PROTOCOL}:`) {
throw new Error(`Invalid URL protocol: ${url.protocol}`);
}
// Decode the entropy.
const entropy = new Uint8Array(Buffer.from(url.pathname, "base64"));
// Pick out our encoding keys from the URL
const params = BCHMnemonicURL.schema.parse(
Object.fromEntries(url.searchParams.entries()),
);
// Create and return the backup with validated parameters
return BCHMnemonicURL.fromRaw({
entropy,
language: params[BCHMnemonicURL.ENCODING_KEYS.language],
comment: params[BCHMnemonicURL.ENCODING_KEYS.comment],
path: params[BCHMnemonicURL.ENCODING_KEYS.path],
startHeight: params[BCHMnemonicURL.ENCODING_KEYS.startHeight],
});
}
/**
* Create a new WalletBackup from a raw object
*
* @param raw - The raw object to create the WalletBackup from
* @returns The created WalletBackup
*/
static fromRaw(raw: BCHMnemonicURLRaw): BCHMnemonicURL {
// Add entropy validation
if (!raw.entropy || raw.entropy.length === 0) {
throw new Error("Invalid entropy: must be non-empty");
}
// Validate entropy length (typically 16, 20, 24, 28, or 32 bytes for BIP39)
const validLengths = [16, 20, 24, 28, 32];
if (!validLengths.includes(raw.entropy.length)) {
throw new Error(`Invalid entropy length: ${raw.entropy.length} bytes`);
}
return new BCHMnemonicURL(raw);
}
static fromSeed(seed: string): BCHMnemonicURL {
// Encode the seed to a Uint8Array
const entropy = decodeBip39Mnemonic(seed);
// If the decode failed, throw an error
if (typeof entropy === "string") {
throw new Error(`Invalid seed: ${entropy}`);
}
return BCHMnemonicURL.fromRaw({ entropy });
}
constructor(protected raw: BCHMnemonicURLRaw) {}
toObject() {
return this.raw;
}
/**
* Encode the backup into a URL encoding
*
* @param prefix - The prefix to use for the URL encoding
* @returns The URL encoding of the backup
*/
toURL(): string {
// Conver the mnemonic words into the entropy used to derive the mnemonic words
const entropyBase64 = Buffer.from(this.raw.entropy).toString("base64");
// Create a new URL object with the prefix and the base64 encoded mnemonic
const url = new URL(`${BCHMnemonicURL.PROTOCOL}:${entropyBase64}`);
// Add the raw values to the url encoded string. Only add the values that are defined.
if (this.raw.language !== undefined) {
url.searchParams.set(
BCHMnemonicURL.ENCODING_KEYS.language,
this.raw.language,
);
}
if (this.raw.comment !== undefined) {
url.searchParams.set(
BCHMnemonicURL.ENCODING_KEYS.comment,
this.raw.comment,
);
}
if (this.raw.path !== undefined) {
url.searchParams.set(BCHMnemonicURL.ENCODING_KEYS.path, this.raw.path);
}
if (this.raw.startHeight !== undefined) {
url.searchParams.set(
BCHMnemonicURL.ENCODING_KEYS.startHeight,
this.raw.startHeight.toString(),
);
}
return url.toString();
}
static ENCODING_KEYS = {
language: "l",
passphrase: "p",
comment: "c",
path: "d",
startHeight: "h",
} as const;
static SUPPORTED_LANGUAGES = [
"en",
"zh-CN",
"zh-TW",
"ja",
"es",
"pt",
"ko",
"fr",
"it",
"cs",
] as const;
/**
* Zod schema for validating URL parameters
*/
static schema = z.object({
[BCHMnemonicURL.ENCODING_KEYS.language]: z
.enum(BCHMnemonicURL.SUPPORTED_LANGUAGES)
.optional(),
[BCHMnemonicURL.ENCODING_KEYS.passphrase]: z.string().optional(),
[BCHMnemonicURL.ENCODING_KEYS.comment]: z.string().optional(),
[BCHMnemonicURL.ENCODING_KEYS.path]: z.string().optional(),
[BCHMnemonicURL.ENCODING_KEYS.startHeight]: z.coerce.number().optional(),
});
}

152
src/utils/event-emitter.ts Normal file
View File

@@ -0,0 +1,152 @@
// TODO: You'll probably want to use WeakRef's here.
export type EventMap = Record<string, unknown>;
type Listener<T> = (detail: T) => void;
interface ListenerEntry<T> {
listener: Listener<T>;
wrappedListener: Listener<T>;
debounceTime?: number;
once?: boolean;
}
export type OffCallback = () => void;
export class EventEmitter<T extends EventMap> {
private listeners: Map<keyof T, Set<ListenerEntry<T[keyof T]>>> = new Map();
on<K extends keyof T>(
type: K,
listener: Listener<T[K]>,
debounceMilliseconds?: number,
): OffCallback {
const wrappedListener =
debounceMilliseconds && debounceMilliseconds > 0
? this.debounce(listener, debounceMilliseconds)
: listener;
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set());
}
const listenerEntry: ListenerEntry<T[K]> = {
listener,
wrappedListener,
debounceTime: debounceMilliseconds,
};
this.listeners.get(type)?.add(listenerEntry as ListenerEntry<T[keyof T]>);
// Return an "off" callback that can be called to stop listening for events.
return () => this.off(type, listener);
}
once<K extends keyof T>(
type: K,
listener: Listener<T[K]>,
debounceMilliseconds?: number,
): OffCallback {
const wrappedListener: Listener<T[K]> = (detail: T[K]) => {
this.off(type, listener);
listener(detail);
};
const debouncedListener =
debounceMilliseconds && debounceMilliseconds > 0
? this.debounce(wrappedListener, debounceMilliseconds)
: wrappedListener;
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set());
}
const listenerEntry: ListenerEntry<T[K]> = {
listener,
wrappedListener: debouncedListener,
debounceTime: debounceMilliseconds,
once: true,
};
this.listeners.get(type)?.add(listenerEntry as ListenerEntry<T[keyof T]>);
// Return an "off" callback that can be called to stop listening for events.
return () => this.off(type, listener);
}
off<K extends keyof T>(type: K, listener: Listener<T[K]>): void {
const listeners = this.listeners.get(type);
if (!listeners) return;
const listenerEntry = Array.from(listeners).find(
(entry) =>
entry.listener === listener || entry.wrappedListener === listener,
);
if (listenerEntry) {
listeners.delete(listenerEntry);
}
}
emit<K extends keyof T>(type: K, payload: T[K]): boolean {
const listeners = this.listeners.get(type);
if (!listeners) return false;
listeners.forEach((entry) => {
entry.wrappedListener(payload);
});
return listeners.size > 0;
}
removeAllListeners(): void {
this.listeners.clear();
}
async waitFor<K extends keyof T>(
type: K,
predicate: (payload: T[K]) => boolean,
timeoutMs?: number,
): Promise<T[K]> {
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const listener = (payload: T[K]) => {
if (predicate(payload)) {
// Clean up
this.off(type, listener);
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
resolve(payload);
}
};
// Set up timeout if specified
if (timeoutMs !== undefined) {
timeoutId = setTimeout(() => {
this.off(type, listener);
reject(new Error(`Timeout waiting for event "${String(type)}"`));
}, timeoutMs);
}
this.on(type, listener);
});
}
private debounce<K extends keyof T>(
func: Listener<T[K]>,
wait: number,
): Listener<T[K]> {
let timeout: ReturnType<typeof setTimeout>;
return (detail: T[K]) => {
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func(detail);
}, wait);
};
}
}

View File

@@ -71,7 +71,7 @@ export class ExponentialBackoff {
fn: () => Promise<T>,
onError = (_error: Error) => {},
): Promise<T> {
let lastError: Error = new Error('Exponential backoff: Max retries hit');
let lastError: Error = new Error("Exponential backoff: Max retries hit");
let attempt = 0;

View File

@@ -1,13 +1,13 @@
/**
* Extended JSON encoding/decoding utilities.
* Handles BigInt and Uint8Array serialization for communication with sync-server.
*
*
* TODO: These are intended as temporary stand-ins until this functionality has been implemented directly in LibAuth.
* We are doing this so that we may better standardize with the rest of the BCH eco-system in future.
* See: https://github.com/bitauth/libauth/pull/108
*/
import { binToHex, hexToBin } from '@bitauth/libauth';
import { binToHex, hexToBin } from "@bitauth/libauth";
/**
* Replaces BigInt and Uint8Array values with their ExtJSON string representations.
@@ -15,7 +15,7 @@ import { binToHex, hexToBin } from '@bitauth/libauth';
* @returns The replaced value as an ExtJSON string, or the original value
*/
export const extendedJsonReplacer = function (value: unknown): unknown {
if (typeof value === 'bigint') {
if (typeof value === "bigint") {
return `<bigint: ${value.toString()}n>`;
} else if (value instanceof Uint8Array) {
return `<Uint8Array: ${binToHex(value)}>`;
@@ -36,7 +36,7 @@ export const extendedJsonReviver = function (value: unknown): unknown {
// Only perform a check if the value is a string.
// NOTE: We can skip all other values as all Extended JSON encoded fields WILL be a string.
if (typeof value === 'string') {
if (typeof value === "string") {
// Check if this value matches an Extended JSON encoded bigint.
const bigintMatch = value.match(bigIntRegex);
if (bigintMatch) {
@@ -70,7 +70,7 @@ export const encodeExtendedJsonObject = function (value: unknown): unknown {
// If this is an object type (and it is not null - which is technically an "object")...
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
if (
typeof value === 'object' &&
typeof value === "object" &&
value !== null &&
!ArrayBuffer.isView(value)
) {
@@ -83,7 +83,9 @@ export const encodeExtendedJsonObject = function (value: unknown): unknown {
const encodedObject: Record<string, unknown> = {};
// Iterate through each entry and encode it to extended JSON.
for (const [key, valueToEncode] of Object.entries(value as Record<string, unknown>)) {
for (const [key, valueToEncode] of Object.entries(
value as Record<string, unknown>,
)) {
encodedObject[key] = encodeExtendedJsonObject(valueToEncode);
}
@@ -104,7 +106,7 @@ export const decodeExtendedJsonObject = function (value: unknown): unknown {
// If this is an object type (and it is not null - which is technically an "object")...
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
if (
typeof value === 'object' &&
typeof value === "object" &&
value !== null &&
!ArrayBuffer.isView(value)
) {
@@ -117,7 +119,9 @@ export const decodeExtendedJsonObject = function (value: unknown): unknown {
const decodedObject: Record<string, unknown> = {};
// Iterate through each entry and decode it from extended JSON.
for (const [key, valueToEncode] of Object.entries(value as Record<string, unknown>)) {
for (const [key, valueToEncode] of Object.entries(
value as Record<string, unknown>,
)) {
decodedObject[key] = decodeExtendedJsonObject(valueToEncode);
}

130
src/utils/history-utils.ts Normal file
View File

@@ -0,0 +1,130 @@
import type {
HistoryItem,
WalletHistoryInput,
WalletHistoryItem,
WalletHistoryOutput,
} from "../services/history.js";
export type HistoryColorName =
| "info"
| "warning"
| "success"
| "error"
| "muted"
| "text";
export type HistoryRowType =
| "history_item"
| "history_input"
| "history_output";
export interface HistoryDisplayRow {
id: string;
type: HistoryRowType;
label: string;
description?: string;
timestamp?: number;
isNested: boolean;
valueSatoshis?: bigint;
reserved?: boolean;
input?: WalletHistoryInput;
output?: WalletHistoryOutput;
item?: WalletHistoryItem;
}
export function formatHistoryDate(timestamp?: number): string | undefined {
if (!timestamp) return undefined;
return new Date(timestamp).toLocaleDateString();
}
export function buildHistoryDisplayRows(
items: HistoryItem[],
): HistoryDisplayRow[] {
const rows: HistoryDisplayRow[] = [];
for (const item of items) {
const roles = item.roles.length > 0 ? item.roles.join(", ") : "unknown";
if (item.source === "utxo") {
for (const output of item.outputs) {
rows.push({
id: `${item.id}-output-${output.id}`,
type: "history_output",
label: output.outpoint
? `${output.outpoint.txid}:${output.outpoint.index}`
: output.outputIdentifier ?? "Output",
description: `${item.template} | ${roles} | ${output.description}`,
timestamp: item.createdAtTimestamp,
isNested: false,
valueSatoshis: output.valueSatoshis,
reserved: output.reserved,
output,
item,
});
}
continue;
}
rows.push({
id: item.id,
type: "history_item",
label: `${item.template} | ${roles} | ${item.description}`,
description: item.action,
timestamp: item.createdAtTimestamp,
isNested: false,
valueSatoshis: item.valueSatoshis,
item,
});
if (item.source !== "invitation") continue;
for (const input of item.inputs) {
rows.push({
id: `${item.id}-input-${input.id}`,
type: "history_input",
label: `${input.outpoint.txid}:${input.outpoint.index}`,
description: input.description,
isNested: true,
valueSatoshis: input.valueSatoshis,
input,
item,
});
}
for (const output of item.outputs) {
rows.push({
id: `${item.id}-output-${output.id}`,
type: "history_output",
label: output.outpoint
? `${output.outpoint.txid}:${output.outpoint.index}`
: output.outputIdentifier ?? "Output",
description: output.description,
isNested: true,
valueSatoshis: output.valueSatoshis,
reserved: output.reserved,
output,
item,
});
}
}
return rows;
}
export function getHistoryItemColorName(
row: HistoryDisplayRow,
isSelected: boolean = false,
): HistoryColorName {
if (isSelected) return "info";
switch (row.type) {
case "history_input":
return "error";
case "history_output":
return row.reserved ? "warning" : "success";
case "history_item":
if ((row.valueSatoshis ?? 0n) < 0n) return "error";
if ((row.valueSatoshis ?? 0n) > 0n) return "success";
return "text";
default:
return "text";
}
}

View File

@@ -0,0 +1,209 @@
import type { XOTemplate, XOTemplateTransactionOutput } from "@xo-cash/types";
import type { Invitation } from "../services/invitation.js";
import { cashAddressToLockingBytecode, binToHex } from "@bitauth/libauth";
export interface SelectableUtxoLike {
outpointTransactionHash: string;
outpointIndex: number;
valueSatoshis: bigint;
lockingBytecode?: string;
selected: boolean;
}
// TODO: Move to engine
export const hasMissingRequirements = (missingRequirements: {
variables?: string[];
inputs?: string[];
outputs?: string[];
roles?: Record<string, unknown>;
}): boolean => {
return (
(missingRequirements.variables?.length ?? 0) > 0 ||
(missingRequirements.inputs?.length ?? 0) > 0 ||
(missingRequirements.outputs?.length ?? 0) > 0 ||
(missingRequirements.roles !== undefined &&
Object.keys(missingRequirements.roles).length > 0)
);
};
export const isInvitationRequirementsComplete = async (
invitation: Invitation,
): Promise<boolean> => {
const missingRequirements = await invitation.getMissingRequirements();
return !hasMissingRequirements(missingRequirements);
};
// TODO: Move to engine in templates.ts
export const resolveActionRoles = (
template: XOTemplate | undefined,
actionIdentifier: string | undefined,
rolesFromNavigation?: string[],
): string[] => {
if (rolesFromNavigation && rolesFromNavigation.length > 0) {
return [...new Set(rolesFromNavigation)];
}
if (!template || !actionIdentifier) return [];
const starts = template.start ?? [];
const roleIds = starts
.filter((entry) => entry.action === actionIdentifier)
.map((entry) => entry.role)
.filter((roleId) => roleId !== undefined);
return [...new Set(roleIds)];
};
// TODO: Move to engine
export const roleRequiresInputs = (
template: XOTemplate | undefined,
actionIdentifier: string | undefined,
roleIdentifier: string | undefined,
): boolean => {
if (!template || !actionIdentifier || !roleIdentifier) return false;
const action = template.actions?.[actionIdentifier];
if (!action) return false;
const actionRole = action.roles?.[roleIdentifier];
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;
const transactionIdentifier = action.transaction;
const transaction = transactionIdentifier
? template.transactions?.[transactionIdentifier]
: undefined;
const roleInputs = transaction?.roles?.[roleIdentifier]?.inputs;
return (roleInputs?.length ?? 0) > 0;
};
export const getTransactionOutputIdentifier = (
output: XOTemplateTransactionOutput,
): string | undefined => {
if (typeof output === "string") return output;
if (
output &&
typeof output === "object" &&
"output" in output &&
typeof output.output === "string"
) {
return output.output;
}
return undefined;
};
export const normalizeLockingBytecodeHex = (value: string): string =>
value.trim().replace(/^0x/i, "");
/**
* Checks whether a string looks like a CashAddress and, if so, converts it
* to locking bytecode hex. Returns undefined when the value is not a
* recognizable CashAddress (callers should fall through to treat it as raw hex).
*/
export const tryCashAddressToLockingBytecodeHex = (
value: string,
): string | undefined => {
const trimmed = value.trim();
// Quick prefix check so we don't run the decoder on obvious hex strings.
const looksLikeCashAddress =
trimmed.startsWith("bitcoincash:") ||
trimmed.startsWith("bchtest:") ||
trimmed.startsWith("bchreg:") ||
// Handle prefix-less addresses (e.g. "qp..." or "pp...")
/^[qpQP][a-zA-Z0-9]{41,}$/.test(trimmed);
if (!looksLikeCashAddress) return undefined;
const result = cashAddressToLockingBytecode(trimmed);
// cashAddressToLockingBytecode returns a string on failure.
if (typeof result === "string") return undefined;
return binToHex(result.bytecode);
};
// Replace with libauth compiler in the engine
export const resolveProvidedLockingBytecodeHex = (
template: XOTemplate,
outputIdentifier: string,
variableValues: Record<string, string>,
): string | undefined => {
const outputDefinition = template.outputs?.[outputIdentifier];
if (!outputDefinition || typeof outputDefinition.lockingScript !== "string") {
return undefined;
}
const lockingScriptDefinition = template.lockingScripts?.[outputDefinition.lockingScript];
const scriptIdentifier = lockingScriptDefinition?.lockingBytecode;
if (!scriptIdentifier) return undefined;
const scriptExpression = (
template.scripts as Record<string, unknown> | undefined
)?.[scriptIdentifier];
if (typeof scriptExpression !== "string") return undefined;
const directVariableMatch = scriptExpression.match(
/^<\s*([A-Za-z0-9_]+)\s*>$/,
);
if (!directVariableMatch) return undefined;
const variableIdentifier = directVariableMatch[1];
if (!variableIdentifier) return undefined;
const providedValue = variableValues[variableIdentifier];
if (!providedValue) return undefined;
// If the user pasted a CashAddress, convert it to locking bytecode hex.
const fromAddress = tryCashAddressToLockingBytecodeHex(providedValue);
if (fromAddress) return fromAddress;
return normalizeLockingBytecodeHex(providedValue);
};
export const mapUnspentOutputsToSelectable = (
unspentOutputs: any[],
): SelectableUtxoLike[] => {
return unspentOutputs.map((utxo: any) => ({
outpointTransactionHash: utxo.outpointTransactionHash,
outpointIndex: utxo.outpointIndex,
valueSatoshis: BigInt(utxo.valueSatoshis),
lockingBytecode: utxo.lockingBytecode
? typeof utxo.lockingBytecode === "string"
? utxo.lockingBytecode
: Buffer.from(utxo.lockingBytecode).toString("hex")
: undefined,
selected: false,
}));
};
export const autoSelectGreedyUtxos = (
utxos: SelectableUtxoLike[],
requiredWithFee: bigint,
): SelectableUtxoLike[] => {
let accumulated = 0n;
const seenLockingBytecodes = new Set<string>();
for (const utxo of utxos) {
if (
utxo.lockingBytecode &&
seenLockingBytecodes.has(utxo.lockingBytecode)
) {
continue;
}
if (utxo.lockingBytecode) {
seenLockingBytecodes.add(utxo.lockingBytecode);
}
utxo.selected = true;
accumulated += utxo.valueSatoshis;
if (accumulated >= requiredWithFee) {
break;
}
}
return utxos;
};

Some files were not shown because too many files have changed in this diff Show More