Compare commits
21 Commits
b475b23beb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f2e515d72 | |||
| 7ffb5c44b5 | |||
| f978d740fe | |||
| 6196d33b2a | |||
| ccfaf3fd70 | |||
| 531e53d2ae | |||
| b708c8c1f8 | |||
| 53ad7b729e | |||
| e73fb24422 | |||
| b282bbf5d6 | |||
| bd1ae909b5 | |||
| e97054fa34 | |||
| a43a45831c | |||
| 1bbc21c742 | |||
| 9fa87d01b3 | |||
| 7ad17a7c0e | |||
| dbfb2c68d2 | |||
| 32c42cdc2d | |||
| ff2fe126c6 | |||
| df4f438f6d | |||
| 55c75501d5 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,6 +3,7 @@ Electrum.sqlite
|
||||
XO.sqlite
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
@@ -10,3 +11,5 @@ dist/
|
||||
*.sqlite-journal
|
||||
resolvedTemplate.json
|
||||
mnemonic-*
|
||||
inv-*.json
|
||||
.xo-cli-wallet
|
||||
904
p2pkh-template.json
Normal file
904
p2pkh-template.json
Normal file
@@ -0,0 +1,904 @@
|
||||
{
|
||||
"$schema": "https://libauth.org/schemas/wallet-template-v0.schema.json",
|
||||
"name": "Wallet (P2PKH)",
|
||||
"description": "A standard single-factor wallet template that uses Pay-to-Public-Key-Hash (P2PKH) locking scripts.",
|
||||
"icon": "wallet",
|
||||
"version": 0,
|
||||
"supported": ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"],
|
||||
"roles": {
|
||||
"owner": {
|
||||
"name": "Wallet Owner",
|
||||
"description": "The party who can spend from this wallet.",
|
||||
"icon": "owner"
|
||||
},
|
||||
"receiver": {
|
||||
"name": "Receiver",
|
||||
"description": "A party that is receiving value.",
|
||||
"icon": "receiver"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sender",
|
||||
"description": "A party that is sending value.",
|
||||
"icon": "sender"
|
||||
}
|
||||
},
|
||||
"start": [
|
||||
{ "action": "receive", "role": "receiver" },
|
||||
{ "action": "requestSatoshis", "role": "receiver" },
|
||||
{ "action": "requestFungibleTokens", "role": "receiver" },
|
||||
{ "action": "requestNonfungibleTokens", "role": "receiver" }
|
||||
],
|
||||
"actions": {
|
||||
"receive": {
|
||||
"name": "Receive",
|
||||
"description": "Receive an unspecified amount of cash and/or tokens from one or more senders.",
|
||||
"icon": "receive",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Receive",
|
||||
"description": "Receive an unspecified amount of cash and/or tokens from one or more senders.",
|
||||
"icon": "receive",
|
||||
"requirements": { "generate": ["ownerKey"] }
|
||||
},
|
||||
"sender": {
|
||||
"name": "Send",
|
||||
"description": "Send an unspecified amount of cash and/or tokens to the provided receiver.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [
|
||||
{ "role": "receiver", "slots": { "min": 1, "max": 1 } },
|
||||
{ "role": "sender", "slots": { "min": 1 } }
|
||||
],
|
||||
"variables": ["requestedSatoshis"]
|
||||
},
|
||||
"transaction": "receiveTransaction"
|
||||
},
|
||||
"requestSatoshis": {
|
||||
"name": "Request Satoshis",
|
||||
"description": "Requests a specific amount of Bitcoin Cash from one or more senders.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Request Satoshis",
|
||||
"description": "Requests a specific amount of Bitcoin Cash from one or more senders.",
|
||||
"icon": "request",
|
||||
"requirements": {
|
||||
"generate": ["ownerKey"],
|
||||
"variables": ["requestedSatoshis"]
|
||||
}
|
||||
},
|
||||
"sender": {
|
||||
"name": "Send",
|
||||
"description": "Send a specific amount of Bitcoin Cash to the provided receiver.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [
|
||||
{ "role": "receiver", "slots": { "min": 1, "max": 1 } },
|
||||
{ "role": "sender", "slots": { "min": 1 } }
|
||||
]
|
||||
},
|
||||
"transaction": "requestSatoshisTransaction"
|
||||
},
|
||||
"requestFungibleTokens": {
|
||||
"name": "Request Fungible Tokens",
|
||||
"description": "Requests a specific amount of a fungible tokens from one or more senders.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Request Fungible Tokens",
|
||||
"description": "Requests a specific amount of a fungible tokens from one or more senders.",
|
||||
"icon": "request",
|
||||
"requirements": {
|
||||
"generate": ["ownerKey"],
|
||||
"variables": ["requestedTokenCategory", "requestedTokenAmount"]
|
||||
}
|
||||
},
|
||||
"sender": {
|
||||
"name": "Send",
|
||||
"description": "Send a specific amount of fungible tokens to the provided receiver.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [
|
||||
{ "role": "receiver", "slots": { "min": 1, "max": 1 } },
|
||||
{ "role": "sender", "slots": { "min": 1 } }
|
||||
]
|
||||
},
|
||||
"transaction": "requestFungibleTokensTransaction"
|
||||
},
|
||||
"requestNonfungibleTokens": {
|
||||
"name": "Request a Non-fungible Token",
|
||||
"description": "Requests a non-fungible token from one or more senders.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Request a Non-fungible Token",
|
||||
"description": "Requests a non-fungible token from one or more senders.",
|
||||
"icon": "request",
|
||||
"requirements": {
|
||||
"generate": ["ownerKey"],
|
||||
"variables": [
|
||||
"requestedTokenCategory",
|
||||
"requestedTokenCapability",
|
||||
"requestedTokenCommitment"
|
||||
]
|
||||
}
|
||||
},
|
||||
"sender": {
|
||||
"name": "Send",
|
||||
"description": "Send a non-fungible token to the provided receiver.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [
|
||||
{ "role": "receiver", "slots": { "min": 1, "max": 1 } },
|
||||
{ "role": "sender", "slots": { "min": 1 } }
|
||||
]
|
||||
},
|
||||
"transaction": "requestNonfungibleTokensTransaction"
|
||||
},
|
||||
"sendSatoshis": {
|
||||
"name": "Send Satoshis",
|
||||
"description": "Sends a specific amount of Bitcoin Cash to a given recipient.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"sender": {
|
||||
"requirements": {
|
||||
"variables": ["transferredSatoshis", "recipientLockingscript"],
|
||||
"secrets": ["ownerKey"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [{ "role": "sender", "slots": { "min": 1, "max": 1 } }]
|
||||
},
|
||||
"condition": "$(OP_INPUTINDEX OP_UTXOVALUE <dustLimit> OP_GREATERTHAN)",
|
||||
"transaction": "transferSatoshisTransaction"
|
||||
},
|
||||
"sendFungibleTokens": {
|
||||
"name": "Send Fungible Tokens",
|
||||
"description": "Send a specific amount of a fungible token to a given recipient.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"sender": {
|
||||
"requirements": {
|
||||
"variables": [
|
||||
"transferredTokenCategory",
|
||||
"transferredTokenAmount",
|
||||
"recipientLockingscript"
|
||||
],
|
||||
"secrets": ["ownerKey"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [{ "role": "sender", "slots": { "min": 1, "max": 1 } }]
|
||||
},
|
||||
"condition": "$(OP_INPUTINDEX OP_UTXOTOKENAMOUNT <0> OP_GREATERTHAN)",
|
||||
"transaction": "transferFungibleTokensTransaction"
|
||||
},
|
||||
"sendNonfungibleTokens": {
|
||||
"name": "Send a Non-fungible Token",
|
||||
"description": "Send a non-fungible token to a given recipient.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"sender": {
|
||||
"requirements": {
|
||||
"variables": [
|
||||
"transferredTokenCategory",
|
||||
"transferredTokenCapability",
|
||||
"transferredTokenCommitment",
|
||||
"recipientLockingscript"
|
||||
],
|
||||
"secrets": ["ownerKey"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [{ "role": "sender", "slots": { "min": 1, "max": 1 } }]
|
||||
},
|
||||
"condition": "$(OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_SIZE OP_NIP <32> OP_GREATERTHAN)",
|
||||
"transaction": "transferNonfungibleTokensTransaction"
|
||||
},
|
||||
"burnFungibleTokens": {
|
||||
"name": "Delete Fungible Tokens",
|
||||
"description": "Permanently and irreversibly deletes one or more fungible tokens.",
|
||||
"icon": "burn",
|
||||
"roles": {
|
||||
"owner": {
|
||||
"requirements": {
|
||||
"variables": ["burnedTokenCategory", "burnedTokenAmount"],
|
||||
"secrets": ["ownerKey"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [{ "role": "owner", "slots": { "min": 1, "max": 1 } }]
|
||||
},
|
||||
"condition": "$(OP_INPUTINDEX OP_UTXOTOKENAMOUNT <0> OP_GREATERTHAN)",
|
||||
"transaction": "burnFungibleTokensTransaction"
|
||||
},
|
||||
"burnNonfungibleTokens": {
|
||||
"name": "Delete a Non-fungible Token",
|
||||
"description": "Permanently and irreversibly deletes one non-fungible token.",
|
||||
"icon": "burn",
|
||||
"roles": {
|
||||
"owner": {
|
||||
"requirements": {
|
||||
"variables": [
|
||||
"burnedTokenCategory",
|
||||
"burnedTokenCapability",
|
||||
"burnedTokenCommitment"
|
||||
],
|
||||
"secrets": ["ownerKey"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [{ "role": "owner", "slots": { "min": 1, "max": 1 } }]
|
||||
},
|
||||
"condition": "$(OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_SIZE OP_NIP <32> OP_GREATERTHAN)",
|
||||
"transaction": "burnNonfungibleTokenTransaction"
|
||||
},
|
||||
"sign": {
|
||||
"name": "Sign Message",
|
||||
"description": "Signs a provided message using the Bitcoin message signing protocol.",
|
||||
"icon": "sign",
|
||||
"roles": {
|
||||
"owner": {
|
||||
"requirements": {
|
||||
"variables": ["messageToSign"],
|
||||
"secrets": ["ownerKey"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [{ "role": "owner", "slots": { "min": 1, "max": 1 } }]
|
||||
},
|
||||
"data": ["messageSignature"]
|
||||
},
|
||||
"verify": {
|
||||
"name": "Verify Message Signature",
|
||||
"description": "Verifies a provided message signature according to the Bitcoin message signing protocol.",
|
||||
"icon": "verify",
|
||||
"roles": {
|
||||
"owner": {
|
||||
"requirements": {
|
||||
"variables": ["messageSignature", "messageToVerify"],
|
||||
"secrets": ["ownerKey"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": {
|
||||
"roles": [{ "role": "owner", "slots": { "min": 1, "max": 1 } }]
|
||||
},
|
||||
"data": ["messageSignatureValidity"]
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"messageSignature": {
|
||||
"value": "$(<messagePrefix> <message> OP_CAT <ownerKey.data_signature.top_stack_element>)",
|
||||
"type": "bytes",
|
||||
"hint": "signature"
|
||||
},
|
||||
"messageSignatureValidity": {
|
||||
"value": "$(<messageSignature> <messagePrefix> <message> OP_CAT <ownerKey.public_key> OP_CHECKDATASIG)",
|
||||
"type": "integer",
|
||||
"hint": "script_boolean"
|
||||
}
|
||||
},
|
||||
"transactions": {
|
||||
"receiveTransaction": {
|
||||
"name": "Transfer request",
|
||||
"description": "Transfer request for an unspecified amount of cash and/or tokens.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received an unspecified amount of cash and/or tokens.",
|
||||
"icon": "receive"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent an unspecified amount of cash and/or tokens.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [{ "output": "receiveOutput" }],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
},
|
||||
"requestSatoshisTransaction": {
|
||||
"name": "Transfer request",
|
||||
"description": "Transfer request for $(<requestedSatoshis>) satoshis.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received $(<requestedSatoshis>) satoshis.",
|
||||
"icon": "receive"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent $(<requestedSatoshis>) satoshis.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [{ "output": "requestSatoshisOutput" }],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
},
|
||||
"requestFungibleTokensTransaction": {
|
||||
"name": "Transfer request",
|
||||
"description": "Transfer request for $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens.",
|
||||
"icon": "receive"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [{ "output": "requestFungibleTokensOutput" }],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
},
|
||||
"requestNonfungibleTokensTransaction": {
|
||||
"name": "Transfer request",
|
||||
"description": "Transfer request for one non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<requestedTokenCategory.symbol>) token, with $(<requestedTokenCommitment>) commitment.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received one non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<requestedTokenCategory.symbol>) token, with $(<requestedTokenCommitment>) commitment.",
|
||||
"icon": "receive"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent the requested non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<requestedTokenCategory.symbol>) token, with $(<requestedTokenCommitment>) commitment.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [{ "output": "requestNonfungibleTokensOutput" }],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
},
|
||||
"transferSatoshisTransaction": {
|
||||
"name": "Satoshis Transferred",
|
||||
"description": "$(<transferredSatoshis>) satoshis were transferred to a recipient.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received $(<transferredSatoshis>) satoshis.",
|
||||
"icon": "receive"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent $(<transferredSatoshis>) satoshis.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [{ "output": "transferSatoshisOutput" }],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
},
|
||||
"transferFungibleTokensTransaction": {
|
||||
"name": "Fungible Tokens Transferred",
|
||||
"description": "$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens were transferred to a recipient.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received $(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens.",
|
||||
"icon": "receive"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent $(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [{ "output": "transferFungibleTokensOutput" }],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
},
|
||||
"transferNonfungibleTokensTransaction": {
|
||||
"name": "Non-fungible Token Transferred",
|
||||
"description": "One non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<transferredTokenCategory.symbol>) token was transferred to a recipient, with $(<transferredTokenCommitment>) commitment.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received one non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<transferredTokenCategory.symbol>) token, with $(<transferredTokenCommitment>) commitment.",
|
||||
"icon": "receive"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent one non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<transferredTokenCategory.symbol>) token, with $(<transferredTokenCommitment>) commitment.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [{ "output": "transferNonfungibleTokenOutput" }],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
},
|
||||
"burnFungibleTokensTransaction": {
|
||||
"name": "Deleted fungible tokens",
|
||||
"description": "Permanently and irreversibly deleted $(<burnedTokenAmount> <burnedTokenCategory.decimalsFactor> OP_DIV).$(<burnedTokenAmount> <burnedTokenCategory.decimalsFactor> OP_MOD) $(<burnedTokenCategory.symbol>) tokens.",
|
||||
"icon": "burn",
|
||||
"inputs": [{ "input": "burnFungibleTokensInput" }],
|
||||
"outputs": [],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
},
|
||||
"burnNonfungibleTokenTransaction": {
|
||||
"name": "Deleted non fungible token",
|
||||
"description": "Permanently and irreversibly deleted a non-fungible $(<burnedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <burnedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) (<burnedTokenCategory.symbol>) token, with $(<burnedTokenCommitment>) commitment.",
|
||||
"icon": "burn",
|
||||
"inputs": [{ "input": "burnNonfungibleTokenInput" }],
|
||||
"outputs": [],
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"composable": true
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"receiveOutput": {
|
||||
"name": "Recipient output",
|
||||
"description": "Transferred an unspecified amount of cash and/or tokens to a recipient.",
|
||||
"icon": "receive",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received an unspecified amount of cash and/or tokens."
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent an unspecified amount of cash and/or tokens."
|
||||
}
|
||||
},
|
||||
"lockscript": "receivingLockingScript"
|
||||
},
|
||||
"requestSatoshisOutput": {
|
||||
"name": "Recipient output",
|
||||
"description": "Transferred $(<requestedSatoshis>) satoshis to a recipient.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received $(<requestedSatoshis>) satoshis."
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent $(<requestedSatoshis>) satoshis."
|
||||
}
|
||||
},
|
||||
"lockscript": "receivingLockingScript",
|
||||
"valueSatoshis": "$(<requestedSatoshis>)",
|
||||
"token": null
|
||||
},
|
||||
"requestFungibleTokensOutput": {
|
||||
"name": "Recipient output",
|
||||
"description": "Transferred $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens to a recipient.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens."
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>) tokens."
|
||||
}
|
||||
},
|
||||
"lockscript": "receivingLockingScript",
|
||||
"valueSatoshis": "1000",
|
||||
"token": {
|
||||
"category": "$(<requestedTokenCategory>)",
|
||||
"amount": "$(<requestedTokenAmount>)",
|
||||
"nft": null
|
||||
}
|
||||
},
|
||||
"requestNonfungibleTokensOutput": {
|
||||
"name": "Recipient output",
|
||||
"description": "Transferred one non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<requestedTokenCategory.symbol>) token to a recipient, with $(<requestedTokenCommitment>) commitment.",
|
||||
"icon": "request",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received one non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<requestedTokenCategory.symbol>) token, with $(<requestedTokenCommitment>) commitment."
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent the requested non-fungible $(<requestedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <requestedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<requestedTokenCategory.symbol>) token, with $(<requestedTokenCommitment>) commitment."
|
||||
}
|
||||
},
|
||||
"lockscript": "receivingLockingScript",
|
||||
"valueSatoshis": "1000",
|
||||
"token": {
|
||||
"category": "$(<requestedTokenCategory>)",
|
||||
"amount": null,
|
||||
"nft": {
|
||||
"capability": "$(<requestedTokenCapability>)",
|
||||
"commitment": "$(<requestedTokenCommitment>)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transferSatoshisOutput": {
|
||||
"name": "Recipient output",
|
||||
"description": "Transferred $(<transferredSatoshis>) satoshis to a recipient.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received $(<transferredSatoshis>) satoshis."
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent $(<transferredSatoshis>) satoshis."
|
||||
}
|
||||
},
|
||||
"lockscript": "sendingLockingscript",
|
||||
"valueSatoshis": "$(<transferredSatoshis>)"
|
||||
},
|
||||
"transferFungibleTokensOutput": {
|
||||
"name": "Recipient output",
|
||||
"description": "Transferred $(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens to a recipient.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received $(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens."
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent $(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_DIV).$(<transferredTokenAmount> <transferredTokenCategory.decimalsFactor> OP_MOD) $(<transferredTokenCategory.symbol>) tokens."
|
||||
}
|
||||
},
|
||||
"lockscript": "sendingLockingscript",
|
||||
"token": {
|
||||
"category": "$(<transferredTokenCategory>)",
|
||||
"amount": "$(<transferredTokenAmount>)"
|
||||
}
|
||||
},
|
||||
"transferNonfungibleTokenOutput": {
|
||||
"name": "Recipient output",
|
||||
"description": "Transferred one non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<transferredTokenCategory.symbol>) token to a recipient, with $(<transferredTokenCommitment>) commitment.",
|
||||
"icon": "send",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Received",
|
||||
"description": "Received one non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<transferredTokenCategory.symbol>) token, with $(<transferredTokenCommitment>) commitment."
|
||||
},
|
||||
"sender": {
|
||||
"name": "Sent",
|
||||
"description": "Sent one non-fungible $(<transferredTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <transferredTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) $(<transferredTokenCategory.symbol>) token, with $(<transferredTokenCommitment>) commitment."
|
||||
}
|
||||
},
|
||||
"lockscript": "sendingLockingscript",
|
||||
"token": {
|
||||
"category": "$(<transferredTokenCategory>)",
|
||||
"nft": {
|
||||
"capability": "$(<transferredTokenCapability>)",
|
||||
"commitment": "$(<transferredTokenCommitment>)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"burnFungibleTokensInput": {
|
||||
"name": "Deleted fungible tokens",
|
||||
"description": "Permanently and irreversibly deleted $(<burnedTokenAmount>) $(<burnedTokenCategory>).",
|
||||
"icon": "burn",
|
||||
"unlockingScript": "unlockP2PKH",
|
||||
"token": {
|
||||
"category": "$(<burnedTokenCategory>)",
|
||||
"amount": "$(<this.fungibleTokenAmount> <burnedTokenAmount> OP_GREATERTHANOREQUAL OP_IF <this.fungibleTokens> OP_ENDIF)"
|
||||
},
|
||||
"omitChangeAmounts": { "fungibleTokens": "${<burnedTokenAmount>}" }
|
||||
},
|
||||
"burnNonfungibleTokenInput": {
|
||||
"name": "Deleted non-fungible token",
|
||||
"description": "Permanently and irreversibly burned one non-fungible $(<burnedTokenCapability> <0x02> OP_EQUAL OP_IF <\"minting\"> OP_ELSE <burnedTokenCapability> <0x01> OP_EQUAL OP_IF <\"mutable\"> OP_ELSE <\"immutable\"> OP_ENDIF OP_ENDIF) token of category $(<burnedTokenCategory>), with a $(<burnedTokenCommitment>) commitment.",
|
||||
"icon": "burn",
|
||||
"unlockingScript": "unlockP2PKH",
|
||||
"token": {
|
||||
"category": "$(<burnedTokenCategory>)",
|
||||
"nft": {
|
||||
"capability": "$(<burnedTokenCapability>)",
|
||||
"commitment": "$(<burnedTokenCommitment>)"
|
||||
}
|
||||
},
|
||||
"omitChangeAmounts": { "nonfungibleTokens": 1 }
|
||||
}
|
||||
},
|
||||
"lockingScripts": {
|
||||
"sendingLockingscript": {
|
||||
"name": "Sent",
|
||||
"description": "Funds sent to an external recipient",
|
||||
"icon": "address",
|
||||
"lockingType": "standard",
|
||||
"lockingScript": "lockToRecipient",
|
||||
"actions": [],
|
||||
"state": [],
|
||||
"secrets": [],
|
||||
"balance": false,
|
||||
"selectable": false,
|
||||
"privacy": false
|
||||
},
|
||||
"receivingLockingScript": {
|
||||
"name": "Received",
|
||||
"description": "Funds received without wallet coordination.",
|
||||
"icon": "address",
|
||||
"lockingType": "standard",
|
||||
"lockingScript": "lockP2PKH",
|
||||
"unlockingScript": "unlockP2PKH",
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"state": { "variables": [], "secrets": ["ownerKey"] },
|
||||
"actions": [
|
||||
{ "action": "sign", "role": "owner", "secrets": ["ownerKey"] },
|
||||
{ "action": "verify", "role": "owner", "secrets": ["ownerKey"] },
|
||||
{
|
||||
"action": "sendSatoshis",
|
||||
"role": "sender",
|
||||
"secrets": ["ownerKey"]
|
||||
},
|
||||
{
|
||||
"action": "sendFungibleTokens",
|
||||
"role": "sender",
|
||||
"secrets": ["ownerKey"]
|
||||
},
|
||||
{
|
||||
"action": "sendNonfungibleTokens",
|
||||
"role": "sender",
|
||||
"secrets": ["ownerKey"]
|
||||
},
|
||||
{
|
||||
"action": "burnFungibleTokens",
|
||||
"role": "sender",
|
||||
"secrets": ["ownerKey"]
|
||||
},
|
||||
{
|
||||
"action": "burnNonfungibleTokens",
|
||||
"role": "sender",
|
||||
"secrets": ["ownerKey"]
|
||||
}
|
||||
],
|
||||
"balance": { "satoshis": true, "fungibleTokens": true },
|
||||
"selectable": true,
|
||||
"privacy": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lockP2PKH": "OP_DUP OP_HASH160 <$(<ownerKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG",
|
||||
"unlockP2PKH": "<ownerKey.schnorr_signature.all_outputs> <ownerKey.public_key>",
|
||||
"lockToRecipient": "<recipientLockingscript>"
|
||||
},
|
||||
"constants": {
|
||||
"dustLimit": {
|
||||
"name": "Dust Limit",
|
||||
"description": "Standard required minimum satoshis for Pay to Public Key Hash outputs.",
|
||||
"type": "integer",
|
||||
"value": 546
|
||||
},
|
||||
"messagePrefix": {
|
||||
"name": "Message Prefix",
|
||||
"description": "Standard message prefix used in the bitcoin signed message protocol.",
|
||||
"type": "bytes",
|
||||
"value": "0x18426974636f696e205369676e6564204d6573736167653a0a"
|
||||
}
|
||||
},
|
||||
"variables": {
|
||||
"ownerKey": {
|
||||
"name": "Owners Private Key",
|
||||
"description": "The private key used to authorize spending of received funds.",
|
||||
"type": "bytes",
|
||||
"hint": "private_key"
|
||||
},
|
||||
"messageToSign": {
|
||||
"name": "Message",
|
||||
"description": "The text message to sign.",
|
||||
"type": "string"
|
||||
},
|
||||
"messageToVerify": {
|
||||
"name": "Message",
|
||||
"description": "The text message to verify.",
|
||||
"type": "string"
|
||||
},
|
||||
"messageSignature": {
|
||||
"name": "Message Signature",
|
||||
"description": "The signature for the message.",
|
||||
"type": "bytes",
|
||||
"hint": "signature"
|
||||
},
|
||||
"requestedSatoshis": {
|
||||
"name": "Requested Amount",
|
||||
"description": "The Bitcoin Cash amount requested",
|
||||
"type": "integer",
|
||||
"hint": "satoshis"
|
||||
},
|
||||
"requestedTokenCategory": {
|
||||
"name": "Requested Token Category",
|
||||
"description": "The token category requested",
|
||||
"type": "bytes",
|
||||
"hint": "token_category"
|
||||
},
|
||||
"requestedTokenAmount": {
|
||||
"name": "Requested Token Amount",
|
||||
"description": "The fungible token amount requested",
|
||||
"type": "integer",
|
||||
"hint": "token_amount"
|
||||
},
|
||||
"requestedTokenCapability": {
|
||||
"name": "Requested Token Capability",
|
||||
"description": "The non-fungible token capability requested",
|
||||
"type": "bytes",
|
||||
"hint": "token_capability"
|
||||
},
|
||||
"requestedTokenCommitment": {
|
||||
"name": "Requested Token Commitment",
|
||||
"description": "The non-fungible token commitment requested",
|
||||
"type": "bytes",
|
||||
"hint": "token_commitment"
|
||||
},
|
||||
"transferredTokenCategory": {
|
||||
"name": "Sending Token Category",
|
||||
"description": "The token category of the token(s) to send",
|
||||
"type": "bytes",
|
||||
"hint": "token_category"
|
||||
},
|
||||
"transferredTokenAmount": {
|
||||
"name": "Sending Token Amount",
|
||||
"description": "The fungible token amount to send",
|
||||
"type": "integer",
|
||||
"hint": "token_amount"
|
||||
},
|
||||
"transferredTokenCapability": {
|
||||
"name": "Sending Token Capability",
|
||||
"description": "The token capability for the non-fungible token to send",
|
||||
"type": "bytes",
|
||||
"hint": "token_capability"
|
||||
},
|
||||
"transferredTokenCommitment": {
|
||||
"name": "Sending Token Commitment",
|
||||
"description": "The token commitment for the non-fungible token to send",
|
||||
"type": "bytes",
|
||||
"hint": "token_commitment"
|
||||
},
|
||||
"burnedTokenCategory": {
|
||||
"name": "Deleted Token Category",
|
||||
"description": "The token category of the token(s) to delete",
|
||||
"type": "bytes",
|
||||
"hint": "token_category"
|
||||
},
|
||||
"burnedTokenAmount": {
|
||||
"name": "Deleted Token Amount",
|
||||
"description": "The fungible token amount to delete",
|
||||
"type": "integer",
|
||||
"hint": "token_amount"
|
||||
},
|
||||
"burnedTokenCapability": {
|
||||
"name": "Deleted Token Capability",
|
||||
"description": "The token capability for the non-fungible token to delete",
|
||||
"type": "bytes",
|
||||
"hint": "token_capability"
|
||||
},
|
||||
"burnedTokenCommitment": {
|
||||
"name": "Deleted Token Commitment",
|
||||
"description": "The token commitment for the non-fungible token to delete",
|
||||
"type": "bytes",
|
||||
"hint": "token_commitment"
|
||||
}
|
||||
},
|
||||
"icons": [
|
||||
{ "name": "wallet", "hash": "0000000000000000000000" },
|
||||
{ "name": "owner", "hash": "0000000000000000000000" },
|
||||
{ "name": "sender", "hash": "0000000000000000000000" },
|
||||
{ "name": "address", "hash": "0000000000000000000000" },
|
||||
{ "name": "receive", "hash": "0000000000000000000000" },
|
||||
{ "name": "request", "hash": "0000000000000000000000" },
|
||||
{ "name": "send", "hash": "0000000000000000000000" },
|
||||
{ "name": "burn", "hash": "0000000000000000000000" },
|
||||
{ "name": "sign", "hash": "0000000000000000000000" },
|
||||
{ "name": "verify", "hash": "0000000000000000000000" }
|
||||
],
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "requesting satoshis",
|
||||
"description": "happy-path evaluation for requesting satoshis.",
|
||||
"action": "requestSatoshis",
|
||||
"roles": [
|
||||
{
|
||||
"role": "receiver",
|
||||
"values": {
|
||||
"generated": {
|
||||
"ownerKey": "KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8"
|
||||
},
|
||||
"variables": { "requestedSatoshis": 2000 },
|
||||
"secrets": {},
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"lockingBytecode": "76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac",
|
||||
"valueSatoshis": 2000
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"role": "sender",
|
||||
"values": {
|
||||
"generated": {},
|
||||
"variables": {},
|
||||
"secrets": {},
|
||||
"inputs": [
|
||||
{
|
||||
"outpointTransactionHash": "4ef28553a31a266719e66ba97fee3aeecd6d1788f7ff6ab12f8ebceda49660c0",
|
||||
"outpointIndex": 0,
|
||||
"sequenceNumber": 0,
|
||||
"unlockingBytecode": "41226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db04a422c9ee49f5eefd26fee24e91910edbbb032b90cc54c34da80a61e69b0ee3d22412103e7ab26c36a7c7f45b2c26f33c08b0fa43a633268700f47216646d4cb37ae5696"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"lockingBytecode": "76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac",
|
||||
"valueSatoshis": 2000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"values": { "generated": {}, "variables": {}, "secrets": {} },
|
||||
"outcome": {
|
||||
"roles": {
|
||||
"receiver": {
|
||||
"name": "Request",
|
||||
"description": "Requested a specific amount of satoshis from one or more senders.",
|
||||
"icon": "request"
|
||||
},
|
||||
"sender": {
|
||||
"name": "Send",
|
||||
"description": "Sent a specific amount of satoshis to the provided receiver.",
|
||||
"icon": "send"
|
||||
}
|
||||
},
|
||||
"transactions": [
|
||||
{
|
||||
"transaction": "0200000001c06096a4edbc8e2fb16afff788176dcdee3aee7fa96be61967261aa35385f24e000000006441226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db04a422c9ee49f5eefd26fee24e91910edbbb032b90cc54c34da80a61e69b0ee3d22412103e7ab26c36a7c7f45b2c26f33c08b0fa43a633268700f47216646d4cb37ae5696000000000267530300000000001976a91475c715ecb74178fe87933e57e947e5e92d904b8188acd0070000000000001976a91475c715ecb74178fe87933e57e947e5e92d904b8188ac00000000",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
2779
package-lock.json
generated
2779
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -3,15 +3,27 @@
|
||||
"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": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
|
||||
"build": "tsc",
|
||||
"build": "tsc && npm run build:copy-scripts",
|
||||
"build:copy-scripts": "cp -r src/cli/autocomplete/scripts dist/cli/autocomplete/",
|
||||
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "vitest --run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"nuke": "tsx scripts/rm-dbs.ts",
|
||||
"nuke:dry": "tsx scripts/rm-dbs.ts --dry",
|
||||
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
|
||||
"format:check": "prettier --check ."
|
||||
"format:check": "prettier --check .",
|
||||
"autocomplete:install": "node dist/cli/index.js completions bash --install",
|
||||
"autocomplete:install:bash": "node dist/cli/index.js completions bash --install",
|
||||
"autocomplete:install:zsh": "node dist/cli/index.js completions zsh --install",
|
||||
"autocomplete:install:fish": "node dist/cli/index.js completions fish --install"
|
||||
},
|
||||
"keywords": [
|
||||
"crypto",
|
||||
@@ -25,10 +37,12 @@
|
||||
"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/templates": "^0.0.1",
|
||||
"@xo-cash/types": "^0.0.1",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"clipboardy": "^5.1.0",
|
||||
"ink": "^6.6.0",
|
||||
@@ -42,7 +56,9 @@
|
||||
"@types/node": "^25.0.10",
|
||||
"@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
107
readme.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# XO-CLI & XO-TUI
|
||||
|
||||
## Installation
|
||||
|
||||
### Full Installation
|
||||
```bash
|
||||
# Create a new directory since we are going to be pulling in engine too
|
||||
mkdir xo-terminal && cd xo-terminal
|
||||
|
||||
# ----- Start Engine Setup -----
|
||||
# Clone the Engine Repo (Note, this uses harvey's fork of the engine repo to access the cli-test branch)
|
||||
git clone git@gitlab.com:Harvmaster/engine.git
|
||||
|
||||
# Move into teh engine directory
|
||||
cd engine
|
||||
|
||||
# Checkout the cli-test branch
|
||||
git checkout cli-test
|
||||
|
||||
# Install the dependencies
|
||||
npm ci
|
||||
|
||||
# Build the engine
|
||||
npm run build
|
||||
# ----- End Engine Setup -----
|
||||
|
||||
# Move back to the top level directory
|
||||
cd ..
|
||||
|
||||
# ----- Start State Setup -----
|
||||
# Clone the State Repo
|
||||
git clone git@gitlab.com:Harvmaster/state.git
|
||||
|
||||
# Move into the state directory
|
||||
cd state
|
||||
|
||||
git checkout in-memory-adapter
|
||||
|
||||
# Install the dependencies
|
||||
npm ci
|
||||
|
||||
# Build the state
|
||||
npm run build
|
||||
# ----- End State Setup -----
|
||||
|
||||
# Move back to the top level directory
|
||||
cd ..
|
||||
|
||||
# ----- Start CLI Setup -----
|
||||
# Clone the CLI Repo
|
||||
git clone git@git.harvmaster.com:Harvmaster/xo-cli.git
|
||||
|
||||
# Move into the cli directory
|
||||
cd xo-cli
|
||||
|
||||
# Install the dependencies
|
||||
npm ci
|
||||
|
||||
# Build the cli
|
||||
npm run build
|
||||
# ----- End CLI Setup -----
|
||||
```
|
||||
|
||||
### Run TUI in dev mode
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Install globally
|
||||
|
||||
```bash
|
||||
# (From the xo-cli directory)
|
||||
npm install -g .
|
||||
```
|
||||
|
||||
### Install autocomplete completions (From the xo-cli directory)
|
||||
|
||||
#### Install for bash
|
||||
```bash
|
||||
npm run autocomplete:install:bash
|
||||
```
|
||||
|
||||
#### Install for zsh
|
||||
```bash
|
||||
npm run autocomplete:install:zsh
|
||||
```
|
||||
|
||||
#### Install for fish
|
||||
```bash
|
||||
npm run autocomplete:install:fish
|
||||
```
|
||||
|
||||
### Run the CLI
|
||||
```bash
|
||||
# If globally installed (Not really usable if not globally installed)
|
||||
xo-cli
|
||||
```
|
||||
|
||||
### Run the TUI
|
||||
```bash
|
||||
# If globally installed
|
||||
xo-tui
|
||||
|
||||
# If not globally installed
|
||||
npm run dev
|
||||
```
|
||||
104
scripts/template-to-json.ts
Normal file
104
scripts/template-to-json.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/**
|
||||
* This just convers the <template>.ts file to a <template>.json file.
|
||||
* Im fairly sure there is a util in the engine or engine-packages for this, but I decided to just keep it as simple as possible because I didn't feel like digging around for it.
|
||||
*
|
||||
* Usage:
|
||||
* tsx scripts/template-to-json.ts ../templates/source/p2pkh.ts ./p2pkh.json p2pkhTemplate
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prints usage to stderr and exits with a non-zero code.
|
||||
*/
|
||||
function printUsageAndExit(): never {
|
||||
console.error(
|
||||
[
|
||||
"Usage: tsx scripts/template-to-json.ts <input.ts> <output.json> [exportName]",
|
||||
"",
|
||||
"Loads a TypeScript module, picks one exported value, and writes JSON.stringify to the output path.",
|
||||
"If exportName is omitted: uses default export, or the only non-function export if there is exactly one.",
|
||||
"",
|
||||
"Example:",
|
||||
" tsx scripts/template-to-json.ts ../templates/source/p2pkh.ts ./p2pkh.json p2pkhTemplate",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects runtime export keys whose values are not functions (typical for data/template objects).
|
||||
*/
|
||||
function listDataExportKeys(mod: Record<string, unknown>): string[] {
|
||||
return Object.keys(mod).filter((key) => {
|
||||
if (key === "__esModule") {
|
||||
return false;
|
||||
}
|
||||
const value = mod[key];
|
||||
return typeof value !== "function";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which export to serialize: explicit name, default, or a single unambiguous data export.
|
||||
*/
|
||||
function resolveExportedValue(
|
||||
mod: Record<string, unknown>,
|
||||
exportName: string | undefined,
|
||||
): unknown {
|
||||
if (exportName !== undefined) {
|
||||
if (!(exportName in mod)) {
|
||||
const keys = listDataExportKeys(mod);
|
||||
console.error(
|
||||
`Export "${exportName}" not found. Available data exports: ${keys.length ? keys.join(", ") : "(none)"}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return mod[exportName];
|
||||
}
|
||||
|
||||
if ("default" in mod && mod.default !== undefined) {
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
const keys = listDataExportKeys(mod);
|
||||
if (keys.length === 1) {
|
||||
return mod[keys[0]!];
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.error(
|
||||
"No suitable exports found (need default or a non-function export).",
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`Multiple data exports found; pass exportName. Candidates: ${keys.join(", ")}`,
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 2) {
|
||||
printUsageAndExit();
|
||||
}
|
||||
|
||||
const [inputRel, outputRel, exportName] = args;
|
||||
const inputPath = path.resolve(process.cwd(), inputRel!);
|
||||
const outputPath = path.resolve(process.cwd(), outputRel!);
|
||||
|
||||
/** Dynamic import needs a file URL so Windows paths and ESM resolution behave. */
|
||||
const fileUrl = pathToFileURL(inputPath).href;
|
||||
const mod = (await import(fileUrl)) as Record<string, unknown>;
|
||||
const value = resolveExportedValue(mod, exportName);
|
||||
|
||||
const json = `${JSON.stringify(value, null, 2)}\n`;
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, json, "utf8");
|
||||
console.log(`Wrote ${outputPath}`);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -3,9 +3,11 @@
|
||||
* Simplified to render TUI immediately and let it handle AppService creation.
|
||||
*/
|
||||
|
||||
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.
|
||||
@@ -46,13 +48,14 @@ export class App {
|
||||
* @returns Running App instance
|
||||
*/
|
||||
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 ?? "./",
|
||||
databasePath: config.databasePath ?? dataDir,
|
||||
databaseFilename: config.databaseFilename ?? "xo-wallet.db",
|
||||
invitationStoragePath:
|
||||
config.invitationStoragePath ?? "./xo-invitations.db",
|
||||
config.invitationStoragePath ?? join(dataDir, "xo-invitations.db"),
|
||||
};
|
||||
|
||||
console.log("Full config:", fullConfig);
|
||||
|
||||
195
src/cli/README.md
Normal file
195
src/cli/README.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# XO CLI
|
||||
|
||||
Command-line interface for the XO Engine. Create wallets, manage templates, build invitations, sign transactions, and broadcast them to the Bitcoin Cash network.
|
||||
|
||||
There are two global commands after install:
|
||||
|
||||
- **`xo-cli`** — non-interactive commands (this document).
|
||||
- **`xo-tui`** — interactive terminal wallet UI (Ink/React).
|
||||
|
||||
## Global config directory
|
||||
|
||||
Wallet state lives under **`~/.config/xo-cli/`** (XDG-style), so you can run commands from any directory:
|
||||
|
||||
| Path | Purpose |
|
||||
| ----------------------------- | ----------------------------------------------------------------------- |
|
||||
| `~/.config/xo-cli/mnemonics/` | Mnemonic files (`mnemonic-*`) |
|
||||
| `~/.config/xo-cli/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) |
|
||||
| `~/.config/xo-cli/.wallet` | Last-used mnemonic reference (so `-m` can be omitted) |
|
||||
|
||||
**Local to your shell’s current directory:** template JSON paths, invitation JSON you create/import, and any path you pass explicitly (e.g. `-m /abs/path/to/file`).
|
||||
|
||||
## Install (global, from this repo)
|
||||
|
||||
`@xo-cash/*` dependencies use `file:` paths, so publish to npm is a separate step. For local development:
|
||||
|
||||
```bash
|
||||
cd engine/cli
|
||||
npm install
|
||||
npm run build
|
||||
npm link
|
||||
# xo-cli and xo-tui are now on your PATH
|
||||
```
|
||||
|
||||
Development without linking:
|
||||
|
||||
```bash
|
||||
cd engine/cli
|
||||
npx tsx src/cli/index.ts <command> [options]
|
||||
npx tsx src/index.ts # TUI
|
||||
```
|
||||
|
||||
### Environment variables (TUI / `xo-tui`)
|
||||
|
||||
| Variable | Default |
|
||||
| ------------------------- | ----------------------------------------- |
|
||||
| `SYNC_SERVER_URL` | `http://localhost:3000` |
|
||||
| `DB_PATH` | `~/.config/xo-cli/data` |
|
||||
| `DB_FILENAME` | `xo-wallet.db` |
|
||||
| `INVITATION_STORAGE_PATH` | `~/.config/xo-cli/data/xo-invitations.db` |
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Wallet Setup
|
||||
|
||||
```bash
|
||||
# Generate a new mnemonic (saved under ~/.config/xo-cli/mnemonics/)
|
||||
xo-cli mnemonic create
|
||||
|
||||
# Import an existing mnemonic seed phrase
|
||||
xo-cli mnemonic import page pencil stock planet limb cluster assault speak off joke private pioneer
|
||||
|
||||
# List mnemonic basenames (use with -m)
|
||||
xo-cli mnemonic list
|
||||
```
|
||||
|
||||
**Options:** `-o <filename>` — basename only; file is written under the global mnemonics directory.
|
||||
|
||||
### Wallet Persistence
|
||||
|
||||
The first time you pass `-m <name>`, that reference is saved to `~/.config/xo-cli/.wallet`. Later runs can omit `-m`.
|
||||
|
||||
Mnemonic resolution order:
|
||||
|
||||
1. Absolute path, if the file exists
|
||||
2. Path relative to the current working directory
|
||||
3. `~/.config/xo-cli/mnemonics/<basename>`
|
||||
|
||||
```bash
|
||||
xo-cli resource list -m mnemonic-nuclear
|
||||
xo-cli resource list
|
||||
```
|
||||
|
||||
## Global Options (`xo-cli`)
|
||||
|
||||
| Flag | Description |
|
||||
| ------------------------------ | --------------------------------------------------- |
|
||||
| `-m`, `--mnemonic-file <file>` | Mnemonic file (basename, cwd-relative, or absolute) |
|
||||
| `-o`, `--output <filename>` | Output filename (used by `mnemonic create`/`import`) |
|
||||
| `-v`, `--verbose` | Verbose output |
|
||||
| `-h`, `--help` | Help |
|
||||
|
||||
Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `~/.config/xo-cli/data/` (see `src/cli/index.ts`).
|
||||
|
||||
## Commands
|
||||
|
||||
### `mnemonic` — Manage Wallet Files
|
||||
|
||||
```bash
|
||||
xo-cli mnemonic create
|
||||
xo-cli mnemonic import <seed words...>
|
||||
xo-cli mnemonic list
|
||||
xo-cli mnemonic expose <mnemonic-file>
|
||||
```
|
||||
|
||||
### `template` — Manage Templates
|
||||
|
||||
```bash
|
||||
xo-cli template import <template-file>
|
||||
xo-cli template list
|
||||
xo-cli template list <category> <template-id>
|
||||
xo-cli template inspect <category> <template-id> <field>
|
||||
xo-cli template set-default <template-file> <output-id> <role>
|
||||
```
|
||||
|
||||
**Categories:** `action`, `transaction`, `output`, `lockingscript`, `variable`
|
||||
|
||||
Template paths are resolved relative to the **current working directory**.
|
||||
|
||||
### `resource` — Manage UTXOs
|
||||
|
||||
```bash
|
||||
xo-cli resource list
|
||||
xo-cli resource list reserved
|
||||
xo-cli resource list all
|
||||
xo-cli resource unreserve <txhash:vout>
|
||||
xo-cli resource unreserve-all
|
||||
```
|
||||
|
||||
### `receive` — Generate a Receiving Address
|
||||
|
||||
```bash
|
||||
xo-cli receive <template-file> <output-identifier> [role-identifier]
|
||||
```
|
||||
|
||||
### `invitation` — Build, Sign & Broadcast
|
||||
|
||||
```bash
|
||||
xo-cli invitation create <template-file> <action-id> [options]
|
||||
xo-cli invitation append <invitation-id> [options]
|
||||
xo-cli invitation sign <invitation-id>
|
||||
xo-cli invitation broadcast <invitation-id>
|
||||
xo-cli invitation requirements <invitation-id>
|
||||
xo-cli invitation import <invitation-file>
|
||||
xo-cli invitation inspect <invitation-file>
|
||||
xo-cli invitation list
|
||||
```
|
||||
|
||||
**Create / append options:**
|
||||
|
||||
| Flag | Description |
|
||||
| --------------------------- | ---------------------------------------- |
|
||||
| `-var-<name> <value>` | Template variable |
|
||||
| `--add-input <txhash:vout>` | Inputs (comma-separated) |
|
||||
| `--add-output <id>` | Override outputs (omit to auto-discover) |
|
||||
| `--auto-inputs` | Auto-select UTXOs |
|
||||
| `-role <role>` | Role for variables / bytecode |
|
||||
| `--sign` | Auto-sign when complete |
|
||||
| `--broadcast` | Auto-broadcast (implies `--sign`) |
|
||||
|
||||
Invitation JSON files from `create` / `append` are written to the **current working directory**.
|
||||
|
||||
### One-command send
|
||||
|
||||
```bash
|
||||
xo-cli resource list
|
||||
xo-cli invitation create p2pkh-template.json sendSatoshis \
|
||||
-var-transferred-satoshis 4678 \
|
||||
-var-recipient-lockingscript "bitcoincash:qz..." \
|
||||
--add-input <txhash>:<vout> \
|
||||
-role sender \
|
||||
--broadcast
|
||||
```
|
||||
|
||||
### `xo-tui`
|
||||
|
||||
```bash
|
||||
xo-tui
|
||||
```
|
||||
|
||||
Launches the full-screen wallet UI; uses the same global data directory unless overridden by env vars.
|
||||
|
||||
## Shell Completions
|
||||
|
||||
```bash
|
||||
eval "$(xo-cli completions bash)"
|
||||
eval "$(xo-cli completions zsh)"
|
||||
xo-cli completions fish | source
|
||||
```
|
||||
|
||||
## File Conventions
|
||||
|
||||
| Location | Purpose |
|
||||
| ------------------- | ------------------------------------------ |
|
||||
| `~/.config/xo-cli/` | Global wallet state |
|
||||
| `./` (cwd) | Templates, invitation JSON, explicit paths |
|
||||
86
src/cli/arguments.ts
Normal file
86
src/cli/arguments.ts
Normal 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 };
|
||||
}
|
||||
393
src/cli/autocomplete/complete.ts
Normal file
393
src/cli/autocomplete/complete.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Lightweight shell completion helper for xo-cli.
|
||||
*
|
||||
* This script reads from local SQLite only - no network connections.
|
||||
* It's designed to be fast enough for interactive tab completion.
|
||||
*
|
||||
* Usage: xo-complete <context> [args...]
|
||||
*
|
||||
* Contexts:
|
||||
* mnemonics - List mnemonic file names
|
||||
* templates - List template names/IDs
|
||||
* actions <template> - List actions for a template
|
||||
* fields <category> <template> - List fields for a template category
|
||||
* invitations - List invitation IDs
|
||||
* resources - List UTXO outpoints (txhash:vout)
|
||||
* subcommands <command> - List subcommands for a top-level command
|
||||
*
|
||||
* Output: One completion suggestion per line, suitable for shell completion.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 - Success (may output zero or more completions)
|
||||
* 1 - Error (no output, fails silently for shell integration)
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
getDataDir,
|
||||
getMnemonicsDir,
|
||||
getWalletConfigPath,
|
||||
} from "../../utils/paths.js";
|
||||
import { loadMnemonic } from "../mnemonic.js";
|
||||
import { Storage } from "../../services/storage.js";
|
||||
import { COMMAND_TREE } from "./completions.js";
|
||||
|
||||
// Lazy-loaded modules (only loaded when needed for dynamic completions)
|
||||
let _offlineEngineModule: typeof import("./offline-engine.js") | null = null;
|
||||
let _engineModule: typeof import("@xo-cash/engine") | null = null;
|
||||
|
||||
async function getOfflineEngineModule() {
|
||||
if (!_offlineEngineModule) {
|
||||
_offlineEngineModule = await import("./offline-engine.js");
|
||||
}
|
||||
return _offlineEngineModule;
|
||||
}
|
||||
|
||||
async function getEngineModule() {
|
||||
if (!_engineModule) {
|
||||
_engineModule = await import("@xo-cash/engine");
|
||||
}
|
||||
return _engineModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs completions to stdout, one per line.
|
||||
* Optionally filters by a prefix (for partial word completion).
|
||||
*/
|
||||
function outputCompletions(items: readonly string[], prefix?: string): void {
|
||||
const filtered = prefix
|
||||
? items.filter((item) =>
|
||||
item.toLowerCase().startsWith(prefix.toLowerCase()),
|
||||
)
|
||||
: items;
|
||||
|
||||
for (const item of filtered) {
|
||||
console.log(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists mnemonic file names from the mnemonics directory.
|
||||
* Fast path: no engine needed, just filesystem.
|
||||
*/
|
||||
function listMnemonics(prefix?: string): void {
|
||||
try {
|
||||
const mnemonicsDir = getMnemonicsDir();
|
||||
const files = readdirSync(mnemonicsDir).filter((f) =>
|
||||
f.startsWith("mnemonic-"),
|
||||
);
|
||||
outputCompletions(files, prefix);
|
||||
} catch {
|
||||
// Silently fail - no completions available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists subcommands for a given top-level command.
|
||||
* Uses the static COMMAND_TREE.
|
||||
*/
|
||||
function listSubcommands(command: string, prefix?: string): void {
|
||||
if (command in COMMAND_TREE) {
|
||||
const subcommands = COMMAND_TREE[command as keyof typeof COMMAND_TREE];
|
||||
outputCompletions(subcommands, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current wallet's mnemonic seed from the saved config.
|
||||
* Returns null if no wallet is configured.
|
||||
*/
|
||||
function getCurrentMnemonic(): string | null {
|
||||
try {
|
||||
const walletConfigPath = getWalletConfigPath();
|
||||
if (!existsSync(walletConfigPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mnemonicFile = readFileSync(walletConfigPath, "utf8").trim();
|
||||
if (!mnemonicFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mnemonicsDir = getMnemonicsDir();
|
||||
return loadMnemonic(mnemonicsDir, mnemonicFile);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists templates from the engine.
|
||||
*/
|
||||
async function listTemplates(prefix?: string): Promise<void> {
|
||||
const mnemonic = getCurrentMnemonic();
|
||||
if (!mnemonic) return;
|
||||
|
||||
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
|
||||
const { generateTemplateIdentifier } = await getEngineModule();
|
||||
|
||||
const engine = await tryCreateOfflineEngine(mnemonic, {
|
||||
databasePath: getDataDir(),
|
||||
databaseFilename: "xo-wallet.db",
|
||||
});
|
||||
|
||||
if (!engine) return;
|
||||
|
||||
try {
|
||||
const templates = await engine.listImportedTemplates();
|
||||
const completions: string[] = [];
|
||||
|
||||
for (const template of templates) {
|
||||
// Add template name (for user-friendly completion)
|
||||
if (template.name) {
|
||||
completions.push(template.name);
|
||||
}
|
||||
// Also add template identifier (for precise matching)
|
||||
const id = generateTemplateIdentifier(template);
|
||||
if (id && !completions.includes(id)) {
|
||||
completions.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
outputCompletions(completions, prefix);
|
||||
} finally {
|
||||
await engine.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a template by name or ID.
|
||||
*/
|
||||
async function resolveTemplate(
|
||||
engine: Awaited<
|
||||
ReturnType<
|
||||
Awaited<
|
||||
ReturnType<typeof getOfflineEngineModule>
|
||||
>["tryCreateOfflineEngine"]
|
||||
>
|
||||
>,
|
||||
templateQuery: string,
|
||||
) {
|
||||
if (!engine) return null;
|
||||
|
||||
const { generateTemplateIdentifier } = await getEngineModule();
|
||||
const templates = await engine.listImportedTemplates();
|
||||
|
||||
// Try exact match on name or ID
|
||||
let template = templates.find(
|
||||
(t) =>
|
||||
t.name === templateQuery ||
|
||||
generateTemplateIdentifier(t) === templateQuery,
|
||||
);
|
||||
|
||||
// Try partial match on name
|
||||
if (!template) {
|
||||
template = templates.find((t) =>
|
||||
t.name?.toLowerCase().includes(templateQuery.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
return template ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists actions for a specific template.
|
||||
*/
|
||||
async function listActions(
|
||||
templateQuery: string,
|
||||
prefix?: string,
|
||||
): Promise<void> {
|
||||
const mnemonic = getCurrentMnemonic();
|
||||
if (!mnemonic) return;
|
||||
|
||||
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
|
||||
|
||||
const engine = await tryCreateOfflineEngine(mnemonic, {
|
||||
databasePath: getDataDir(),
|
||||
databaseFilename: "xo-wallet.db",
|
||||
});
|
||||
|
||||
if (!engine) return;
|
||||
|
||||
try {
|
||||
const template = await resolveTemplate(engine, templateQuery);
|
||||
|
||||
if (template && template.actions) {
|
||||
const actions = Object.keys(template.actions);
|
||||
outputCompletions(actions, prefix);
|
||||
}
|
||||
} finally {
|
||||
await engine.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists fields (actions, transactions, outputs, etc.) for a specific template category.
|
||||
* Used for completing the 3rd argument of `template inspect <category> <template> <field>`.
|
||||
*/
|
||||
async function listFields(
|
||||
category: string,
|
||||
templateQuery: string,
|
||||
prefix?: string,
|
||||
): Promise<void> {
|
||||
const mnemonic = getCurrentMnemonic();
|
||||
if (!mnemonic) return;
|
||||
|
||||
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
|
||||
|
||||
const engine = await tryCreateOfflineEngine(mnemonic, {
|
||||
databasePath: getDataDir(),
|
||||
databaseFilename: "xo-wallet.db",
|
||||
});
|
||||
|
||||
if (!engine) return;
|
||||
|
||||
try {
|
||||
const template = await resolveTemplate(engine, templateQuery);
|
||||
if (!template) return;
|
||||
|
||||
let fields: string[] = [];
|
||||
|
||||
switch (category) {
|
||||
case "action":
|
||||
fields = Object.keys(template.actions || {});
|
||||
break;
|
||||
case "transaction":
|
||||
fields = Object.keys(template.transactions || {});
|
||||
break;
|
||||
case "output":
|
||||
fields = Object.keys(template.outputs || {});
|
||||
break;
|
||||
case "lockingscript":
|
||||
fields = Object.keys(template.lockingScripts || {});
|
||||
break;
|
||||
case "variable":
|
||||
fields = Object.keys(template.variables || {});
|
||||
break;
|
||||
}
|
||||
|
||||
outputCompletions(fields, prefix);
|
||||
} finally {
|
||||
await engine.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists invitation IDs from the invitation storage.
|
||||
*/
|
||||
async function listInvitations(prefix?: string): Promise<void> {
|
||||
const mnemonic = getCurrentMnemonic();
|
||||
if (!mnemonic) return;
|
||||
|
||||
try {
|
||||
// Compute seed hash to find the right storage namespace
|
||||
const seedHash = createHash("sha256").update(mnemonic).digest("hex");
|
||||
const invitationsDbPath = join(getDataDir(), "xo-invitations.db");
|
||||
|
||||
if (!existsSync(invitationsDbPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = await Storage.create(invitationsDbPath);
|
||||
const walletStorage = storage.child(seedHash.slice(0, 8));
|
||||
const invitationsStorage = walletStorage.child("invitations");
|
||||
|
||||
const invitations = await invitationsStorage.all();
|
||||
const ids = invitations.map((inv) => inv.key);
|
||||
|
||||
outputCompletions(ids, prefix);
|
||||
} catch {
|
||||
// Silently fail - no completions available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists UTXO outpoints (resources) from the engine.
|
||||
*/
|
||||
async function listResources(prefix?: string): Promise<void> {
|
||||
const mnemonic = getCurrentMnemonic();
|
||||
if (!mnemonic) return;
|
||||
|
||||
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
|
||||
|
||||
const engine = await tryCreateOfflineEngine(mnemonic, {
|
||||
databasePath: getDataDir(),
|
||||
databaseFilename: "xo-wallet.db",
|
||||
});
|
||||
|
||||
if (!engine) return;
|
||||
|
||||
try {
|
||||
const utxos = await engine.listUnspentOutputsData();
|
||||
const outpoints = utxos.map(
|
||||
(u) => `${u.outpointTransactionHash}:${u.outpointIndex}`,
|
||||
);
|
||||
outputCompletions(outpoints, prefix);
|
||||
} finally {
|
||||
await engine.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point.
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const context = process.argv[2];
|
||||
const arg1 = process.argv[3];
|
||||
const arg2 = process.argv[4];
|
||||
|
||||
if (!context) {
|
||||
// No context provided - output nothing
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
switch (context) {
|
||||
case "mnemonics":
|
||||
listMnemonics(arg1);
|
||||
break;
|
||||
|
||||
case "subcommands":
|
||||
if (arg1) {
|
||||
listSubcommands(arg1, arg2);
|
||||
}
|
||||
break;
|
||||
|
||||
case "templates":
|
||||
await listTemplates(arg1);
|
||||
break;
|
||||
|
||||
case "actions":
|
||||
if (arg1) {
|
||||
await listActions(arg1, arg2);
|
||||
}
|
||||
break;
|
||||
|
||||
case "fields":
|
||||
// fields <category> <template> [prefix]
|
||||
if (arg1 && arg2) {
|
||||
await listFields(arg1, arg2, process.argv[5]);
|
||||
}
|
||||
break;
|
||||
|
||||
case "invitations":
|
||||
await listInvitations(arg1);
|
||||
break;
|
||||
|
||||
case "resources":
|
||||
await listResources(arg1);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown context - output nothing
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(() => {
|
||||
// Silently fail for shell integration
|
||||
process.exit(1);
|
||||
});
|
||||
307
src/cli/autocomplete/completions.ts
Normal file
307
src/cli/autocomplete/completions.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Shell completion script generation.
|
||||
*
|
||||
* Loads shell-native template files and replaces placeholders with
|
||||
* dynamic values. This approach keeps the shell scripts readable
|
||||
* and auditable in their native format.
|
||||
*
|
||||
* The generated scripts use the `xo-complete` helper binary for dynamic
|
||||
* completions (invitation IDs, template names, resources, etc.).
|
||||
*
|
||||
* Usage:
|
||||
* eval "$(xo-cli completions bash)"
|
||||
* eval "$(xo-cli completions zsh)"
|
||||
* xo-cli completions fish | source
|
||||
*
|
||||
* Install to shell config:
|
||||
* xo-cli completions bash --install
|
||||
* xo-cli completions zsh --install
|
||||
* xo-cli completions fish --install
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
appendFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
/**
|
||||
* Single source of truth for the CLI command tree.
|
||||
* Each top-level key is a command, and its value is an array of sub-commands.
|
||||
*
|
||||
* IMPORTANT: Keep this in sync with actual switch statements in command handlers:
|
||||
* - mnemonic.ts: create, import, list, expose
|
||||
* - template.ts: import, list, inspect, set-default
|
||||
* - invitation.ts: create, append, sign, broadcast, requirements, import, inspect, list
|
||||
* - resource.ts: list, unreserve, unreserve-all
|
||||
*/
|
||||
|
||||
/** Subcommands for the mnemonic command */
|
||||
const MNEMONIC_SUBS = ["create", "import", "list", "expose"];
|
||||
/** Subcommands for the template command */
|
||||
const TEMPLATE_SUBS = ["import", "list", "inspect", "set-default"];
|
||||
/** Subcommands for the invitation command */
|
||||
const INVITATION_SUBS = [
|
||||
"create",
|
||||
"append",
|
||||
"sign",
|
||||
"broadcast",
|
||||
"requirements",
|
||||
"import",
|
||||
"inspect",
|
||||
"list",
|
||||
];
|
||||
/** Subcommands for the resource command */
|
||||
const RESOURCE_SUBS = ["list", "unreserve", "unreserve-all"];
|
||||
/** Subcommands for the completions command */
|
||||
const COMPLETIONS_SUBS = ["bash", "zsh", "fish"];
|
||||
|
||||
export const COMMAND_TREE = {
|
||||
mnemonic: MNEMONIC_SUBS,
|
||||
template: TEMPLATE_SUBS,
|
||||
invitation: INVITATION_SUBS,
|
||||
receive: [],
|
||||
resource: RESOURCE_SUBS,
|
||||
help: [],
|
||||
completions: COMPLETIONS_SUBS,
|
||||
} as const;
|
||||
|
||||
/** Global option flags available on every command. */
|
||||
const GLOBAL_OPTIONS = [
|
||||
"-h",
|
||||
"--help",
|
||||
"-v",
|
||||
"--verbose",
|
||||
"-m",
|
||||
"--mnemonic-file",
|
||||
"-o",
|
||||
"--output",
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets the path to the scripts directory containing shell templates.
|
||||
*/
|
||||
function getScriptsDir(): string {
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
return join(dirname(currentFile), "scripts");
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a shell template file and replaces placeholders.
|
||||
* @param templateName - The template file name (e.g., "bash.sh")
|
||||
* @param binName - The CLI binary name
|
||||
*/
|
||||
function loadAndProcessTemplate(templateName: string, binName: string): string {
|
||||
const scriptsDir = getScriptsDir();
|
||||
const templatePath = join(scriptsDir, templateName);
|
||||
|
||||
if (!existsSync(templatePath)) {
|
||||
throw new Error(`Template file not found: ${templatePath}`);
|
||||
}
|
||||
|
||||
let content = readFileSync(templatePath, "utf8");
|
||||
const funcName = binName.replace(/-/g, "_");
|
||||
const commands = Object.keys(COMMAND_TREE).join(" ");
|
||||
const options = GLOBAL_OPTIONS.join(" ");
|
||||
|
||||
// Replace all placeholders
|
||||
content = content.replace(/\{\{BIN_NAME\}\}/g, binName);
|
||||
content = content.replace(/\{\{FUNC_NAME\}\}/g, funcName);
|
||||
content = content.replace(/\{\{COMMANDS\}\}/g, commands);
|
||||
content = content.replace(/\{\{OPTIONS\}\}/g, options);
|
||||
content = content.replace(/\{\{MNEMONIC_SUBS\}\}/g, MNEMONIC_SUBS.join(" "));
|
||||
content = content.replace(/\{\{TEMPLATE_SUBS\}\}/g, TEMPLATE_SUBS.join(" "));
|
||||
content = content.replace(
|
||||
/\{\{INVITATION_SUBS\}\}/g,
|
||||
INVITATION_SUBS.join(" "),
|
||||
);
|
||||
content = content.replace(/\{\{RESOURCE_SUBS\}\}/g, RESOURCE_SUBS.join(" "));
|
||||
|
||||
// Fish-specific placeholders
|
||||
if (templateName.endsWith(".fish")) {
|
||||
content = content.replace(
|
||||
/\{\{TOP_LEVEL_COMMANDS\}\}/g,
|
||||
generateFishTopLevelCommands(binName),
|
||||
);
|
||||
content = content.replace(
|
||||
/\{\{STATIC_SUBCOMMANDS\}\}/g,
|
||||
generateFishStaticSubcommands(binName),
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates fish top-level command completions.
|
||||
*/
|
||||
function generateFishTopLevelCommands(binName: string): string {
|
||||
const lines: string[] = [];
|
||||
for (const cmd of Object.keys(COMMAND_TREE)) {
|
||||
lines.push(
|
||||
`complete -c ${binName} -n "__fish_use_subcommand" -a "${cmd}" -d "${cmd} command"`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates fish static subcommand completions.
|
||||
*/
|
||||
function generateFishStaticSubcommands(binName: string): string {
|
||||
const lines: string[] = [];
|
||||
for (const [cmd, subs] of Object.entries(COMMAND_TREE)) {
|
||||
for (const sub of subs) {
|
||||
lines.push(
|
||||
`complete -c ${binName} -n "__fish_seen_subcommand_from ${cmd}; and not __fish_seen_subcommand_from ${subs.join(" ")}" -a "${sub}" -d "${cmd} ${sub}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a bash completion script.
|
||||
* @param binName - The name of the CLI binary.
|
||||
*/
|
||||
export function generateBashCompletions(binName: string): string {
|
||||
return loadAndProcessTemplate("bash.sh", binName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a zsh completion script.
|
||||
* @param binName - The name of the CLI binary.
|
||||
*/
|
||||
export function generateZshCompletions(binName: string): string {
|
||||
return loadAndProcessTemplate("zsh.zsh", binName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a fish completion script.
|
||||
* @param binName - The name of the CLI binary.
|
||||
*/
|
||||
export function generateFishCompletions(binName: string): string {
|
||||
return loadAndProcessTemplate("fish.fish", binName);
|
||||
}
|
||||
|
||||
type ShellType = "bash" | "zsh" | "fish";
|
||||
|
||||
const generators: Record<ShellType, (binName: string) => string> = {
|
||||
bash: generateBashCompletions,
|
||||
zsh: generateZshCompletions,
|
||||
fish: generateFishCompletions,
|
||||
};
|
||||
|
||||
/**
|
||||
* Shell config file paths and eval commands for each shell type.
|
||||
*/
|
||||
const shellConfigs: Record<
|
||||
ShellType,
|
||||
{ configFile: string; evalCommand: (binName: string) => string }
|
||||
> = {
|
||||
bash: {
|
||||
configFile: join(homedir(), ".bashrc"),
|
||||
evalCommand: (binName) => `eval "$(${binName} completions bash)"`,
|
||||
},
|
||||
zsh: {
|
||||
configFile: join(homedir(), ".zshrc"),
|
||||
evalCommand: (binName) => `eval "$(${binName} completions zsh)"`,
|
||||
},
|
||||
fish: {
|
||||
configFile: join(homedir(), ".config", "fish", "config.fish"),
|
||||
evalCommand: (binName) => `${binName} completions fish | source`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Installs completions to the user's shell config file.
|
||||
* Adds the eval command if not already present.
|
||||
* @param shell - The shell type
|
||||
* @param binName - The CLI binary name
|
||||
* @returns true if installed, false if already present
|
||||
*/
|
||||
function installCompletions(shell: ShellType, binName: string): boolean {
|
||||
const config = shellConfigs[shell];
|
||||
const evalCommand = config.evalCommand(binName);
|
||||
|
||||
// Check if config file exists and already has the completion line
|
||||
let existingContent = "";
|
||||
if (existsSync(config.configFile)) {
|
||||
existingContent = readFileSync(config.configFile, "utf8");
|
||||
if (existingContent.includes(evalCommand)) {
|
||||
return false; // Already installed
|
||||
}
|
||||
}
|
||||
|
||||
// Append the completion line
|
||||
const newLine =
|
||||
existingContent.endsWith("\n") || existingContent === "" ? "" : "\n";
|
||||
const completionBlock = `${newLine}\n# ${binName} shell completions\n${evalCommand}\n`;
|
||||
|
||||
appendFileSync(config.configFile, completionBlock);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `completions` command.
|
||||
* Prints the generated completion script for the given shell to stdout,
|
||||
* or installs it to the shell config file with --install.
|
||||
* @param args - Positional args after "completions", e.g. ["bash"].
|
||||
* @param options - Parsed command options (may include "install").
|
||||
* @param binName - The CLI binary name to use in the completion script.
|
||||
*/
|
||||
export function handleCompletionsCommand(
|
||||
args: string[],
|
||||
options: Record<string, string> = {},
|
||||
binName: string = "xo-cli",
|
||||
): void {
|
||||
const shell = args[0] as ShellType | undefined;
|
||||
const installFlag = options["install"] === "true";
|
||||
|
||||
if (!shell || !generators[shell]) {
|
||||
const supported = Object.keys(generators).join(", ");
|
||||
console.error(`Usage: ${binName} completions <${supported}> [--install]`);
|
||||
console.error("");
|
||||
console.error("Examples:");
|
||||
console.error(
|
||||
` eval "$(${binName} completions bash)" # Output to stdout (add to ~/.bashrc)`,
|
||||
);
|
||||
console.error(
|
||||
` eval "$(${binName} completions zsh)" # Output to stdout (add to ~/.zshrc)`,
|
||||
);
|
||||
console.error(
|
||||
` ${binName} completions fish | source # Output to stdout (add to fish config)`,
|
||||
);
|
||||
console.error("");
|
||||
console.error("Install directly to shell config:");
|
||||
console.error(
|
||||
` ${binName} completions bash --install # Appends to ~/.bashrc`,
|
||||
);
|
||||
console.error(
|
||||
` ${binName} completions zsh --install # Appends to ~/.zshrc`,
|
||||
);
|
||||
console.error(
|
||||
` ${binName} completions fish --install # Appends to ~/.config/fish/config.fish`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (installFlag) {
|
||||
const config = shellConfigs[shell];
|
||||
const installed = installCompletions(shell, binName);
|
||||
|
||||
if (installed) {
|
||||
console.log(`Completions installed to ${config.configFile}`);
|
||||
console.log(`Restart your shell or run: source ${config.configFile}`);
|
||||
} else {
|
||||
console.log(`Completions already installed in ${config.configFile}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(generators[shell](binName));
|
||||
}
|
||||
103
src/cli/autocomplete/offline-engine.ts
Normal file
103
src/cli/autocomplete/offline-engine.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
199
src/cli/autocomplete/scripts/bash.sh
Normal file
199
src/cli/autocomplete/scripts/bash.sh
Normal file
@@ -0,0 +1,199 @@
|
||||
# bash completion for {{BIN_NAME}}
|
||||
# Add to ~/.bashrc: eval "$({{BIN_NAME}} completions bash)"
|
||||
|
||||
# Find xo-complete in the same directory as xo-cli
|
||||
__xo_complete_bin=""
|
||||
if command -v xo-complete &>/dev/null; then
|
||||
__xo_complete_bin="xo-complete"
|
||||
elif command -v {{BIN_NAME}} &>/dev/null; then
|
||||
__xo_complete_bin="$(dirname "$(command -v {{BIN_NAME}})")/xo-complete"
|
||||
fi
|
||||
|
||||
# Wrapper to call xo-complete helper
|
||||
__xo_complete() {
|
||||
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
_{{FUNC_NAME}}_completions() {
|
||||
local cur prev words cword
|
||||
_init_completion || return
|
||||
|
||||
# Handle -m/--mnemonic-file argument (previous word was -m)
|
||||
if [[ "${prev}" == "-m" || "${prev}" == "--mnemonic-file" ]]; then
|
||||
local mnemonics
|
||||
mnemonics=$(__xo_complete mnemonics "${cur}")
|
||||
if [[ -n "${mnemonics}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("$line")
|
||||
done <<< "${mnemonics}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# If the current word starts with "-", offer option flags
|
||||
if [[ "${cur}" == -* ]]; then
|
||||
COMPREPLY=($(compgen -W "{{OPTIONS}}" -- "${cur}"))
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Find the command and subcommand positions
|
||||
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
|
||||
for ((i=1; i < cword; i++)); do
|
||||
if [[ "${words[i]}" != -* ]]; then
|
||||
if [[ -z "${cmd}" ]]; then
|
||||
cmd="${words[i]}"
|
||||
cmd_idx=$i
|
||||
else
|
||||
subcmd="${words[i]}"
|
||||
subcmd_idx=$i
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# No command yet — offer the top-level commands
|
||||
if [[ -z "${cmd}" ]]; then
|
||||
COMPREPLY=($(compgen -W "{{COMMANDS}}" -- "${cur}"))
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Handle each command's completion
|
||||
case "${cmd}" in
|
||||
mnemonic)
|
||||
if [[ -z "${subcmd}" ]]; then
|
||||
COMPREPLY=($(compgen -W "{{MNEMONIC_SUBS}}" -- "${cur}"))
|
||||
fi
|
||||
;;
|
||||
|
||||
template)
|
||||
if [[ -z "${subcmd}" ]]; then
|
||||
COMPREPLY=($(compgen -W "{{TEMPLATE_SUBS}}" -- "${cur}"))
|
||||
elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then
|
||||
# template list/inspect <category> <template> [field] - category first, then template, then field
|
||||
local pos=$((cword - subcmd_idx))
|
||||
if [[ $pos -eq 1 ]]; then
|
||||
COMPREPLY=($(compgen -W "action transaction output lockingscript variable" -- "${cur}"))
|
||||
elif [[ $pos -eq 2 ]]; then
|
||||
local templates
|
||||
templates=$(__xo_complete templates "${cur}")
|
||||
if [[ -n "${templates}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("$line")
|
||||
done <<< "${templates}"
|
||||
fi
|
||||
elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then
|
||||
# Get the category and template from previous args
|
||||
local category="${words[subcmd_idx + 1]}"
|
||||
local template_arg="${words[subcmd_idx + 2]}"
|
||||
local fields
|
||||
fields=$(__xo_complete fields "${category}" "${template_arg}" "${cur}")
|
||||
if [[ -n "${fields}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("$line")
|
||||
done <<< "${fields}"
|
||||
fi
|
||||
fi
|
||||
elif [[ "${subcmd}" == "set-default" ]]; then
|
||||
# template set-default <template> <output> <role> - template first
|
||||
local pos=$((cword - subcmd_idx))
|
||||
if [[ $pos -eq 1 ]]; then
|
||||
local templates
|
||||
templates=$(__xo_complete templates "${cur}")
|
||||
if [[ -n "${templates}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("$line")
|
||||
done <<< "${templates}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
invitation)
|
||||
if [[ -z "${subcmd}" ]]; then
|
||||
COMPREPLY=($(compgen -W "{{INVITATION_SUBS}}" -- "${cur}"))
|
||||
else
|
||||
case "${subcmd}" in
|
||||
create)
|
||||
# invitation create <template> <action> - offer templates then actions
|
||||
local pos=$((cword - subcmd_idx))
|
||||
if [[ $pos -eq 1 ]]; then
|
||||
local templates
|
||||
templates=$(__xo_complete templates "${cur}")
|
||||
if [[ -n "${templates}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("$line")
|
||||
done <<< "${templates}"
|
||||
fi
|
||||
elif [[ $pos -eq 2 ]]; then
|
||||
local template_arg="${words[subcmd_idx + 1]}"
|
||||
local actions
|
||||
actions=$(__xo_complete actions "${template_arg}" "${cur}")
|
||||
if [[ -n "${actions}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("$line")
|
||||
done <<< "${actions}"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
append|sign|broadcast|requirements|inspect)
|
||||
# These take an invitation ID
|
||||
local pos=$((cword - subcmd_idx))
|
||||
if [[ $pos -eq 1 ]]; then
|
||||
local invitations
|
||||
invitations=$(__xo_complete invitations "${cur}")
|
||||
if [[ -n "${invitations}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("$line")
|
||||
done <<< "${invitations}"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
import)
|
||||
# import takes a file path - use default file completion
|
||||
COMPREPLY=($(compgen -f -- "${cur}"))
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
;;
|
||||
|
||||
resource)
|
||||
if [[ -z "${subcmd}" ]]; then
|
||||
COMPREPLY=($(compgen -W "{{RESOURCE_SUBS}}" -- "${cur}"))
|
||||
elif [[ "${subcmd}" == "unreserve" ]]; then
|
||||
# resource unreserve <txhash:vout> - offer resources
|
||||
local pos=$((cword - subcmd_idx))
|
||||
if [[ $pos -eq 1 ]]; then
|
||||
local resources
|
||||
resources=$(__xo_complete resources "${cur}")
|
||||
if [[ -n "${resources}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("$line")
|
||||
done <<< "${resources}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
receive)
|
||||
# receive <template> [output] - offer templates
|
||||
local pos=$((cword - cmd_idx))
|
||||
if [[ $pos -eq 1 ]]; then
|
||||
local templates
|
||||
templates=$(__xo_complete templates "${cur}")
|
||||
if [[ -n "${templates}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("$line")
|
||||
done <<< "${templates}"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
completions)
|
||||
if [[ -z "${subcmd}" ]]; then
|
||||
COMPREPLY=($(compgen -W "bash zsh fish" -- "${cur}"))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
complete -F _{{FUNC_NAME}}_completions {{BIN_NAME}}
|
||||
70
src/cli/autocomplete/scripts/fish.fish
Normal file
70
src/cli/autocomplete/scripts/fish.fish
Normal file
@@ -0,0 +1,70 @@
|
||||
# fish completion for {{BIN_NAME}}
|
||||
# Add to fish config: {{BIN_NAME}} completions fish | source
|
||||
|
||||
# Disable file completions by default
|
||||
complete -c {{BIN_NAME}} -f
|
||||
|
||||
# Helper function to get dynamic completions
|
||||
# Finds xo-complete in the same directory as {{BIN_NAME}}
|
||||
function __{{FUNC_NAME}}_complete_dynamic
|
||||
set -l xo_complete_bin ""
|
||||
if command -q xo-complete
|
||||
set xo_complete_bin xo-complete
|
||||
else if command -q {{BIN_NAME}}
|
||||
set xo_complete_bin (dirname (command -s {{BIN_NAME}}))/xo-complete
|
||||
end
|
||||
if test -n "$xo_complete_bin"
|
||||
$xo_complete_bin $argv 2>/dev/null
|
||||
end
|
||||
end
|
||||
|
||||
# Global options
|
||||
complete -c {{BIN_NAME}} -s h -d "Show help"
|
||||
complete -c {{BIN_NAME}} -l help -d "Show help"
|
||||
complete -c {{BIN_NAME}} -s v -d "Verbose output"
|
||||
complete -c {{BIN_NAME}} -l verbose -d "Verbose output"
|
||||
complete -c {{BIN_NAME}} -s o -d "Output file"
|
||||
complete -c {{BIN_NAME}} -l output -d "Output file"
|
||||
|
||||
# Dynamic mnemonic file completion for -m
|
||||
complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_dynamic mnemonics)'
|
||||
|
||||
# Top-level commands
|
||||
{{TOP_LEVEL_COMMANDS}}
|
||||
|
||||
# Static sub-commands
|
||||
{{STATIC_SUBCOMMANDS}}
|
||||
|
||||
# Dynamic completions
|
||||
|
||||
# invitation create: template names
|
||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
||||
|
||||
# invitation create: action names (2nd arg)
|
||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic actions (commandline -opc)[4])'
|
||||
|
||||
# invitation append/sign/broadcast/requirements/inspect: invitation IDs
|
||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from append; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from sign; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from broadcast; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from requirements; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||
|
||||
# invitation import: file completion
|
||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from import" -F
|
||||
|
||||
# template list/inspect: category first (pos 3), then template (pos 4), then field (pos 5 for inspect)
|
||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'
|
||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'
|
||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 5" -xa '(__{{FUNC_NAME}}_complete_dynamic fields (commandline -opc)[4] (commandline -opc)[5])'
|
||||
|
||||
# template set-default: template first
|
||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from set-default; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
||||
|
||||
# resource unreserve: UTXO outpoints
|
||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from resource; and __fish_seen_subcommand_from unreserve; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic resources)'
|
||||
|
||||
# receive: template names
|
||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from receive; and test (count (commandline -opc)) -eq 2" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
||||
176
src/cli/autocomplete/scripts/zsh.zsh
Normal file
176
src/cli/autocomplete/scripts/zsh.zsh
Normal file
@@ -0,0 +1,176 @@
|
||||
# zsh completion for {{BIN_NAME}}
|
||||
# Add to ~/.zshrc: eval "$({{BIN_NAME}} completions zsh)"
|
||||
|
||||
# Find xo-complete in the same directory as xo-cli
|
||||
__xo_complete_bin=""
|
||||
if (( $+commands[xo-complete] )); then
|
||||
__xo_complete_bin="xo-complete"
|
||||
elif (( $+commands[{{BIN_NAME}}] )); then
|
||||
__xo_complete_bin="${commands[{{BIN_NAME}}]:h}/xo-complete"
|
||||
fi
|
||||
|
||||
# Wrapper to call xo-complete helper
|
||||
__xo_complete() {
|
||||
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
_{{FUNC_NAME}}_completions() {
|
||||
local -a commands
|
||||
commands=({{COMMANDS}})
|
||||
|
||||
# Handle -m/--mnemonic-file argument (previous word was -m)
|
||||
if [[ "${words[CURRENT-1]}" == "-m" || "${words[CURRENT-1]}" == "--mnemonic-file" ]]; then
|
||||
local mnemonics
|
||||
mnemonics=("${(@f)$(__xo_complete mnemonics "${words[CURRENT]}")}")
|
||||
if [[ ${#mnemonics[@]} -gt 0 ]]; then
|
||||
compadd -- "${mnemonics[@]}"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# If typing an option flag, complete options
|
||||
if [[ "${words[${CURRENT}]}" == -* ]]; then
|
||||
compadd -- {{OPTIONS}}
|
||||
return
|
||||
fi
|
||||
|
||||
# Find the command and subcommand
|
||||
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
|
||||
for ((i=2; i < CURRENT; i++)); do
|
||||
if [[ "${words[i]}" != -* ]]; then
|
||||
if [[ -z "${cmd}" ]]; then
|
||||
cmd="${words[i]}"
|
||||
cmd_idx=$i
|
||||
else
|
||||
subcmd="${words[i]}"
|
||||
subcmd_idx=$i
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# No command yet — offer top-level commands
|
||||
if [[ -z "${cmd}" ]]; then
|
||||
compadd -- ${commands[@]}
|
||||
return
|
||||
fi
|
||||
|
||||
# Handle each command's completion
|
||||
case "${cmd}" in
|
||||
mnemonic)
|
||||
if [[ -z "${subcmd}" ]]; then
|
||||
compadd -- {{MNEMONIC_SUBS}}
|
||||
fi
|
||||
;;
|
||||
|
||||
template)
|
||||
if [[ -z "${subcmd}" ]]; then
|
||||
compadd -- {{TEMPLATE_SUBS}}
|
||||
elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then
|
||||
# template list/inspect <category> <template> [field] - category first, then template, then field
|
||||
local pos=$((CURRENT - subcmd_idx))
|
||||
if [[ $pos -eq 1 ]]; then
|
||||
compadd -- action transaction output lockingscript variable
|
||||
elif [[ $pos -eq 2 ]]; then
|
||||
local templates
|
||||
templates=("${(@f)$(__xo_complete templates "${words[CURRENT]}")}")
|
||||
if [[ ${#templates[@]} -gt 0 ]]; then
|
||||
compadd -- "${templates[@]}"
|
||||
fi
|
||||
elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then
|
||||
# Get the category and template from previous args
|
||||
local category="${words[subcmd_idx + 1]}"
|
||||
local template_arg="${words[subcmd_idx + 2]}"
|
||||
local fields
|
||||
fields=("${(@f)$(__xo_complete fields "${category}" "${template_arg}" "${words[CURRENT]}")}")
|
||||
if [[ ${#fields[@]} -gt 0 ]]; then
|
||||
compadd -- "${fields[@]}"
|
||||
fi
|
||||
fi
|
||||
elif [[ "${subcmd}" == "set-default" ]]; then
|
||||
# template set-default <template> <output> <role> - template first
|
||||
local pos=$((CURRENT - subcmd_idx))
|
||||
if [[ $pos -eq 1 ]]; then
|
||||
local templates
|
||||
templates=("${(@f)$(__xo_complete templates "${words[CURRENT]}")}")
|
||||
if [[ ${#templates[@]} -gt 0 ]]; then
|
||||
compadd -- "${templates[@]}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
invitation)
|
||||
if [[ -z "${subcmd}" ]]; then
|
||||
compadd -- {{INVITATION_SUBS}}
|
||||
else
|
||||
case "${subcmd}" in
|
||||
create)
|
||||
local pos=$((CURRENT - subcmd_idx))
|
||||
if [[ $pos -eq 1 ]]; then
|
||||
local templates
|
||||
templates=("${(@f)$(__xo_complete templates "${words[CURRENT]}")}")
|
||||
if [[ ${#templates[@]} -gt 0 ]]; then
|
||||
compadd -- "${templates[@]}"
|
||||
fi
|
||||
elif [[ $pos -eq 2 ]]; then
|
||||
local template_arg="${words[subcmd_idx + 1]}"
|
||||
local actions
|
||||
actions=("${(@f)$(__xo_complete actions "${template_arg}" "${words[CURRENT]}")}")
|
||||
if [[ ${#actions[@]} -gt 0 ]]; then
|
||||
compadd -- "${actions[@]}"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
append|sign|broadcast|requirements|inspect)
|
||||
local pos=$((CURRENT - subcmd_idx))
|
||||
if [[ $pos -eq 1 ]]; then
|
||||
local invitations
|
||||
invitations=("${(@f)$(__xo_complete invitations "${words[CURRENT]}")}")
|
||||
if [[ ${#invitations[@]} -gt 0 ]]; then
|
||||
compadd -- "${invitations[@]}"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
import)
|
||||
_files
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
;;
|
||||
|
||||
resource)
|
||||
if [[ -z "${subcmd}" ]]; then
|
||||
compadd -- {{RESOURCE_SUBS}}
|
||||
elif [[ "${subcmd}" == "unreserve" ]]; then
|
||||
local pos=$((CURRENT - subcmd_idx))
|
||||
if [[ $pos -eq 1 ]]; then
|
||||
local resources
|
||||
resources=("${(@f)$(__xo_complete resources "${words[CURRENT]}")}")
|
||||
if [[ ${#resources[@]} -gt 0 ]]; then
|
||||
compadd -- "${resources[@]}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
receive)
|
||||
local pos=$((CURRENT - cmd_idx))
|
||||
if [[ $pos -eq 1 ]]; then
|
||||
local templates
|
||||
templates=("${(@f)$(__xo_complete templates "${words[CURRENT]}")}")
|
||||
if [[ ${#templates[@]} -gt 0 ]]; then
|
||||
compadd -- "${templates[@]}"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
completions)
|
||||
if [[ -z "${subcmd}" ]]; then
|
||||
compadd -- bash zsh fish
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
compdef _{{FUNC_NAME}}_completions {{BIN_NAME}}
|
||||
7
src/cli/commands/index.ts
Normal file
7
src/cli/commands/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { handleMnemonicCommand, printMnemonicHelp } from "./mnemonic.js";
|
||||
export { handleTemplateCommand, printTemplateHelp } from "./template.js";
|
||||
export { handleInvitationCommand, printInvitationHelp } from "./invitation.js";
|
||||
export { handleReceiveCommand, printReceiveHelp } from "./receive.js";
|
||||
export { handleResourceCommand, printResourceHelp } from "./resource.js";
|
||||
|
||||
export * from "./types.js";
|
||||
774
src/cli/commands/invitation.ts
Normal file
774
src/cli/commands/invitation.ts
Normal file
@@ -0,0 +1,774 @@
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||
import { binToHex, hexToBin } from "@bitauth/libauth";
|
||||
|
||||
import { bold, dim, formatObject } from "../utils.js";
|
||||
import type { CommandDependencies, CommandIO } from "./types.js";
|
||||
import { CommandError } from "./types.js";
|
||||
import type { Invitation } from "../../services/invitation.js";
|
||||
import {
|
||||
resolveProvidedLockingBytecodeHex,
|
||||
mapUnspentOutputsToSelectable,
|
||||
autoSelectGreedyUtxos,
|
||||
} from "../../utils/invitation-flow.js";
|
||||
import { encodeExtendedJson } from "../../utils/ext-json.js";
|
||||
import { resolveTemplate } from "../utils.js";
|
||||
|
||||
const DEFAULT_FEE = 500n;
|
||||
const DUST_THRESHOLD = 546n;
|
||||
|
||||
/**
|
||||
* Result of parsing CLI options into inputs and outputs for an append call.
|
||||
* A `null` return signals a fatal error that was already logged to stderr.
|
||||
*/
|
||||
interface BuildAppendResult {
|
||||
inputs: { outpointTransactionHash: Uint8Array; outpointIndex: number }[];
|
||||
outputs: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts invitation variables from CLI option flags.
|
||||
* Keys starting with "var" are treated as variables — the prefix is stripped
|
||||
* and the next character is lowercased to reconstruct the camelCase identifier.
|
||||
*
|
||||
* When a `-role` flag is present the role identifier is attached to every
|
||||
* variable so the engine stores them under the correct role-level requirements.
|
||||
*
|
||||
* @example `-var-requested-satoshis 1000 -role sender` → `{ variableIdentifier: "requestedSatoshis", value: "1000", roleIdentifier: "sender" }`
|
||||
*/
|
||||
function parseVariablesFromOptions(
|
||||
options: Record<string, string>,
|
||||
): { variableIdentifier: string; value: string; roleIdentifier?: string }[] {
|
||||
const roleIdentifier = options["role"];
|
||||
|
||||
return Object.entries(options)
|
||||
.filter(([key]) => key.startsWith("var"))
|
||||
.map(([key, value]) => ({
|
||||
variableIdentifier: key.substring(3, 4).toLowerCase() + key.substring(4),
|
||||
value,
|
||||
...(roleIdentifier ? { roleIdentifier } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses CLI options into the inputs and outputs needed for an invitation
|
||||
* append call. Shared by both `create` and `append` so the same flags
|
||||
* (`--add-input`, `--add-output`, `--auto-inputs`, `-role`) work in either.
|
||||
*
|
||||
* Variables should already be committed to the invitation before calling this
|
||||
* so that `getSatsOut()` can resolve variable-dependent output values for the
|
||||
* automatic change calculation.
|
||||
*
|
||||
* @param deps - Command dependencies (engine, logger, etc.)
|
||||
* @param invitation - The invitation instance (variables should already be committed).
|
||||
* @param options - Parsed CLI option flags.
|
||||
* @returns The structured params, or `null` when a fatal error was printed.
|
||||
*/
|
||||
async function buildAppendParams(
|
||||
deps: CommandDependencies,
|
||||
invitation: Invitation,
|
||||
options: Record<string, string>,
|
||||
): Promise<BuildAppendResult | null> {
|
||||
// --- Inputs ---
|
||||
// Accepts comma-separated <txhash>:<vout> pairs via --add-input,
|
||||
// OR automatic selection via --auto-inputs.
|
||||
let inputs: { outpointTransactionHash: Uint8Array; outpointIndex: number }[] =
|
||||
[];
|
||||
|
||||
if (options["autoInputs"] === "true") {
|
||||
// Auto-select UTXOs using the greedy algorithm from invitation-flow.
|
||||
const suitableResources = await invitation.findSuitableResources();
|
||||
const selectable = mapUnspentOutputsToSelectable(suitableResources);
|
||||
|
||||
const requiredWithFee =
|
||||
(await invitation.getSatsOut().catch(() => 0n)) + DEFAULT_FEE;
|
||||
autoSelectGreedyUtxos(selectable, requiredWithFee);
|
||||
|
||||
inputs = selectable
|
||||
.filter((u) => u.selected)
|
||||
.map((u) => ({
|
||||
outpointTransactionHash: hexToBin(u.outpointTransactionHash),
|
||||
outpointIndex: u.outpointIndex,
|
||||
}));
|
||||
|
||||
if (inputs.length === 0) {
|
||||
deps.io.err("No suitable UTXOs found for auto-input selection.");
|
||||
return null;
|
||||
}
|
||||
deps.io.verbose(`Auto-selected ${inputs.length} input(s)`);
|
||||
} else if (options["addInput"]) {
|
||||
inputs = options["addInput"].split(",").map((entry) => {
|
||||
const separatorIndex = entry.lastIndexOf(":");
|
||||
if (separatorIndex === -1) {
|
||||
throw new Error(
|
||||
`Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`,
|
||||
);
|
||||
}
|
||||
const txHash = entry.substring(0, separatorIndex);
|
||||
const vout = parseInt(entry.substring(separatorIndex + 1), 10);
|
||||
if (!txHash || isNaN(vout)) {
|
||||
throw new Error(
|
||||
`Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
outpointTransactionHash: hexToBin(txHash),
|
||||
outpointIndex: vout,
|
||||
};
|
||||
});
|
||||
}
|
||||
deps.io.verbose(
|
||||
`Inputs: ${formatObject(inputs.map((i) => ({ txHash: binToHex(i.outpointTransactionHash), vout: i.outpointIndex })))}`,
|
||||
);
|
||||
|
||||
// --- Outputs ---
|
||||
// When --add-output is provided, use those identifiers explicitly.
|
||||
// Otherwise, auto-discover all required outputs from the template so the
|
||||
// user doesn't have to name them manually.
|
||||
const roleIdentifier = options["role"];
|
||||
let outputIdentifiers: string[] = [];
|
||||
|
||||
if (options["addOutput"]) {
|
||||
outputIdentifiers = options["addOutput"].split(",");
|
||||
} else {
|
||||
// Pull every output identifier the template requires (top-level + role-specific).
|
||||
const requirements = await invitation.getRequirements();
|
||||
const discovered = new Set<string>();
|
||||
for (const id of requirements.outputs ?? []) discovered.add(id);
|
||||
if (requirements.roles) {
|
||||
for (const role of Object.values(requirements.roles)) {
|
||||
for (const id of role.outputs ?? []) discovered.add(id);
|
||||
}
|
||||
}
|
||||
outputIdentifiers = [...discovered];
|
||||
if (outputIdentifiers.length > 0) {
|
||||
deps.io.verbose(
|
||||
`Auto-discovered output(s) from template: ${outputIdentifiers.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build a variable-values map from all committed variables so
|
||||
// resolveProvidedLockingBytecodeHex can resolve outputs whose locking
|
||||
// script depends on a variable (e.g. <recipientLockingscript>).
|
||||
const variableValuesByIdentifier: Record<string, string> = {};
|
||||
for (const commit of invitation.data.commits) {
|
||||
for (const v of commit.data?.variables ?? []) {
|
||||
if (v.variableIdentifier && typeof v.value === "string") {
|
||||
variableValuesByIdentifier[v.variableIdentifier] = v.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const template = await deps.app.engine.getTemplate(
|
||||
invitation.data.templateIdentifier,
|
||||
);
|
||||
|
||||
const outputs: any[] = await Promise.all(
|
||||
outputIdentifiers.map(async (outputId) => {
|
||||
// Try variable-based resolution first (e.g. sendSatoshis → recipientLockingscript)
|
||||
const providedHex = template
|
||||
? resolveProvidedLockingBytecodeHex(
|
||||
template,
|
||||
outputId,
|
||||
variableValuesByIdentifier,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const lockingBytecodeHex =
|
||||
providedHex ??
|
||||
(await invitation.generateLockingBytecode(outputId, roleIdentifier));
|
||||
|
||||
deps.io.verbose(
|
||||
`Locking bytecode for output "${outputId}": ${lockingBytecodeHex}`,
|
||||
);
|
||||
return {
|
||||
outputIdentifier: outputId,
|
||||
lockingBytecode: new Uint8Array(Buffer.from(lockingBytecodeHex, "hex")),
|
||||
};
|
||||
}),
|
||||
);
|
||||
deps.io.verbose(
|
||||
`Outputs: ${formatObject(outputs.map((o) => o.outputIdentifier))}`,
|
||||
);
|
||||
|
||||
// --- Auto change output ---
|
||||
// When inputs are provided, look up each UTXO's value, compute the
|
||||
// required sats, and return the excess minus fees back to the user.
|
||||
if (inputs.length > 0) {
|
||||
const allUtxos = await deps.app.engine.listUnspentOutputsData();
|
||||
const utxoMap = new Map(
|
||||
allUtxos.map((u) => [
|
||||
`${u.outpointTransactionHash}:${u.outpointIndex}`,
|
||||
u,
|
||||
]),
|
||||
);
|
||||
|
||||
let totalInputSats = 0n;
|
||||
for (const input of inputs) {
|
||||
const txHashHex = binToHex(input.outpointTransactionHash);
|
||||
const utxo = utxoMap.get(`${txHashHex}:${input.outpointIndex}`);
|
||||
if (!utxo) {
|
||||
deps.io.err(
|
||||
`UTXO not found: ${txHashHex}:${input.outpointIndex}. Make sure it exists in your wallet.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
totalInputSats += BigInt(utxo.valueSatoshis);
|
||||
}
|
||||
deps.io.verbose(`Total input value: ${totalInputSats} satoshis`);
|
||||
|
||||
const requiredSats = await invitation.getSatsOut();
|
||||
deps.io.verbose(`Required output value: ${requiredSats} satoshis`);
|
||||
|
||||
const changeAmount = totalInputSats - requiredSats - DEFAULT_FEE;
|
||||
deps.io.verbose(
|
||||
`Change amount: ${changeAmount} satoshis (fee: ${DEFAULT_FEE})`,
|
||||
);
|
||||
|
||||
if (changeAmount < 0n) {
|
||||
deps.io.err(
|
||||
`Insufficient funds. Inputs total ${totalInputSats} sats, but need ${requiredSats + DEFAULT_FEE} sats (${requiredSats} required + ${DEFAULT_FEE} fee).`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (changeAmount >= DUST_THRESHOLD) {
|
||||
outputs.push({ valueSatoshis: changeAmount });
|
||||
deps.io.out(`Auto-adding change output: ${changeAmount} satoshis`);
|
||||
} else if (changeAmount > 0n) {
|
||||
deps.io.out(
|
||||
`Change ${changeAmount} sats is below dust threshold (${DUST_THRESHOLD} sats), donating to miners as fee.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { inputs, outputs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the help message for the invitation command
|
||||
*/
|
||||
export const printInvitationHelp = (io: CommandIO): void => {
|
||||
io.out(
|
||||
`
|
||||
${bold("Usage:")} xo-cli invitation <sub-command>
|
||||
|
||||
${bold("Sub-commands:")}
|
||||
- create <template-file> <action-id> ${dim("Create a new invitation")}
|
||||
- append <invitation-id> ${dim("Add variables/outputs to an invitation")}
|
||||
- sign <invitation-id> ${dim("Sign an invitation")}
|
||||
- broadcast <invitation-id> ${dim("Broadcast an invitation")}
|
||||
- requirements <invitation-id> ${dim("Show requirements for an invitation")}
|
||||
- import <invitation-file> ${dim("Import an invitation from a file")}
|
||||
- inspect <invitation-id | invitation-file> ${dim("Inspect an invitation")}
|
||||
- list ${dim("List all invitations")}
|
||||
|
||||
${bold("Create / Append options:")}
|
||||
-var-<name> <value> ${dim("Set a variable (e.g. -var-requested-satoshis 1000)")}
|
||||
--add-input <txhash:vout> ${dim("Add UTXO input(s), comma-separated (e.g. abc123:0,def456:1)")}
|
||||
--add-output <id> ${dim("Override output(s) — omit to auto-discover from template")}
|
||||
--auto-inputs ${dim("Automatically select UTXOs as inputs")}
|
||||
-role <role> ${dim("Role for output bytecode generation (fallback)")}
|
||||
--sign ${dim("Auto-sign after all requirements are satisfied")}
|
||||
--broadcast ${dim("Auto-broadcast after signing (implies --sign)")}
|
||||
|
||||
${dim("When inputs are provided, a change output is automatically added if the")}
|
||||
${dim("input total exceeds the required amount + fee.")}
|
||||
`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Result data returned by invitation commands on success.
|
||||
*/
|
||||
export type InvitationCommandResult = {
|
||||
invitationIdentifier?: string;
|
||||
txHash?: string;
|
||||
count?: number;
|
||||
templateName?: string;
|
||||
actionIdentifier?: string;
|
||||
status?: string;
|
||||
entities?: { entityIdentifier: string; roles: (string | undefined)[] }[];
|
||||
inputs?: unknown[];
|
||||
outputs?: unknown[];
|
||||
variables?: unknown[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the invitation command.
|
||||
* Throws CommandError on failure, returns result data on success.
|
||||
* @param deps - The command dependencies.
|
||||
* @param args - Positional args after the command name, e.g. ["create", "template.json", "action-id"].
|
||||
* @param options - Parsed option flags, e.g. { varRequestedSatohis: "1000", role: "receiver" }.
|
||||
*/
|
||||
export const handleInvitationCommand = async (
|
||||
deps: CommandDependencies,
|
||||
args: string[],
|
||||
options: Record<string, string>,
|
||||
): Promise<InvitationCommandResult> => {
|
||||
const subCommand = args[0];
|
||||
deps.io.verbose(`Invitation sub-command: ${subCommand}`);
|
||||
|
||||
if (!subCommand) {
|
||||
deps.io.verbose("No sub-command provided");
|
||||
printInvitationHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"invitation.subcommand.missing",
|
||||
"No sub-command provided",
|
||||
);
|
||||
}
|
||||
|
||||
switch (subCommand) {
|
||||
case "create": {
|
||||
const templateQuery = args[1];
|
||||
const actionIdentifier = args[2];
|
||||
deps.io.verbose(
|
||||
`Template query: ${templateQuery}, action identifier: ${actionIdentifier}`,
|
||||
);
|
||||
|
||||
if (!templateQuery || !actionIdentifier) {
|
||||
deps.io.verbose("No template file or action identifier provided");
|
||||
printInvitationHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"invitation.create.arguments_missing",
|
||||
"No template file or action identifier provided",
|
||||
);
|
||||
}
|
||||
|
||||
const template = await resolveTemplate(deps, templateQuery);
|
||||
const templateIdentifier = generateTemplateIdentifier(template);
|
||||
const rawInvitation = await deps.app.engine.createInvitation({
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
});
|
||||
deps.io.verbose(`XOInvitation created: ${formatObject(rawInvitation)}`);
|
||||
|
||||
const invitationInstance = await deps.app.createInvitation(rawInvitation);
|
||||
deps.io.verbose(
|
||||
`Invitation created: ${formatObject(invitationInstance.data)}`,
|
||||
);
|
||||
|
||||
const variables = parseVariablesFromOptions(options);
|
||||
deps.io.verbose(`Variables: ${formatObject(variables)}`);
|
||||
if (variables.length > 0) {
|
||||
await invitationInstance.addVariables(variables);
|
||||
}
|
||||
|
||||
const params = await buildAppendParams(deps, invitationInstance, options);
|
||||
if (!params) {
|
||||
throw new CommandError(
|
||||
"invitation.create.append_params_failed",
|
||||
"Failed to build append parameters",
|
||||
);
|
||||
}
|
||||
|
||||
const { inputs, outputs } = params;
|
||||
if (inputs.length > 0 || outputs.length > 0) {
|
||||
await invitationInstance.append({ inputs, outputs });
|
||||
}
|
||||
|
||||
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitationInstance.data.invitationIdentifier}.json`;
|
||||
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
|
||||
writeFileSync(
|
||||
invitationFilePath,
|
||||
encodeExtendedJson(invitationInstance.data, 2),
|
||||
);
|
||||
deps.io.out(
|
||||
`Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`,
|
||||
);
|
||||
|
||||
const missingRequirements =
|
||||
await invitationInstance.getMissingRequirements();
|
||||
const hasMissing =
|
||||
(missingRequirements.variables?.length ?? 0) > 0 ||
|
||||
(missingRequirements.inputs?.length ?? 0) > 0 ||
|
||||
(missingRequirements.outputs?.length ?? 0) > 0 ||
|
||||
(missingRequirements.roles !== undefined &&
|
||||
Object.keys(missingRequirements.roles).length > 0);
|
||||
|
||||
if (hasMissing) {
|
||||
deps.io.out(`\n${bold("Remaining requirements:")}`);
|
||||
deps.io.out(formatObject(missingRequirements));
|
||||
} else {
|
||||
const shouldSign =
|
||||
options["sign"] === "true" || options["broadcast"] === "true";
|
||||
const shouldBroadcast = options["broadcast"] === "true";
|
||||
|
||||
if (shouldSign) {
|
||||
await invitationInstance.sign();
|
||||
deps.io.out(
|
||||
`Invitation signed: ${invitationInstance.data.invitationIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldBroadcast) {
|
||||
const txHash = await invitationInstance.broadcast();
|
||||
deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
|
||||
} else if (!shouldSign) {
|
||||
deps.io.out(
|
||||
`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationInstance.data.invitationIdentifier}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
invitationIdentifier: invitationInstance.data.invitationIdentifier,
|
||||
};
|
||||
}
|
||||
|
||||
case "append": {
|
||||
const invitationIdentifier = args[1];
|
||||
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
|
||||
|
||||
if (!invitationIdentifier) {
|
||||
deps.io.verbose("No invitation identifier provided");
|
||||
printInvitationHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"invitation.append.identifier_missing",
|
||||
"No invitation identifier provided",
|
||||
);
|
||||
}
|
||||
|
||||
const invitation = deps.app.invitations.find(
|
||||
(inv) => inv.data.invitationIdentifier === invitationIdentifier,
|
||||
);
|
||||
if (!invitation) {
|
||||
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
|
||||
throw new CommandError(
|
||||
"invitation.append.not_found",
|
||||
`Invitation not found: ${invitationIdentifier}`,
|
||||
);
|
||||
}
|
||||
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
|
||||
|
||||
const variables = parseVariablesFromOptions(options);
|
||||
deps.io.verbose(`Variables to append: ${formatObject(variables)}`);
|
||||
if (variables.length > 0) {
|
||||
await invitation.addVariables(variables);
|
||||
}
|
||||
|
||||
const params = await buildAppendParams(deps, invitation, options);
|
||||
if (!params) {
|
||||
throw new CommandError(
|
||||
"invitation.append.params_failed",
|
||||
"Failed to build append parameters",
|
||||
);
|
||||
}
|
||||
|
||||
const { inputs, outputs } = params;
|
||||
if (
|
||||
variables.length === 0 &&
|
||||
inputs.length === 0 &&
|
||||
outputs.length === 0
|
||||
) {
|
||||
const error =
|
||||
"Nothing to append. Provide variables (-var-<name> <value>), inputs (--add-input <txhash>:<vout>), or outputs (--add-output <identifier>).";
|
||||
deps.io.err(error);
|
||||
throw new CommandError("invitation.append.empty", error);
|
||||
}
|
||||
|
||||
if (inputs.length > 0 || outputs.length > 0) {
|
||||
await invitation.append({ inputs, outputs });
|
||||
}
|
||||
deps.io.verbose(`Invitation appended: ${formatObject(invitation.data)}`);
|
||||
deps.io.out(`Invitation appended: ${invitationIdentifier}`);
|
||||
|
||||
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`;
|
||||
writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2));
|
||||
deps.io.out(
|
||||
`Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`,
|
||||
);
|
||||
|
||||
const missingRequirements = await invitation.getMissingRequirements();
|
||||
const hasMissing =
|
||||
(missingRequirements.variables?.length ?? 0) > 0 ||
|
||||
(missingRequirements.inputs?.length ?? 0) > 0 ||
|
||||
(missingRequirements.outputs?.length ?? 0) > 0 ||
|
||||
(missingRequirements.roles !== undefined &&
|
||||
Object.keys(missingRequirements.roles).length > 0);
|
||||
|
||||
if (hasMissing) {
|
||||
deps.io.out(`\n${bold("Remaining requirements:")}`);
|
||||
deps.io.out(formatObject(missingRequirements));
|
||||
} else {
|
||||
const shouldSign =
|
||||
options["sign"] === "true" || options["broadcast"] === "true";
|
||||
const shouldBroadcast = options["broadcast"] === "true";
|
||||
|
||||
if (shouldSign) {
|
||||
await invitation.sign();
|
||||
deps.io.out(`Invitation signed: ${invitationIdentifier}`);
|
||||
}
|
||||
|
||||
if (shouldBroadcast) {
|
||||
const txHash = await invitation.broadcast();
|
||||
deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
|
||||
} else if (!shouldSign) {
|
||||
deps.io.out(
|
||||
`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationIdentifier}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { invitationIdentifier };
|
||||
}
|
||||
|
||||
case "sign": {
|
||||
const invitationIdentifier = args[1];
|
||||
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
|
||||
if (!invitationIdentifier) {
|
||||
deps.io.verbose("No invitation identifier provided");
|
||||
printInvitationHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"invitation.sign.identifier_missing",
|
||||
"No invitation identifier provided",
|
||||
);
|
||||
}
|
||||
|
||||
const invitation = deps.app.invitations.find(
|
||||
(candidate) =>
|
||||
candidate.data.invitationIdentifier === invitationIdentifier,
|
||||
);
|
||||
if (!invitation) {
|
||||
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
|
||||
throw new CommandError(
|
||||
"invitation.sign.not_found",
|
||||
`Invitation not found: ${invitationIdentifier}`,
|
||||
);
|
||||
}
|
||||
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
|
||||
|
||||
await invitation.sign();
|
||||
deps.io.verbose(`Invitation signed: ${formatObject(invitation.data)}`);
|
||||
deps.io.out(`Invitation signed: ${invitationIdentifier}`);
|
||||
return { invitationIdentifier };
|
||||
}
|
||||
|
||||
case "broadcast": {
|
||||
const invitationIdentifier = args[1];
|
||||
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
|
||||
if (!invitationIdentifier) {
|
||||
deps.io.verbose("No invitation identifier provided");
|
||||
printInvitationHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"invitation.broadcast.identifier_missing",
|
||||
"No invitation identifier provided",
|
||||
);
|
||||
}
|
||||
|
||||
const invitation = deps.app.invitations.find(
|
||||
(candidate) =>
|
||||
candidate.data.invitationIdentifier === invitationIdentifier,
|
||||
);
|
||||
if (!invitation) {
|
||||
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
|
||||
throw new CommandError(
|
||||
"invitation.broadcast.not_found",
|
||||
`Invitation not found: ${invitationIdentifier}`,
|
||||
);
|
||||
}
|
||||
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
|
||||
|
||||
const txHash = await invitation.broadcast();
|
||||
deps.io.verbose(
|
||||
`Invitation broadcasted: ${formatObject(invitation.data)}`,
|
||||
);
|
||||
deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
|
||||
return { invitationIdentifier, txHash };
|
||||
}
|
||||
|
||||
case "requirements": {
|
||||
const invitationIdentifier = args[1];
|
||||
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
|
||||
if (!invitationIdentifier) {
|
||||
deps.io.verbose("No invitation identifier provided");
|
||||
printInvitationHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"invitation.requirements.identifier_missing",
|
||||
"No invitation identifier provided",
|
||||
);
|
||||
}
|
||||
|
||||
const invitation = deps.app.invitations.find(
|
||||
(candidate) =>
|
||||
candidate.data.invitationIdentifier === invitationIdentifier,
|
||||
);
|
||||
if (!invitation) {
|
||||
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
|
||||
throw new CommandError(
|
||||
"invitation.requirements.not_found",
|
||||
`Invitation not found: ${invitationIdentifier}`,
|
||||
);
|
||||
}
|
||||
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
|
||||
|
||||
const requirements = await deps.app.engine.listRequirements(
|
||||
invitation.data,
|
||||
);
|
||||
deps.io.verbose(`Requirements: ${formatObject(requirements)}`);
|
||||
deps.io.out(formatObject(requirements));
|
||||
return { invitationIdentifier };
|
||||
}
|
||||
|
||||
case "inspect": {
|
||||
const invitationFilePath = args[1];
|
||||
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
|
||||
|
||||
if (!invitationFilePath) {
|
||||
deps.io.verbose("No invitation file provided");
|
||||
printInvitationHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"invitation.inspect.file_missing",
|
||||
"No invitation file provided",
|
||||
);
|
||||
}
|
||||
|
||||
const invitationFile = await readFileSync(invitationFilePath, "utf8");
|
||||
deps.io.verbose(`Invitation file: ${invitationFile}`);
|
||||
|
||||
const invitation = JSON.parse(invitationFile);
|
||||
deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
|
||||
|
||||
const invitationInstance = await deps.app.createInvitation(invitation);
|
||||
deps.io.verbose(
|
||||
`Invitation created: ${formatObject(invitationInstance.data)}`,
|
||||
);
|
||||
|
||||
const template = await deps.app.engine.getTemplate(
|
||||
invitationInstance.data.templateIdentifier,
|
||||
);
|
||||
|
||||
const action =
|
||||
template?.actions[invitationInstance.data.actionIdentifier];
|
||||
deps.io.verbose(`Action: ${formatObject(action)}`);
|
||||
if (!action) {
|
||||
deps.io.err(
|
||||
`Action not found: ${invitationInstance.data.actionIdentifier}`,
|
||||
);
|
||||
throw new CommandError(
|
||||
"invitation.inspect.action_not_found",
|
||||
`Action not found: ${invitationInstance.data.actionIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
const status = invitationInstance.status;
|
||||
deps.io.verbose(`Status: ${status}`);
|
||||
|
||||
const entities = Array.from(
|
||||
new Set(
|
||||
invitationInstance.data.commits.map(
|
||||
(commit) => commit.entityIdentifier,
|
||||
),
|
||||
),
|
||||
);
|
||||
deps.io.verbose(`Entities: ${formatObject(entities)}`);
|
||||
|
||||
const entitiesWithRoles = entities.map((entity) => {
|
||||
return {
|
||||
entityIdentifier: entity,
|
||||
roles: invitationInstance.data.commits
|
||||
.filter((commit) => commit.entityIdentifier === entity)
|
||||
.map((commit) => {
|
||||
return [
|
||||
...(commit.data.inputs?.map((input) => input.roleIdentifier) ??
|
||||
[]),
|
||||
...(commit.data.outputs?.map(
|
||||
(output) => output.roleIdentifier,
|
||||
) ?? []),
|
||||
...(commit.data.variables?.map(
|
||||
(variable) => variable.roleIdentifier,
|
||||
) ?? []),
|
||||
];
|
||||
})
|
||||
.flat()
|
||||
.filter((role) => role !== undefined),
|
||||
};
|
||||
});
|
||||
|
||||
const inputs = invitationInstance.data.commits.flatMap(
|
||||
(commit) => commit.data.inputs ?? [],
|
||||
);
|
||||
deps.io.verbose(`Inputs: ${formatObject(inputs)}`);
|
||||
|
||||
const outputs = invitationInstance.data.commits.flatMap(
|
||||
(commit) => commit.data.outputs ?? [],
|
||||
);
|
||||
deps.io.verbose(`Outputs: ${formatObject(outputs)}`);
|
||||
|
||||
const variables = invitationInstance.data.commits.flatMap(
|
||||
(commit) => commit.data.variables ?? [],
|
||||
);
|
||||
deps.io.verbose(`Variables: ${formatObject(variables)}`);
|
||||
|
||||
return {
|
||||
templateName: template?.name ?? "Unknown",
|
||||
actionIdentifier: invitationInstance.data.actionIdentifier,
|
||||
status: status,
|
||||
entities: entitiesWithRoles,
|
||||
inputs: inputs,
|
||||
outputs: outputs,
|
||||
variables: variables,
|
||||
};
|
||||
}
|
||||
|
||||
case "import": {
|
||||
const invitationFilePath = args[1];
|
||||
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
|
||||
|
||||
if (!invitationFilePath) {
|
||||
deps.io.verbose("No invitation file provided");
|
||||
printInvitationHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"invitation.import.file_missing",
|
||||
"No invitation file provided",
|
||||
);
|
||||
}
|
||||
|
||||
const invitationFile = await readFileSync(invitationFilePath, "utf8");
|
||||
deps.io.verbose(`Invitation file: ${invitationFile}`);
|
||||
const invitation = JSON.parse(invitationFile);
|
||||
deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
|
||||
const xoInvitation = await deps.app.engine.createInvitation(invitation);
|
||||
const invitationInstance = await deps.app.createInvitation(xoInvitation);
|
||||
deps.io.verbose(
|
||||
`Invitation created: ${formatObject(invitationInstance.data)}`,
|
||||
);
|
||||
return {
|
||||
invitationIdentifier: invitationInstance.data.invitationIdentifier,
|
||||
};
|
||||
}
|
||||
|
||||
case "list": {
|
||||
const invitations = await Promise.all(
|
||||
deps.app.invitations.map(async (invitation) => {
|
||||
const template = await deps.app.engine.getTemplate(
|
||||
invitation.data.templateIdentifier,
|
||||
);
|
||||
return {
|
||||
invitationIdentifier: invitation.data.invitationIdentifier,
|
||||
templateIdentifier: invitation.data.templateIdentifier,
|
||||
actionIdentifier: invitation.data.actionIdentifier,
|
||||
templateName: template?.name ?? "Unknown",
|
||||
status: invitation.status,
|
||||
roleIdentifier: "TODO: Get role identifier",
|
||||
};
|
||||
}),
|
||||
);
|
||||
deps.io.verbose(`Invitations: ${formatObject(invitations)}`);
|
||||
const formattedInvitations = invitations.map(
|
||||
(invitation) =>
|
||||
`${bold(invitation.templateName)} ${dim(invitation.status)} ${dim(invitation.invitationIdentifier)} ${dim(invitation.actionIdentifier)} (${dim(invitation.roleIdentifier)})`,
|
||||
);
|
||||
deps.io.out(formattedInvitations.join("\n"));
|
||||
return { count: invitations.length };
|
||||
}
|
||||
|
||||
default:
|
||||
deps.io.verbose(`Unknown invitation sub-command: ${subCommand}`);
|
||||
printInvitationHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"invitation.subcommand.unknown",
|
||||
`Unknown invitation sub-command: ${subCommand}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
129
src/cli/commands/mnemonic.ts
Normal file
129
src/cli/commands/mnemonic.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { bold, dim } from "../utils.js";
|
||||
import {
|
||||
listMnemonicFiles,
|
||||
createMnemonicFile,
|
||||
createMnemonicSeed,
|
||||
loadMnemonic,
|
||||
} from "../mnemonic.js";
|
||||
import type { BaseCommandDependencies, CommandIO } from "./types.js";
|
||||
import { CommandError } from "./types.js";
|
||||
|
||||
/**
|
||||
* Prints the help message for the mnemonic command
|
||||
*/
|
||||
export const printMnemonicHelp = (io: CommandIO): void => {
|
||||
io.out(
|
||||
`
|
||||
${bold("Usage:")} xo-cli mnemonic <sub-command>
|
||||
|
||||
${bold("Sub-commands:")}
|
||||
- create <mnemonic-seed> ${dim("Create a new mnemonic file")}
|
||||
- list ${dim("List all mnemonic files")}
|
||||
- import <mnemonic-seed> ${dim("Import a mnemonic seed from a file")}
|
||||
- expose <mnemonic-file> ${dim("Expose a mnemonic file")}
|
||||
|
||||
${bold("Options:")}
|
||||
-o --output <output-filename> ${dim("Output filename for the mnemonic file")}
|
||||
-h --help ${dim("Show this help message")}
|
||||
`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the mnemonic command.
|
||||
* Throws CommandError on failure, returns result data on success.
|
||||
* @param deps - The command dependencies.
|
||||
* @param args - Positional args after the command name, e.g. ["create"] or ["import", "page", "pencil", ...].
|
||||
* @param options - Parsed option flags, e.g. { output: "mnemonic.txt" }.
|
||||
*/
|
||||
export const handleMnemonicCommand = async (
|
||||
deps: BaseCommandDependencies,
|
||||
args: string[],
|
||||
options: Record<string, string>,
|
||||
): Promise<{ savedAs?: string; count?: number; mnemonic?: string }> => {
|
||||
const subCommand = args[0];
|
||||
const { mnemonicsDir } = deps.paths;
|
||||
|
||||
if (!subCommand) {
|
||||
deps.io.verbose("No sub-command provided");
|
||||
printMnemonicHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"mnemonic.subcommand.missing",
|
||||
"No sub-command provided",
|
||||
);
|
||||
}
|
||||
|
||||
switch (subCommand) {
|
||||
case "create": {
|
||||
const mnemonicSeed = createMnemonicSeed();
|
||||
const savedAs = createMnemonicFile(
|
||||
mnemonicsDir,
|
||||
mnemonicSeed,
|
||||
options["output"],
|
||||
);
|
||||
|
||||
deps.io.out(`Mnemonic file created: ${savedAs} (${mnemonicSeed})`);
|
||||
return { savedAs };
|
||||
}
|
||||
|
||||
case "import": {
|
||||
const mnemonicSeed = args.slice(1).join(" ");
|
||||
|
||||
if (!mnemonicSeed) {
|
||||
deps.io.verbose("No mnemonic seed provided");
|
||||
printMnemonicHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"mnemonic.import.seed_missing",
|
||||
"No mnemonic seed provided",
|
||||
);
|
||||
}
|
||||
|
||||
deps.io.verbose(`Mnemonic seed: ${mnemonicSeed}`);
|
||||
const savedAs = createMnemonicFile(
|
||||
mnemonicsDir,
|
||||
mnemonicSeed,
|
||||
options["output"],
|
||||
);
|
||||
deps.io.out(`Mnemonic file created: ${savedAs}`);
|
||||
return { savedAs };
|
||||
}
|
||||
|
||||
case "list": {
|
||||
const mnemonicFiles = listMnemonicFiles(mnemonicsDir);
|
||||
deps.io.out(mnemonicFiles.join("\n"));
|
||||
return { count: mnemonicFiles.length };
|
||||
}
|
||||
|
||||
case "expose": {
|
||||
const mnemonicFile = args[1];
|
||||
|
||||
if (!mnemonicFile) {
|
||||
deps.io.verbose("No mnemonic file provided");
|
||||
printMnemonicHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"mnemonic.expose.file_missing",
|
||||
"No mnemonic file provided",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const mnemonic = loadMnemonic(mnemonicsDir, mnemonicFile);
|
||||
deps.io.out(mnemonic);
|
||||
return { mnemonic };
|
||||
} catch (error) {
|
||||
throw new CommandError(
|
||||
"mnemonic.expose.file_not_found",
|
||||
`Mnemonic file not found: ${mnemonicFile}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
deps.io.err(`Unknown sub-command: ${subCommand}`);
|
||||
printMnemonicHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"mnemonic.subcommand.unknown",
|
||||
`Unknown sub-command: ${subCommand}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
93
src/cli/commands/receive.ts
Normal file
93
src/cli/commands/receive.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||
import { hexToBin, lockingBytecodeToCashAddress } from "@bitauth/libauth";
|
||||
|
||||
import { bold, dim } from "../utils.js";
|
||||
import type { CommandDependencies, CommandIO } from "./types.js";
|
||||
import { CommandError } from "./types.js";
|
||||
|
||||
import { resolveTemplate } from "../utils.js";
|
||||
|
||||
/**
|
||||
* Prints the help message for the receive command
|
||||
*/
|
||||
export const printReceiveHelp = (io: CommandIO): void => {
|
||||
io.out(
|
||||
`
|
||||
${bold("Usage:")} xo-cli receive <template-file> <output-identifier> [role-identifier]
|
||||
|
||||
${bold("Description:")}
|
||||
Generate a single-use receiving address from a template.
|
||||
|
||||
${bold("Arguments:")}
|
||||
<template-file> ${dim("Path to the template JSON file")}
|
||||
<output-identifier> ${dim("The output identifier within the template (e.g. 'receiveOutput')")}
|
||||
[role-identifier] ${dim("The role identifier (e.g. 'receiver'). Auto-selects the first role if omitted.")}
|
||||
|
||||
${bold("Options:")}
|
||||
-h --help ${dim("Show this help message")}
|
||||
`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Command which creates a single-use address/lockingScript for a given template and role.
|
||||
* Throws CommandError on failure, returns address data on success.
|
||||
*
|
||||
* @param deps - The command dependencies.
|
||||
* @param args - Positional args after the command name, e.g. ["template.json", "receiveOutput", "receiver"].
|
||||
* @param options - Parsed option flags.
|
||||
* @returns The address data.
|
||||
* @throws CommandError if the command fails.
|
||||
*/
|
||||
export const handleReceiveCommand = async (
|
||||
deps: CommandDependencies,
|
||||
args: string[],
|
||||
_options: Record<string, string>,
|
||||
): Promise<{ address: string }> => {
|
||||
const templateQuery = args[0];
|
||||
const outputIdentifier = args[1];
|
||||
const roleIdentifier = args[2];
|
||||
|
||||
deps.io.verbose(
|
||||
`Receive args - template: ${templateQuery}, output: ${outputIdentifier}, role: ${roleIdentifier}`,
|
||||
);
|
||||
|
||||
if (!templateQuery || !outputIdentifier) {
|
||||
deps.io.verbose("Missing required arguments");
|
||||
printReceiveHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"receive.arguments.missing",
|
||||
"Missing required arguments",
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve and read the template file
|
||||
const template = await resolveTemplate(deps, templateQuery);
|
||||
const templateIdentifier = generateTemplateIdentifier(template);
|
||||
deps.io.verbose(`Template identifier: ${templateIdentifier}`);
|
||||
|
||||
// Generate the locking bytecode (returned as a hex string)
|
||||
const lockingBytecodeHex = await deps.app.engine.generateLockingBytecode(
|
||||
templateIdentifier,
|
||||
outputIdentifier,
|
||||
roleIdentifier,
|
||||
);
|
||||
deps.io.verbose(`Locking bytecode hex: ${lockingBytecodeHex}`);
|
||||
|
||||
// Convert the locking bytecode to a BCH cash address
|
||||
const result = lockingBytecodeToCashAddress({
|
||||
bytecode: hexToBin(lockingBytecodeHex),
|
||||
prefix: "bitcoincash",
|
||||
});
|
||||
|
||||
if (typeof result === "string") {
|
||||
deps.io.err(`Failed to encode address: ${result}`);
|
||||
throw new CommandError(
|
||||
"receive.address.encode_failed",
|
||||
`Failed to encode address: ${result}`,
|
||||
);
|
||||
}
|
||||
|
||||
deps.io.out(result.address);
|
||||
return { address: result.address };
|
||||
};
|
||||
184
src/cli/commands/resource.ts
Normal file
184
src/cli/commands/resource.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { hexToBin } from "@bitauth/libauth";
|
||||
|
||||
import { bold, dim } from "../utils.js";
|
||||
import type { CommandDependencies, CommandIO } from "./types.js";
|
||||
import type { UnspentOutputData } from "@xo-cash/state";
|
||||
import { CommandError } from "./types.js";
|
||||
|
||||
/**
|
||||
* Prints the help message for the resource command.
|
||||
*/
|
||||
export const printResourceHelp = (io: CommandIO): void => {
|
||||
io.out(
|
||||
`
|
||||
${bold("Usage:")} xo-cli resource <sub-command>
|
||||
|
||||
${bold("Sub-commands:")}
|
||||
- list ${dim("List all unreserved resources")}
|
||||
- list reserved ${dim("List reserved resources")}
|
||||
- list all ${dim("List all resources (reserved + unreserved)")}
|
||||
- unreserve <txhash:vout> ${dim("Unreserve a specific UTXO")}
|
||||
- unreserve-all ${dim("Unreserve all reserved UTXOs")}
|
||||
`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a single UTXO for display, optionally including reservation info.
|
||||
*/
|
||||
function formatResource(
|
||||
resource: UnspentOutputData,
|
||||
showReserved = false,
|
||||
): string {
|
||||
const outpoint = bold(
|
||||
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
|
||||
);
|
||||
const value = dim(`${resource.valueSatoshis} sats`);
|
||||
const output = dim(resource.outputIdentifier);
|
||||
const height = dim(`(height ${resource.minedAtHeight})`);
|
||||
|
||||
if (showReserved && resource.reservedBy) {
|
||||
const inv = dim(`reserved for ${resource.reservedBy}`);
|
||||
return `${outpoint} ${value} ${output} ${height} ${inv}`;
|
||||
}
|
||||
|
||||
return `${outpoint} ${value} ${output} ${height}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the resource command.
|
||||
* Throws CommandError on failure, returns result data on success.
|
||||
* @param deps - The command dependencies.
|
||||
* @param args - Positional args after the command name, e.g. ["list"].
|
||||
* @param options - Parsed option flags.
|
||||
*/
|
||||
export const handleResourceCommand = async (
|
||||
deps: CommandDependencies,
|
||||
args: string[],
|
||||
_options: Record<string, string>,
|
||||
): Promise<{ count?: number }> => {
|
||||
const subCommand = args[0];
|
||||
deps.io.verbose(`Resource sub-command: ${subCommand}`);
|
||||
|
||||
if (!subCommand) {
|
||||
deps.io.verbose("No sub-command provided");
|
||||
printResourceHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"resource.subcommand.missing",
|
||||
"No sub-command provided",
|
||||
);
|
||||
}
|
||||
|
||||
switch (subCommand) {
|
||||
case "list": {
|
||||
const qualifier = args[1];
|
||||
const allResources = await deps.app.engine.listUnspentOutputsData();
|
||||
|
||||
let filtered;
|
||||
if (qualifier === "reserved") {
|
||||
filtered = allResources.filter((r) => r.reservedBy);
|
||||
} else if (qualifier === "all") {
|
||||
filtered = allResources;
|
||||
} else {
|
||||
filtered = allResources.filter((r) => !r.reservedBy);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
deps.io.out(dim("No resources found."));
|
||||
return { count: 0 };
|
||||
}
|
||||
|
||||
const showReserved = qualifier === "all" || qualifier === "reserved";
|
||||
const formattedResources = filtered.map((r) =>
|
||||
formatResource(r, showReserved),
|
||||
);
|
||||
deps.io.out(formattedResources.join("\n"));
|
||||
deps.io.out(
|
||||
`Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`,
|
||||
);
|
||||
deps.io.out(`Total resources: ${filtered.length}`);
|
||||
return { count: filtered.length };
|
||||
}
|
||||
|
||||
case "unreserve": {
|
||||
const outpointArg = args[1];
|
||||
if (!outpointArg) {
|
||||
deps.io.err("Please provide a UTXO in <txhash>:<vout> format.");
|
||||
printResourceHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"resource.unreserve.outpoint_missing",
|
||||
"Please provide a UTXO in <txhash>:<vout> format.",
|
||||
);
|
||||
}
|
||||
|
||||
const separatorIndex = outpointArg.lastIndexOf(":");
|
||||
if (separatorIndex === -1) {
|
||||
deps.io.err(
|
||||
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
|
||||
);
|
||||
throw new CommandError(
|
||||
"resource.unreserve.outpoint_invalid",
|
||||
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
|
||||
);
|
||||
}
|
||||
|
||||
const txHash = outpointArg.substring(0, separatorIndex);
|
||||
const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10);
|
||||
if (!txHash || isNaN(vout)) {
|
||||
deps.io.err(
|
||||
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
|
||||
);
|
||||
throw new CommandError(
|
||||
"resource.unreserve.outpoint_invalid",
|
||||
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
|
||||
);
|
||||
}
|
||||
|
||||
const allResources = await deps.app.engine.listUnspentOutputsData();
|
||||
const target = allResources.find(
|
||||
(r) => r.outpointTransactionHash === txHash && r.outpointIndex === vout,
|
||||
);
|
||||
|
||||
if (!target) {
|
||||
deps.io.err(`UTXO not found: ${txHash}:${vout}`);
|
||||
throw new CommandError(
|
||||
"resource.unreserve.utxo_missing",
|
||||
`UTXO not found: ${txHash}:${vout}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!target.reservedBy) {
|
||||
deps.io.out(dim("UTXO is not reserved. Nothing to do."));
|
||||
return {};
|
||||
}
|
||||
|
||||
await deps.app.engine.unreserveResources(
|
||||
[{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }],
|
||||
target.reservedBy,
|
||||
);
|
||||
deps.io.out(
|
||||
`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
case "unreserve-all": {
|
||||
const count = await deps.app.unreserveAllResources();
|
||||
if (count === 0) {
|
||||
deps.io.out(dim("No reserved resources to unreserve."));
|
||||
} else {
|
||||
deps.io.out(`Unreserved ${bold(String(count))} resource(s).`);
|
||||
}
|
||||
return { count };
|
||||
}
|
||||
|
||||
default: {
|
||||
deps.io.verbose(`Unknown resource sub-command: ${subCommand}`);
|
||||
printResourceHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"resource.subcommand.unknown",
|
||||
`Unknown resource sub-command: ${subCommand}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
353
src/cli/commands/template.ts
Normal file
353
src/cli/commands/template.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||
import type { XOTemplate } from "@xo-cash/types";
|
||||
|
||||
import { bold, dim, formatObject } from "../utils.js";
|
||||
import { resolveTemplateReferences } from "../../utils/templates.js";
|
||||
import type { CommandDependencies, CommandIO } from "./types.js";
|
||||
import { CommandError } from "./types.js";
|
||||
import { resolveTemplate } from "../utils.js";
|
||||
|
||||
/**
|
||||
* Prints the help message for the template command
|
||||
*/
|
||||
export const printTemplateHelp = (io: CommandIO): void => {
|
||||
io.out(
|
||||
`
|
||||
${bold("Usage:")} xo-cli template <sub-command>
|
||||
|
||||
${bold("Sub-commands:")}
|
||||
- import <template-file> ${dim("Import a template from a file")}
|
||||
- list ${dim("List all templates")}
|
||||
- list <category> <identifier> ${dim("List all options of the field type in a template")}
|
||||
- inspect <category> <identifier> <field> ${dim("Inspect a field in a template")}
|
||||
- set-default <template-file> <output-identifier> <role-identifier> ${dim("Set the default template")}
|
||||
`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the template list command.
|
||||
* Throws CommandError on failure, returns result data on success.
|
||||
* @param deps - The command dependencies.
|
||||
* @param args - Positional args after the command name, e.g. ["list", "action"] or ["list", "action", "1234567890"].
|
||||
*/
|
||||
export const handleTemplateListCommand = async (
|
||||
deps: CommandDependencies,
|
||||
args: string[],
|
||||
): Promise<{ count?: number }> => {
|
||||
const templateCategory = args[0];
|
||||
deps.io.verbose(`Template list category: ${templateCategory}`);
|
||||
|
||||
if (!templateCategory) {
|
||||
const templates = await deps.app.engine.listImportedTemplates();
|
||||
const formattedTemplates = templates.map(
|
||||
(template: XOTemplate) =>
|
||||
`${bold(generateTemplateIdentifier(template))} - ${dim(template.name)} ${dim(template.description)}`,
|
||||
);
|
||||
deps.io.out(formattedTemplates.join("\n"));
|
||||
return { count: templates.length };
|
||||
}
|
||||
|
||||
const templateIdentifier = args[1];
|
||||
deps.io.verbose(`Template identifier: ${templateIdentifier}`);
|
||||
|
||||
if (!templateIdentifier) {
|
||||
deps.io.err("No template identifier provided");
|
||||
throw new CommandError(
|
||||
"template.list.identifier_missing",
|
||||
"No template identifier provided",
|
||||
);
|
||||
}
|
||||
|
||||
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
|
||||
if (!rawTemplate) {
|
||||
deps.io.err(`No template found: ${templateIdentifier}`);
|
||||
throw new CommandError(
|
||||
"template.list.not_found",
|
||||
`No template found: ${templateIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
const template = await resolveTemplateReferences(rawTemplate);
|
||||
deps.io.verbose(`Template: ${formatObject(template)}`);
|
||||
|
||||
switch (templateCategory) {
|
||||
case "action": {
|
||||
const actions = template.actions;
|
||||
const formattedActions = Object.entries(actions).map(
|
||||
([actionIdentifier, action]) =>
|
||||
`${bold(actionIdentifier)} ${dim(action.name)} ${dim(action.description)}`,
|
||||
);
|
||||
deps.io.out(formattedActions.join("\n"));
|
||||
return {};
|
||||
}
|
||||
case "transaction": {
|
||||
const transactions = template.transactions;
|
||||
const formattedTransactions = Object.entries(transactions).map(
|
||||
([transactionIdentifier, transaction]) =>
|
||||
`${bold(transactionIdentifier)} ${dim(transaction.name)} ${dim(transaction.description)}`,
|
||||
);
|
||||
deps.io.out(formattedTransactions.join("\n"));
|
||||
return {};
|
||||
}
|
||||
case "output": {
|
||||
const outputs = template.outputs;
|
||||
const formattedOutputs = Object.entries(outputs).map(
|
||||
([outputIdentifier, output]) =>
|
||||
`${bold(outputIdentifier)} ${dim(output.name)} ${dim(output.description)}`,
|
||||
);
|
||||
deps.io.out(formattedOutputs.join("\n"));
|
||||
return {};
|
||||
}
|
||||
case "lockingscript": {
|
||||
const lockingscripts = template.lockingScripts;
|
||||
const formattedLockingscripts = Object.entries(lockingscripts).map(
|
||||
([lockingScriptIdentifier, lockingScript]) =>
|
||||
`${bold(lockingScriptIdentifier)} ${dim(lockingScript.name)} ${dim(lockingScript.description)}`,
|
||||
);
|
||||
deps.io.out(formattedLockingscripts.join("\n"));
|
||||
return {};
|
||||
}
|
||||
case "variable": {
|
||||
const variables = template.variables || {};
|
||||
const formattedVariables = Object.entries(variables).map(
|
||||
([variableIdentifier, variable]) =>
|
||||
`${bold(variableIdentifier)} ${dim(variable.name)} ${dim(variable.description)}`,
|
||||
);
|
||||
deps.io.out(formattedVariables.join("\n"));
|
||||
return {};
|
||||
}
|
||||
default: {
|
||||
deps.io.verbose(`Unknown template category: ${templateCategory}`);
|
||||
throw new CommandError(
|
||||
"template.list.category_unknown",
|
||||
`Unknown template category: ${templateCategory}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Prints the help message for the template inspect command
|
||||
*/
|
||||
export const printTemplateInspectHelp = (io: CommandIO): void => {
|
||||
io.out(
|
||||
`
|
||||
${bold("Usage:")} xo-cli template inspect <category> <identifier> <field>
|
||||
|
||||
${bold("Arguments:")}
|
||||
<category> ${dim("The category of the template to inspect")}
|
||||
<identifier> ${dim("The identifier of the template to inspect")}
|
||||
<field> ${dim("The field of the template to inspect")}
|
||||
|
||||
${bold("Categories:")}
|
||||
- action <action-identifier> ${dim("Inspect an action")}
|
||||
- transaction <transaction-identifier> ${dim("Inspect a transaction")}
|
||||
- output <output-identifier> ${dim("Inspect an output")}
|
||||
- lockingscript <lockingscript-identifier> ${dim("Inspect a lockingscript")}
|
||||
- variable <variable-identifier> ${dim("Inspect a variable")}
|
||||
`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the template inspect command.
|
||||
* Throws CommandError on failure, returns empty object on success.
|
||||
* @param deps - The command dependencies.
|
||||
* @param args - Positional args after the command name, e.g. ["inspect", "transaction", "1234567890"].
|
||||
*/
|
||||
export const handleTemplateInspectCommand = async (
|
||||
deps: CommandDependencies,
|
||||
args: string[],
|
||||
): Promise<Record<string, never>> => {
|
||||
const templateCategory = args[0];
|
||||
const templateQuery = args[1];
|
||||
const templateField = args[2];
|
||||
|
||||
deps.io.verbose(
|
||||
`Template inspect args - category: ${templateCategory}, identifier: ${templateQuery}, field: ${templateField}`,
|
||||
);
|
||||
|
||||
if (!templateCategory || !templateQuery || !templateField) {
|
||||
deps.io.err("No template category, identifier, or field provided");
|
||||
printTemplateInspectHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"template.inspect.arguments_missing",
|
||||
"No template category, identifier, or field provided",
|
||||
);
|
||||
}
|
||||
|
||||
const originalTemplate = await resolveTemplate(deps, templateQuery);
|
||||
deps.io.verbose(`Original Template: ${formatObject(originalTemplate)}`);
|
||||
|
||||
const template = await resolveTemplateReferences(originalTemplate);
|
||||
deps.io.verbose(`Extended Template: ${formatObject(template)}`);
|
||||
|
||||
switch (templateCategory) {
|
||||
case "action": {
|
||||
const action = template.actions[templateField];
|
||||
if (!action) {
|
||||
deps.io.err(`No action found: ${templateField}`);
|
||||
throw new CommandError(
|
||||
"template.inspect.action_missing",
|
||||
`No action found: ${templateField}`,
|
||||
);
|
||||
}
|
||||
deps.io.out(formatObject(action));
|
||||
return {};
|
||||
}
|
||||
case "transaction": {
|
||||
const transaction = template.transactions?.[templateField];
|
||||
if (!transaction) {
|
||||
deps.io.err(`No transaction found: ${templateField}`);
|
||||
throw new CommandError(
|
||||
"template.inspect.transaction_missing",
|
||||
`No transaction found: ${templateField}`,
|
||||
);
|
||||
}
|
||||
deps.io.out(formatObject(transaction));
|
||||
return {};
|
||||
}
|
||||
case "output": {
|
||||
const output = template.outputs[templateField];
|
||||
if (!output) {
|
||||
deps.io.err(`No output found: ${templateField}`);
|
||||
throw new CommandError(
|
||||
"template.inspect.output_missing",
|
||||
`No output found: ${templateField}`,
|
||||
);
|
||||
}
|
||||
deps.io.out(formatObject(output));
|
||||
return {};
|
||||
}
|
||||
case "lockingscript": {
|
||||
const lockingscript = template.lockingScripts[templateField];
|
||||
if (!lockingscript) {
|
||||
deps.io.err(`No lockingscript found: ${templateField}`);
|
||||
throw new CommandError(
|
||||
"template.inspect.lockingscript_missing",
|
||||
`No lockingscript found: ${templateField}`,
|
||||
);
|
||||
}
|
||||
deps.io.out(formatObject(lockingscript));
|
||||
return {};
|
||||
}
|
||||
case "variable": {
|
||||
const variable = template.variables?.[templateField];
|
||||
if (!variable) {
|
||||
deps.io.err(`No variable found: ${templateField}`);
|
||||
throw new CommandError(
|
||||
"template.inspect.variable_missing",
|
||||
`No variable found: ${templateField}`,
|
||||
);
|
||||
}
|
||||
deps.io.out(formatObject(variable));
|
||||
return {};
|
||||
}
|
||||
default: {
|
||||
deps.io.verbose(`Unknown template category: ${templateCategory}`);
|
||||
throw new CommandError(
|
||||
"template.inspect.category_unknown",
|
||||
`Unknown template category: ${templateCategory}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the template command.
|
||||
* Throws CommandError on failure, returns result data on success.
|
||||
* @param deps - The command dependencies.
|
||||
* @param args - Positional args after the command name, e.g. ["import", "template.json"] or ["set-default", "tpl", "out", "role"].
|
||||
* @param options - Parsed option flags.
|
||||
*/
|
||||
export const handleTemplateCommand = async (
|
||||
deps: CommandDependencies,
|
||||
args: string[],
|
||||
_options: Record<string, string>,
|
||||
): Promise<{ templateFile?: string; count?: number }> => {
|
||||
const subCommand = args[0];
|
||||
|
||||
if (!subCommand) {
|
||||
deps.io.verbose("No sub-command provided");
|
||||
printTemplateHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"template.subcommand.missing",
|
||||
"No sub-command provided",
|
||||
);
|
||||
}
|
||||
|
||||
switch (subCommand) {
|
||||
case "import": {
|
||||
const templateFile = args[1];
|
||||
deps.io.verbose(`Template file: ${templateFile}`);
|
||||
|
||||
if (!templateFile) {
|
||||
deps.io.verbose("No template file provided");
|
||||
printTemplateHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"template.import.file_missing",
|
||||
"No template file provided",
|
||||
);
|
||||
}
|
||||
|
||||
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`);
|
||||
deps.io.verbose(`Template path: ${templatePath}`);
|
||||
|
||||
if (!existsSync(templatePath)) {
|
||||
deps.io.err(`Template file does not exist: ${templatePath}`);
|
||||
printTemplateHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"template.import.file_not_found",
|
||||
`Template file does not exist: ${templatePath}`,
|
||||
);
|
||||
}
|
||||
|
||||
const template = await readFileSync(templatePath, "utf8");
|
||||
|
||||
deps.io.verbose(`Importing template: ${templateFile}`);
|
||||
await deps.app.engine.importTemplate(template);
|
||||
deps.io.verbose(`Template imported: ${templateFile}`);
|
||||
return { templateFile };
|
||||
}
|
||||
case "list": {
|
||||
return handleTemplateListCommand(deps, args.slice(1));
|
||||
}
|
||||
case "inspect": {
|
||||
return handleTemplateInspectCommand(deps, args.slice(1));
|
||||
}
|
||||
case "set-default": {
|
||||
const templateFile = args[1];
|
||||
const outputIdentifier = args[2];
|
||||
const roleIdentifier = args[3];
|
||||
if (!templateFile || !outputIdentifier || !roleIdentifier) {
|
||||
deps.io.verbose(
|
||||
"No template file, output identifier, or role identifier provided",
|
||||
);
|
||||
printTemplateHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"template.default.arguments_missing",
|
||||
"No template file, output identifier, or role identifier provided",
|
||||
);
|
||||
}
|
||||
deps.io.verbose(
|
||||
`Template file: ${templateFile}, output identifier: ${outputIdentifier}, role identifier: ${roleIdentifier}`,
|
||||
);
|
||||
await deps.app.engine.setDefaultLockingParameters(
|
||||
templateFile,
|
||||
outputIdentifier,
|
||||
roleIdentifier,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
default:
|
||||
deps.io.verbose(`Unknown template sub-command: ${subCommand}`);
|
||||
printTemplateHelp(deps.io);
|
||||
throw new CommandError(
|
||||
"template.subcommand.unknown",
|
||||
`Unknown template sub-command: ${subCommand}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
59
src/cli/commands/types.ts
Normal file
59
src/cli/commands/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { AppService } from "../../services/app.js";
|
||||
|
||||
/**
|
||||
* IO contract for CLI command handlers.
|
||||
* Handlers write user-visible output through this abstraction so unit tests can
|
||||
* assert behavior without spying on global console methods.
|
||||
*/
|
||||
export type CommandIO = {
|
||||
out: (message: string) => void;
|
||||
err: (message: string) => void;
|
||||
verbose: (message: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Paths configuration for CLI commands.
|
||||
* Allows injection of custom paths for testing.
|
||||
*/
|
||||
export type CommandPaths = {
|
||||
/** Directory for mnemonic wallet files */
|
||||
mnemonicsDir: string;
|
||||
/** Directory for engine DB and invitation storage files */
|
||||
dataDir: string;
|
||||
/** File storing the last-used mnemonic reference */
|
||||
walletConfigPath: string;
|
||||
/** Working directory for file output (invitation files, etc.) */
|
||||
workingDir: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Base dependencies available to every command handler.
|
||||
*/
|
||||
export type BaseCommandDependencies = {
|
||||
io: CommandIO;
|
||||
paths: CommandPaths;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dependencies for app-backed commands.
|
||||
*/
|
||||
export type CommandDependencies = BaseCommandDependencies & {
|
||||
app: AppService;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom error class for command failures.
|
||||
* Thrown by command handlers when an operation fails.
|
||||
* The `event` property can be used for telemetry/testing to identify failure types.
|
||||
*/
|
||||
export class CommandError extends Error {
|
||||
public readonly event: string;
|
||||
public readonly code: number;
|
||||
|
||||
constructor(event: string, message: string, code = 1) {
|
||||
super(message);
|
||||
this.name = "CommandError";
|
||||
this.event = event;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
272
src/cli/index.ts
Normal file
272
src/cli/index.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CLI entry point.
|
||||
*
|
||||
* TODO: Decide the best way to handle CLI arguments. We have the option of:
|
||||
* - Handling it in the `bin` folder
|
||||
* - Switch / if statements in here
|
||||
* - Dedicated command parser
|
||||
* - Separate files?
|
||||
*
|
||||
* What kind of commands do we want to support?
|
||||
* Worth noting that we shouldn't need to list invitations? Maybe we will though? If we do, then we will need to reuse the storage + xo-invitations.db file. I think this is fine to do though?
|
||||
* Nah, lets use the storage + xo-invitations.db file. Will allow us to persist invitations.
|
||||
* How do we want to import invitations though? Should we just take in the ID still? Probably makes more sense to allow for reading from a file though...
|
||||
* But thats an entirely different flow to what we have already. And how would we handle writing the invitation? Do we just overwrite the file? Probably... Just take in an -o option; default to overwrite?
|
||||
*
|
||||
* Commands:
|
||||
* xo-cli mnemonic create [mnemonic seed]
|
||||
* xo-cli mnemonic list
|
||||
*
|
||||
* xo-cli template import <template-file>
|
||||
* xo-cli template list
|
||||
* xo-cli template set-default <template-file> <output-identifier> <role-identifier>
|
||||
*
|
||||
* xo-cli invitation list
|
||||
* xo-cli invitation create <template-file> <action-id> [-o Output file, var-${action-variable-name}=${value}, role=${value}]
|
||||
* xo-cli invitation import <invitation-file>
|
||||
* xo-cli invitation sign <invitation-file>
|
||||
* xo-cli invitation broadcast <invitation-file>
|
||||
*
|
||||
* xo-cli resource list
|
||||
*
|
||||
* universal Args:
|
||||
* -h --help
|
||||
* -m --mnemonic-file <mnemonic-file>
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
import { AppService } from "../services/app.js";
|
||||
import { convertArgsToObject } from "./arguments.js";
|
||||
import { bold, dim, formatObject } from "./utils.js";
|
||||
import { listGlobalMnemonicFiles, loadMnemonic } from "./mnemonic.js";
|
||||
import {
|
||||
getDataDir,
|
||||
getMnemonicsDir,
|
||||
getWalletConfigPath,
|
||||
} from "../utils/paths.js";
|
||||
|
||||
import {
|
||||
type CommandDependencies,
|
||||
type CommandIO,
|
||||
type CommandPaths,
|
||||
CommandError,
|
||||
handleMnemonicCommand,
|
||||
handleTemplateCommand,
|
||||
handleInvitationCommand,
|
||||
handleReceiveCommand,
|
||||
handleResourceCommand,
|
||||
} from "./commands/index.js";
|
||||
|
||||
import { handleCompletionsCommand } from "./autocomplete/completions.js";
|
||||
|
||||
const createCommandIO = (verbose: boolean): CommandIO => ({
|
||||
out: (message: string) => {
|
||||
console.log(message);
|
||||
},
|
||||
err: (message: string) => {
|
||||
console.error(message);
|
||||
},
|
||||
verbose: (message: string) => {
|
||||
if (verbose) console.log(message);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Main entry point.
|
||||
* We will:
|
||||
* - Initialize the app service?
|
||||
* - Extract the command being called
|
||||
* - Extract CLI Args (Depends on the command being called. Eww. But we can probably use Zod to validate the args in a decent way?)
|
||||
* - Execute the command
|
||||
* - Export if configured?
|
||||
* - Exit with the appropriate code
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
// Initialize the app service
|
||||
// NOTE: We are going to assume that they are using a mnemonic file for now
|
||||
const { args, options } = convertArgsToObject(process.argv.slice(2));
|
||||
|
||||
// Create a verbose logger if the user set the verbose flag
|
||||
const io = createCommandIO(options["verbose"] === "true");
|
||||
|
||||
// Log the parsed app args
|
||||
io.verbose(`Parsed args: ${formatObject(args)}`);
|
||||
io.verbose(`Parsed options: ${formatObject(options)}`);
|
||||
|
||||
// Handle the command
|
||||
const command = args[0];
|
||||
io.verbose(`Command: ${command}`);
|
||||
if (!command) {
|
||||
// TODO: Print help, probably...
|
||||
io.err("No command provided");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Positional args after the command name (sub-command, files, etc.)
|
||||
const subArgs = args.slice(1);
|
||||
|
||||
// Build paths object from global path functions
|
||||
const paths: CommandPaths = {
|
||||
mnemonicsDir: getMnemonicsDir(),
|
||||
dataDir: getDataDir(),
|
||||
walletConfigPath: getWalletConfigPath(),
|
||||
workingDir: process.cwd(),
|
||||
};
|
||||
|
||||
// Early handling for completions command
|
||||
if (command === "completions") {
|
||||
handleCompletionsCommand(subArgs, options);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "mnemonic") {
|
||||
try {
|
||||
await handleMnemonicCommand({ io, paths }, subArgs, options);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
if (error instanceof CommandError) {
|
||||
process.exit(error.code);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve mnemonic file: explicit flag > persisted config > error.
|
||||
let mnemonicFile = options["mnemonicFile"];
|
||||
if (!mnemonicFile && existsSync(paths.walletConfigPath)) {
|
||||
mnemonicFile = readFileSync(paths.walletConfigPath, "utf8").trim();
|
||||
io.verbose(`Using persisted wallet: ${mnemonicFile}`);
|
||||
}
|
||||
if (!mnemonicFile) {
|
||||
io.err("No mnemonic file provided");
|
||||
io.out(
|
||||
`You can create a mnemonic file with the following command: xo-cli mnemonic create <mnemonic-seed> or use one of the following files: \n${listGlobalMnemonicFiles().join("\n")}`,
|
||||
);
|
||||
io.out(
|
||||
`\nTip: pass -m <file> once and it will be remembered in ${paths.walletConfigPath}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Persist the choice so subsequent commands can omit -m.
|
||||
writeFileSync(paths.walletConfigPath, mnemonicFile);
|
||||
|
||||
const mnemonic = loadMnemonic(paths.mnemonicsDir, mnemonicFile);
|
||||
io.verbose(`Loaded mnemonic from file: ${mnemonicFile} ${mnemonic}`);
|
||||
|
||||
// Create an App instance
|
||||
io.verbose("Creating app instance...");
|
||||
const app = await AppService.create(mnemonic, {
|
||||
syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000",
|
||||
engineConfig: {
|
||||
databasePath: options["databasePath"] ?? paths.dataDir,
|
||||
databaseFilename: options["databaseFilename"] ?? "xo-wallet.db",
|
||||
},
|
||||
invitationStoragePath:
|
||||
options["invitationStoragePath"] ??
|
||||
join(paths.dataDir, "xo-invitations.db"),
|
||||
});
|
||||
io.verbose("App instance created");
|
||||
|
||||
// Start the app
|
||||
// TODO: Rethink this. Do we really want to start the app here? It just slows it down if we dont actually have to have it started for the command
|
||||
io.verbose("Starting app...");
|
||||
await app.start();
|
||||
io.verbose("App started");
|
||||
|
||||
const commandDependencies: CommandDependencies = {
|
||||
io,
|
||||
paths,
|
||||
app,
|
||||
};
|
||||
|
||||
// Handle the command
|
||||
try {
|
||||
let result: unknown;
|
||||
switch (command) {
|
||||
case "template":
|
||||
result = await handleTemplateCommand(
|
||||
commandDependencies,
|
||||
subArgs,
|
||||
options,
|
||||
);
|
||||
break;
|
||||
case "invitation":
|
||||
result = await handleInvitationCommand(
|
||||
commandDependencies,
|
||||
subArgs,
|
||||
options,
|
||||
);
|
||||
break;
|
||||
case "receive":
|
||||
result = await handleReceiveCommand(
|
||||
commandDependencies,
|
||||
subArgs,
|
||||
options,
|
||||
);
|
||||
break;
|
||||
case "resource":
|
||||
result = await handleResourceCommand(
|
||||
commandDependencies,
|
||||
subArgs,
|
||||
options,
|
||||
);
|
||||
break;
|
||||
case "help":
|
||||
result = await handleHelpCommand(commandDependencies, subArgs, options);
|
||||
break;
|
||||
default:
|
||||
io.err(`Unknown command: ${command}`);
|
||||
throw new CommandError(
|
||||
"cli.command.unknown",
|
||||
`Unknown command: ${command}`,
|
||||
);
|
||||
}
|
||||
|
||||
// console.log(result);
|
||||
|
||||
// objectPrint(result);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
if (error instanceof CommandError) {
|
||||
io.err(error.message);
|
||||
process.exit(error.code);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const handleHelpCommand = async (
|
||||
deps: CommandDependencies,
|
||||
_args: string[],
|
||||
_options: Record<string, string>,
|
||||
): Promise<Record<string, never>> => {
|
||||
deps.io.out(
|
||||
`${bold("XO-CLI Help:")}
|
||||
|
||||
${bold("Usage:")} xo-cli <command> [options]
|
||||
|
||||
Commands:
|
||||
mnemonic ${dim("Manage mnemonic files")}
|
||||
template ${dim("Manage templates")}
|
||||
invitation ${dim("Manage invitations")}
|
||||
receive ${dim("Generate a single-use receiving address")}
|
||||
resource ${dim("Manage resources")}
|
||||
completions ${dim("Generate shell completion scripts (bash, zsh, fish)")}
|
||||
help ${dim("Show this help message")}
|
||||
|
||||
Options:
|
||||
-h, --help ${dim("Show this help message")}
|
||||
-m, --mnemonic-file <mnemonic-file> ${dim("Use a specific mnemonic file")}
|
||||
-v, --verbose ${dim("Show verbose output")}`,
|
||||
);
|
||||
return {};
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
123
src/cli/mnemonic.ts
Normal file
123
src/cli/mnemonic.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, isAbsolute, join, resolve } from "node:path";
|
||||
import { encodeBip39Mnemonic, generateBip39Mnemonic } from "@bitauth/libauth";
|
||||
|
||||
import { BCHMnemonicURL } from "../utils/bch-mnemonic-url.js";
|
||||
import { getMnemonicsDir as getGlobalMnemonicsDir } from "../utils/paths.js";
|
||||
|
||||
/**
|
||||
* Create a new mnemonic seed phrase
|
||||
*/
|
||||
export const createMnemonicSeed = (): string => {
|
||||
return generateBip39Mnemonic();
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a mnemonic file from a mnemonic seed
|
||||
* @param mnemonicsDir - Directory to store mnemonic files
|
||||
* @param mnemonic - The mnemonic seed
|
||||
* @param outputFilename - The filename to write the mnemonic to. If not provided, the first word from the mnemonic will be used as the filename
|
||||
* @returns The filename of the created mnemonic file
|
||||
*/
|
||||
export const createMnemonicFile = (
|
||||
mnemonicsDir: string,
|
||||
mnemonic: string,
|
||||
outputFilename?: string,
|
||||
): string => {
|
||||
const mnemonicUrl = BCHMnemonicURL.fromSeed(mnemonic);
|
||||
|
||||
let fileName = outputFilename;
|
||||
if (!fileName) {
|
||||
const firstWord = mnemonic.split(" ")[0]?.toLowerCase();
|
||||
if (!firstWord) {
|
||||
throw new Error(
|
||||
"Failed to create mnemonic file: Unable to extract first word from the mnemonic",
|
||||
);
|
||||
}
|
||||
fileName = `mnemonic-${firstWord}`;
|
||||
}
|
||||
|
||||
const safeName = basename(fileName);
|
||||
const outPath = join(mnemonicsDir, safeName);
|
||||
writeFileSync(outPath, mnemonicUrl.toURL());
|
||||
|
||||
return safeName;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a mnemonic reference to an absolute path.
|
||||
* Order: absolute path if it exists → path relative to cwd → mnemonicsDir/<basename>.
|
||||
*
|
||||
* @param mnemonicsDir - Directory containing mnemonic files
|
||||
* @param mnemonicRef - Path or basename (e.g. `mnemonic-nuclear`)
|
||||
* @returns Absolute path to the mnemonic file
|
||||
* @throws If no matching file exists
|
||||
*/
|
||||
export const resolveMnemonicFilePath = (
|
||||
mnemonicsDir: string,
|
||||
mnemonicRef: string,
|
||||
): string => {
|
||||
if (isAbsolute(mnemonicRef) && existsSync(mnemonicRef)) {
|
||||
return mnemonicRef;
|
||||
}
|
||||
|
||||
const relativeToCwd = resolve(process.cwd(), mnemonicRef);
|
||||
if (existsSync(relativeToCwd)) {
|
||||
return relativeToCwd;
|
||||
}
|
||||
|
||||
const inMnemonics = join(mnemonicsDir, basename(mnemonicRef));
|
||||
if (existsSync(inMnemonics)) {
|
||||
return inMnemonics;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Mnemonic file not found: ${mnemonicRef}. Run "xo-cli mnemonic list" to see available files.`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads a mnemonic from a mnemonic file
|
||||
* @param mnemonicsDir - Directory containing mnemonic files
|
||||
* @param mnemonicFile - The filename of the mnemonic file
|
||||
* @returns The mnemonic seed
|
||||
*/
|
||||
export const loadMnemonic = (
|
||||
mnemonicsDir: string,
|
||||
mnemonicFile: string,
|
||||
): string => {
|
||||
const resolvedPath = resolveMnemonicFilePath(mnemonicsDir, mnemonicFile);
|
||||
const mnemonicUrl = BCHMnemonicURL.fromURL(
|
||||
readFileSync(resolvedPath, "utf8"),
|
||||
);
|
||||
const { entropy } = mnemonicUrl.toObject();
|
||||
|
||||
const mnemonic = encodeBip39Mnemonic(entropy);
|
||||
|
||||
if (typeof mnemonic === "string") {
|
||||
throw new Error(`Failed to convert entropy to mnemonic: ${mnemonic}`);
|
||||
}
|
||||
|
||||
return mnemonic.phrase;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists mnemonic files in the given directory.
|
||||
* @param mnemonicsDir - Directory containing mnemonic files
|
||||
* @returns Basenames suitable for `-m <name>`
|
||||
*/
|
||||
export const listMnemonicFiles = (mnemonicsDir: string): string[] => {
|
||||
const filenames = readdirSync(mnemonicsDir).filter((f: string) =>
|
||||
f.startsWith("mnemonic-"),
|
||||
);
|
||||
return filenames;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists mnemonic files using the global mnemonics directory.
|
||||
* Convenience function for use in the CLI entry point before paths are resolved.
|
||||
* @returns Basenames suitable for `-m <name>`
|
||||
*/
|
||||
export const listGlobalMnemonicFiles = (): string[] => {
|
||||
return listMnemonicFiles(getGlobalMnemonicsDir());
|
||||
};
|
||||
106
src/cli/utils.ts
Normal file
106
src/cli/utils.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import util from "node:util";
|
||||
|
||||
import type { XOTemplate } from "@xo-cash/types";
|
||||
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||
|
||||
import type { CommandDependencies } from "./commands/types.js";
|
||||
import { CommandError } from "./commands/types.js";
|
||||
|
||||
/**
|
||||
* Iterate through the templates, trying to match the id or the name with the given input.
|
||||
* Use multiple for-loops.
|
||||
* First, check the id of every template
|
||||
* Then, check the name of every template. If multiple names match, throw an error.
|
||||
* If no match is found, throw an error.
|
||||
*
|
||||
* @param deps - The command dependencies.
|
||||
* @param query - The id or name of the template to resolve.
|
||||
* @returns The template object.
|
||||
* @throws CommandError if no template is found.
|
||||
* @throws CommandError if multiple templates are found.
|
||||
*/
|
||||
export const resolveTemplate = async (
|
||||
deps: CommandDependencies,
|
||||
query: string,
|
||||
): Promise<XOTemplate> => {
|
||||
const templates = await deps.app.engine.listImportedTemplates();
|
||||
|
||||
const matches = new Set<XOTemplate>();
|
||||
|
||||
for (const template of templates) {
|
||||
if (generateTemplateIdentifier(template) === query) {
|
||||
return template;
|
||||
}
|
||||
}
|
||||
|
||||
for (const template of templates) {
|
||||
if (template.name === query) {
|
||||
matches.add(template);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.size > 1) {
|
||||
throw new CommandError(
|
||||
"template.resolve.multiple_matches",
|
||||
`Multiple templates found for "${query}": ${Array.from(matches)
|
||||
.map(
|
||||
(template) =>
|
||||
`${template.name} (${generateTemplateIdentifier(template)})`,
|
||||
)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (matches.size === 1) {
|
||||
return matches.values().next().value!;
|
||||
}
|
||||
|
||||
throw new CommandError(
|
||||
"template.resolve.not_found",
|
||||
`Template not found: ${query}`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Text formatting utilities for the CLI.
|
||||
*
|
||||
* Uses ANSI escape codes to format text.
|
||||
*
|
||||
* AI Generated links:
|
||||
* @see https://en.wikipedia.org/wiki/ANSI_escape_code
|
||||
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters
|
||||
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
|
||||
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Formatting
|
||||
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Cursor_movement
|
||||
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Screen_manipulation
|
||||
*/
|
||||
|
||||
const BOLD = "\x1b[1m";
|
||||
export const bold = (text: string) => `${BOLD}${text}${RESET}`;
|
||||
|
||||
const DIM = "\x1b[2m";
|
||||
export const dim = (text: string) => `${DIM}${text}${RESET}`;
|
||||
|
||||
const UNDERLINE = "\x1b[4m";
|
||||
export const underline = (text: string) => `${UNDERLINE}${text}${RESET}`;
|
||||
|
||||
const INVERSE = "\x1b[7m";
|
||||
export const inverse = (text: string) => `${INVERSE}${text}${RESET}`;
|
||||
|
||||
const HIDDEN = "\x1b[8m";
|
||||
export const hidden = (text: string) => `${HIDDEN}${text}${RESET}`;
|
||||
|
||||
const STRIKETHROUGH = "\x1b[9m";
|
||||
export const strikethrough = (text: string) =>
|
||||
`${STRIKETHROUGH}${text}${RESET}`;
|
||||
|
||||
const RESET = "\x1b[0m";
|
||||
export const reset = (text: string) => `${RESET}${text}${RESET}`;
|
||||
|
||||
export const formatObject = (obj: unknown) => {
|
||||
return util.inspect(obj, {
|
||||
depth: null,
|
||||
colors: true,
|
||||
compact: false,
|
||||
});
|
||||
};
|
||||
10
src/index.ts
10
src/index.ts
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* XO Wallet CLI - Terminal User Interface for XO crypto wallet.
|
||||
*
|
||||
@@ -9,18 +10,25 @@
|
||||
* 5. Real-time updates via SSE
|
||||
*/
|
||||
|
||||
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"] ?? "./",
|
||||
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);
|
||||
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
import type { XOInvitation } from "@xo-cash/types";
|
||||
|
||||
import { Invitation } from "./invitation.js";
|
||||
import { Storage } from "./storage.js";
|
||||
import { BaseStorage, Storage } from "./storage.js";
|
||||
import { SyncServer } from "../utils/sync-server.js";
|
||||
import { HistoryService } from "./history.js";
|
||||
import { ElectrumService } from "./electrum.js";
|
||||
import { type BlockchainService, ElectrumService } from "./electrum.js";
|
||||
import { RatesService } from "./rates.js";
|
||||
|
||||
import { EventEmitter } from "../utils/event-emitter.js";
|
||||
|
||||
@@ -18,10 +19,19 @@ import { EventEmitter } from "../utils/event-emitter.js";
|
||||
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 {
|
||||
@@ -34,12 +44,20 @@ export interface AppConfig {
|
||||
|
||||
export class AppService extends EventEmitter<AppEventMap> {
|
||||
public engine: Engine;
|
||||
public storage: Storage;
|
||||
public storage: BaseStorage;
|
||||
public config: AppConfig;
|
||||
public history: HistoryService;
|
||||
public electrum: ElectrumService;
|
||||
public electrum: BlockchainService;
|
||||
public rates: RatesService;
|
||||
|
||||
public invitations: Invitation[] = [];
|
||||
private invitationEventCleanup = new Map<
|
||||
string,
|
||||
{
|
||||
onUpdated: (invitation: XOInvitation) => void;
|
||||
onStatusChanged: (status: string) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
static async create(seed: string, config: AppConfig): Promise<AppService> {
|
||||
// Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app.
|
||||
@@ -57,15 +75,28 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
|
||||
// TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here
|
||||
// Import the default P2PKH template
|
||||
await engine.importTemplate(p2pkhTemplate);
|
||||
const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate);
|
||||
|
||||
// console.log('p2pkhTemplate', JSON.stringify(p2pkhTemplate.transactions, null, 2));
|
||||
engine
|
||||
.subscribeToLockingBytecodesForTemplate(templateIdentifier)
|
||||
.catch((err) =>
|
||||
console.error(
|
||||
`Error subscribing to locking bytecodes for template ${templateIdentifier}: ${err}`,
|
||||
),
|
||||
);
|
||||
engine
|
||||
.updateUnspentOutputsForTemplate(templateIdentifier)
|
||||
.catch((err) =>
|
||||
console.error(
|
||||
`Error updating unspent outputs for template ${templateIdentifier}: ${err}`,
|
||||
),
|
||||
);
|
||||
|
||||
// Set default locking parameters for P2PKH
|
||||
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
|
||||
// TODO: Add discovery for funds in the first index? Or until we return 0 TXs?
|
||||
await engine.setDefaultLockingParameters(
|
||||
generateTemplateIdentifier(p2pkhTemplate),
|
||||
generateTemplateIdentifier(parseTemplate(p2pkhTemplate)),
|
||||
"receiveOutput",
|
||||
"receiver",
|
||||
);
|
||||
@@ -79,41 +110,17 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
host: config.electrumHost,
|
||||
applicationIdentifier: config.electrumApplicationIdentifier,
|
||||
});
|
||||
const rates = await RatesService.create();
|
||||
|
||||
// TEMP because testing is painful
|
||||
// Remove all reserved UTXOs on startup
|
||||
// First, get every unspent output
|
||||
const allUnspentOutputs = await engine.listUnspentOutputsData();
|
||||
|
||||
// Get a set of all the invitation identifiers
|
||||
const allInvitationIdentifiers = new Set(
|
||||
allUnspentOutputs.map((output) => output.invitationIdentifier),
|
||||
);
|
||||
|
||||
// Iterate over the invitation identifiers and unreserve the outputs
|
||||
for (const invitationIdentifier of allInvitationIdentifiers) {
|
||||
// Get the outputs for the invitation
|
||||
const outputs = allUnspentOutputs.filter(
|
||||
(output) => output.invitationIdentifier === invitationIdentifier,
|
||||
);
|
||||
// Unreserve the outputs
|
||||
await engine.unreserveResources(
|
||||
outputs.map((output) => ({
|
||||
outpointTransactionHash: hexToBin(output.outpointTransactionHash),
|
||||
outpointIndex: output.outpointIndex,
|
||||
})),
|
||||
invitationIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
return new AppService(engine, walletStorage, config, electrum);
|
||||
return new AppService(engine, walletStorage, config, electrum, rates);
|
||||
}
|
||||
|
||||
constructor(
|
||||
engine: Engine,
|
||||
storage: Storage,
|
||||
storage: BaseStorage,
|
||||
config: AppConfig,
|
||||
electrum: ElectrumService,
|
||||
electrum: BlockchainService,
|
||||
rates: RatesService,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -121,6 +128,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
this.storage = storage;
|
||||
this.config = config;
|
||||
this.electrum = electrum;
|
||||
this.rates = rates;
|
||||
this.history = new HistoryService(engine, this.invitations);
|
||||
}
|
||||
|
||||
@@ -153,22 +161,113 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
}
|
||||
|
||||
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> {
|
||||
// Remove the invitation from the invitations array
|
||||
this.invitations = this.invitations.filter((i) => i !== invitation);
|
||||
const invitationIdentifier = invitation.data.invitationIdentifier;
|
||||
this.detachInvitationListeners(invitationIdentifier);
|
||||
|
||||
// Remove the invitation from the invitations array while preserving the array reference.
|
||||
const invitationIndex = this.invitations.indexOf(invitation);
|
||||
if (invitationIndex >= 0) {
|
||||
this.invitations.splice(invitationIndex, 1);
|
||||
}
|
||||
|
||||
// Emit the invitation-removed event
|
||||
this.emit("invitation-removed", invitation);
|
||||
this.emit("wallet-state-changed", {
|
||||
reason: "invitation-removed",
|
||||
invitationIdentifier,
|
||||
});
|
||||
}
|
||||
|
||||
private attachInvitationListeners(invitation: Invitation): void {
|
||||
const invitationIdentifier = invitation.data.invitationIdentifier;
|
||||
if (this.invitationEventCleanup.has(invitationIdentifier)) return;
|
||||
|
||||
const onUpdated = () => {
|
||||
this.emit("wallet-state-changed", {
|
||||
reason: "invitation-updated",
|
||||
invitationIdentifier,
|
||||
});
|
||||
};
|
||||
const onStatusChanged = () => {
|
||||
this.emit("wallet-state-changed", {
|
||||
reason: "invitation-status-changed",
|
||||
invitationIdentifier,
|
||||
});
|
||||
};
|
||||
|
||||
invitation.on("invitation-updated", onUpdated);
|
||||
invitation.on("invitation-status-changed", onStatusChanged);
|
||||
|
||||
this.invitationEventCleanup.set(invitationIdentifier, {
|
||||
onUpdated,
|
||||
onStatusChanged,
|
||||
});
|
||||
}
|
||||
|
||||
private detachInvitationListeners(invitationIdentifier: string): void {
|
||||
const trackedInvitation = this.invitations.find(
|
||||
(candidate) =>
|
||||
candidate.data.invitationIdentifier === invitationIdentifier,
|
||||
);
|
||||
const cleanup = this.invitationEventCleanup.get(invitationIdentifier);
|
||||
if (!trackedInvitation || !cleanup) return;
|
||||
|
||||
trackedInvitation.off("invitation-updated", cleanup.onUpdated);
|
||||
trackedInvitation.off("invitation-status-changed", cleanup.onStatusChanged);
|
||||
this.invitationEventCleanup.delete(invitationIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unreserves all reserved UTXOs across every invitation.
|
||||
* Useful when stale reservations from previous sessions block spending.
|
||||
*
|
||||
* @returns The number of UTXOs that were unreserved.
|
||||
*/
|
||||
async unreserveAllResources(): Promise<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");
|
||||
|
||||
@@ -180,7 +279,9 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
|
||||
await Promise.all(
|
||||
invitations.map(async ({ key }) => {
|
||||
await this.createInvitation(key);
|
||||
await this.createInvitation(key).catch((err) =>
|
||||
console.error(`Error creating invitation ${key}: ${err}`),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ export interface ElectrumServiceConfig {
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { binToHex } from "@bitauth/libauth";
|
||||
import { compileCashAssemblyString, type Engine } from "@xo-cash/engine";
|
||||
import {
|
||||
compileCashAssemblyString,
|
||||
type Engine,
|
||||
listInvitationCommitsByEntity,
|
||||
} from "@xo-cash/engine";
|
||||
import type { UnspentOutputData } from "@xo-cash/state";
|
||||
import type {
|
||||
XOInvitation,
|
||||
@@ -59,6 +63,7 @@ interface InvitationContext {
|
||||
invitation: Invitation;
|
||||
template: XOTemplate | null;
|
||||
variables: Record<string, XOInvitationVariableValue>;
|
||||
walletCommits: XOInvitationCommit[];
|
||||
walletEntityIdentifier?: string;
|
||||
}
|
||||
|
||||
@@ -73,12 +78,8 @@ export class HistoryService {
|
||||
private invitations: Invitation[],
|
||||
) {}
|
||||
|
||||
async extractEntities(invitation: XOInvitation): Promise<string[]> {
|
||||
const entities = new Set<string>();
|
||||
for (const commit of invitation.commits) {
|
||||
entities.add(commit.entityIdentifier);
|
||||
}
|
||||
return Array.from(entities);
|
||||
extractEntities(invitation: XOInvitation): Record<string, XOInvitationCommit[]> {
|
||||
return listInvitationCommitsByEntity(invitation);
|
||||
}
|
||||
|
||||
// Entities are currently static per invitation. So, we can try to match the roles to entities by:
|
||||
@@ -127,8 +128,6 @@ export class HistoryService {
|
||||
|
||||
async getHistory(): Promise<HistoryItem[]> {
|
||||
const allUtxos = await this.engine.listUnspentOutputsData();
|
||||
const ownOutpoints = new Set<string>();
|
||||
const ownLockingBytecodes = new Set<string>();
|
||||
const invitationByOrigin = new Map<string, UtxoOriginContext>();
|
||||
const outpointValueSatoshis = new Map<string, bigint>();
|
||||
|
||||
@@ -137,8 +136,6 @@ export class HistoryService {
|
||||
utxo.outpointTransactionHash,
|
||||
utxo.outpointIndex,
|
||||
);
|
||||
ownOutpoints.add(outpointKey);
|
||||
ownLockingBytecodes.add(utxo.lockingBytecode);
|
||||
outpointValueSatoshis.set(outpointKey, BigInt(utxo.valueSatoshis));
|
||||
}
|
||||
|
||||
@@ -148,15 +145,15 @@ export class HistoryService {
|
||||
const template =
|
||||
(await this.engine.getTemplate(invitation.data.templateIdentifier)) ??
|
||||
null;
|
||||
const walletEntityIdentifier = this.resolveWalletEntityIdentifier(
|
||||
invitation,
|
||||
ownOutpoints,
|
||||
ownLockingBytecodes,
|
||||
const walletCommits = await this.getWalletCommitsForInvitation(
|
||||
invitation.data,
|
||||
);
|
||||
const walletEntityIdentifier = walletCommits[0]?.entityIdentifier;
|
||||
contexts.set(invitation.data.invitationIdentifier, {
|
||||
invitation,
|
||||
template,
|
||||
variables,
|
||||
walletCommits,
|
||||
walletEntityIdentifier,
|
||||
});
|
||||
this.indexInvitationOutputsByUtxoOrigin(invitationByOrigin, invitation);
|
||||
@@ -186,7 +183,6 @@ export class HistoryService {
|
||||
const invitationInputs = this.buildWalletInputItemsForInvitation(
|
||||
context,
|
||||
roles[0],
|
||||
invitationOutputs.length > 0,
|
||||
outpointValueSatoshis,
|
||||
);
|
||||
const invitationDescription = this.deriveInvitationDescription(
|
||||
@@ -287,51 +283,25 @@ export class HistoryService {
|
||||
return outputs;
|
||||
}
|
||||
|
||||
private async getWalletCommitsForInvitation(
|
||||
invitation: XOInvitation,
|
||||
): Promise<XOInvitationCommit[]> {
|
||||
try {
|
||||
return await this.engine.getOwnCommits(invitation);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private buildWalletInputItemsForInvitation(
|
||||
context: InvitationContext,
|
||||
walletRole?: string,
|
||||
hasWalletOutputs: boolean = false,
|
||||
outpointValueSatoshis: Map<string, bigint> = new Map(),
|
||||
): HistoryUtxoItem[] {
|
||||
const invitation = context.invitation.data;
|
||||
const commits = invitation.commits ?? [];
|
||||
const commitsByEntity = context.walletEntityIdentifier
|
||||
? commits.filter(
|
||||
(commit) =>
|
||||
commit.entityIdentifier === context.walletEntityIdentifier,
|
||||
)
|
||||
: [];
|
||||
const commitsByRole = walletRole
|
||||
? commits.filter(
|
||||
(commit) =>
|
||||
this.deriveCommitRoleIdentifier(
|
||||
commit,
|
||||
invitation,
|
||||
context.template,
|
||||
) === walletRole,
|
||||
)
|
||||
: [];
|
||||
|
||||
let relevantCommits = commitsByEntity.filter(
|
||||
const relevantCommits = context.walletCommits.filter(
|
||||
(commit) => (commit.data.inputs?.length ?? 0) > 0,
|
||||
);
|
||||
if (relevantCommits.length === 0) {
|
||||
relevantCommits = commitsByRole.filter(
|
||||
(commit) => (commit.data.inputs?.length ?? 0) > 0,
|
||||
);
|
||||
}
|
||||
if (relevantCommits.length === 0 && walletRole === "sender") {
|
||||
relevantCommits = commits.filter(
|
||||
(commit) => (commit.data.inputs?.length ?? 0) > 0,
|
||||
);
|
||||
}
|
||||
// Sender fallback only when no wallet outputs were matched.
|
||||
if (relevantCommits.length === 0 && !hasWalletOutputs) {
|
||||
relevantCommits = commits.filter(
|
||||
(commit) => (commit.data.inputs?.length ?? 0) > 0,
|
||||
);
|
||||
}
|
||||
|
||||
const txDescription = this.deriveTransactionActivityDescription(
|
||||
invitation,
|
||||
context.template,
|
||||
@@ -355,7 +325,10 @@ export class HistoryService {
|
||||
context.variables,
|
||||
);
|
||||
const templateName = context.template?.name ?? "UnknownTemplate";
|
||||
const role = walletRole ?? "sender";
|
||||
const role =
|
||||
this.deriveCommitRoleIdentifier(commit, invitation, context.template) ??
|
||||
walletRole ??
|
||||
"sender";
|
||||
const inputValue = this.resolveInputSatoshis(
|
||||
txHash,
|
||||
inputIndex,
|
||||
@@ -401,7 +374,7 @@ export class HistoryService {
|
||||
return {
|
||||
kind: "utxo",
|
||||
id: this.getUtxoId(utxo),
|
||||
invitationIdentifier: utxo.invitationIdentifier || undefined,
|
||||
invitationIdentifier: utxo.reservedBy || undefined,
|
||||
templateIdentifier: utxo.templateIdentifier,
|
||||
outputIdentifier: utxo.outputIdentifier,
|
||||
outpoint: {
|
||||
@@ -409,7 +382,7 @@ export class HistoryService {
|
||||
index: utxo.outpointIndex,
|
||||
},
|
||||
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||
reserved: utxo.reserved,
|
||||
reserved: utxo.reservedBy ? true : false,
|
||||
direction,
|
||||
description,
|
||||
descriptionParts: {
|
||||
@@ -422,13 +395,6 @@ export class HistoryService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: This is completely incorrect. It should be deriving the roles of the user based on the entity IDs in the commits, then mapping each commit to Inputs/Outputs so we know what belongs to the user.
|
||||
* There are a few changes that will need to be made to make this work:
|
||||
* 1. Provide a way to derive all entity IDs of the user for the invitation (If we are going with XPub)
|
||||
* 2. Provide a way to get only the User's commits (and their inputs/outputs)
|
||||
* 3. (Maybe) Include role on inputs and outputs - This one might be fine with just using the commit entity id
|
||||
*/
|
||||
private deriveWalletRolesForInvitation(
|
||||
context: InvitationContext,
|
||||
outputs: HistoryUtxoItem[],
|
||||
@@ -444,33 +410,20 @@ export class HistoryService {
|
||||
roles.add("receiver");
|
||||
}
|
||||
|
||||
const hasInputCommit = (
|
||||
context.walletEntityIdentifier
|
||||
? context.invitation.data.commits.filter(
|
||||
(c) => c.entityIdentifier === context.walletEntityIdentifier,
|
||||
)
|
||||
: context.invitation.data.commits
|
||||
).some((c) => (c.data.inputs?.length ?? 0) > 0);
|
||||
|
||||
if (hasInputCommit) roles.add("sender");
|
||||
if (
|
||||
!hasInputCommit &&
|
||||
outputs.length === 0 &&
|
||||
context.invitation.data.commits.some(
|
||||
(c) => (c.data.inputs?.length ?? 0) > 0,
|
||||
)
|
||||
) {
|
||||
roles.add("sender");
|
||||
}
|
||||
if (roles.size === 0) {
|
||||
const inferred = this.extractInvitationRoleIdentifier(
|
||||
for (const commit of context.walletCommits) {
|
||||
const role = this.deriveCommitRoleIdentifier(
|
||||
commit,
|
||||
context.invitation.data,
|
||||
context.template,
|
||||
context.walletEntityIdentifier,
|
||||
);
|
||||
if (inferred) roles.add(inferred);
|
||||
if (role) roles.add(role);
|
||||
}
|
||||
|
||||
const hasInputCommit = context.walletCommits.some(
|
||||
(c) => (c.data.inputs?.length ?? 0) > 0,
|
||||
);
|
||||
if (hasInputCommit) roles.add("sender");
|
||||
|
||||
return roles.size > 0 ? Array.from(roles) : ["unknown"];
|
||||
}
|
||||
|
||||
@@ -517,11 +470,11 @@ export class HistoryService {
|
||||
utxo: UnspentOutputData,
|
||||
invitationByUtxoOrigin: Map<string, UtxoOriginContext>,
|
||||
): string | undefined {
|
||||
if (utxo.invitationIdentifier) return utxo.invitationIdentifier;
|
||||
if (utxo.reservedBy) return utxo.reservedBy;
|
||||
const originKey = this.getUtxoOriginKey(
|
||||
utxo.templateIdentifier,
|
||||
utxo.outputIdentifier,
|
||||
utxo.lockingBytecode,
|
||||
utxo.scriptHash,
|
||||
);
|
||||
return invitationByUtxoOrigin.get(originKey)?.invitationIdentifier;
|
||||
}
|
||||
@@ -533,59 +486,11 @@ export class HistoryService {
|
||||
const originKey = this.getUtxoOriginKey(
|
||||
utxo.templateIdentifier,
|
||||
utxo.outputIdentifier,
|
||||
utxo.lockingBytecode,
|
||||
utxo.scriptHash,
|
||||
);
|
||||
return invitationByUtxoOrigin.get(originKey)?.roleIdentifier;
|
||||
}
|
||||
|
||||
private resolveWalletEntityIdentifier(
|
||||
invitation: Invitation,
|
||||
ownUtxoOutpointKeys: Set<string>,
|
||||
ownLockingBytecodes: Set<string>,
|
||||
): string | undefined {
|
||||
const scores = new Map<string, number>();
|
||||
const addScore = (entityIdentifier: string, delta: number): void => {
|
||||
scores.set(entityIdentifier, (scores.get(entityIdentifier) ?? 0) + delta);
|
||||
};
|
||||
|
||||
for (const commit of invitation.data.commits) {
|
||||
for (const input of commit.data.inputs ?? []) {
|
||||
const txHash = input.outpointTransactionHash
|
||||
? input.outpointTransactionHash instanceof Uint8Array
|
||||
? binToHex(input.outpointTransactionHash)
|
||||
: String(input.outpointTransactionHash)
|
||||
: undefined;
|
||||
if (!txHash || input.outpointIndex === undefined) continue;
|
||||
if (
|
||||
ownUtxoOutpointKeys.has(
|
||||
this.getOutpointKey(txHash, input.outpointIndex),
|
||||
)
|
||||
) {
|
||||
addScore(commit.entityIdentifier, 3);
|
||||
}
|
||||
}
|
||||
for (const output of commit.data.outputs ?? []) {
|
||||
const lockingBytecodeHex = output.lockingBytecode
|
||||
? this.toLockingBytecodeHex(output.lockingBytecode)
|
||||
: undefined;
|
||||
if (!lockingBytecodeHex) continue;
|
||||
if (ownLockingBytecodes.has(lockingBytecodeHex)) {
|
||||
addScore(commit.entityIdentifier, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let bestEntity: string | undefined;
|
||||
let bestScore = 0;
|
||||
for (const [entity, score] of scores.entries()) {
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestEntity = entity;
|
||||
}
|
||||
}
|
||||
return bestEntity;
|
||||
}
|
||||
|
||||
private deriveUtxoDescription(
|
||||
utxo: UnspentOutputData,
|
||||
template: XOTemplate | null,
|
||||
@@ -715,27 +620,6 @@ export class HistoryService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private extractInvitationRoleIdentifier(
|
||||
invitation: XOInvitation,
|
||||
template: XOTemplate | null,
|
||||
walletEntityIdentifier?: string,
|
||||
): string | undefined {
|
||||
if (walletEntityIdentifier) {
|
||||
const commits = invitation.commits.filter(
|
||||
(commit) => commit.entityIdentifier === walletEntityIdentifier,
|
||||
);
|
||||
for (const commit of commits) {
|
||||
const role = this.deriveCommitRoleIdentifier(
|
||||
commit,
|
||||
invitation,
|
||||
template,
|
||||
);
|
||||
if (role) return role;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private inferRoleFromOutputIdentifier(
|
||||
outputIdentifier: string,
|
||||
): string | undefined {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
AcceptInvitationParameters,
|
||||
AppendInvitationParameters,
|
||||
Engine,
|
||||
FindSuitableResourcesParameters,
|
||||
GetSpendableResourcesParameters,
|
||||
} from "@xo-cash/engine";
|
||||
import { hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
|
||||
import type {
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
|
||||
import type { SSEvent } from "../utils/sse-client.js";
|
||||
import type { SyncServer } from "../utils/sync-server.js";
|
||||
import type { Storage } from "./storage.js";
|
||||
import type { ElectrumService } from "./electrum.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";
|
||||
@@ -34,13 +34,14 @@ import { compileCashAssemblyString } from "@xo-cash/engine";
|
||||
export type InvitationEventMap = {
|
||||
"invitation-updated": XOInvitation;
|
||||
"invitation-status-changed": string;
|
||||
error: Error;
|
||||
};
|
||||
|
||||
export type InvitationDependencies = {
|
||||
syncServer: SyncServer;
|
||||
storage: Storage;
|
||||
storage: BaseStorage;
|
||||
engine: Engine;
|
||||
electrum: ElectrumService;
|
||||
electrum: BlockchainService;
|
||||
};
|
||||
|
||||
export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
@@ -84,8 +85,11 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
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(invitation, dependencies);
|
||||
const invitationInstance = new Invitation(engineInvitation, dependencies);
|
||||
|
||||
// Start the invitation and its tracking
|
||||
await invitationInstance.start();
|
||||
@@ -118,8 +122,8 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
* 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: Storage;
|
||||
private electrum: ElectrumService;
|
||||
private storage: BaseStorage;
|
||||
private electrum: BlockchainService;
|
||||
|
||||
/**
|
||||
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
|
||||
@@ -146,32 +150,38 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
* Start the invitation - Connect sync server and download latest invitation data.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
// 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),
|
||||
]);
|
||||
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;
|
||||
// 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 ?? [],
|
||||
);
|
||||
// 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 };
|
||||
// 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);
|
||||
// Store the invitation in the storage
|
||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||
|
||||
// Publish the invitation to the sync server
|
||||
this.syncServer.publishInvitation(this.data);
|
||||
// Publish the invitation to the sync server
|
||||
this.publishInvitation(this.data);
|
||||
|
||||
// Compute and emit initial status
|
||||
await this.updateStatus();
|
||||
// Compute and emit initial status
|
||||
await this.updateStatus();
|
||||
} catch (err) {
|
||||
// console.error(`Error starting invitation, could not connect to sync server or get invitation`, err);
|
||||
// Emit the error event. We might want to throw? but we need a better way of handling errors in the invitation system because we need the invitation to successfully initialize.
|
||||
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,6 +215,20 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the invitation to the sync server
|
||||
*/
|
||||
private async publishInvitation(
|
||||
invitation: XOInvitation = this.data,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.syncServer.publishInvitation(invitation);
|
||||
} catch (err) {
|
||||
// Emit the error event. We might want to throw? but we need a better way of handling errors in the invitation system because we need the invitation to successfully initialize.
|
||||
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the commits
|
||||
* @param initial - The initial commits
|
||||
@@ -359,7 +383,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
this.data = await this.engine.acceptInvitation(this.data, acceptParams);
|
||||
|
||||
// Sync the invitation to the sync server
|
||||
this.syncServer.publishInvitation(this.data);
|
||||
this.publishInvitation(this.data);
|
||||
|
||||
// Update the status of the invitation
|
||||
await this.updateStatus();
|
||||
@@ -373,7 +397,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
const signedInvitation = await this.engine.signInvitation(this.data);
|
||||
|
||||
// Publish the signed invitation to the sync server
|
||||
this.syncServer.publishInvitation(signedInvitation);
|
||||
this.publishInvitation(signedInvitation);
|
||||
|
||||
// Store the signed invitation in the storage
|
||||
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
|
||||
@@ -385,16 +409,17 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast the invitation
|
||||
* Broadcast the invitation.
|
||||
* @returns The transaction hash returned by the network after broadcast.
|
||||
*/
|
||||
async broadcast(): Promise<void> {
|
||||
// Broadcast the transaction (executeAction returns transaction hash when broadcastTransaction: true)
|
||||
await this.engine.executeAction(this.data, {
|
||||
async broadcast(): Promise<string> {
|
||||
const txHash = await this.engine.executeAction(this.data, {
|
||||
broadcastTransaction: true,
|
||||
});
|
||||
|
||||
// Update the status of the invitation
|
||||
await this.updateStatus();
|
||||
|
||||
return String(txHash);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -409,7 +434,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
this.data = await this.engine.appendInvitation(this.data, data);
|
||||
|
||||
// Sync the invitation to the sync server
|
||||
await this.syncServer.publishInvitation(this.data);
|
||||
await this.publishInvitation(this.data);
|
||||
|
||||
// Store the invitation in the storage
|
||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||
@@ -426,7 +451,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
await this.append({ inputs });
|
||||
|
||||
// Sync the invitation to the sync server
|
||||
await this.syncServer.publishInvitation(this.data);
|
||||
await this.publishInvitation(this.data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -449,7 +474,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
await this.append({ outputs });
|
||||
|
||||
// Sync the invitation to the sync server
|
||||
await this.syncServer.publishInvitation(this.data);
|
||||
await this.publishInvitation(this.data);
|
||||
}
|
||||
|
||||
async addVariables(variables: XOInvitationVariable[]): Promise<void> {
|
||||
@@ -457,16 +482,31 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
await this.append({ variables });
|
||||
|
||||
// Sync the invitation to the sync server
|
||||
await this.syncServer.publishInvitation(this.data);
|
||||
await this.publishInvitation(this.data);
|
||||
}
|
||||
|
||||
async findSuitableResources(
|
||||
options: Partial<FindSuitableResourcesParameters> = {},
|
||||
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 ?? "",
|
||||
};
|
||||
|
||||
// Find the suitable resources
|
||||
const { unspentOutputs } = await this.engine.findSuitableResources(
|
||||
const { unspentOutputs } = await this.engine.getSpendableResources(
|
||||
this.data,
|
||||
options,
|
||||
resolvedOptions,
|
||||
);
|
||||
|
||||
// Update the status of the invitation
|
||||
|
||||
197
src/services/rates.ts
Normal file
197
src/services/rates.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { EventEmitter } from '../utils/event-emitter.js';
|
||||
import {
|
||||
type RatesEventMap,
|
||||
} from '../utils/rates/base-rates.js';
|
||||
import { RatesOracle } from '../utils/rates/rates-oracles.js';
|
||||
|
||||
/**
|
||||
* Event map emitted by {@link RatesService}.
|
||||
*/
|
||||
export type RatesServiceEventMap = {
|
||||
'rate-updated': {
|
||||
numeratorUnitCode: string;
|
||||
denominatorUnitCode: string;
|
||||
price: number;
|
||||
pair: string;
|
||||
updatedAt: number;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* In-memory representation of a market rate.
|
||||
*/
|
||||
type CachedRate = {
|
||||
price: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal adapter contract that RatesService depends on.
|
||||
*
|
||||
* Using a small interface keeps the service decoupled and avoids inheriting
|
||||
* implementation-specific type constraints from concrete adapters.
|
||||
*/
|
||||
export interface RatesAdapter {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
listPairs(): Promise<Set<string>>;
|
||||
formatCurrency(amount: number, targetCurrency: string): string;
|
||||
on(
|
||||
type: 'rateUpdated',
|
||||
listener: (detail: RatesEventMap['rateUpdated']) => void,
|
||||
): () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates the rates adapter lifecycle and provides BCH -> fiat helpers
|
||||
* for the TUI.
|
||||
*
|
||||
* This service keeps a small in-memory snapshot of the latest prices and emits
|
||||
* a normalized event whenever a pair changes. React components can subscribe
|
||||
* through `useSyncExternalStore` for clean and predictable reactivity.
|
||||
*/
|
||||
export class RatesService extends EventEmitter<RatesServiceEventMap> {
|
||||
private readonly adapter: RatesAdapter;
|
||||
private readonly ratesByPair = new Map<string, CachedRate>();
|
||||
private unsubscribeFromAdapter: (() => void) | null = null;
|
||||
private started = false;
|
||||
|
||||
constructor(adapter: RatesAdapter) {
|
||||
super();
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a rates service.
|
||||
*
|
||||
* If no adapter is passed, this defaults to the Oracle-backed adapter.
|
||||
*/
|
||||
public static async create(adapter?: RatesAdapter): Promise<RatesService> {
|
||||
const resolvedAdapter = adapter ?? (await RatesOracle.from());
|
||||
return new RatesService(resolvedAdapter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the underlying adapter and begins collecting live updates.
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
|
||||
this.unsubscribeFromAdapter = this.adapter.on('rateUpdated', (event) => {
|
||||
this.handleRateUpdated(event);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.adapter.start();
|
||||
} catch (error) {
|
||||
this.unsubscribeFromAdapter?.();
|
||||
this.unsubscribeFromAdapter = null;
|
||||
this.started = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops live rate collection.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.started) {
|
||||
return;
|
||||
}
|
||||
this.started = false;
|
||||
|
||||
this.unsubscribeFromAdapter?.();
|
||||
this.unsubscribeFromAdapter = null;
|
||||
|
||||
await this.adapter.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the latest price for a pair in NUMERATOR/DENOMINATOR form.
|
||||
*
|
||||
* Example: `getRate("USD", "BCH")`.
|
||||
*/
|
||||
public getRate(
|
||||
numeratorUnitCode: string,
|
||||
denominatorUnitCode: string,
|
||||
): number | null {
|
||||
const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode);
|
||||
return this.ratesByPair.get(pair)?.price ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts satoshis to fiat using the latest BCH/fiat rate.
|
||||
*
|
||||
* Example: `convertBchToFiat(1234n, "USD")`.
|
||||
*/
|
||||
public convertBchToFiat(
|
||||
satoshis: bigint,
|
||||
targetCurrency: string = 'USD',
|
||||
): number | null {
|
||||
const rate = this.getRate(targetCurrency, 'BCH');
|
||||
if (rate === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const amountInBch = Number(satoshis) / 100_000_000;
|
||||
return amountInBch * rate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a BCH -> fiat converted amount using the adapter formatter.
|
||||
*/
|
||||
public formatBchToFiat(
|
||||
satoshis: bigint,
|
||||
targetCurrency: string = 'USD',
|
||||
): string | null {
|
||||
const normalizedCurrency = targetCurrency.toUpperCase();
|
||||
const amount = this.convertBchToFiat(satoshis, normalizedCurrency);
|
||||
if (amount === null) {
|
||||
return null;
|
||||
}
|
||||
return this.adapter.formatCurrency(amount, normalizedCurrency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an arbitrary fiat amount in a currency-aware way.
|
||||
*/
|
||||
public formatCurrency(amount: number, currencyCode: string): string {
|
||||
return this.adapter.formatCurrency(amount, currencyCode.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles normalized updates from the underlying adapter.
|
||||
*/
|
||||
private handleRateUpdated(event: RatesEventMap['rateUpdated']): void {
|
||||
const numeratorUnitCode = event.numeratorUnitCode.toUpperCase();
|
||||
const denominatorUnitCode = event.denominatorUnitCode.toUpperCase();
|
||||
const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode);
|
||||
const updatedAt = Date.now();
|
||||
|
||||
this.ratesByPair.set(pair, {
|
||||
price: event.price,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
this.emit('rate-updated', {
|
||||
numeratorUnitCode,
|
||||
denominatorUnitCode,
|
||||
price: event.price,
|
||||
pair,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a stable key for pair lookups.
|
||||
*/
|
||||
private getPairKey(
|
||||
numeratorUnitCode: string,
|
||||
denominatorUnitCode: string,
|
||||
): string {
|
||||
return `${numeratorUnitCode.toUpperCase()}/${denominatorUnitCode.toUpperCase()}`;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js";
|
||||
|
||||
export class Storage {
|
||||
export abstract class BaseStorage {
|
||||
abstract all(): Promise<{ key: string; value: any }[]>;
|
||||
abstract set(key: string, value: any): Promise<void>;
|
||||
abstract get(key: string): Promise<any>;
|
||||
abstract remove(key: string): Promise<void>;
|
||||
abstract clear(): Promise<void>;
|
||||
abstract child(key: string): BaseStorage;
|
||||
}
|
||||
|
||||
export class Storage extends BaseStorage {
|
||||
static async create(dbPath: string): Promise<Storage> {
|
||||
// Create the database
|
||||
const database = new Database(dbPath);
|
||||
@@ -19,7 +28,9 @@ export class Storage {
|
||||
constructor(
|
||||
private readonly database: Database.Database,
|
||||
private readonly basePath: string,
|
||||
) {}
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full key with basePath prefix
|
||||
@@ -117,3 +128,104 @@ export class 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.
|
||||
*/
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import TextInput from "./TextInput.js";
|
||||
import { useSatoshisConversion } from "../hooks/useSatoshisConversion.js";
|
||||
|
||||
interface VariableInputFieldProps {
|
||||
variable: {
|
||||
@@ -18,6 +19,45 @@ interface VariableInputFieldProps {
|
||||
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,
|
||||
@@ -27,6 +67,26 @@ export function VariableInputField({
|
||||
borderColor,
|
||||
focusColor,
|
||||
}: VariableInputFieldProps): React.ReactElement {
|
||||
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
|
||||
useSatoshisConversion("USD");
|
||||
const satoshisValue = useMemo(
|
||||
() => parseSatoshis(variable.value),
|
||||
[variable.value],
|
||||
);
|
||||
const formattedBch = useMemo(() => {
|
||||
if (satoshisValue === null) {
|
||||
return null;
|
||||
}
|
||||
return formatBchFromSatoshis(satoshisValue);
|
||||
}, [satoshisValue]);
|
||||
const formattedFiat = useMemo(() => {
|
||||
if (satoshisValue === null) {
|
||||
return null;
|
||||
}
|
||||
return formatSatoshisToFiat(satoshisValue);
|
||||
}, [satoshisValue, formatSatoshisToFiat]);
|
||||
const shouldShowSatoshisConversion = isSatoshisVariable(variable);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={focusColor}>{variable.name}</Text>
|
||||
@@ -54,12 +114,29 @@ export function VariableInputField({
|
||||
<Text color={borderColor} dimColor>{variable.hint}</Text>
|
||||
|
||||
</Box>
|
||||
{variable.type === 'integer' && variable.hint === 'satoshis' && (
|
||||
<Box>
|
||||
<Text color={borderColor} dimColor>
|
||||
{/* Convert from sats to bch. NOTE: we can't use the formatSatoshis function because it is too verbose and returns too many values in the string*/}
|
||||
{(Number(variable.value) / 100_000_000).toFixed(8)} BCH
|
||||
</Text>
|
||||
{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>
|
||||
|
||||
@@ -23,3 +23,5 @@ export {
|
||||
useBlockableInput,
|
||||
useIsInputCaptured,
|
||||
} from "./useInputLayer.js";
|
||||
export { useRate, useBchToFiatRate } from "./useRates.js";
|
||||
export { useSatoshisConversion } from "./useSatoshisConversion.js";
|
||||
|
||||
68
src/tui/hooks/useRates.tsx
Normal file
68
src/tui/hooks/useRates.tsx
Normal 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');
|
||||
}
|
||||
42
src/tui/hooks/useSatoshisConversion.tsx
Normal file
42
src/tui/hooks/useSatoshisConversion.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useAppContext } from './useAppContext.js';
|
||||
import { useBchToFiatRate } from './useRates.js';
|
||||
|
||||
/**
|
||||
* Reactive BCH satoshis -> fiat conversion helpers for TUI screens.
|
||||
*
|
||||
* This hook subscribes to rate updates through `useBchToFiatRate`, so any
|
||||
* component using it will re-render automatically when the selected pair
|
||||
* receives a new quote.
|
||||
*/
|
||||
export function useSatoshisConversion(targetCurrency: string = 'USD') {
|
||||
const { appService } = useAppContext();
|
||||
const currencyCode = useMemo(() => targetCurrency.toUpperCase(), [targetCurrency]);
|
||||
const fiatPerBchRate = useBchToFiatRate(currencyCode);
|
||||
|
||||
const formattedFiatPerBchRate = useMemo(() => {
|
||||
if (!appService || fiatPerBchRate === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return appService.rates.formatCurrency(fiatPerBchRate, currencyCode);
|
||||
}, [appService, fiatPerBchRate, currencyCode]);
|
||||
|
||||
const formatSatoshisToFiat = useCallback(
|
||||
(satoshis: bigint): string | null => {
|
||||
if (!appService || fiatPerBchRate === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return appService.rates.formatBchToFiat(satoshis, currencyCode);
|
||||
},
|
||||
[appService, fiatPerBchRate, currencyCode],
|
||||
);
|
||||
|
||||
return {
|
||||
currencyCode,
|
||||
fiatPerBchRate,
|
||||
formattedFiatPerBchRate,
|
||||
formatSatoshisToFiat,
|
||||
} as const;
|
||||
}
|
||||
@@ -16,6 +16,8 @@ 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 } from '@bitauth/libauth';
|
||||
|
||||
@@ -39,33 +41,41 @@ interface MnemonicFileEntry {
|
||||
* Focus sections the user can tab between.
|
||||
* When saved wallets exist the file list is shown first.
|
||||
*/
|
||||
type FocusSection = 'files' | 'input' | 'button';
|
||||
type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'button';
|
||||
|
||||
/**
|
||||
* Reads mnemonic-* files from cwd, parses each as a BCHMnemonicURL,
|
||||
* and converts the entropy back to a BIP39 English mnemonic phrase.
|
||||
* 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 cwd = process.cwd();
|
||||
const filenames = fs.readdirSync(cwd).filter((f) => f.startsWith('mnemonic-'));
|
||||
const dirs = [getMnemonicsDir(), process.cwd()];
|
||||
const seenBasenames = new Set<string>();
|
||||
const entries: MnemonicFileEntry[] = [];
|
||||
|
||||
for (const filename of filenames) {
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(cwd, filename), 'utf-8').trim();
|
||||
const parsed = BCHMnemonicURL.fromURL(content);
|
||||
const raw = parsed.toObject();
|
||||
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;
|
||||
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(/\.[^.]+$/, '');
|
||||
/** 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 });
|
||||
} catch {
|
||||
// Skip files that can't be parsed
|
||||
entries.push({ filename, label, mnemonic: mnemonicResult.phrase });
|
||||
seenBasenames.add(filename);
|
||||
} catch {
|
||||
// Skip files that can't be parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
@@ -91,6 +101,9 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
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');
|
||||
|
||||
@@ -104,8 +117,8 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
* The ordered list of focusable sections (files section only when entries exist).
|
||||
*/
|
||||
const focusSections: FocusSection[] = mnemonicFiles.length > 0
|
||||
? ['files', 'input', 'button']
|
||||
: ['input', 'button'];
|
||||
? ['files', 'input', 'saveCheckbox', 'button']
|
||||
: ['input', 'saveCheckbox', 'button'];
|
||||
|
||||
/**
|
||||
* Shows a status message with the given type.
|
||||
@@ -118,28 +131,46 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
/**
|
||||
* Shared wallet initialization handler used by both manual entry and file selection.
|
||||
*/
|
||||
const doInitialize = useCallback(async (seed: string) => {
|
||||
showStatus('Initializing wallet...', 'loading');
|
||||
setStatus('Initializing wallet...');
|
||||
setIsSubmitting(true);
|
||||
const doInitialize = useCallback(
|
||||
async (seed: string, options?: { saveMnemonic?: boolean }) => {
|
||||
showStatus('Initializing wallet...', 'loading');
|
||||
setStatus('Initializing wallet...');
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await initializeWallet(seed);
|
||||
try {
|
||||
await initializeWallet(seed);
|
||||
|
||||
showStatus('Wallet initialized successfully!', 'success');
|
||||
setStatus('Wallet ready');
|
||||
setSeedPhrase('');
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
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.
|
||||
@@ -158,8 +189,8 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
return;
|
||||
}
|
||||
|
||||
await doInitialize(seed);
|
||||
}, [seedPhrase, doInitialize, showStatus]);
|
||||
await doInitialize(seed, { saveMnemonic: saveMnemonicChecked });
|
||||
}, [seedPhrase, saveMnemonicChecked, doInitialize, showStatus]);
|
||||
|
||||
/**
|
||||
* Handles selecting a mnemonic file from the list.
|
||||
@@ -186,6 +217,14 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// Space or Enter toggles "save mnemonic" when that row is focused
|
||||
if (focusedSection === 'saveCheckbox') {
|
||||
if (_input === ' ' || key.return) {
|
||||
setSaveMnemonicChecked((v) => !v);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow keys inside the file list
|
||||
if (focusedSection === 'files' && mnemonicFiles.length > 0) {
|
||||
if (key.upArrow) {
|
||||
@@ -319,6 +358,32 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
/>
|
||||
</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 && (
|
||||
@@ -345,7 +410,7 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
{/* Help text */}
|
||||
<Box marginTop={2}>
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
Tab: navigate sections • Enter: submit • Esc: back
|
||||
Tab: navigate • Enter: submit, load wallet, or toggle save • Space: toggle save • Esc: back
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -100,8 +100,8 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
for (const startingAction of rawStartingActions) {
|
||||
const existing = actionMap.get(startingAction.action);
|
||||
if (existing) {
|
||||
if (!existing.roles.includes(startingAction.role)) {
|
||||
existing.roles.push(startingAction.role);
|
||||
if (!existing.roles.includes(startingAction.role ?? '')) {
|
||||
existing.roles.push(startingAction.role ?? '');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -111,7 +111,7 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
actionIdentifier: startingAction.action,
|
||||
name: actionDef?.name || startingAction.action,
|
||||
description: actionDef?.description,
|
||||
roles: [startingAction.role],
|
||||
roles: [startingAction.role ?? ''],
|
||||
source: 'starting',
|
||||
});
|
||||
}
|
||||
@@ -119,9 +119,9 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>();
|
||||
for (const outputIdentifier of ownedOutputIdentifiers) {
|
||||
const outputDef = template.outputs?.[outputIdentifier];
|
||||
if (!outputDef || typeof outputDef.lockscript !== 'string') continue;
|
||||
if (!outputDef || typeof outputDef.lockingScript !== 'string') continue;
|
||||
|
||||
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockscript] as
|
||||
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockingScript] as
|
||||
| { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> }
|
||||
| undefined;
|
||||
if (!lockingScriptDefinition?.roles) continue;
|
||||
@@ -217,14 +217,10 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
action.roles.length,
|
||||
index
|
||||
);
|
||||
const sourceSuffix = action.source === 'next'
|
||||
? ' [next]'
|
||||
: action.source === 'starting+next'
|
||||
? ' [start+next]'
|
||||
: '';
|
||||
|
||||
return {
|
||||
key: action.actionIdentifier,
|
||||
label: `${formatted.label}${sourceSuffix}`,
|
||||
label: `${formatted.label}`,
|
||||
description: formatted.description,
|
||||
value: action,
|
||||
hidden: !formatted.isValid,
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useBlockableInput } from '../hooks/useInputLayer.js';
|
||||
import { useInvitation } from '../hooks/useInvitations.js';
|
||||
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
|
||||
import { copyToClipboard } from '../utils/clipboard.js';
|
||||
import type { XOInvitation } from '@xo-cash/types';
|
||||
|
||||
/**
|
||||
* Action menu items.
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ScrollableList, type ListItemData } from '../components/List.js';
|
||||
import { QRCode } from '../components/QRCode.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';
|
||||
@@ -58,6 +59,7 @@ const menuItems: ListItemData<string>[] = [
|
||||
{ key: 'import', label: 'Import Invitation', value: 'import' },
|
||||
{ key: 'invitations', label: 'View Invitations', value: 'invitations' },
|
||||
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' },
|
||||
{ key: 'unreserve-all', label: 'Unreserve All Resources', value: 'unreserve-all' },
|
||||
{ key: 'refresh', label: 'Refresh', value: 'refresh' },
|
||||
];
|
||||
|
||||
@@ -107,6 +109,12 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
const { navigate } = useNavigation();
|
||||
const { appService, showError, showInfo } = useAppContext();
|
||||
const { setStatus } = useStatus();
|
||||
const {
|
||||
currencyCode,
|
||||
fiatPerBchRate,
|
||||
formattedFiatPerBchRate,
|
||||
formatSatoshisToFiat,
|
||||
} = useSatoshisConversion('USD');
|
||||
|
||||
// State
|
||||
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
||||
@@ -160,6 +168,21 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Keep wallet state in sync with invitation lifecycle and updates.
|
||||
useEffect(() => {
|
||||
if (!appService) return;
|
||||
|
||||
const onWalletStateChanged = () => {
|
||||
void refresh();
|
||||
};
|
||||
|
||||
appService.on('wallet-state-changed', onWalletStateChanged);
|
||||
|
||||
return () => {
|
||||
appService.off('wallet-state-changed', onWalletStateChanged);
|
||||
};
|
||||
}, [appService, refresh]);
|
||||
|
||||
/**
|
||||
* Generates a new receiving address and displays it as a QR code.
|
||||
*/
|
||||
@@ -211,6 +234,25 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
}
|
||||
}, [appService, setStatus, showError, refresh]);
|
||||
|
||||
/**
|
||||
* Unreserves all reserved UTXOs and refreshes the wallet state.
|
||||
*/
|
||||
const unreserveAll = useCallback(async () => {
|
||||
if (!appService) {
|
||||
showError('AppService not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus('Unreserving all resources...');
|
||||
const count = await appService.unreserveAllResources();
|
||||
showInfo(`Unreserved ${count} resource(s)`);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(`Failed to unreserve resources: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}, [appService, setStatus, showError, showInfo, refresh]);
|
||||
|
||||
/**
|
||||
* Handles menu action.
|
||||
*/
|
||||
@@ -228,11 +270,14 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
case 'new-address':
|
||||
generateNewAddress();
|
||||
break;
|
||||
case 'unreserve-all':
|
||||
unreserveAll();
|
||||
break;
|
||||
case 'refresh':
|
||||
refresh();
|
||||
break;
|
||||
}
|
||||
}, [navigate, generateNewAddress, refresh]);
|
||||
}, [navigate, generateNewAddress, unreserveAll, refresh]);
|
||||
|
||||
/**
|
||||
* Handle menu item activation.
|
||||
@@ -259,6 +304,26 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
});
|
||||
}, [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();
|
||||
|
||||
@@ -297,11 +362,16 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
}
|
||||
|
||||
if (row.type === 'invitation_input') {
|
||||
const inputSatoshis = row.utxo?.valueSatoshis;
|
||||
const inputFiatSuffix = inputSatoshis !== undefined
|
||||
? getFiatSuffix(inputSatoshis)
|
||||
: '';
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Box>
|
||||
<Text color={itemColor}>
|
||||
{indicator}{groupingPrefix}[Input] {row.label}
|
||||
{inputFiatSuffix}
|
||||
</Text>
|
||||
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
||||
</Box>
|
||||
@@ -317,6 +387,7 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
<Box flexDirection="row">
|
||||
<Text color={itemColor}>
|
||||
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
|
||||
{getFiatSuffix(sats)}
|
||||
</Text>
|
||||
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
||||
</Box>
|
||||
@@ -331,7 +402,10 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Box flexDirection="row">
|
||||
<Text color={itemColor}>{indicator}{formatSatoshis(sats)}</Text>
|
||||
<Text color={itemColor}>
|
||||
{indicator}{formatSatoshis(sats)}
|
||||
{getFiatSuffix(sats)}
|
||||
</Text>
|
||||
{row.description && <Text color={colors.textMuted}> {row.description}{reservedTag}</Text>}
|
||||
</Box>
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
@@ -348,7 +422,7 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}, []);
|
||||
}, [getFiatSuffix]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
@@ -380,6 +454,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>
|
||||
|
||||
@@ -15,7 +15,7 @@ export { DataWizardFlow } from "./DataWizardFlow.js";
|
||||
*/
|
||||
export function createWizardFlow(action: XOTemplateAction): WizardFlow {
|
||||
if (action.data?.length && !action.transaction) {
|
||||
return new DataWizardFlow(action.data);
|
||||
return new DataWizardFlow([action.data]);
|
||||
}
|
||||
return new TransactionWizardFlow();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -22,6 +23,13 @@ export function InputsStep({
|
||||
changeAmount,
|
||||
focusArea,
|
||||
}: Props): React.ReactElement {
|
||||
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
||||
|
||||
const getFiatSuffix = (satoshis: bigint): string => {
|
||||
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||
return fiatValue ? ` (~${fiatValue})` : '';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text color={colors.text} bold>
|
||||
@@ -32,6 +40,7 @@ export function InputsStep({
|
||||
<Text color={colors.textMuted}>
|
||||
Required: {formatSatoshis(requiredAmount)} +{' '}
|
||||
{formatSatoshis(fee)} fee
|
||||
{getFiatSuffix(requiredAmount + fee)}
|
||||
</Text>
|
||||
<Text
|
||||
color={
|
||||
@@ -41,10 +50,12 @@ export function InputsStep({
|
||||
}
|
||||
>
|
||||
Selected: {formatSatoshis(selectedAmount)}
|
||||
{getFiatSuffix(selectedAmount)}
|
||||
</Text>
|
||||
{selectedAmount > requiredAmount + fee && (
|
||||
<Text color={colors.info}>
|
||||
Change: {formatSatoshis(changeAmount)}
|
||||
{getFiatSuffix(changeAmount)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
@@ -65,6 +76,7 @@ export function InputsStep({
|
||||
return (
|
||||
<Box
|
||||
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
||||
flexDirection='column'
|
||||
>
|
||||
<Text
|
||||
color={isCursor ? colors.focus : colors.text}
|
||||
@@ -75,6 +87,15 @@ export function InputsStep({
|
||||
{formatHex(utxo.outpointTransactionHash, 12)}:
|
||||
{utxo.outpointIndex}
|
||||
</Text>
|
||||
{(() => {
|
||||
const fiatValue = formatSatoshisToFiat(utxo.valueSatoshis);
|
||||
if (!fiatValue) return null;
|
||||
return (
|
||||
<Text color={colors.textMuted}>
|
||||
{' '}≈ {fiatValue}
|
||||
</Text>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -22,6 +23,32 @@ export function ReviewStep({
|
||||
changeAmount,
|
||||
}: ReviewStepProps): React.ReactElement {
|
||||
const selectedUtxos = availableUtxos.filter((u) => u.selected);
|
||||
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
||||
|
||||
const getFiatSuffix = (satoshis: bigint): string => {
|
||||
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||
return fiatValue ? ` (~${fiatValue})` : '';
|
||||
};
|
||||
|
||||
const getVariableFiatSuffix = (variable: VariableInput): string => {
|
||||
if (variable.type !== 'integer') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (variable.hint?.toLowerCase().includes('satoshi') !== true) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!/^[-]?\d+$/.test(variable.value.trim())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return getFiatSuffix(BigInt(variable.value));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
@@ -44,6 +71,7 @@ export function ReviewStep({
|
||||
<Text key={v.id} color={colors.textMuted}>
|
||||
{' '}
|
||||
{v.name}: {v.value || '(empty)'}
|
||||
{v.value ? getVariableFiatSuffix(v) : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
@@ -62,6 +90,7 @@ export function ReviewStep({
|
||||
>
|
||||
{' '}
|
||||
{formatSatoshis(u.valueSatoshis)}
|
||||
{getFiatSuffix(u.valueSatoshis)}
|
||||
</Text>
|
||||
))}
|
||||
{selectedUtxos.length > 3 && (
|
||||
@@ -78,6 +107,7 @@ export function ReviewStep({
|
||||
<Text color={colors.text}>Outputs:</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
{' '}Change: {formatSatoshis(changeAmount)}
|
||||
{getFiatSuffix(changeAmount)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -61,13 +61,16 @@ export function RoleSelectStep({
|
||||
{availableRoles.length === 0 ? (
|
||||
<Text color={colors.textMuted}>No roles available</Text>
|
||||
) : (
|
||||
availableRoles.map((roleId, index) => {
|
||||
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
|
||||
@@ -96,10 +99,10 @@ export function RoleSelectStep({
|
||||
{' '}
|
||||
</Text>
|
||||
)}
|
||||
{requirements.slots && requirements.slots.min > 0 && (
|
||||
{actionRoleRequirements && actionRoleRequirements.slots && actionRoleRequirements.slots.min > 0 && (
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
{requirements.slots.min} input slot
|
||||
{requirements.slots.min !== 1 ? 's' : ''}
|
||||
{actionRoleRequirements.slots.min} input slot
|
||||
{actionRoleRequirements.slots.min !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -17,6 +17,7 @@ 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';
|
||||
@@ -88,6 +89,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
const { setStatus } = useStatus();
|
||||
|
||||
const invitations = useInvitations();
|
||||
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
|
||||
useSatoshisConversion('USD');
|
||||
|
||||
// ── UI state ─────────────────────────────────────────────────────────────
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
@@ -99,6 +102,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
// Two phases: first the ID input dialog, then the multi-step import flow.
|
||||
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());
|
||||
@@ -158,7 +162,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
});
|
||||
|
||||
return [importItem, ...invitationItems];
|
||||
}, [invitations, templateCache]);
|
||||
}, [invitations.length, templateCache]);
|
||||
|
||||
const selectedItem = listItems[selectedIndex];
|
||||
const selectedInvitation = selectedItem?.value ?? null;
|
||||
@@ -193,10 +197,30 @@ export function InvitationScreen(): React.ReactElement {
|
||||
/**
|
||||
* Import flow closed (completed or cancelled).
|
||||
*/
|
||||
const handleImportFlowClose = useCallback(() => {
|
||||
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 () => {
|
||||
@@ -327,10 +351,10 @@ export function InvitationScreen(): React.ReactElement {
|
||||
const seenLockingBytecodes = new Set<string>();
|
||||
|
||||
for (const utxo of utxos) {
|
||||
const lockingBytecodeHex = utxo.lockingBytecode
|
||||
? typeof utxo.lockingBytecode === 'string'
|
||||
? utxo.lockingBytecode
|
||||
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
||||
const lockingBytecodeHex = utxo.scriptHash
|
||||
? typeof utxo.scriptHash === 'string'
|
||||
? utxo.scriptHash
|
||||
: Buffer.from(utxo.scriptHash).toString('hex')
|
||||
: undefined;
|
||||
|
||||
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue;
|
||||
@@ -494,6 +518,44 @@ export function InvitationScreen(): React.ReactElement {
|
||||
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 */}
|
||||
@@ -514,6 +576,11 @@ export function InvitationScreen(): React.ReactElement {
|
||||
<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>
|
||||
)}
|
||||
@@ -542,6 +609,11 @@ export function InvitationScreen(): React.ReactElement {
|
||||
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}`}
|
||||
@@ -550,6 +622,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
{' '}{isUserInput ? '• ' : '○ '}
|
||||
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
||||
{inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
@@ -564,6 +637,9 @@ export function InvitationScreen(): React.ReactElement {
|
||||
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}`}
|
||||
@@ -571,7 +647,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
>
|
||||
{' '}{isUserOutput ? '• ' : '○ '}
|
||||
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
|
||||
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
@@ -591,6 +667,9 @@ export function InvitationScreen(): React.ReactElement {
|
||||
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}`}
|
||||
@@ -598,6 +677,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
>
|
||||
{' '}{isUserVariable ? '• ' : '○ '}
|
||||
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
|
||||
{parsedVariableSatoshis !== null &&
|
||||
` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`}
|
||||
{varTemplate?.description && (
|
||||
<Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text>
|
||||
)}
|
||||
|
||||
@@ -148,7 +148,7 @@ export function InvitationImportFlow({
|
||||
`Action: ${invitation?.data.actionIdentifier ?? 'Unknown'}`
|
||||
);
|
||||
setStatus('Ready');
|
||||
onClose();
|
||||
onClose(invitation?.data.invitationIdentifier);
|
||||
}, [selectedRole, template, invitation, showInfo, setStatus, onClose]);
|
||||
|
||||
// ── Keyboard handling ────────────────────────────────────────────────────
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
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';
|
||||
@@ -32,6 +33,7 @@ export function InputsSelectStep({
|
||||
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
||||
|
||||
const fee = DEFAULT_FEE;
|
||||
|
||||
@@ -42,6 +44,11 @@ export function InputsSelectStep({
|
||||
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.
|
||||
*/
|
||||
@@ -94,10 +101,7 @@ export function InputsSelectStep({
|
||||
// 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({
|
||||
|
||||
outputIdentifier,
|
||||
});
|
||||
const suitableResources = await invitation.findSuitableResources();
|
||||
for (const suitableResource of suitableResources) {
|
||||
utxoIdToSuitableResource.set(suitableResource.outpointTransactionHash + ':' + suitableResource.outpointIndex, suitableResource);
|
||||
}
|
||||
@@ -142,7 +146,7 @@ export function InputsSelectStep({
|
||||
setFocusedIndex(prev => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow || input === 'j') {
|
||||
setFocusedIndex(prev => Math.min(utxos.length - 1, prev + 1));
|
||||
} else if (input === ' ' || (key.return && utxos.length > 0)) {
|
||||
} else if (input === ' ') {
|
||||
if (utxos.length > 0) toggleSelection(focusedIndex);
|
||||
} else if (input === 'a') {
|
||||
setUtxos(prev => prev.map(u => ({ ...u, selected: true })));
|
||||
@@ -196,18 +200,32 @@ export function InputsSelectStep({
|
||||
{/* Summary bar */}
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Text color={colors.primary} bold>Required: </Text>
|
||||
<Text color={colors.text}>{formatSatoshis(requiredAmount + fee)}</Text>
|
||||
<Text color={colors.text}>
|
||||
{formatSatoshis(requiredAmount + fee)}
|
||||
{getFiatSuffix(requiredAmount + fee)}
|
||||
</Text>
|
||||
<Text color={colors.textMuted}> (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)})</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Text color={colors.primary} bold>Selected: </Text>
|
||||
<Text color={hasEnough ? colors.success : colors.error}>{formatSatoshis(selectedAmount)}</Text>
|
||||
<Text color={hasEnough ? colors.success : colors.error}>
|
||||
{formatSatoshis(selectedAmount)}
|
||||
{getFiatSuffix(selectedAmount)}
|
||||
</Text>
|
||||
{hasEnough && changeAmount >= DUST_THRESHOLD && (
|
||||
<Text color={colors.textMuted}> (change: {formatSatoshis(changeAmount)})</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
{' '}
|
||||
(change: {formatSatoshis(changeAmount)}
|
||||
{getFiatSuffix(changeAmount)})
|
||||
</Text>
|
||||
)}
|
||||
{!hasEnough && (
|
||||
<Text color={colors.error}> — need {formatSatoshis(requiredAmount + fee - selectedAmount)} more</Text>
|
||||
<Text color={colors.error}>
|
||||
{' '}
|
||||
— need {formatSatoshis(requiredAmount + fee - selectedAmount)}
|
||||
{getFiatSuffix(requiredAmount + fee - selectedAmount)} more
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -219,13 +237,22 @@ export function InputsSelectStep({
|
||||
const txShort = utxo.outpointTransactionHash.slice(0, 8);
|
||||
|
||||
return (
|
||||
<Text
|
||||
<Box
|
||||
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
||||
color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text}
|
||||
bold={isFocused}
|
||||
flexDirection="column"
|
||||
>
|
||||
{isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}…:{utxo.outpointIndex})
|
||||
</Text>
|
||||
<Text
|
||||
color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text}
|
||||
bold={isFocused}
|
||||
>
|
||||
{isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}…:{utxo.outpointIndex})
|
||||
</Text>
|
||||
{formatSatoshisToFiat(utxo.valueSatoshis) && (
|
||||
<Text color={colors.textMuted}>
|
||||
{' '}≈ {formatSatoshisToFiat(utxo.valueSatoshis)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
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,
|
||||
@@ -41,6 +42,8 @@ export function PreviewInvitationStep({
|
||||
onCancel,
|
||||
isActive,
|
||||
}: PreviewStepProps): React.ReactElement {
|
||||
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
||||
|
||||
useLayeredInput('import-flow', (_input, key) => {
|
||||
if (key.return) onComplete();
|
||||
if (key.escape) onCancel();
|
||||
@@ -168,11 +171,15 @@ export function PreviewInvitationStep({
|
||||
) : (
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
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';
|
||||
|
||||
@@ -32,6 +33,7 @@ export function ReviewStep({
|
||||
}: ReviewStepProps): React.ReactElement {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
||||
|
||||
const fee = DEFAULT_FEE;
|
||||
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||
@@ -39,6 +41,11 @@ export function ReviewStep({
|
||||
// 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.
|
||||
*/
|
||||
@@ -85,14 +92,34 @@ export function ReviewStep({
|
||||
<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)}</Text>
|
||||
<Text color={colors.text}> • Required: {formatSatoshis(requiredAmount)}</Text>
|
||||
<Text color={colors.text}> • Fee: {formatSatoshis(fee)}</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)}</Text>
|
||||
<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}>
|
||||
|
||||
@@ -116,8 +116,12 @@ export interface ImportFlowProps {
|
||||
mode: ImportFlowMode;
|
||||
/** The application service — injected, not pulled from context. */
|
||||
appService: AppService;
|
||||
/** Called when the flow completes or is cancelled. */
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Called when the flow completes or is cancelled.
|
||||
* When import succeeds, the invitation identifier is provided so callers can
|
||||
* select/focus the imported invitation in their UI.
|
||||
*/
|
||||
onClose: (importedInvitationId?: string) => void;
|
||||
/** Display an error message to the user. */
|
||||
showError: (message: string) => void;
|
||||
/** Display an info message to the user. */
|
||||
|
||||
@@ -8,6 +8,36 @@ 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.
|
||||
@@ -21,46 +51,25 @@ export async function copyToClipboard(text: string): Promise<void> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
} else if (platform === "win32") {
|
||||
// Windows - use clip.exe
|
||||
await execAsync(`echo|set /p="${text}" | clip`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Native command failed, try clipboardy
|
||||
}
|
||||
const availableMethods = Object.values(clipboardMethods).filter(method => method.platform(platform));
|
||||
|
||||
// Fallback to clipboardy
|
||||
try {
|
||||
clipboardy.writeSync(text);
|
||||
return;
|
||||
} catch {
|
||||
// clipboardy also failed
|
||||
const errors: Error[] = [];
|
||||
|
||||
for (const method of availableMethods) {
|
||||
try {
|
||||
if (method.platform(platform)) {
|
||||
await method.command(escapedText);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
} catch(error) {
|
||||
if (error instanceof Error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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')}`);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Pulled directly from the old stack package.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { decodeBip39Mnemonic } from "@bitauth/libauth";
|
||||
|
||||
export type BCHMnemonicURLRaw = {
|
||||
entropy: Uint8Array;
|
||||
@@ -86,6 +87,18 @@ export class BCHMnemonicURL {
|
||||
return new BCHMnemonicURL(raw);
|
||||
}
|
||||
|
||||
static fromSeed(seed: string): BCHMnemonicURL {
|
||||
// Encode the seed to a Uint8Array
|
||||
const entropy = decodeBip39Mnemonic(seed);
|
||||
|
||||
// If the decode failed, throw an error
|
||||
if (typeof entropy === "string") {
|
||||
throw new Error(`Invalid seed: ${entropy}`);
|
||||
}
|
||||
|
||||
return BCHMnemonicURL.fromRaw({ entropy });
|
||||
}
|
||||
|
||||
constructor(protected raw: BCHMnemonicURLRaw) {}
|
||||
|
||||
toObject() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { XOTemplate, XOTemplateTransactionOutput } from "@xo-cash/types";
|
||||
import type { Invitation } from "../services/invitation.js";
|
||||
import { cashAddressToLockingBytecode, binToHex } from "@bitauth/libauth";
|
||||
|
||||
export interface SelectableUtxoLike {
|
||||
outpointTransactionHash: string;
|
||||
@@ -44,7 +45,8 @@ export const resolveActionRoles = (
|
||||
const starts = template.start ?? [];
|
||||
const roleIds = starts
|
||||
.filter((entry) => entry.action === actionIdentifier)
|
||||
.map((entry) => entry.role);
|
||||
.map((entry) => entry.role)
|
||||
.filter((roleId) => roleId !== undefined);
|
||||
|
||||
return [...new Set(roleIds)];
|
||||
};
|
||||
@@ -59,17 +61,11 @@ export const roleRequiresInputs = (
|
||||
if (!action) return false;
|
||||
|
||||
const actionRole = action.roles?.[roleIdentifier];
|
||||
const roleSlotsMin = actionRole?.requirements?.slots?.min ?? 0;
|
||||
const actionRequirements = action.requirements;
|
||||
const actionRoleRequirements = actionRole && actionRequirements && actionRequirements.participants?.find((participant) => participant.role === roleIdentifier);
|
||||
const roleSlotsMin = actionRoleRequirements && actionRoleRequirements.slots && actionRoleRequirements.slots.min > 0 ? actionRoleRequirements.slots.min : 0;
|
||||
if (roleSlotsMin > 0) return true;
|
||||
|
||||
// Some templates specify slot/input requirements at action.requirements.roles
|
||||
// instead of role.requirements. Respect those as well.
|
||||
const roleRequirement = action.requirements?.roles?.find(
|
||||
(requirement) => requirement.role === roleIdentifier,
|
||||
);
|
||||
const actionLevelSlotsMin = roleRequirement?.slots?.min ?? 0;
|
||||
if (actionLevelSlotsMin > 0) return true;
|
||||
|
||||
const transactionIdentifier = action.transaction;
|
||||
const transaction = transactionIdentifier
|
||||
? template.transactions?.[transactionIdentifier]
|
||||
@@ -97,19 +93,46 @@ export const getTransactionOutputIdentifier = (
|
||||
export const normalizeLockingBytecodeHex = (value: string): string =>
|
||||
value.trim().replace(/^0x/i, "");
|
||||
|
||||
/**
|
||||
* Checks whether a string looks like a CashAddress and, if so, converts it
|
||||
* to locking bytecode hex. Returns undefined when the value is not a
|
||||
* recognizable CashAddress (callers should fall through to treat it as raw hex).
|
||||
*/
|
||||
export const tryCashAddressToLockingBytecodeHex = (
|
||||
value: string,
|
||||
): string | undefined => {
|
||||
const trimmed = value.trim();
|
||||
|
||||
// Quick prefix check so we don't run the decoder on obvious hex strings.
|
||||
const looksLikeCashAddress =
|
||||
trimmed.startsWith("bitcoincash:") ||
|
||||
trimmed.startsWith("bchtest:") ||
|
||||
trimmed.startsWith("bchreg:") ||
|
||||
// Handle prefix-less addresses (e.g. "qp..." or "pp...")
|
||||
/^[qpQP][a-zA-Z0-9]{41,}$/.test(trimmed);
|
||||
|
||||
if (!looksLikeCashAddress) return undefined;
|
||||
|
||||
const result = cashAddressToLockingBytecode(trimmed);
|
||||
|
||||
// cashAddressToLockingBytecode returns a string on failure.
|
||||
if (typeof result === "string") return undefined;
|
||||
|
||||
return binToHex(result.bytecode);
|
||||
};
|
||||
|
||||
export const resolveProvidedLockingBytecodeHex = (
|
||||
template: XOTemplate,
|
||||
outputIdentifier: string,
|
||||
variableValues: Record<string, string>,
|
||||
): string | undefined => {
|
||||
const outputDefinition = template.outputs?.[outputIdentifier];
|
||||
if (!outputDefinition || typeof outputDefinition.lockscript !== "string")
|
||||
if (!outputDefinition || typeof outputDefinition.lockingScript !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lockingScriptDefinition = (
|
||||
template.lockingScripts as Record<string, unknown> | undefined
|
||||
)?.[outputDefinition.lockscript] as { lockingScript?: string } | undefined;
|
||||
const scriptIdentifier = lockingScriptDefinition?.lockingScript;
|
||||
const lockingScriptDefinition = template.lockingScripts?.[outputDefinition.lockingScript];
|
||||
const scriptIdentifier = lockingScriptDefinition?.lockingBytecode;
|
||||
if (!scriptIdentifier) return undefined;
|
||||
|
||||
const scriptExpression = (
|
||||
@@ -128,6 +151,10 @@ export const resolveProvidedLockingBytecodeHex = (
|
||||
const providedValue = variableValues[variableIdentifier];
|
||||
if (!providedValue) return undefined;
|
||||
|
||||
// If the user pasted a CashAddress, convert it to locking bytecode hex.
|
||||
const fromAddress = tryCashAddressToLockingBytecodeHex(providedValue);
|
||||
if (fromAddress) return fromAddress;
|
||||
|
||||
return normalizeLockingBytecodeHex(providedValue);
|
||||
};
|
||||
|
||||
|
||||
70
src/utils/paths.ts
Normal file
70
src/utils/paths.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Global XO CLI config layout (XDG-style: ~/.config/xo-cli/).
|
||||
* User-provided paths (templates, invitation JSON) stay relative to cwd.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Base config directory. Created on first access.
|
||||
*/
|
||||
export function getConfigDir(): string {
|
||||
const dir = join(homedir(), ".config", "xo-cli");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory for mnemonic wallet files (mnemonic-*).
|
||||
*/
|
||||
export function getMnemonicsDir(): string {
|
||||
const dir = join(getConfigDir(), "mnemonics");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory for engine DB and invitation storage SQLite files.
|
||||
*/
|
||||
export function getDataDir(): string {
|
||||
const dir = join(getConfigDir(), "data");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* File storing the last-used mnemonic reference for `-m` omission.
|
||||
*/
|
||||
export function getWalletConfigPath(): string {
|
||||
return join(getConfigDir(), ".wallet");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a mnemonic reference to an absolute path.
|
||||
* Order: absolute path if it exists → path relative to cwd → ~/.config/xo-cli/mnemonics/<basename>.
|
||||
*
|
||||
* @param mnemonicRef - Path or basename (e.g. `mnemonic-nuclear`)
|
||||
* @returns Absolute path to the mnemonic file
|
||||
* @throws If no matching file exists
|
||||
*/
|
||||
export function resolveMnemonicFilePath(mnemonicRef: string): string {
|
||||
if (isAbsolute(mnemonicRef) && existsSync(mnemonicRef)) {
|
||||
return mnemonicRef;
|
||||
}
|
||||
|
||||
const relativeToCwd = resolve(process.cwd(), mnemonicRef);
|
||||
if (existsSync(relativeToCwd)) {
|
||||
return relativeToCwd;
|
||||
}
|
||||
|
||||
const inMnemonics = join(getMnemonicsDir(), basename(mnemonicRef));
|
||||
if (existsSync(inMnemonics)) {
|
||||
return inMnemonics;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Mnemonic file not found: ${mnemonicRef}. Run "xo-cli mnemonic list" to see available files.`,
|
||||
);
|
||||
}
|
||||
56
src/utils/rates/base-rates.ts
Normal file
56
src/utils/rates/base-rates.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { EventEmitter } from '../event-emitter.js';
|
||||
|
||||
/**
|
||||
* Events emitted by our Rates Adapters
|
||||
*/
|
||||
export type RatesEventMap = {
|
||||
rateUpdated: {
|
||||
numeratorUnitCode: string;
|
||||
denominatorUnitCode: string;
|
||||
price: number;
|
||||
};
|
||||
};
|
||||
|
||||
export abstract class BaseRates<
|
||||
T extends RatesEventMap = RatesEventMap,
|
||||
> extends EventEmitter<T> {
|
||||
/** Starts the given rates adapter so that it will emit events on price updates. */
|
||||
public abstract start(): Promise<void>;
|
||||
|
||||
/** Stops the given rates adapter so that it will stop checking for price updates. */
|
||||
public abstract stop(): Promise<void>;
|
||||
|
||||
/**
|
||||
* List all available market products (pairs).
|
||||
* @returns A set of strings in the format "NUMERATOR/DENOMINATOR"
|
||||
*/
|
||||
public abstract listPairs(): Promise<Set<string>>;
|
||||
|
||||
// TODO: Consider whether we actually want the below.
|
||||
// Ideally, we will want to replace this with something like the Units class:
|
||||
// See: https://gitlab.com/GeneralProtocols/xo/stack/-/issues/44
|
||||
/**
|
||||
* Format the amount in the target currency to the correct number of decimal places.
|
||||
*
|
||||
* @param {number} amount - The amount to format.
|
||||
* @param {string} targetCurrency - The target currency.
|
||||
*
|
||||
* @returns The formatted amount.
|
||||
*/
|
||||
public formatCurrency(amount: number, targetCurrency: string): string {
|
||||
const minimumFractionDigitsMap: { [currency: string]: number } = {
|
||||
AUD: 2,
|
||||
BCH: 8,
|
||||
USD: 2,
|
||||
};
|
||||
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: targetCurrency,
|
||||
currencyDisplay: 'narrowSymbol',
|
||||
minimumFractionDigits: minimumFractionDigitsMap[targetCurrency] || 0,
|
||||
});
|
||||
|
||||
return formatter.format(amount);
|
||||
}
|
||||
}
|
||||
170
src/utils/rates/rates-oracles.ts
Normal file
170
src/utils/rates/rates-oracles.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
OracleClient,
|
||||
OracleMetadataMessage,
|
||||
OraclePriceMessage,
|
||||
type OracleMetadataMap,
|
||||
} from '@generalprotocols/oracle-client';
|
||||
|
||||
import { type RatesEventMap, BaseRates } from './base-rates.js';
|
||||
|
||||
// Add the Oracle Price Message to our Events for this Adapter.
|
||||
export type RatesOracleEventMap = RatesEventMap & {
|
||||
rateUpdated: {
|
||||
oraclePriceMessage: OraclePriceMessage;
|
||||
};
|
||||
};
|
||||
|
||||
// TODO: Add RatesHistorical trait since Oracles can provide historical rates.
|
||||
export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
||||
/**
|
||||
* Create a new rates oracle.
|
||||
*
|
||||
* @param client The underlying oracle client. If not provided, a new client will be created.
|
||||
* @returns The rates oracle.
|
||||
*/
|
||||
static async from(client?: OracleClient) {
|
||||
const ratesOracle = new RatesOracle(client ?? (await OracleClient.from()));
|
||||
|
||||
return ratesOracle;
|
||||
}
|
||||
|
||||
private client: OracleClient;
|
||||
private oracles: OracleMetadataMap;
|
||||
|
||||
private started: boolean = false;
|
||||
|
||||
private constructor(client: OracleClient) {
|
||||
super();
|
||||
|
||||
this.client = client;
|
||||
this.oracles = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the rates oracle and the underlying client.
|
||||
*/
|
||||
async start() {
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
|
||||
// Create event listeners for the client.
|
||||
this.client.setOnMetadataMessage(this.handleMetadataMessage.bind(this));
|
||||
this.client.setOnPriceMessage(this.handlePriceMessage.bind(this));
|
||||
|
||||
// Get the metadata for the client.
|
||||
this.oracles = await this.client.getMetadataMap();
|
||||
|
||||
// Start the client.
|
||||
await this.client.start();
|
||||
|
||||
// Refresh the prices.
|
||||
await this.refreshPrices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the rates oracle and the underlying client.
|
||||
*/
|
||||
async stop() {
|
||||
if (!this.started) {
|
||||
return;
|
||||
}
|
||||
this.started = false;
|
||||
|
||||
// Remove event listeners by setting them to empty functions.
|
||||
this.client.setOnMetadataMessage(() => {});
|
||||
this.client.setOnPriceMessage(() => {});
|
||||
|
||||
await this.client.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* List the pairs that we are tracking.
|
||||
*
|
||||
* @returns A set of pairs.
|
||||
*/
|
||||
async listPairs() {
|
||||
return new Set(
|
||||
Object.values(this.oracles).map((oracle) => {
|
||||
return `${oracle.SOURCE_NUMERATOR_UNIT_CODE}/${oracle.SOURCE_DENOMINATOR_UNIT_CODE}`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest prices for all the pairs and emit a rate updated event for each.
|
||||
*/
|
||||
public async refreshPrices() {
|
||||
const oracles = await this.client.getOracles();
|
||||
|
||||
// For each oracle, get the lastest dataSequence (price) message and emit a rate updated event.
|
||||
await Promise.allSettled(
|
||||
oracles.map(async (oracle) => {
|
||||
try {
|
||||
const messages = await this.client.getOracleMessages({
|
||||
publicKey: oracle.publicKey,
|
||||
minDataSequence: 1,
|
||||
count: 1,
|
||||
});
|
||||
|
||||
// We are only expecting a single message back. Just in case, we take the latest one.
|
||||
const message = messages.reduce((latest, msg) => {
|
||||
if (
|
||||
msg instanceof OraclePriceMessage &&
|
||||
msg.messageSequence > (latest?.messageSequence ?? 0)
|
||||
) {
|
||||
return msg;
|
||||
}
|
||||
return latest;
|
||||
}, messages[0]);
|
||||
|
||||
// If the message is a price message, handle it.
|
||||
if (message instanceof OraclePriceMessage) {
|
||||
this.handlePriceMessage(message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing prices for oracle:', oracle.publicKey, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the metadata map that we use to track the pairs.
|
||||
*
|
||||
* @param message The metadata message.
|
||||
*/
|
||||
private handleMetadataMessage(message: OracleMetadataMessage) {
|
||||
this.oracles = OracleClient.updateMetadataMap(this.oracles, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a rate updated event for the given pair.
|
||||
*
|
||||
* @param message The price message.
|
||||
*/
|
||||
private handlePriceMessage(message: OraclePriceMessage) {
|
||||
const oracle = this.oracles[message.toHexObject().publicKey];
|
||||
|
||||
// If the oracle doesn't have the required metadata, we can't use it.
|
||||
if (
|
||||
!oracle ||
|
||||
!oracle.SOURCE_NUMERATOR_UNIT_CODE ||
|
||||
!oracle.SOURCE_DENOMINATOR_UNIT_CODE ||
|
||||
!oracle.ATTESTATION_SCALING
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Scale the price
|
||||
const priceValue = message.priceValue / oracle.ATTESTATION_SCALING;
|
||||
|
||||
this.emit('rateUpdated', {
|
||||
numeratorUnitCode: oracle.SOURCE_NUMERATOR_UNIT_CODE,
|
||||
denominatorUnitCode: oracle.SOURCE_DENOMINATOR_UNIT_CODE,
|
||||
price: priceValue,
|
||||
oraclePriceMessage: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -188,11 +188,13 @@ export function getRolesForAction(
|
||||
);
|
||||
|
||||
return startEntries.map((entry) => {
|
||||
const roleDef = template.roles?.[entry.role];
|
||||
const roleDef = template.roles?.[entry.role || ''];
|
||||
const roleObj = typeof roleDef === "object" ? roleDef : null;
|
||||
|
||||
// TODO: This is ugly. Lot of conditionals. Need to take a much closer look at this.
|
||||
return {
|
||||
roleId: entry.role,
|
||||
name: roleObj?.name || entry.role,
|
||||
roleId: entry.role || '',
|
||||
name: roleObj?.name || entry.role || '',
|
||||
description: roleObj?.description,
|
||||
};
|
||||
});
|
||||
|
||||
69
tests/cli/arguments.test.ts
Normal file
69
tests/cli/arguments.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { convertArgsToObject } from "../../src/cli/arguments";
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
input: ["-h", "--help", "-m", "--mnemonic-file", "mnemonic.txt"],
|
||||
expected: {
|
||||
args: [],
|
||||
options: { help: "true", mnemonicFile: "mnemonic.txt" },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: ["-var-requested-satohis", "1000", "-role", "receiver"],
|
||||
expected: {
|
||||
args: [],
|
||||
options: { varRequestedSatohis: "1000", role: "receiver" },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: [
|
||||
"-o",
|
||||
"output.json",
|
||||
"-var-requested-satohis",
|
||||
"1000",
|
||||
"-role",
|
||||
"receiver",
|
||||
],
|
||||
expected: {
|
||||
args: [],
|
||||
options: {
|
||||
output: "output.json",
|
||||
varRequestedSatohis: "1000",
|
||||
role: "receiver",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: ["mnemonic", "create", "page", "pencil", "-v", "-o", "mnemonic.txt"],
|
||||
expected: {
|
||||
args: ["mnemonic", "create", "page", "pencil"],
|
||||
options: { verbose: "true", output: "mnemonic.txt" },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: ["-v", "invitation", "list", "-m", "mnemonicFile"],
|
||||
expected: {
|
||||
args: ["invitation", "list"],
|
||||
options: { verbose: "true", mnemonicFile: "mnemonicFile" },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: ["--help", "template", "import", "template.json"],
|
||||
expected: {
|
||||
args: ["template", "import", "template.json"],
|
||||
options: { help: "true" },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("convertArgsToObject", () => {
|
||||
it.each(testCases)(
|
||||
"should split positional args from options",
|
||||
({ input, expected }) => {
|
||||
const result = convertArgsToObject(input);
|
||||
expect(result).toEqual(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
94
tests/cli/commands/handler-contracts.test.ts
Normal file
94
tests/cli/commands/handler-contracts.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { handleMnemonicCommand } from "../../../src/cli/commands/mnemonic";
|
||||
import { handleTemplateCommand } from "../../../src/cli/commands/template";
|
||||
import { handleReceiveCommand } from "../../../src/cli/commands/receive";
|
||||
import { handleResourceCommand } from "../../../src/cli/commands/resource";
|
||||
import { CommandError } from "../../../src/cli/commands/types";
|
||||
import {
|
||||
createBaseCommandDeps,
|
||||
createCommandDeps,
|
||||
createMockIO,
|
||||
} from "../mocks/command";
|
||||
|
||||
const fakeApp = {
|
||||
engine: {},
|
||||
invitations: [],
|
||||
unreserveAllResources: async () => 0,
|
||||
} as any;
|
||||
|
||||
describe("command handler contracts", () => {
|
||||
test("mnemonic throws and prints help for missing subcommand", async () => {
|
||||
const { io, capture } = createMockIO();
|
||||
|
||||
await expect(
|
||||
handleMnemonicCommand(createBaseCommandDeps(io), [], {}),
|
||||
).rejects.toThrow(CommandError);
|
||||
|
||||
try {
|
||||
await handleMnemonicCommand(createBaseCommandDeps(io), [], {});
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CommandError);
|
||||
expect((error as CommandError).event).toBe("mnemonic.subcommand.missing");
|
||||
}
|
||||
|
||||
expect(capture.out.join("\n")).toContain("Usage:");
|
||||
});
|
||||
|
||||
test("template throws for missing subcommand", async () => {
|
||||
const { io, capture } = createMockIO();
|
||||
|
||||
await expect(
|
||||
handleTemplateCommand(createCommandDeps(fakeApp, io), [], {}),
|
||||
).rejects.toThrow(CommandError);
|
||||
|
||||
try {
|
||||
await handleTemplateCommand(createCommandDeps(fakeApp, io), [], {});
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CommandError);
|
||||
expect((error as CommandError).event).toBe("template.subcommand.missing");
|
||||
}
|
||||
|
||||
expect(capture.out.join("\n")).toContain("Usage:");
|
||||
});
|
||||
|
||||
test("receive throws for missing args", async () => {
|
||||
const { io, capture } = createMockIO();
|
||||
|
||||
await expect(
|
||||
handleReceiveCommand(createCommandDeps(fakeApp, io), [], {}),
|
||||
).rejects.toThrow(CommandError);
|
||||
|
||||
try {
|
||||
await handleReceiveCommand(createCommandDeps(fakeApp, io), [], {});
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CommandError);
|
||||
expect((error as CommandError).event).toBe("receive.arguments.missing");
|
||||
}
|
||||
|
||||
expect(capture.out.join("\n")).toContain("Usage:");
|
||||
});
|
||||
|
||||
test("resource throws for unknown subcommand", async () => {
|
||||
const { io } = createMockIO();
|
||||
|
||||
await expect(
|
||||
handleResourceCommand(
|
||||
createCommandDeps(fakeApp, io),
|
||||
["does-not-exist"],
|
||||
{},
|
||||
),
|
||||
).rejects.toThrow(CommandError);
|
||||
|
||||
try {
|
||||
await handleResourceCommand(
|
||||
createCommandDeps(fakeApp, io),
|
||||
["does-not-exist"],
|
||||
{},
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CommandError);
|
||||
expect((error as CommandError).event).toBe("resource.subcommand.unknown");
|
||||
}
|
||||
});
|
||||
});
|
||||
1447
tests/cli/commands/invitation.test.ts
Normal file
1447
tests/cli/commands/invitation.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
165
tests/cli/commands/mnemonic.test.ts
Normal file
165
tests/cli/commands/mnemonic.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { DEFAULT_SEED } from "../mocks/engine";
|
||||
|
||||
import { handleMnemonicCommand } from "../../../src/cli/commands/mnemonic";
|
||||
import { CommandError } from "../../../src/cli/commands/types";
|
||||
import {
|
||||
createMockIO,
|
||||
createMockPaths,
|
||||
expectLogs,
|
||||
type LogExpectation,
|
||||
} from "../mocks/command";
|
||||
import { BCHMnemonicURL } from "../../../src/utils/bch-mnemonic-url";
|
||||
|
||||
type TestCase = {
|
||||
inputs: string[];
|
||||
options?: Record<string, string>;
|
||||
shouldThrow: boolean;
|
||||
expectedEvent?: string;
|
||||
expectedData?: Record<string, unknown>;
|
||||
logs?: LogExpectation[];
|
||||
};
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
// Successful creation of a mnemonic file
|
||||
{
|
||||
inputs: ["create"],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
savedAs: expect.stringMatching(/^mnemonic-\w+$/),
|
||||
},
|
||||
logs: [{ out: "Mnemonic file created" }],
|
||||
},
|
||||
// Successfully creating a mnemonic file with a custom filename
|
||||
{
|
||||
inputs: ["create"],
|
||||
options: { output: "custom-filename" },
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
savedAs: "custom-filename",
|
||||
},
|
||||
logs: [{ out: "custom-filename" }],
|
||||
},
|
||||
// Successfully listing mnemonic files
|
||||
{
|
||||
inputs: ["list"],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
count: expect.toSatisfy((count: number) => count >= 1),
|
||||
},
|
||||
logs: [{ out: "mnemonic-test" }],
|
||||
},
|
||||
// Successfully exposing a mnemonic file
|
||||
{
|
||||
inputs: ["expose", "mnemonic-test"],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
mnemonic: DEFAULT_SEED,
|
||||
},
|
||||
logs: [{ out: DEFAULT_SEED }],
|
||||
},
|
||||
// Successfully importing a mnemonic file
|
||||
{
|
||||
inputs: ["import", ...DEFAULT_SEED.split(" ")],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
savedAs: expect.stringMatching(/^mnemonic-\w+$/),
|
||||
},
|
||||
logs: [{ out: "Mnemonic file created" }],
|
||||
},
|
||||
// Failure to import a mnemonic file due to missing arguments
|
||||
{
|
||||
inputs: ["import"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "mnemonic.import.seed_missing",
|
||||
},
|
||||
// Failure to expose a mnemonic file due to missing arguments
|
||||
{
|
||||
inputs: ["expose"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "mnemonic.expose.file_missing",
|
||||
},
|
||||
// Failure to expose a mnemonic file due to unknown mnemonic file
|
||||
{
|
||||
inputs: ["expose", "unknown-mnemonic-file"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "mnemonic.expose.file_not_found",
|
||||
},
|
||||
// Missing sub-command
|
||||
{
|
||||
inputs: [],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "mnemonic.subcommand.missing",
|
||||
},
|
||||
// Unknown sub-command
|
||||
{
|
||||
inputs: ["unknown"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "mnemonic.subcommand.unknown",
|
||||
},
|
||||
];
|
||||
|
||||
describe("mnemonic commands", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-mnemonic-tests-"));
|
||||
|
||||
// Write a single test mnemonic file to the temp directory
|
||||
writeFileSync(
|
||||
path.join(tempDir, "mnemonic-test"),
|
||||
BCHMnemonicURL.fromSeed(DEFAULT_SEED).toURL(),
|
||||
"utf8",
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.each(testCases)(
|
||||
"mnemonic command: $inputs",
|
||||
async ({
|
||||
inputs,
|
||||
options,
|
||||
shouldThrow,
|
||||
expectedEvent,
|
||||
expectedData,
|
||||
logs,
|
||||
}) => {
|
||||
const { io, spies } = createMockIO();
|
||||
const paths = createMockPaths(tempDir);
|
||||
|
||||
if (shouldThrow) {
|
||||
try {
|
||||
await handleMnemonicCommand({ io, paths }, inputs, options ?? {});
|
||||
expect.fail("Expected command to throw");
|
||||
} catch (error) {
|
||||
if (expectedEvent) {
|
||||
expect(error).toBeInstanceOf(CommandError);
|
||||
expect((error as CommandError).event).toBe(expectedEvent);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const result = await handleMnemonicCommand(
|
||||
{ io, paths },
|
||||
inputs,
|
||||
options ?? {},
|
||||
);
|
||||
if (expectedData) {
|
||||
Object.entries(expectedData).forEach(([key, value]) => {
|
||||
expect(result[key as keyof typeof result]).toEqual(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (logs) {
|
||||
expectLogs(spies, logs);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
142
tests/cli/commands/receive.test.ts
Normal file
142
tests/cli/commands/receive.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
createMockAppService,
|
||||
createMockEngine,
|
||||
DEFAULT_SEED,
|
||||
} from "../mocks/engine";
|
||||
import { type Engine } from "@xo-cash/engine";
|
||||
import { p2pkhTemplate } from "../mocks/template-p2pkh";
|
||||
import { AppService } from "../../../src/services/app";
|
||||
|
||||
import { handleReceiveCommand } from "../../../src/cli/commands/receive";
|
||||
import { CommandError } from "../../../src/cli/commands/types";
|
||||
import {
|
||||
createCommandDeps,
|
||||
createMockIO,
|
||||
expectLogs,
|
||||
type LogExpectation,
|
||||
} from "../mocks/command";
|
||||
|
||||
type TestCase = {
|
||||
name: string;
|
||||
inputs: string[];
|
||||
options?: Record<string, string>;
|
||||
shouldThrow: boolean;
|
||||
expectedEvent?: string;
|
||||
expectedData?: Record<string, unknown>;
|
||||
logs?: LogExpectation[];
|
||||
};
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
// Successful address generation with template name and output identifier
|
||||
{
|
||||
name: "generates address with template name and output",
|
||||
inputs: ["Wallet (P2PKH)", "receiveOutput"],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
address: expect.stringMatching(/^bitcoincash:q[a-z0-9]+$/),
|
||||
},
|
||||
logs: [{ out: "bitcoincash:q" }],
|
||||
},
|
||||
// Successful address generation with role specified
|
||||
{
|
||||
name: "generates address with template name, output, and role",
|
||||
inputs: ["Wallet (P2PKH)", "receiveOutput", "receiver"],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
address: expect.stringMatching(/^bitcoincash:q[a-z0-9]+$/),
|
||||
},
|
||||
logs: [{ out: "bitcoincash:q" }],
|
||||
},
|
||||
// Missing all required arguments
|
||||
{
|
||||
name: "throws when no arguments provided",
|
||||
inputs: [],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "receive.arguments.missing",
|
||||
},
|
||||
// Missing output identifier
|
||||
{
|
||||
name: "throws when output identifier missing",
|
||||
inputs: ["Wallet (P2PKH)"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "receive.arguments.missing",
|
||||
},
|
||||
// Unknown template
|
||||
{
|
||||
name: "throws when template not found",
|
||||
inputs: ["unknown-template", "receiveOutput"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.resolve.not_found",
|
||||
},
|
||||
];
|
||||
|
||||
describe("receive command", () => {
|
||||
let engine: Engine;
|
||||
let app: AppService;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||
engine = mockEngine.engine;
|
||||
await engine.importTemplate(p2pkhTemplate);
|
||||
|
||||
app = await createMockAppService(engine);
|
||||
|
||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-receive-tests-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await engine.stop();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.each(testCases)(
|
||||
"$name",
|
||||
async ({
|
||||
inputs,
|
||||
options,
|
||||
shouldThrow,
|
||||
expectedEvent,
|
||||
expectedData,
|
||||
logs,
|
||||
}) => {
|
||||
const { io, spies } = createMockIO();
|
||||
|
||||
if (shouldThrow) {
|
||||
try {
|
||||
await handleReceiveCommand(
|
||||
createCommandDeps(app, io),
|
||||
inputs,
|
||||
options ?? {},
|
||||
);
|
||||
expect.fail("Expected command to throw");
|
||||
} catch (error) {
|
||||
if (expectedEvent) {
|
||||
expect(error).toBeInstanceOf(CommandError);
|
||||
expect((error as CommandError).event).toBe(expectedEvent);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const result = await handleReceiveCommand(
|
||||
createCommandDeps(app, io),
|
||||
inputs,
|
||||
options ?? {},
|
||||
);
|
||||
if (expectedData) {
|
||||
Object.entries(expectedData).forEach(([key, value]) => {
|
||||
expect(result[key as keyof typeof result]).toEqual(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (logs) {
|
||||
expectLogs(spies, logs);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
366
tests/cli/commands/resource.test.ts
Normal file
366
tests/cli/commands/resource.test.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
addFakeResource,
|
||||
createMockAppService,
|
||||
createMockEngine,
|
||||
DEFAULT_SEED,
|
||||
reserveResource,
|
||||
} from "../mocks/engine";
|
||||
import { type Engine } from "@xo-cash/engine";
|
||||
import { p2pkhTemplate } from "../mocks/template-p2pkh";
|
||||
import { AppService } from "../../../src/services/app";
|
||||
|
||||
import { handleResourceCommand } from "../../../src/cli/commands/resource";
|
||||
import { CommandError } from "../../../src/cli/commands/types";
|
||||
import {
|
||||
createCommandDeps,
|
||||
createMockIO,
|
||||
expectLogs,
|
||||
type LogExpectation,
|
||||
} from "../mocks/command";
|
||||
import { State } from "@xo-cash/state";
|
||||
|
||||
type TestCase = {
|
||||
name: string;
|
||||
inputs: string[];
|
||||
options?: Record<string, string>;
|
||||
shouldThrow: boolean;
|
||||
expectedEvent?: string;
|
||||
expectedData?: Record<string, unknown>;
|
||||
logs?: LogExpectation[];
|
||||
};
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
// List commands (no resources in empty wallet)
|
||||
{
|
||||
name: "list returns empty count when no resources",
|
||||
inputs: ["list"],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
count: 0,
|
||||
},
|
||||
logs: [{ out: "No resources found" }],
|
||||
},
|
||||
{
|
||||
name: "list reserved returns empty count when no reserved resources",
|
||||
inputs: ["list", "reserved"],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
count: 0,
|
||||
},
|
||||
logs: [{ out: "No resources found" }],
|
||||
},
|
||||
{
|
||||
name: "list all returns empty count when no resources",
|
||||
inputs: ["list", "all"],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
count: 0,
|
||||
},
|
||||
logs: [{ out: "No resources found" }],
|
||||
},
|
||||
// Unreserve-all with no resources
|
||||
{
|
||||
name: "unreserve-all returns zero count when no reserved resources",
|
||||
inputs: ["unreserve-all"],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
count: 0,
|
||||
},
|
||||
logs: [{ out: "No reserved resources" }],
|
||||
},
|
||||
// Error cases
|
||||
{
|
||||
name: "throws when no subcommand provided",
|
||||
inputs: [],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "resource.subcommand.missing",
|
||||
},
|
||||
{
|
||||
name: "throws when unknown subcommand provided",
|
||||
inputs: ["unknown-subcommand"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "resource.subcommand.unknown",
|
||||
},
|
||||
{
|
||||
name: "throws when unreserve called without outpoint",
|
||||
inputs: ["unreserve"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "resource.unreserve.outpoint_missing",
|
||||
},
|
||||
{
|
||||
name: "throws when unreserve called with invalid outpoint format (no colon)",
|
||||
inputs: ["unreserve", "invalid-outpoint"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "resource.unreserve.outpoint_invalid",
|
||||
},
|
||||
{
|
||||
name: "throws when unreserve called with invalid outpoint format (no vout)",
|
||||
inputs: ["unreserve", "abc123:"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "resource.unreserve.outpoint_invalid",
|
||||
},
|
||||
{
|
||||
name: "throws when unreserve called with non-existent UTXO",
|
||||
inputs: [
|
||||
"unreserve",
|
||||
"0000000000000000000000000000000000000000000000000000000000000000:0",
|
||||
],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "resource.unreserve.utxo_missing",
|
||||
},
|
||||
];
|
||||
|
||||
describe("resource command", () => {
|
||||
let engine: Engine;
|
||||
let app: AppService;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||
engine = mockEngine.engine;
|
||||
await engine.importTemplate(p2pkhTemplate);
|
||||
|
||||
app = await createMockAppService(engine);
|
||||
|
||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await engine.stop();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.each(testCases)(
|
||||
"$name",
|
||||
async ({
|
||||
inputs,
|
||||
options,
|
||||
shouldThrow,
|
||||
expectedEvent,
|
||||
expectedData,
|
||||
logs,
|
||||
}) => {
|
||||
const { io, spies } = createMockIO();
|
||||
|
||||
if (shouldThrow) {
|
||||
try {
|
||||
await handleResourceCommand(
|
||||
createCommandDeps(app, io),
|
||||
inputs,
|
||||
options ?? {},
|
||||
);
|
||||
expect.fail("Expected command to throw");
|
||||
} catch (error) {
|
||||
if (expectedEvent) {
|
||||
expect(error).toBeInstanceOf(CommandError);
|
||||
expect((error as CommandError).event).toBe(expectedEvent);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const result = await handleResourceCommand(
|
||||
createCommandDeps(app, io),
|
||||
inputs,
|
||||
options ?? {},
|
||||
);
|
||||
if (expectedData) {
|
||||
Object.entries(expectedData).forEach(([key, value]) => {
|
||||
expect(result[key as keyof typeof result]).toEqual(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (logs) {
|
||||
expectLogs(spies, logs);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("resource command with populated data", () => {
|
||||
let engine: Engine;
|
||||
let state: State;
|
||||
let app: AppService;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||
engine = mockEngine.engine;
|
||||
state = mockEngine.state;
|
||||
await engine.importTemplate(p2pkhTemplate);
|
||||
app = await createMockAppService(engine);
|
||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await engine.stop();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("list returns count when resources exist", async () => {
|
||||
await addFakeResource(state, { valueSatoshis: 50000 });
|
||||
await addFakeResource(state, { valueSatoshis: 25000 });
|
||||
|
||||
const { io, spies } = createMockIO();
|
||||
const result = await handleResourceCommand(
|
||||
createCommandDeps(app, io),
|
||||
["list"],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.count).toBe(2);
|
||||
expectLogs(spies, [{ out: "Total resources: 2" }]);
|
||||
});
|
||||
|
||||
test("list shows total satoshis", async () => {
|
||||
await addFakeResource(state, { valueSatoshis: 50000 });
|
||||
await addFakeResource(state, { valueSatoshis: 25000 });
|
||||
|
||||
const { io, spies } = createMockIO();
|
||||
await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
|
||||
|
||||
expectLogs(spies, [{ out: "Total satoshis: 75000" }]);
|
||||
});
|
||||
|
||||
test("list excludes reserved resources by default", async () => {
|
||||
await addFakeResource(state, { valueSatoshis: 50000 });
|
||||
await addFakeResource(state, {
|
||||
valueSatoshis: 25000,
|
||||
reservedBy: "inv-123",
|
||||
});
|
||||
|
||||
const { io } = createMockIO();
|
||||
const result = await handleResourceCommand(
|
||||
createCommandDeps(app, io),
|
||||
["list"],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.count).toBe(1);
|
||||
});
|
||||
|
||||
test("list reserved shows only reserved resources", async () => {
|
||||
await addFakeResource(state, { valueSatoshis: 50000 });
|
||||
await addFakeResource(state, {
|
||||
valueSatoshis: 25000,
|
||||
reservedBy: "inv-123",
|
||||
});
|
||||
await addFakeResource(state, {
|
||||
valueSatoshis: 10000,
|
||||
reservedBy: "inv-456",
|
||||
});
|
||||
|
||||
const { io, spies } = createMockIO();
|
||||
const result = await handleResourceCommand(
|
||||
createCommandDeps(app, io),
|
||||
["list", "reserved"],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.count).toBe(2);
|
||||
expectLogs(spies, [{ out: "reserved for inv-123" }]);
|
||||
});
|
||||
|
||||
test("list all shows both reserved and unreserved", async () => {
|
||||
await addFakeResource(state, { valueSatoshis: 50000 });
|
||||
await addFakeResource(state, {
|
||||
valueSatoshis: 25000,
|
||||
reservedBy: "inv-123",
|
||||
});
|
||||
|
||||
const { io } = createMockIO();
|
||||
const result = await handleResourceCommand(
|
||||
createCommandDeps(app, io),
|
||||
["list", "all"],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.count).toBe(2);
|
||||
});
|
||||
|
||||
test("unreserve releases a reserved UTXO", async () => {
|
||||
const resource = await addFakeResource(state, {
|
||||
valueSatoshis: 25000,
|
||||
reservedBy: "inv-123",
|
||||
});
|
||||
|
||||
const { io, spies } = createMockIO();
|
||||
await handleResourceCommand(
|
||||
createCommandDeps(app, io),
|
||||
[
|
||||
"unreserve",
|
||||
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
|
||||
],
|
||||
{},
|
||||
);
|
||||
|
||||
expectLogs(spies, [
|
||||
{ out: "Unreserved" },
|
||||
{ out: "was reserved for inv-123" },
|
||||
]);
|
||||
|
||||
const resources = await engine.listUnspentOutputsData();
|
||||
const target = resources.find(
|
||||
(r) => r.outpointTransactionHash === resource.outpointTransactionHash,
|
||||
);
|
||||
expect(target?.reservedBy).toBeUndefined();
|
||||
});
|
||||
|
||||
test("unreserve reports when UTXO is not reserved", async () => {
|
||||
const resource = await addFakeResource(state, { valueSatoshis: 25000 });
|
||||
|
||||
const { io, spies } = createMockIO();
|
||||
await handleResourceCommand(
|
||||
createCommandDeps(app, io),
|
||||
[
|
||||
"unreserve",
|
||||
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
|
||||
],
|
||||
{},
|
||||
);
|
||||
|
||||
expectLogs(spies, [{ out: "UTXO is not reserved" }]);
|
||||
});
|
||||
|
||||
test("unreserve-all releases all reserved UTXOs", async () => {
|
||||
await addFakeResource(state, { valueSatoshis: 50000 });
|
||||
await addFakeResource(state, {
|
||||
valueSatoshis: 25000,
|
||||
reservedBy: "inv-123",
|
||||
});
|
||||
await addFakeResource(state, {
|
||||
valueSatoshis: 10000,
|
||||
reservedBy: "inv-456",
|
||||
});
|
||||
|
||||
const { io, spies } = createMockIO();
|
||||
const result = await handleResourceCommand(
|
||||
createCommandDeps(app, io),
|
||||
["unreserve-all"],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.count).toBe(2);
|
||||
expectLogs(spies, [{ out: "Unreserved" }, { out: "2" }]);
|
||||
|
||||
const resources = await engine.listUnspentOutputsData();
|
||||
const reserved = resources.filter((r) => r.reservedBy);
|
||||
expect(reserved).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("list displays outpoint information", async () => {
|
||||
const resource = await addFakeResource(state, { valueSatoshis: 12345 });
|
||||
|
||||
const { io, spies } = createMockIO();
|
||||
await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
|
||||
|
||||
expectLogs(spies, [
|
||||
{ out: resource.outpointTransactionHash },
|
||||
{ out: "12345 sats" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
266
tests/cli/commands/template.test.ts
Normal file
266
tests/cli/commands/template.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
createMockAppService,
|
||||
createMockEngine,
|
||||
DEFAULT_SEED,
|
||||
} from "../mocks/engine";
|
||||
import { type Engine } from "@xo-cash/engine";
|
||||
import {
|
||||
p2pkhTemplate,
|
||||
p2pkhTemplateIdentifier,
|
||||
} from "../mocks/template-p2pkh";
|
||||
import { AppService } from "../../../src/services/app";
|
||||
|
||||
import { handleTemplateCommand } from "../../../src/cli/commands/template";
|
||||
import { CommandError } from "../../../src/cli/commands/types";
|
||||
import {
|
||||
createCommandDeps,
|
||||
createMockIO,
|
||||
expectLogs,
|
||||
type LogExpectation,
|
||||
} from "../mocks/command";
|
||||
|
||||
type TestCase = {
|
||||
name: string;
|
||||
inputs: string[];
|
||||
options?: Record<string, string>;
|
||||
shouldThrow: boolean;
|
||||
expectedEvent?: string;
|
||||
expectedData?: Record<string, unknown>;
|
||||
logs?: LogExpectation[];
|
||||
};
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
// List command
|
||||
{
|
||||
name: "list returns count of imported templates",
|
||||
inputs: ["list"],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
count: 1,
|
||||
},
|
||||
logs: [{ out: "Wallet (P2PKH)" }],
|
||||
},
|
||||
// List by category
|
||||
{
|
||||
name: "list action returns actions for template",
|
||||
inputs: ["list", "action", p2pkhTemplateIdentifier],
|
||||
shouldThrow: false,
|
||||
expectedData: {},
|
||||
logs: [{ out: "receive" }],
|
||||
},
|
||||
{
|
||||
name: "list output returns outputs for template",
|
||||
inputs: ["list", "output", p2pkhTemplateIdentifier],
|
||||
shouldThrow: false,
|
||||
expectedData: {},
|
||||
logs: [{ out: "receiveOutput" }],
|
||||
},
|
||||
{
|
||||
name: "list variable returns variables for template",
|
||||
inputs: ["list", "variable", p2pkhTemplateIdentifier],
|
||||
shouldThrow: false,
|
||||
expectedData: {},
|
||||
logs: [{ out: "ownerKey" }],
|
||||
},
|
||||
{
|
||||
name: "list transaction returns transactions for template",
|
||||
inputs: ["list", "transaction", p2pkhTemplateIdentifier],
|
||||
shouldThrow: false,
|
||||
expectedData: {},
|
||||
},
|
||||
{
|
||||
name: "list lockingscript returns locking scripts for template",
|
||||
inputs: ["list", "lockingscript", p2pkhTemplateIdentifier],
|
||||
shouldThrow: false,
|
||||
expectedData: {},
|
||||
},
|
||||
// Inspect command
|
||||
{
|
||||
name: "inspect action returns action details",
|
||||
inputs: ["inspect", "action", p2pkhTemplateIdentifier, "receive"],
|
||||
shouldThrow: false,
|
||||
expectedData: {},
|
||||
},
|
||||
{
|
||||
name: "inspect output returns output details",
|
||||
inputs: ["inspect", "output", p2pkhTemplateIdentifier, "receiveOutput"],
|
||||
shouldThrow: false,
|
||||
expectedData: {},
|
||||
},
|
||||
{
|
||||
name: "inspect variable returns variable details",
|
||||
inputs: ["inspect", "variable", p2pkhTemplateIdentifier, "ownerKey"],
|
||||
shouldThrow: false,
|
||||
expectedData: {},
|
||||
},
|
||||
// Error cases - subcommand
|
||||
{
|
||||
name: "throws when no subcommand provided",
|
||||
inputs: [],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.subcommand.missing",
|
||||
},
|
||||
{
|
||||
name: "throws when unknown subcommand provided",
|
||||
inputs: ["unknown-subcommand"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.subcommand.unknown",
|
||||
},
|
||||
// Error cases - import
|
||||
{
|
||||
name: "throws when import called without file",
|
||||
inputs: ["import"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.import.file_missing",
|
||||
},
|
||||
{
|
||||
name: "throws when import called with non-existent file",
|
||||
inputs: ["import", "non-existent-file.json"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.import.file_not_found",
|
||||
},
|
||||
// Error cases - list category
|
||||
{
|
||||
name: "throws when list category called without template identifier",
|
||||
inputs: ["list", "action"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.list.identifier_missing",
|
||||
},
|
||||
{
|
||||
name: "throws when list category called with unknown template",
|
||||
inputs: ["list", "action", "unknown-template"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.list.not_found",
|
||||
},
|
||||
{
|
||||
name: "throws when list called with unknown category",
|
||||
inputs: ["list", "unknown-category", p2pkhTemplateIdentifier],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.list.category_unknown",
|
||||
},
|
||||
// Error cases - inspect
|
||||
{
|
||||
name: "throws when inspect called without all arguments",
|
||||
inputs: ["inspect", "action"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.inspect.arguments_missing",
|
||||
},
|
||||
{
|
||||
name: "throws when inspect called with unknown template",
|
||||
inputs: ["inspect", "action", "unknown-template", "receive"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.resolve.not_found",
|
||||
},
|
||||
{
|
||||
name: "throws when inspect called with unknown action",
|
||||
inputs: ["inspect", "action", p2pkhTemplateIdentifier, "unknown-action"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.inspect.action_missing",
|
||||
},
|
||||
{
|
||||
name: "throws when inspect called with unknown category",
|
||||
inputs: ["inspect", "unknown-category", p2pkhTemplateIdentifier, "field"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.inspect.category_unknown",
|
||||
},
|
||||
// Error cases - set-default
|
||||
{
|
||||
name: "throws when set-default called without all arguments",
|
||||
inputs: ["set-default", "template"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.default.arguments_missing",
|
||||
},
|
||||
];
|
||||
|
||||
describe("template command", () => {
|
||||
let engine: Engine;
|
||||
let app: AppService;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||
engine = mockEngine.engine;
|
||||
await engine.importTemplate(p2pkhTemplate);
|
||||
|
||||
app = await createMockAppService(engine);
|
||||
|
||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-template-tests-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await engine.stop();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.each(testCases)(
|
||||
"$name",
|
||||
async ({
|
||||
inputs,
|
||||
options,
|
||||
shouldThrow,
|
||||
expectedEvent,
|
||||
expectedData,
|
||||
logs,
|
||||
}) => {
|
||||
const { io, spies } = createMockIO();
|
||||
|
||||
if (shouldThrow) {
|
||||
try {
|
||||
await handleTemplateCommand(
|
||||
createCommandDeps(app, io),
|
||||
inputs,
|
||||
options ?? {},
|
||||
);
|
||||
expect.fail("Expected command to throw");
|
||||
} catch (error) {
|
||||
if (expectedEvent) {
|
||||
expect(error).toBeInstanceOf(CommandError);
|
||||
expect((error as CommandError).event).toBe(expectedEvent);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const result = await handleTemplateCommand(
|
||||
createCommandDeps(app, io),
|
||||
inputs,
|
||||
options ?? {},
|
||||
);
|
||||
if (expectedData) {
|
||||
Object.entries(expectedData).forEach(([key, value]) => {
|
||||
expect(result[key as keyof typeof result]).toEqual(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (logs) {
|
||||
expectLogs(spies, logs);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("import imports template from file", async () => {
|
||||
const templatePath = path.join(tempDir, "test-template.json");
|
||||
writeFileSync(templatePath, JSON.stringify(p2pkhTemplate));
|
||||
|
||||
const { io } = createMockIO();
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tempDir);
|
||||
|
||||
try {
|
||||
const result = await handleTemplateCommand(
|
||||
createCommandDeps(app, io),
|
||||
["import", "test-template.json"],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.templateFile).toBe("test-template.json");
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
28
tests/cli/integration/entry.test.ts
Normal file
28
tests/cli/integration/entry.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
const runCli = (args: string[]) => {
|
||||
const tsxPath = path.resolve(process.cwd(), "node_modules/.bin/tsx");
|
||||
const cliPath = path.resolve(process.cwd(), "src/cli/index.ts");
|
||||
|
||||
return spawnSync(tsxPath, [cliPath, ...args], {
|
||||
encoding: "utf8",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
};
|
||||
|
||||
describe("cli entry boundary behavior", () => {
|
||||
test("returns non-zero for incomplete mnemonic invocation", () => {
|
||||
const result = runCli(["mnemonic"]);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toContain("Usage:");
|
||||
});
|
||||
|
||||
test("returns zero for mnemonic list invocation", () => {
|
||||
const result = runCli(["mnemonic", "list"]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
});
|
||||
214
tests/cli/mnemonic.test.ts
Normal file
214
tests/cli/mnemonic.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
createMnemonicSeed,
|
||||
createMnemonicFile,
|
||||
resolveMnemonicFilePath,
|
||||
loadMnemonic,
|
||||
listMnemonicFiles,
|
||||
} from "../../src/cli/mnemonic";
|
||||
import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url";
|
||||
|
||||
const TEST_SEED =
|
||||
"page pencil stock planet limb cluster assault speak off joke private pioneer";
|
||||
|
||||
describe("mnemonic utilities", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = path.join(tmpdir(), `xo-cli-mnemonic-utils-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("createMnemonicSeed", () => {
|
||||
test("generates a valid BIP39 mnemonic", () => {
|
||||
const mnemonic = createMnemonicSeed();
|
||||
|
||||
expect(typeof mnemonic).toBe("string");
|
||||
const words = mnemonic.split(" ");
|
||||
expect(words.length).toBe(12);
|
||||
});
|
||||
|
||||
test("generates unique mnemonics on each call", () => {
|
||||
const mnemonic1 = createMnemonicSeed();
|
||||
const mnemonic2 = createMnemonicSeed();
|
||||
|
||||
expect(mnemonic1).not.toBe(mnemonic2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMnemonicFile", () => {
|
||||
test("creates a mnemonic file with auto-generated name", () => {
|
||||
const filename = createMnemonicFile(tempDir, TEST_SEED);
|
||||
|
||||
expect(filename).toMatch(/^mnemonic-page$/);
|
||||
expect(existsSync(path.join(tempDir, filename))).toBe(true);
|
||||
});
|
||||
|
||||
test("creates a mnemonic file with custom name", () => {
|
||||
const filename = createMnemonicFile(tempDir, TEST_SEED, "my-wallet");
|
||||
|
||||
expect(filename).toBe("my-wallet");
|
||||
expect(existsSync(path.join(tempDir, filename))).toBe(true);
|
||||
});
|
||||
|
||||
test("writes valid BCHMnemonicURL format", () => {
|
||||
const filename = createMnemonicFile(tempDir, TEST_SEED, "test-wallet");
|
||||
const content = readFileSync(path.join(tempDir, filename), "utf8");
|
||||
|
||||
expect(content).toMatch(/^bch-mnemonic:/);
|
||||
const parsed = BCHMnemonicURL.fromURL(content);
|
||||
expect(parsed).toBeDefined();
|
||||
});
|
||||
|
||||
test("sanitizes filename to basename only", () => {
|
||||
const filename = createMnemonicFile(
|
||||
tempDir,
|
||||
TEST_SEED,
|
||||
"../../../evil-path",
|
||||
);
|
||||
|
||||
expect(filename).toBe("evil-path");
|
||||
expect(existsSync(path.join(tempDir, "evil-path"))).toBe(true);
|
||||
expect(existsSync(path.join(tempDir, "../../../evil-path"))).toBe(false);
|
||||
});
|
||||
|
||||
test("throws when mnemonic is empty", () => {
|
||||
expect(() => createMnemonicFile(tempDir, "")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMnemonicFilePath", () => {
|
||||
test("resolves absolute path when file exists", () => {
|
||||
const filePath = path.join(tempDir, "mnemonic-absolute");
|
||||
writeFileSync(filePath, "test");
|
||||
|
||||
const resolved = resolveMnemonicFilePath(tempDir, filePath);
|
||||
expect(resolved).toBe(filePath);
|
||||
});
|
||||
|
||||
test("resolves path relative to cwd when file exists", () => {
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tempDir);
|
||||
|
||||
try {
|
||||
writeFileSync(path.join(tempDir, "mnemonic-relative"), "test");
|
||||
const resolved = resolveMnemonicFilePath(
|
||||
"/nonexistent",
|
||||
"mnemonic-relative",
|
||||
);
|
||||
expect(resolved).toBe(path.join(tempDir, "mnemonic-relative"));
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves from mnemonicsDir when file exists there", () => {
|
||||
writeFileSync(path.join(tempDir, "mnemonic-test"), "test");
|
||||
|
||||
const resolved = resolveMnemonicFilePath(tempDir, "mnemonic-test");
|
||||
expect(resolved).toBe(path.join(tempDir, "mnemonic-test"));
|
||||
});
|
||||
|
||||
test("throws when file not found anywhere", () => {
|
||||
expect(() =>
|
||||
resolveMnemonicFilePath(tempDir, "nonexistent-file"),
|
||||
).toThrow(/Mnemonic file not found/);
|
||||
});
|
||||
|
||||
test("strips path components and looks up basename in mnemonicsDir", () => {
|
||||
writeFileSync(path.join(tempDir, "mnemonic-basename"), "test");
|
||||
|
||||
const resolved = resolveMnemonicFilePath(
|
||||
tempDir,
|
||||
"some/path/mnemonic-basename",
|
||||
);
|
||||
expect(resolved).toBe(path.join(tempDir, "mnemonic-basename"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadMnemonic", () => {
|
||||
test("loads mnemonic from file", () => {
|
||||
createMnemonicFile(tempDir, TEST_SEED, "test-load");
|
||||
|
||||
const loaded = loadMnemonic(tempDir, "test-load");
|
||||
expect(loaded).toBe(TEST_SEED);
|
||||
});
|
||||
|
||||
test("loads mnemonic from absolute path", () => {
|
||||
const filePath = path.join(tempDir, "mnemonic-absolute-load");
|
||||
createMnemonicFile(tempDir, TEST_SEED, "mnemonic-absolute-load");
|
||||
|
||||
const loaded = loadMnemonic(tempDir, filePath);
|
||||
expect(loaded).toBe(TEST_SEED);
|
||||
});
|
||||
|
||||
test("throws when file not found", () => {
|
||||
expect(() => loadMnemonic(tempDir, "nonexistent")).toThrow(
|
||||
/Mnemonic file not found/,
|
||||
);
|
||||
});
|
||||
|
||||
test("throws when file contains invalid data", () => {
|
||||
writeFileSync(
|
||||
path.join(tempDir, "mnemonic-invalid"),
|
||||
"not a valid mnemonic url",
|
||||
);
|
||||
|
||||
expect(() => loadMnemonic(tempDir, "mnemonic-invalid")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listMnemonicFiles", () => {
|
||||
test("returns empty array when no mnemonic files exist", () => {
|
||||
const files = listMnemonicFiles(tempDir);
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
test("lists only files starting with 'mnemonic-'", () => {
|
||||
writeFileSync(path.join(tempDir, "mnemonic-one"), "test");
|
||||
writeFileSync(path.join(tempDir, "mnemonic-two"), "test");
|
||||
writeFileSync(path.join(tempDir, "other-file"), "test");
|
||||
writeFileSync(path.join(tempDir, "wallet.json"), "test");
|
||||
|
||||
const files = listMnemonicFiles(tempDir);
|
||||
expect(files).toHaveLength(2);
|
||||
expect(files).toContain("mnemonic-one");
|
||||
expect(files).toContain("mnemonic-two");
|
||||
expect(files).not.toContain("other-file");
|
||||
expect(files).not.toContain("wallet.json");
|
||||
});
|
||||
|
||||
test("returns sorted or consistent ordering", () => {
|
||||
writeFileSync(path.join(tempDir, "mnemonic-zebra"), "test");
|
||||
writeFileSync(path.join(tempDir, "mnemonic-alpha"), "test");
|
||||
writeFileSync(path.join(tempDir, "mnemonic-beta"), "test");
|
||||
|
||||
const files = listMnemonicFiles(tempDir);
|
||||
expect(files).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip", () => {
|
||||
test("create and load preserves mnemonic exactly", () => {
|
||||
const original = createMnemonicSeed();
|
||||
createMnemonicFile(tempDir, original, "roundtrip-test");
|
||||
|
||||
const loaded = loadMnemonic(tempDir, "roundtrip-test");
|
||||
expect(loaded).toBe(original);
|
||||
});
|
||||
});
|
||||
});
|
||||
171
tests/cli/mocks/command.ts
Normal file
171
tests/cli/mocks/command.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { vi, expect, type Mock } from "vitest";
|
||||
import type {
|
||||
BaseCommandDependencies,
|
||||
CommandDependencies,
|
||||
CommandIO,
|
||||
CommandPaths,
|
||||
} from "../../../src/cli/commands/types";
|
||||
import type { AppService } from "../../../src/services/app";
|
||||
|
||||
/**
|
||||
* Captured CLI IO buffers used by tests.
|
||||
*/
|
||||
export type MockIOCapture = {
|
||||
out: string[];
|
||||
err: string[];
|
||||
verbose: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Spy functions for each IO channel.
|
||||
*/
|
||||
export type MockIOSpies = {
|
||||
out: Mock;
|
||||
err: Mock;
|
||||
verbose: Mock;
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete mock IO result including the IO adapter, capture buffers, and spies.
|
||||
*/
|
||||
export type MockIO = {
|
||||
io: CommandIO;
|
||||
capture: MockIOCapture;
|
||||
spies: MockIOSpies;
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines an expected log message for assertion.
|
||||
* At least one of out, err, or verbose should be specified.
|
||||
*/
|
||||
export type LogExpectation = {
|
||||
/** Expected substring (or exact match if exact=true) in io.out */
|
||||
out?: string;
|
||||
/** Expected substring (or exact match if exact=true) in io.err */
|
||||
err?: string;
|
||||
/** Expected substring (or exact match if exact=true) in io.verbose */
|
||||
verbose?: string;
|
||||
/** If true, match the string exactly instead of using contains (default: false) */
|
||||
exact?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a command IO adapter that records every message using vi.fn() spies.
|
||||
* This enables vitest's built-in matchers like toHaveBeenCalledWith.
|
||||
*/
|
||||
export const createMockIO = (): MockIO => {
|
||||
const capture: MockIOCapture = {
|
||||
out: [],
|
||||
err: [],
|
||||
verbose: [],
|
||||
};
|
||||
|
||||
const outSpy = vi.fn((message: string) => {
|
||||
capture.out.push(message);
|
||||
});
|
||||
const errSpy = vi.fn((message: string) => {
|
||||
capture.err.push(message);
|
||||
});
|
||||
const verboseSpy = vi.fn((message: string) => {
|
||||
capture.verbose.push(message);
|
||||
});
|
||||
|
||||
const io: CommandIO = {
|
||||
out: outSpy,
|
||||
err: errSpy,
|
||||
verbose: verboseSpy,
|
||||
};
|
||||
|
||||
return {
|
||||
io,
|
||||
capture,
|
||||
spies: {
|
||||
out: outSpy,
|
||||
err: errSpy,
|
||||
verbose: verboseSpy,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Asserts that the expected log messages were printed to the appropriate IO channels.
|
||||
* @param spies - The mock IO spies from createMockIO
|
||||
* @param logs - Array of log expectations to validate
|
||||
*/
|
||||
export const expectLogs = (
|
||||
spies: MockIOSpies,
|
||||
logs: LogExpectation[],
|
||||
): void => {
|
||||
for (const log of logs) {
|
||||
if (log.out !== undefined) {
|
||||
if (log.exact) {
|
||||
expect(spies.out).toHaveBeenCalledWith(log.out);
|
||||
} else {
|
||||
expect(spies.out).toHaveBeenCalledWith(
|
||||
expect.stringContaining(log.out),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (log.err !== undefined) {
|
||||
if (log.exact) {
|
||||
expect(spies.err).toHaveBeenCalledWith(log.err);
|
||||
} else {
|
||||
expect(spies.err).toHaveBeenCalledWith(
|
||||
expect.stringContaining(log.err),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (log.verbose !== undefined) {
|
||||
if (log.exact) {
|
||||
expect(spies.verbose).toHaveBeenCalledWith(log.verbose);
|
||||
} else {
|
||||
expect(spies.verbose).toHaveBeenCalledWith(
|
||||
expect.stringContaining(log.verbose),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates mock paths for testing.
|
||||
* @param tempDir - Optional temp directory to use as base for all paths
|
||||
*/
|
||||
export const createMockPaths = (tempDir?: string): CommandPaths => {
|
||||
const base = tempDir ?? "/tmp/xo-cli-test";
|
||||
return {
|
||||
mnemonicsDir: base,
|
||||
dataDir: base,
|
||||
walletConfigPath: `${base}/.wallet`,
|
||||
workingDir: base,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates base command dependencies for commands that do not require the app.
|
||||
* @param io - Command IO adapter
|
||||
* @param paths - Optional custom paths (defaults to mock paths)
|
||||
*/
|
||||
export const createBaseCommandDeps = (
|
||||
io: CommandIO,
|
||||
paths?: CommandPaths,
|
||||
): BaseCommandDependencies => ({
|
||||
io,
|
||||
paths: paths ?? createMockPaths(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates command dependencies for app-backed command handlers.
|
||||
* @param app - App service instance
|
||||
* @param io - Command IO adapter
|
||||
* @param paths - Optional custom paths (defaults to mock paths)
|
||||
*/
|
||||
export const createCommandDeps = (
|
||||
app: AppService,
|
||||
io: CommandIO,
|
||||
paths?: CommandPaths,
|
||||
): CommandDependencies => ({
|
||||
app,
|
||||
io,
|
||||
paths: paths ?? createMockPaths(),
|
||||
});
|
||||
12
tests/cli/mocks/electrum-service.ts
Normal file
12
tests/cli/mocks/electrum-service.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Mock Electrum service for testing.
|
||||
* NOTE & TODO: Do we even need this in the actual app? I forget why we had this, but it seems like its just overly complicating things
|
||||
* And we end up in stupid situations where we are creating a mock for a single function class.
|
||||
*/
|
||||
export class MockElectrumService {
|
||||
constructor() {}
|
||||
|
||||
async hasSeenTransaction(transactionHash: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
180
tests/cli/mocks/engine.ts
Normal file
180
tests/cli/mocks/engine.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { BlockchainMonitor, Engine } from "@xo-cash/engine";
|
||||
|
||||
import {
|
||||
createStorageAdapter,
|
||||
State,
|
||||
StorageType,
|
||||
UnspentOutputStatus,
|
||||
type UnspentOutputData,
|
||||
} from "@xo-cash/state";
|
||||
import { InMemoryBlockchainProvider } from "@xo-cash/engine";
|
||||
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
|
||||
|
||||
import { binToHex, sha256 } from "@bitauth/libauth";
|
||||
import { AppService } from "../../../src/services/app";
|
||||
import { InMemoryStorage } from "../../../src/services/storage";
|
||||
import { MockElectrumService } from "./electrum-service";
|
||||
import { MockRatesService } from "./rates-service";
|
||||
import { RatesService } from "../../../src/services/rates";
|
||||
|
||||
export const DEFAULT_SEED =
|
||||
"page pencil stock planet limb cluster assault speak off joke private pioneer";
|
||||
|
||||
/**
|
||||
* Options for creating a fake resource (UTXO) in tests.
|
||||
*/
|
||||
export type FakeResourceOptions = {
|
||||
/** Transaction hash of the outpoint. Auto-generated if not provided. */
|
||||
outpointTransactionHash?: string;
|
||||
/** Index of the outpoint in the transaction. Defaults to 0. */
|
||||
outpointIndex?: number;
|
||||
/** Value in satoshis. Defaults to 10000. */
|
||||
valueSatoshis?: number;
|
||||
/** Template identifier. Defaults to "test-template". */
|
||||
templateIdentifier?: string;
|
||||
/** Output identifier from the template. Defaults to "receiveOutput". */
|
||||
outputIdentifier?: string;
|
||||
/** Locking bytecode for this output. Defaults to a placeholder. */
|
||||
lockingBytecode?: string;
|
||||
/** Block height where the UTXO was mined. Defaults to 800000. */
|
||||
minedAtHeight?: number;
|
||||
/** Invitation identifier that reserves this output. Undefined means unreserved. */
|
||||
reservedBy?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a random 64-character hex string representing a transaction hash.
|
||||
*/
|
||||
export const randomTxHash = (): string => {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a fake resource (UTXO) to the engine's state for testing purposes.
|
||||
* @param engine - The engine instance to add the resource to.
|
||||
* @param options - Options for the fake resource. All fields have sensible defaults.
|
||||
* @returns The created UnspentOutputData object.
|
||||
*/
|
||||
export const addFakeResource = async (
|
||||
state: State,
|
||||
options: FakeResourceOptions = {},
|
||||
): Promise<UnspentOutputData> => {
|
||||
const resource: UnspentOutputData = {
|
||||
status: UnspentOutputStatus.CONFIRMED,
|
||||
selectable: true,
|
||||
privacy: false,
|
||||
templateIdentifier: options.templateIdentifier ?? "test-template",
|
||||
outputIdentifier: options.outputIdentifier ?? "receiveOutput",
|
||||
outpointIndex: options.outpointIndex ?? 0,
|
||||
outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(),
|
||||
minedAtHeight: options.minedAtHeight ?? 800000,
|
||||
valueSatoshis: options.valueSatoshis ?? 10000,
|
||||
scriptHash:
|
||||
options.lockingBytecode ??
|
||||
"76a914000000000000000000000000000000000000000088ac",
|
||||
reservedBy: options.reservedBy,
|
||||
};
|
||||
|
||||
await state.storeUnspentOutputData(resource);
|
||||
return resource;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reserves a resource for a specific invitation.
|
||||
* @param engine - The engine instance.
|
||||
* @param outpointTransactionHash - The transaction hash of the UTXO to reserve.
|
||||
* @param outpointIndex - The output index of the UTXO to reserve.
|
||||
* @param invitationIdentifier - The invitation identifier to reserve for.
|
||||
*/
|
||||
export const reserveResource = async (
|
||||
state: State,
|
||||
outpointTransactionHash: string,
|
||||
outpointIndex: number,
|
||||
invitationIdentifier: string,
|
||||
): Promise<void> => {
|
||||
await state.executeBulkUnspentOutputReservation(
|
||||
[{ outpointTransactionHash, outpointIndex }],
|
||||
true,
|
||||
invitationIdentifier,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Unreserves a resource from a specific invitation.
|
||||
* @param engine - The engine instance.
|
||||
* @param outpointTransactionHash - The transaction hash of the UTXO to unreserve.
|
||||
* @param outpointIndex - The output index of the UTXO to unreserve.
|
||||
* @param invitationIdentifier - The invitation identifier to unreserve from.
|
||||
*/
|
||||
export const unreserveResource = async (
|
||||
state: State,
|
||||
outpointTransactionHash: string,
|
||||
outpointIndex: number,
|
||||
invitationIdentifier: string,
|
||||
): Promise<void> => {
|
||||
await state.executeBulkUnspentOutputReservation(
|
||||
[{ outpointTransactionHash, outpointIndex }],
|
||||
false,
|
||||
invitationIdentifier,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a mock engine instance with a given seed. Uses the in-memory storage and blockchain provider.
|
||||
* @param seed - The seed to use for the engine.
|
||||
* @returns A mock engine instance.
|
||||
*/
|
||||
export const createMockEngine = async (seed: string) => {
|
||||
// Create the in-memory storage adapter.
|
||||
const storage = await createStorageAdapter({
|
||||
storageType: "inmemory",
|
||||
accountHash: binToHex(sha256.hash(convertMnemonicToSeedBytes(seed))),
|
||||
});
|
||||
|
||||
// Initialize the storage adapter.
|
||||
await storage.initialize();
|
||||
|
||||
// Create the state instance.
|
||||
const state = new State(storage);
|
||||
|
||||
// Create the in-memory blockchain provider.
|
||||
const blockchainProvider = new InMemoryBlockchainProvider();
|
||||
await blockchainProvider.initialize({
|
||||
applicationIdentifier: "xo-cli-tests",
|
||||
electrumOptions: {},
|
||||
});
|
||||
|
||||
// Create the blockchain monitor instance.
|
||||
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
|
||||
await blockchainMonitor.initializeEventListeners();
|
||||
|
||||
// Create the engine instance.
|
||||
const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider);
|
||||
await engine.initializeStateSync();
|
||||
|
||||
return { engine, state, blockchainMonitor, blockchainProvider };
|
||||
};
|
||||
|
||||
export const createMockAppService = async (engine: Engine) => {
|
||||
const storage = await InMemoryStorage.create();
|
||||
|
||||
const mockRates = new MockRatesService();
|
||||
const rates = new RatesService(mockRates);
|
||||
|
||||
const mockElectrum = new MockElectrumService();
|
||||
|
||||
const config = {
|
||||
syncServerUrl: "http://localhost:3000",
|
||||
engineConfig: {
|
||||
databasePath: "test-data",
|
||||
databaseFilename: "xo-wallet.db",
|
||||
},
|
||||
invitationStoragePath: "test-invitations.db",
|
||||
};
|
||||
|
||||
return new AppService(engine, storage, config, mockElectrum, rates);
|
||||
};
|
||||
23
tests/cli/mocks/rates-service.ts
Normal file
23
tests/cli/mocks/rates-service.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { BaseRates } from "../../../src/utils/rates/base-rates";
|
||||
|
||||
export class MockRatesService extends BaseRates {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
async getRate(numeratorUnitCode: string, denominatorUnitCode: string): Promise<number> {
|
||||
return 1;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
async listPairs(): Promise<Set<string>> {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
1440
tests/cli/mocks/template-p2pkh.ts
Normal file
1440
tests/cli/mocks/template-p2pkh.ts
Normal file
File diff suppressed because it is too large
Load Diff
153
tests/cli/paths.test.ts
Normal file
153
tests/cli/paths.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
getConfigDir,
|
||||
getMnemonicsDir,
|
||||
getDataDir,
|
||||
getWalletConfigPath,
|
||||
resolveMnemonicFilePath,
|
||||
} from "../../src/utils/paths";
|
||||
|
||||
describe("paths utilities", () => {
|
||||
describe("getConfigDir", () => {
|
||||
test("returns path under ~/.config/xo-cli", () => {
|
||||
const configDir = getConfigDir();
|
||||
|
||||
expect(configDir).toBe(path.join(homedir(), ".config", "xo-cli"));
|
||||
});
|
||||
|
||||
test("creates the directory if it does not exist", () => {
|
||||
const configDir = getConfigDir();
|
||||
|
||||
expect(existsSync(configDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMnemonicsDir", () => {
|
||||
test("returns path under config dir", () => {
|
||||
const mnemonicsDir = getMnemonicsDir();
|
||||
|
||||
expect(mnemonicsDir).toBe(
|
||||
path.join(homedir(), ".config", "xo-cli", "mnemonics"),
|
||||
);
|
||||
});
|
||||
|
||||
test("creates the directory if it does not exist", () => {
|
||||
const mnemonicsDir = getMnemonicsDir();
|
||||
|
||||
expect(existsSync(mnemonicsDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDataDir", () => {
|
||||
test("returns path under config dir", () => {
|
||||
const dataDir = getDataDir();
|
||||
|
||||
expect(dataDir).toBe(path.join(homedir(), ".config", "xo-cli", "data"));
|
||||
});
|
||||
|
||||
test("creates the directory if it does not exist", () => {
|
||||
const dataDir = getDataDir();
|
||||
|
||||
expect(existsSync(dataDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWalletConfigPath", () => {
|
||||
test("returns .wallet file path under config dir", () => {
|
||||
const walletConfigPath = getWalletConfigPath();
|
||||
|
||||
expect(walletConfigPath).toBe(
|
||||
path.join(homedir(), ".config", "xo-cli", ".wallet"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMnemonicFilePath (global)", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = path.join(tmpdir(), `xo-cli-paths-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("resolves absolute path when file exists", () => {
|
||||
const filePath = path.join(tempDir, "mnemonic-test");
|
||||
writeFileSync(filePath, "test");
|
||||
|
||||
const resolved = resolveMnemonicFilePath(filePath);
|
||||
expect(resolved).toBe(filePath);
|
||||
});
|
||||
|
||||
test("resolves path relative to cwd when file exists", () => {
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tempDir);
|
||||
|
||||
try {
|
||||
writeFileSync(path.join(tempDir, "mnemonic-cwd-test"), "test");
|
||||
const resolved = resolveMnemonicFilePath("mnemonic-cwd-test");
|
||||
expect(resolved).toBe(path.join(tempDir, "mnemonic-cwd-test"));
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves from global mnemonics dir when file exists there", () => {
|
||||
const mnemonicsDir = getMnemonicsDir();
|
||||
const testFile = path.join(mnemonicsDir, "mnemonic-global-test");
|
||||
|
||||
try {
|
||||
writeFileSync(testFile, "test");
|
||||
const resolved = resolveMnemonicFilePath("mnemonic-global-test");
|
||||
expect(resolved).toBe(testFile);
|
||||
} finally {
|
||||
if (existsSync(testFile)) {
|
||||
rmSync(testFile);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("throws when file not found anywhere", () => {
|
||||
expect(() =>
|
||||
resolveMnemonicFilePath("nonexistent-mnemonic-file-xyz"),
|
||||
).toThrow(/Mnemonic file not found/);
|
||||
});
|
||||
|
||||
test("does not resolve absolute path if file does not exist", () => {
|
||||
const nonExistentPath = "/nonexistent/path/mnemonic-test";
|
||||
expect(() => resolveMnemonicFilePath(nonExistentPath)).toThrow(
|
||||
/Mnemonic file not found/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("path hierarchy", () => {
|
||||
test("mnemonics dir is under config dir", () => {
|
||||
const configDir = getConfigDir();
|
||||
const mnemonicsDir = getMnemonicsDir();
|
||||
|
||||
expect(mnemonicsDir.startsWith(configDir)).toBe(true);
|
||||
});
|
||||
|
||||
test("data dir is under config dir", () => {
|
||||
const configDir = getConfigDir();
|
||||
const dataDir = getDataDir();
|
||||
|
||||
expect(dataDir.startsWith(configDir)).toBe(true);
|
||||
});
|
||||
|
||||
test("wallet config is under config dir", () => {
|
||||
const configDir = getConfigDir();
|
||||
const walletConfig = getWalletConfigPath();
|
||||
|
||||
expect(walletConfig.startsWith(configDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user