Compare commits
64 Commits
66e9918e04
...
kiok-updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
cfcba02bb3
|
|||
|
3ee2d53766
|
|||
|
d089e909f8
|
|||
|
771968dfbb
|
|||
|
d2c37fd957
|
|||
|
bca736dab4
|
|||
|
69adee180a
|
|||
|
c7e1d69e2d
|
|||
|
b30243f674
|
|||
|
5e9c6db412
|
|||
|
5bec49858f
|
|||
|
f1ac89ef91
|
|||
|
17a41cf29a
|
|||
|
0b848989a2
|
|||
|
1776fbbf61
|
|||
|
0acc70b613
|
|||
|
14e74fab6c
|
|||
|
a7f0ed69a2
|
|||
|
2f8dad7d8d
|
|||
|
85746c3306
|
|||
|
def261b568
|
|||
|
3d6518e465
|
|||
| bcc3277cb9 | |||
|
b2ccff5b19
|
|||
| 12b7bde74f | |||
| 42d23fa35e | |||
| b6ee25d1dd | |||
| b4d82b8b1f | |||
| a0d9775015 | |||
| 6c01ac1c1b | |||
| ebe1d8acda | |||
| c2334b2cdd | |||
| dec228063b | |||
| 3c47ee8a4c | |||
| 8d7856f32e | |||
| b8b0a4a1ba | |||
| dedfb69dff | |||
| 2f2e515d72 | |||
| 7ffb5c44b5 | |||
| f978d740fe | |||
| 6196d33b2a | |||
| ccfaf3fd70 | |||
| 531e53d2ae | |||
| b708c8c1f8 | |||
| 53ad7b729e | |||
| e73fb24422 | |||
| b282bbf5d6 | |||
| bd1ae909b5 | |||
| e97054fa34 | |||
| a43a45831c | |||
| 1bbc21c742 | |||
| 9fa87d01b3 | |||
| 7ad17a7c0e | |||
| dbfb2c68d2 | |||
| 32c42cdc2d | |||
| ff2fe126c6 | |||
| df4f438f6d | |||
| 55c75501d5 | |||
| b475b23beb | |||
| 7fd89c5663 | |||
| a28d43a68b | |||
| be52f73e64 | |||
| dd275593cd | |||
| 9ef1720e1f |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -3,8 +3,13 @@ Electrum.sqlite
|
|||||||
XO.sqlite
|
XO.sqlite
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
coverage/
|
||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
*.sqlite-journal
|
||||||
resolvedTemplate.json
|
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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
2986
package-lock.json
generated
2986
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
42
package.json
42
package.json
@@ -3,11 +3,26 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"xo-cli": "./dist/cli/index.js",
|
||||||
|
"xo-tui": "./dist/index.js",
|
||||||
|
"xo-complete": "./dist/cli/autocomplete/complete.js"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
|
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc && npm run build:copy-scripts",
|
||||||
|
"build:copy-scripts": "cp -r src/cli/autocomplete/scripts dist/cli/autocomplete/",
|
||||||
|
"build:unsafe": "tsc --nocheck --noEmitOnError false || true && npm run build:copy-scripts",
|
||||||
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "vitest --run --passWithNoTests",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||||
|
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"autocomplete:install": "node dist/cli/index.js completions bash --install",
|
||||||
|
"autocomplete:install:bash": "node dist/cli/index.js completions bash --install",
|
||||||
|
"autocomplete:install:zsh": "node dist/cli/index.js completions zsh --install",
|
||||||
|
"autocomplete:install:fish": "node dist/cli/index.js completions fish --install"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"crypto",
|
"crypto",
|
||||||
@@ -20,23 +35,30 @@
|
|||||||
"description": "XO Wallet CLI - Terminal User Interface for XO crypto wallet",
|
"description": "XO Wallet CLI - Terminal User Interface for XO crypto wallet",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.0.0",
|
"@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/engine": "file:../engine",
|
||||||
"@xo-cash/state": "file:../state",
|
"@xo-cash/state": "file:../state",
|
||||||
"@xo-cash/templates": "file:../templates",
|
"@xo-cash/templates": "file:../templates",
|
||||||
"@xo-cash/types": "file:../types",
|
"@xo-cash/types": "^0.0.1",
|
||||||
|
"@xo-cash/utils": "file:../utils",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"clipboardy": "^5.1.0",
|
"clipboardy": "^5.1.0",
|
||||||
"ink": "^6.6.0",
|
"ink": "^6.6.0",
|
||||||
"ink-select-input": "^6.0.0",
|
"prettier": "^3.8.1",
|
||||||
"ink-spinner": "^5.0.0",
|
"qrcode": "^1.5.4",
|
||||||
"ink-text-input": "^6.0.0",
|
"react": "^19.2.4",
|
||||||
"react": "^19.2.4"
|
"tsx": "^4.21.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
"@types/react": "^18.3.18",
|
"@types/qrcode": "^1.5.6",
|
||||||
"tsx": "^4.21.0",
|
"@types/react": "^19.2.14",
|
||||||
"typescript": "^5.9.3"
|
"@vitest/coverage-v8": "^4.1.2",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
219
readme.md
Normal file
219
readme.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# 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 State Setup -----
|
||||||
|
# Clone the State Repo
|
||||||
|
git clone https://gitlab.com/Harvmaster/state.git
|
||||||
|
|
||||||
|
# Move into the state directory
|
||||||
|
cd state
|
||||||
|
|
||||||
|
git checkout cli-test
|
||||||
|
|
||||||
|
# Install the dependencies
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
# Build the state
|
||||||
|
npm run build
|
||||||
|
# ----- End State Setup -----
|
||||||
|
|
||||||
|
# Move back to the top level directory
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# ----- Start Primitive Setup -----
|
||||||
|
git clone git@gitlab.com:GeneralProtocols/xo/primitives.git
|
||||||
|
|
||||||
|
cd primitives
|
||||||
|
|
||||||
|
git checkout update/syncup-ui-requirements
|
||||||
|
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# ----- End Primitive Setup -----
|
||||||
|
|
||||||
|
# Move back to the top level directory
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# ----- Start Utils Setup -----
|
||||||
|
git clone git@gitlab.com:Harvmaster/xo-cash-utils.git utils
|
||||||
|
|
||||||
|
cd utils
|
||||||
|
|
||||||
|
git checkout sse-and-backoff
|
||||||
|
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
# ----- End Utils Setup
|
||||||
|
|
||||||
|
# Move back to the top level directory
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# ----- Start Template Setup ----
|
||||||
|
# Clone the Template repo
|
||||||
|
git clone https://gitlab.com/Harvmaster/templates.git
|
||||||
|
|
||||||
|
# Move into themplates directory
|
||||||
|
cd templates
|
||||||
|
|
||||||
|
# Install deps
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
#build the templates
|
||||||
|
npm run build
|
||||||
|
# ----- End Templates Setup ----
|
||||||
|
|
||||||
|
# Move back to the top level directory
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# ----- 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 https://gitlab.com/Harvmaster/engine.git
|
||||||
|
|
||||||
|
# Move into teh engine directory
|
||||||
|
cd engine
|
||||||
|
|
||||||
|
# Checkout the cli-test branch
|
||||||
|
git checkout cli-test-update
|
||||||
|
|
||||||
|
# Install the dependencies
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
# Build the engine
|
||||||
|
npm run build
|
||||||
|
# ----- End Engine Setup -----
|
||||||
|
|
||||||
|
# Move back to the top level directory
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# ----- Start CLI Setup -----
|
||||||
|
# Clone the CLI Repo
|
||||||
|
git clone https://git.harvmaster.com/Harvmaster/xo-cli.git
|
||||||
|
|
||||||
|
# Move into the cli directory
|
||||||
|
cd xo-cli
|
||||||
|
|
||||||
|
git checkout kiok-update
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
These commands add `XO_CONFIG_DIR` to your shell config with a default of
|
||||||
|
`~/.config/xo-cli`. Set it to an absolute path before installing, or edit the
|
||||||
|
generated assignment, to use a different wallet-state 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
### Track invitation sync-server connectivity without blocking the UI
|
||||||
|
|
||||||
|
Each `Invitation` currently owns a `SyncServer` instance for its invitation
|
||||||
|
identifier. The invitation uses that instance to open an SSE connection, fetch
|
||||||
|
remote state, and publish local changes. Publish requests are intentionally
|
||||||
|
fire-and-forget so that invitation actions and the TUI stay responsive when the
|
||||||
|
sync server is slow or unavailable.
|
||||||
|
|
||||||
|
The tradeoff is that failed background requests and SSE connection changes are
|
||||||
|
not represented as application state. `SyncServer` already emits `connected`,
|
||||||
|
`disconnected`, and `error` events, and `Invitation` emits errors from failed
|
||||||
|
publishes, but there is no app-level owner that aggregates those events. The UI
|
||||||
|
therefore cannot reliably tell the user that an invitation may only be updated
|
||||||
|
locally and is not currently syncing with other participants.
|
||||||
|
|
||||||
|
Implement an app-owned `InvitationConnectivityService` (or similarly named
|
||||||
|
invitation watcher) with the following responsibilities:
|
||||||
|
|
||||||
|
- Register an invitation and its `SyncServer` when `AppService` creates or loads
|
||||||
|
it, and unregister it when the invitation is removed or stopped.
|
||||||
|
- Listen for each sync server's `connected`, `disconnected`, and `error` events,
|
||||||
|
plus invitation publish failures.
|
||||||
|
- Track connectivity separately from the invitation's business status
|
||||||
|
(`actionable`, `signed`, `ready`, and so on). Suggested transport states are
|
||||||
|
`connecting`, `online`, `offline`, and `degraded`, with the last error and
|
||||||
|
last successful connection timestamp available for diagnostics.
|
||||||
|
- Expose both per-invitation state and an aggregate app-level state such as
|
||||||
|
"one or more invitations are not syncing".
|
||||||
|
- Emit normalized connectivity-change events that the CLI can log and the TUI
|
||||||
|
can subscribe to without awaiting sync-server requests.
|
||||||
|
|
||||||
|
Keep local persistence and local invitation actions independent from remote
|
||||||
|
sync health. Failed sync attempts should not freeze normal wallet interaction.
|
||||||
|
The service should provide a retry path, or observe retry events from the SSE
|
||||||
|
client, and clear the warning after connectivity recovers. If publish retries
|
||||||
|
are added, make the retry policy explicit and preserve commit idempotency.
|
||||||
|
|
||||||
|
For UI integration, inject a small notification function or subscribe at the
|
||||||
|
app-context layer rather than having invitation instances render UI directly.
|
||||||
|
The first version can show an error dialog when the aggregate state becomes
|
||||||
|
unhealthy. A less intrusive version can expose the same state as a warning icon
|
||||||
|
or message in the TUI status bar and reserve dialogs for prolonged failures or
|
||||||
|
explicit user actions.
|
||||||
|
|
||||||
|
While making this change, consolidate invitation startup ownership. Startup is
|
||||||
|
currently triggered during `Invitation.create()` and again by
|
||||||
|
`AppService.createInvitation()`. The watcher should have one clear lifecycle
|
||||||
|
point so connections, listeners, retries, and cleanup are registered exactly
|
||||||
|
once.
|
||||||
24
src/app.ts
24
src/app.ts
@@ -3,9 +3,11 @@
|
|||||||
* Simplified to render TUI immediately and let it handle AppService creation.
|
* Simplified to render TUI immediately and let it handle AppService creation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import { join } from "node:path";
|
||||||
import { render, type Instance } from 'ink';
|
import React from "react";
|
||||||
import { App as AppComponent } from './tui/App.js';
|
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.
|
* Configuration options for the CLI application.
|
||||||
@@ -46,15 +48,17 @@ export class App {
|
|||||||
* @returns Running App instance
|
* @returns Running App instance
|
||||||
*/
|
*/
|
||||||
static async create(config: Partial<AppConfig> = {}): Promise<App> {
|
static async create(config: Partial<AppConfig> = {}): Promise<App> {
|
||||||
|
const dataDir = getDataDir();
|
||||||
// Set default configuration
|
// Set default configuration
|
||||||
const fullConfig: AppConfig = {
|
const fullConfig: AppConfig = {
|
||||||
syncServerUrl: config.syncServerUrl ?? 'http://localhost:3000',
|
syncServerUrl: config.syncServerUrl ?? "http://localhost:3000",
|
||||||
databasePath: config.databasePath ?? './',
|
databasePath: config.databasePath ?? dataDir,
|
||||||
databaseFilename: config.databaseFilename ?? 'xo-wallet.db',
|
databaseFilename: config.databaseFilename ?? "xo-wallet.db",
|
||||||
invitationStoragePath: config.invitationStoragePath ?? './xo-invitations.db',
|
invitationStoragePath:
|
||||||
|
config.invitationStoragePath ?? join(dataDir, "xo-invitations.db"),
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Full config:', fullConfig);
|
console.log("Full config:", fullConfig);
|
||||||
|
|
||||||
const app = new App(fullConfig);
|
const app = new App(fullConfig);
|
||||||
await app.start();
|
await app.start();
|
||||||
@@ -71,11 +75,13 @@ export class App {
|
|||||||
this.inkInstance = render(
|
this.inkInstance = render(
|
||||||
React.createElement(AppComponent, {
|
React.createElement(AppComponent, {
|
||||||
config: this.config,
|
config: this.config,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for the app to exit
|
// Wait for the app to exit
|
||||||
await this.inkInstance.waitUntilExit();
|
await this.inkInstance.waitUntilExit();
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
214
src/cli/README.md
Normal file
214
src/cli/README.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# 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 **`${XO_CONFIG_DIR:-~/.config/xo-cli}`**, so you can run commands from any directory. Set `XO_CONFIG_DIR` to use a different wallet-state root.
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
| --------------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| `$XO_CONFIG_DIR/mnemonics/` | Mnemonic files (`mnemonic-*`) |
|
||||||
|
| `$XO_CONFIG_DIR/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) |
|
||||||
|
| `$XO_CONFIG_DIR/.wallet` | JSON settings (`default-mnemonic`, `currency`) |
|
||||||
|
|
||||||
|
**Local to your shell’s current directory:** template JSON paths, invitation JSON you create/import, and any path you pass explicitly (e.g. `-m /abs/path/to/file`).
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
| Variable | Default |
|
||||||
|
| ------------------------- | --------------------------------------- |
|
||||||
|
| `XO_CONFIG_DIR` | `~/.config/xo-cli` |
|
||||||
|
| `SYNC_SERVER_URL` | `http://localhost:3000` |
|
||||||
|
| `DB_PATH` | `$XO_CONFIG_DIR/data` |
|
||||||
|
| `DB_FILENAME` | `xo-wallet.db` |
|
||||||
|
| `INVITATION_STORAGE_PATH` | `$XO_CONFIG_DIR/data/xo-invitations.db` |
|
||||||
|
|
||||||
|
Use an absolute path for a custom root. Setting `XO_CONFIG_DIR` does not copy state from the default directory.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Wallet Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a new mnemonic (saved under $XO_CONFIG_DIR/mnemonics/)
|
||||||
|
xo-cli mnemonic create
|
||||||
|
|
||||||
|
# Import an existing mnemonic seed phrase
|
||||||
|
xo-cli mnemonic import oven crop same above under tower promote decrease vocal pretty require slow
|
||||||
|
|
||||||
|
# List mnemonic basenames (use with -m)
|
||||||
|
xo-cli mnemonic list
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:** `-o <filename>` — basename only; file is written under the global mnemonics directory.
|
||||||
|
|
||||||
|
### Wallet Persistence
|
||||||
|
|
||||||
|
The first time you pass `-m <name>`, that reference is saved as
|
||||||
|
`default-mnemonic` in `$XO_CONFIG_DIR/.wallet`. Later runs can omit `-m`.
|
||||||
|
|
||||||
|
`currency` controls the fiat unit used when showing BCH/sats conversions in the TUI.
|
||||||
|
|
||||||
|
Mnemonic resolution order:
|
||||||
|
|
||||||
|
1. Absolute path, if the file exists
|
||||||
|
2. Path relative to the current working directory
|
||||||
|
3. `$XO_CONFIG_DIR/mnemonics/<basename>`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xo-cli resource list -m mnemonic-nuclear
|
||||||
|
xo-cli resource list
|
||||||
|
```
|
||||||
|
|
||||||
|
## Global Options (`xo-cli`)
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
| ------------------------------ | ---------------------------------------------------- |
|
||||||
|
| `-m`, `--mnemonic-file <file>` | Mnemonic file (basename, cwd-relative, or absolute) |
|
||||||
|
| `--currency <code>` | Fiat display currency (e.g. `USD`, `AUD`) |
|
||||||
|
| `-o`, `--output <filename>` | Output filename (used by `mnemonic create`/`import`) |
|
||||||
|
| `-v`, `--verbose` | Verbose output |
|
||||||
|
| `-h`, `--help` | Help |
|
||||||
|
|
||||||
|
Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `$XO_CONFIG_DIR/data/` (see `src/cli/index.ts`).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `mnemonic` — Manage Wallet Files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xo-cli mnemonic create
|
||||||
|
xo-cli mnemonic import <seed words...>
|
||||||
|
xo-cli mnemonic list
|
||||||
|
xo-cli mnemonic expose <mnemonic-file>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `template` — Manage Templates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xo-cli template import <template-file>
|
||||||
|
xo-cli template list
|
||||||
|
xo-cli template list <category> <template-id>
|
||||||
|
xo-cli template inspect <category> <template-id> <field>
|
||||||
|
xo-cli template set-default <template-file> <output-id> <role>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Categories:** `action`, `transaction`, `output`, `lockingscript`, `variable`
|
||||||
|
|
||||||
|
Template paths are resolved relative to the **current working directory**.
|
||||||
|
|
||||||
|
### `resource` — Manage UTXOs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xo-cli resource list
|
||||||
|
xo-cli resource list reserved
|
||||||
|
xo-cli resource list all
|
||||||
|
xo-cli resource unreserve <txhash:vout>
|
||||||
|
xo-cli resource unreserve-all
|
||||||
|
```
|
||||||
|
|
||||||
|
### `settings` — Manage Persisted Settings
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xo-cli settings show
|
||||||
|
xo-cli settings get currency
|
||||||
|
xo-cli settings get default-mnemonic
|
||||||
|
xo-cli settings set currency AUD
|
||||||
|
xo-cli settings set default-mnemonic mnemonic-nuclear
|
||||||
|
```
|
||||||
|
|
||||||
|
### `receive` — Generate a Receiving Address
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xo-cli receive <template-file> <output-identifier> [role-identifier]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `invitation` — Build, Sign & Broadcast
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xo-cli invitation create <template-file> <action-id> [options]
|
||||||
|
xo-cli invitation append <invitation-id> [options]
|
||||||
|
xo-cli invitation sign <invitation-id>
|
||||||
|
xo-cli invitation broadcast <invitation-id>
|
||||||
|
xo-cli invitation requirements <invitation-id>
|
||||||
|
xo-cli invitation import <invitation-file>
|
||||||
|
xo-cli invitation inspect <invitation-file>
|
||||||
|
xo-cli invitation list
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create / append options:**
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
| --------------------------- | ---------------------------------------- |
|
||||||
|
| `-var-<name> <value>` | Template variable |
|
||||||
|
| `--add-input <txhash:vout>` | Inputs (comma-separated) |
|
||||||
|
| `--add-output <id>` | Override outputs (omit to auto-discover) |
|
||||||
|
| `--auto-inputs` | Auto-select UTXOs |
|
||||||
|
| `-role <role>` | Role for variables / bytecode |
|
||||||
|
| `--sign` | Auto-sign when complete |
|
||||||
|
| `--broadcast` | Auto-broadcast (implies `--sign`) |
|
||||||
|
|
||||||
|
Invitation JSON files from `create` / `append` are written to the **current working directory**.
|
||||||
|
|
||||||
|
### One-command send
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xo-cli resource list
|
||||||
|
xo-cli invitation create p2pkh-template.json sendSatoshis \
|
||||||
|
-var-transferred-satoshis 4678 \
|
||||||
|
-var-recipient-lockingscript "bitcoincash:qz..." \
|
||||||
|
--add-input <txhash>:<vout> \
|
||||||
|
-role sender \
|
||||||
|
--broadcast
|
||||||
|
```
|
||||||
|
|
||||||
|
### `xo-tui`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xo-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
Launches the full-screen wallet UI; uses the same global data directory unless overridden by env vars.
|
||||||
|
|
||||||
|
## Shell Completions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(xo-cli completions bash)"
|
||||||
|
eval "$(xo-cli completions zsh)"
|
||||||
|
xo-cli completions fish | source
|
||||||
|
```
|
||||||
|
|
||||||
|
`xo-cli completions <shell> --install` adds a default `XO_CONFIG_DIR` assignment to the shell startup file if one is not already present. Mnemonic aliases are completed directly from `$XO_CONFIG_DIR/mnemonics/`; database-backed suggestions still use `xo-complete`.
|
||||||
|
|
||||||
|
## File Conventions
|
||||||
|
|
||||||
|
| Location | Purpose |
|
||||||
|
| ---------------- | ------------------------------------------ |
|
||||||
|
| `$XO_CONFIG_DIR` | 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 oven crop same above under tower promote decrease vocal pretty require slow -v -o mnemonic.txt` will return:
|
||||||
|
* {
|
||||||
|
* args: ["mnemonic", "create", "oven", "crop", "same", "above", "under", "tower", "promote", "decrease", "vocal", "pretty", "require", "slow"],
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
390
src/cli/autocomplete/complete.ts
Normal file
390
src/cli/autocomplete/complete.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Lightweight shell completion helper for xo-cli.
|
||||||
|
*
|
||||||
|
* This script reads from local SQLite only - no network connections.
|
||||||
|
* It's designed to be fast enough for interactive tab completion.
|
||||||
|
*
|
||||||
|
* Usage: xo-complete <context> [args...]
|
||||||
|
*
|
||||||
|
* Contexts:
|
||||||
|
* mnemonics - List mnemonic file names
|
||||||
|
* templates - List template names/IDs
|
||||||
|
* actions <template> - List actions for a template
|
||||||
|
* fields <category> <template> - List fields for a template category
|
||||||
|
* invitations - List invitation IDs
|
||||||
|
* resources - List UTXO outpoints (txhash:vout)
|
||||||
|
* subcommands <command> - List subcommands for a top-level command
|
||||||
|
*
|
||||||
|
* Output: One completion suggestion per line, suitable for shell completion.
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* 0 - Success (may output zero or more completions)
|
||||||
|
* 1 - Error (no output, fails silently for shell integration)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getDataDir,
|
||||||
|
getMnemonicsDir,
|
||||||
|
getWalletConfigPath,
|
||||||
|
} from "../../utils/paths.js";
|
||||||
|
import { loadMnemonic } from "../mnemonic.js";
|
||||||
|
import { Storage } from "../../services/storage.js";
|
||||||
|
import { SettingsService } from "../../services/settings.js";
|
||||||
|
import { COMMAND_TREE } from "./completions.js";
|
||||||
|
|
||||||
|
// Lazy-loaded modules (only loaded when needed for dynamic completions)
|
||||||
|
let _offlineEngineModule: typeof import("./offline-engine.js") | null = null;
|
||||||
|
let _engineModule: typeof import("@xo-cash/engine") | null = null;
|
||||||
|
|
||||||
|
async function getOfflineEngineModule() {
|
||||||
|
if (!_offlineEngineModule) {
|
||||||
|
_offlineEngineModule = await import("./offline-engine.js");
|
||||||
|
}
|
||||||
|
return _offlineEngineModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEngineModule() {
|
||||||
|
if (!_engineModule) {
|
||||||
|
_engineModule = await import("@xo-cash/engine");
|
||||||
|
}
|
||||||
|
return _engineModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outputs completions to stdout, one per line.
|
||||||
|
* Optionally filters by a prefix (for partial word completion).
|
||||||
|
*/
|
||||||
|
function outputCompletions(items: readonly string[], prefix?: string): void {
|
||||||
|
const filtered = prefix
|
||||||
|
? items.filter((item) =>
|
||||||
|
item.toLowerCase().startsWith(prefix.toLowerCase()),
|
||||||
|
)
|
||||||
|
: items;
|
||||||
|
|
||||||
|
for (const item of filtered) {
|
||||||
|
console.log(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists mnemonic file names from the mnemonics directory.
|
||||||
|
* Fast path: no engine needed, just filesystem.
|
||||||
|
*/
|
||||||
|
function listMnemonics(prefix?: string): void {
|
||||||
|
try {
|
||||||
|
const mnemonicsDir = getMnemonicsDir();
|
||||||
|
const files = readdirSync(mnemonicsDir).filter((f) =>
|
||||||
|
f.startsWith("mnemonic-"),
|
||||||
|
);
|
||||||
|
outputCompletions(files, prefix);
|
||||||
|
} catch {
|
||||||
|
// Silently fail - no completions available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists subcommands for a given top-level command.
|
||||||
|
* Uses the static COMMAND_TREE.
|
||||||
|
*/
|
||||||
|
function listSubcommands(command: string, prefix?: string): void {
|
||||||
|
if (command in COMMAND_TREE) {
|
||||||
|
const subcommands = COMMAND_TREE[command as keyof typeof COMMAND_TREE];
|
||||||
|
outputCompletions(subcommands, prefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current wallet's mnemonic seed from the saved config.
|
||||||
|
* Returns null if no wallet is configured.
|
||||||
|
*/
|
||||||
|
function getCurrentMnemonic(): string | null {
|
||||||
|
try {
|
||||||
|
const settings = new SettingsService(getWalletConfigPath());
|
||||||
|
const mnemonicFile = settings.getDefaultMnemonic();
|
||||||
|
if (!mnemonicFile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mnemonicsDir = getMnemonicsDir();
|
||||||
|
return loadMnemonic(mnemonicsDir, mnemonicFile);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists templates from the engine.
|
||||||
|
*/
|
||||||
|
async function listTemplates(prefix?: string): Promise<void> {
|
||||||
|
const mnemonic = getCurrentMnemonic();
|
||||||
|
if (!mnemonic) return;
|
||||||
|
|
||||||
|
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
|
||||||
|
const { generateTemplateIdentifier } = await getEngineModule();
|
||||||
|
|
||||||
|
const engine = await tryCreateOfflineEngine(mnemonic, {
|
||||||
|
databasePath: getDataDir(),
|
||||||
|
databaseFilename: "xo-wallet.db",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!engine) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const templates = await engine.listImportedTemplates();
|
||||||
|
const completions: string[] = [];
|
||||||
|
|
||||||
|
for (const template of templates) {
|
||||||
|
// Add template name (for user-friendly completion)
|
||||||
|
if (template.name) {
|
||||||
|
completions.push(template.name);
|
||||||
|
}
|
||||||
|
// Also add template identifier (for precise matching)
|
||||||
|
const id = generateTemplateIdentifier(template);
|
||||||
|
if (id && !completions.includes(id)) {
|
||||||
|
completions.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outputCompletions(completions, prefix);
|
||||||
|
} finally {
|
||||||
|
await engine.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a template by name or ID.
|
||||||
|
*/
|
||||||
|
async function resolveTemplate(
|
||||||
|
engine: Awaited<
|
||||||
|
ReturnType<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<typeof getOfflineEngineModule>
|
||||||
|
>["tryCreateOfflineEngine"]
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
templateQuery: string,
|
||||||
|
) {
|
||||||
|
if (!engine) return null;
|
||||||
|
|
||||||
|
const { generateTemplateIdentifier } = await getEngineModule();
|
||||||
|
const templates = await engine.listImportedTemplates();
|
||||||
|
|
||||||
|
// Try exact match on name or ID
|
||||||
|
let template = templates.find(
|
||||||
|
(t) =>
|
||||||
|
t.name === templateQuery ||
|
||||||
|
generateTemplateIdentifier(t) === templateQuery,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try partial match on name
|
||||||
|
if (!template) {
|
||||||
|
template = templates.find((t) =>
|
||||||
|
t.name?.toLowerCase().includes(templateQuery.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return template ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists actions for a specific template.
|
||||||
|
*/
|
||||||
|
async function listActions(
|
||||||
|
templateQuery: string,
|
||||||
|
prefix?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const mnemonic = getCurrentMnemonic();
|
||||||
|
if (!mnemonic) return;
|
||||||
|
|
||||||
|
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
|
||||||
|
|
||||||
|
const engine = await tryCreateOfflineEngine(mnemonic, {
|
||||||
|
databasePath: getDataDir(),
|
||||||
|
databaseFilename: "xo-wallet.db",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!engine) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const template = await resolveTemplate(engine, templateQuery);
|
||||||
|
|
||||||
|
if (template && template.actions) {
|
||||||
|
const actions = Object.keys(template.actions);
|
||||||
|
outputCompletions(actions, prefix);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await engine.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists fields (actions, transactions, outputs, etc.) for a specific template category.
|
||||||
|
* Used for completing the 3rd argument of `template inspect <category> <template> <field>`.
|
||||||
|
*/
|
||||||
|
async function listFields(
|
||||||
|
category: string,
|
||||||
|
templateQuery: string,
|
||||||
|
prefix?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const mnemonic = getCurrentMnemonic();
|
||||||
|
if (!mnemonic) return;
|
||||||
|
|
||||||
|
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
|
||||||
|
|
||||||
|
const engine = await tryCreateOfflineEngine(mnemonic, {
|
||||||
|
databasePath: getDataDir(),
|
||||||
|
databaseFilename: "xo-wallet.db",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!engine) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const template = await resolveTemplate(engine, templateQuery);
|
||||||
|
if (!template) return;
|
||||||
|
|
||||||
|
let fields: string[] = [];
|
||||||
|
|
||||||
|
switch (category) {
|
||||||
|
case "action":
|
||||||
|
fields = Object.keys(template.actions || {});
|
||||||
|
break;
|
||||||
|
case "transaction":
|
||||||
|
fields = Object.keys(template.transactions || {});
|
||||||
|
break;
|
||||||
|
case "output":
|
||||||
|
fields = Object.keys(template.outputs || {});
|
||||||
|
break;
|
||||||
|
case "lockingscript":
|
||||||
|
fields = Object.keys(template.lockingScripts || {});
|
||||||
|
break;
|
||||||
|
case "variable":
|
||||||
|
fields = Object.keys(template.variables || {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
outputCompletions(fields, prefix);
|
||||||
|
} finally {
|
||||||
|
await engine.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists invitation IDs from the invitation storage.
|
||||||
|
*/
|
||||||
|
async function listInvitations(prefix?: string): Promise<void> {
|
||||||
|
const mnemonic = getCurrentMnemonic();
|
||||||
|
if (!mnemonic) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Compute seed hash to find the right storage namespace
|
||||||
|
const seedHash = createHash("sha256").update(mnemonic).digest("hex");
|
||||||
|
const invitationsDbPath = join(getDataDir(), "xo-invitations.db");
|
||||||
|
|
||||||
|
if (!existsSync(invitationsDbPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = await Storage.create(invitationsDbPath);
|
||||||
|
const walletStorage = storage.child(seedHash.slice(0, 8));
|
||||||
|
const invitationsStorage = walletStorage.child("invitations");
|
||||||
|
|
||||||
|
const invitations = await invitationsStorage.all();
|
||||||
|
const ids = invitations.map((inv) => inv.key);
|
||||||
|
|
||||||
|
outputCompletions(ids, prefix);
|
||||||
|
} catch {
|
||||||
|
// Silently fail - no completions available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists UTXO outpoints (resources) from the engine.
|
||||||
|
*/
|
||||||
|
async function listResources(prefix?: string): Promise<void> {
|
||||||
|
const mnemonic = getCurrentMnemonic();
|
||||||
|
if (!mnemonic) return;
|
||||||
|
|
||||||
|
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
|
||||||
|
|
||||||
|
const engine = await tryCreateOfflineEngine(mnemonic, {
|
||||||
|
databasePath: getDataDir(),
|
||||||
|
databaseFilename: "xo-wallet.db",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!engine) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const utxos = await engine.listUnspentOutputsData();
|
||||||
|
const outpoints = utxos.map(
|
||||||
|
(u) => `${u.outpointTransactionHash}:${u.outpointIndex}`,
|
||||||
|
);
|
||||||
|
outputCompletions(outpoints, prefix);
|
||||||
|
} finally {
|
||||||
|
await engine.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entry point.
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const context = process.argv[2];
|
||||||
|
const arg1 = process.argv[3];
|
||||||
|
const arg2 = process.argv[4];
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
// No context provided - output nothing
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (context) {
|
||||||
|
case "mnemonics":
|
||||||
|
listMnemonics(arg1);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "subcommands":
|
||||||
|
if (arg1) {
|
||||||
|
listSubcommands(arg1, arg2);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "templates":
|
||||||
|
await listTemplates(arg1);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "actions":
|
||||||
|
if (arg1) {
|
||||||
|
await listActions(arg1, arg2);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "fields":
|
||||||
|
// fields <category> <template> [prefix]
|
||||||
|
if (arg1 && arg2) {
|
||||||
|
await listFields(arg1, arg2, process.argv[5]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "invitations":
|
||||||
|
await listInvitations(arg1);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "resources":
|
||||||
|
await listResources(arg1);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unknown context - output nothing
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(() => {
|
||||||
|
// Silently fail for shell integration
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
333
src/cli/autocomplete/completions.ts
Normal file
333
src/cli/autocomplete/completions.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
/**
|
||||||
|
* 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, mkdirSync } 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, export, set-default
|
||||||
|
* - invitation.ts: create, append, sign, broadcast, requirements, import, export, inspect, list
|
||||||
|
* - resource.ts: list, unreserve, unreserve-all
|
||||||
|
* - settings.ts: show, get, set
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Subcommands for the mnemonic command */
|
||||||
|
const MNEMONIC_SUBS = ["create", "import", "list", "expose"];
|
||||||
|
/** Subcommands for the template command */
|
||||||
|
const TEMPLATE_SUBS = ["import", "list", "inspect", "export", "set-default"];
|
||||||
|
/** Subcommands for the invitation command */
|
||||||
|
const INVITATION_SUBS = [
|
||||||
|
"create",
|
||||||
|
"append",
|
||||||
|
"sign",
|
||||||
|
"broadcast",
|
||||||
|
"requirements",
|
||||||
|
"import",
|
||||||
|
"export",
|
||||||
|
"inspect",
|
||||||
|
"list",
|
||||||
|
];
|
||||||
|
/** Subcommands for the resource command */
|
||||||
|
const RESOURCE_SUBS = ["list", "unreserve", "unreserve-all"];
|
||||||
|
/** Subcommands for the settings command */
|
||||||
|
const SETTINGS_SUBS = ["show", "get", "set"];
|
||||||
|
/** Subcommands for the completions command */
|
||||||
|
const COMPLETIONS_SUBS = ["bash", "zsh", "fish"];
|
||||||
|
|
||||||
|
export const COMMAND_TREE = {
|
||||||
|
mnemonic: MNEMONIC_SUBS,
|
||||||
|
template: TEMPLATE_SUBS,
|
||||||
|
invitation: INVITATION_SUBS,
|
||||||
|
receive: [],
|
||||||
|
resource: RESOURCE_SUBS,
|
||||||
|
settings: SETTINGS_SUBS,
|
||||||
|
help: [],
|
||||||
|
completions: COMPLETIONS_SUBS,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Global option flags available on every command. */
|
||||||
|
const GLOBAL_OPTIONS = [
|
||||||
|
"-h",
|
||||||
|
"--help",
|
||||||
|
"-v",
|
||||||
|
"--verbose",
|
||||||
|
"-m",
|
||||||
|
"--mnemonic-file",
|
||||||
|
"--currency",
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the path to the scripts directory containing shell templates.
|
||||||
|
*/
|
||||||
|
function getScriptsDir(): string {
|
||||||
|
const currentFile = fileURLToPath(import.meta.url);
|
||||||
|
return join(dirname(currentFile), "scripts");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a shell template file and replaces placeholders.
|
||||||
|
* @param templateName - The template file name (e.g., "bash.sh")
|
||||||
|
* @param binName - The CLI binary name
|
||||||
|
*/
|
||||||
|
function loadAndProcessTemplate(templateName: string, binName: string): string {
|
||||||
|
const scriptsDir = getScriptsDir();
|
||||||
|
const templatePath = join(scriptsDir, templateName);
|
||||||
|
|
||||||
|
if (!existsSync(templatePath)) {
|
||||||
|
throw new Error(`Template file not found: ${templatePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = readFileSync(templatePath, "utf8");
|
||||||
|
const funcName = binName.replace(/-/g, "_");
|
||||||
|
const commands = Object.keys(COMMAND_TREE).join(" ");
|
||||||
|
const options = GLOBAL_OPTIONS.join(" ");
|
||||||
|
|
||||||
|
// Replace all placeholders
|
||||||
|
content = content.replace(/\{\{BIN_NAME\}\}/g, binName);
|
||||||
|
content = content.replace(/\{\{FUNC_NAME\}\}/g, funcName);
|
||||||
|
content = content.replace(/\{\{COMMANDS\}\}/g, commands);
|
||||||
|
content = content.replace(/\{\{OPTIONS\}\}/g, options);
|
||||||
|
content = content.replace(/\{\{MNEMONIC_SUBS\}\}/g, MNEMONIC_SUBS.join(" "));
|
||||||
|
content = content.replace(/\{\{TEMPLATE_SUBS\}\}/g, TEMPLATE_SUBS.join(" "));
|
||||||
|
content = content.replace(
|
||||||
|
/\{\{INVITATION_SUBS\}\}/g,
|
||||||
|
INVITATION_SUBS.join(" "),
|
||||||
|
);
|
||||||
|
content = content.replace(/\{\{RESOURCE_SUBS\}\}/g, RESOURCE_SUBS.join(" "));
|
||||||
|
|
||||||
|
// Fish-specific placeholders
|
||||||
|
if (templateName.endsWith(".fish")) {
|
||||||
|
content = content.replace(
|
||||||
|
/\{\{TOP_LEVEL_COMMANDS\}\}/g,
|
||||||
|
generateFishTopLevelCommands(binName),
|
||||||
|
);
|
||||||
|
content = content.replace(
|
||||||
|
/\{\{STATIC_SUBCOMMANDS\}\}/g,
|
||||||
|
generateFishStaticSubcommands(binName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates fish top-level command completions.
|
||||||
|
*/
|
||||||
|
function generateFishTopLevelCommands(binName: string): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const cmd of Object.keys(COMMAND_TREE)) {
|
||||||
|
lines.push(
|
||||||
|
`complete -c ${binName} -n "__fish_use_subcommand" -a "${cmd}" -d "${cmd} command"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates fish static subcommand completions.
|
||||||
|
*/
|
||||||
|
function generateFishStaticSubcommands(binName: string): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const [cmd, subs] of Object.entries(COMMAND_TREE)) {
|
||||||
|
for (const sub of subs) {
|
||||||
|
lines.push(
|
||||||
|
`complete -c ${binName} -n "__fish_seen_subcommand_from ${cmd}; and not __fish_seen_subcommand_from ${subs.join(" ")}" -a "${sub}" -d "${cmd} ${sub}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a bash completion script.
|
||||||
|
* @param binName - The name of the CLI binary.
|
||||||
|
*/
|
||||||
|
export function generateBashCompletions(binName: string): string {
|
||||||
|
return loadAndProcessTemplate("bash.sh", binName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a zsh completion script.
|
||||||
|
* @param binName - The name of the CLI binary.
|
||||||
|
*/
|
||||||
|
export function generateZshCompletions(binName: string): string {
|
||||||
|
return loadAndProcessTemplate("zsh.zsh", binName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a fish completion script.
|
||||||
|
* @param binName - The name of the CLI binary.
|
||||||
|
*/
|
||||||
|
export function generateFishCompletions(binName: string): string {
|
||||||
|
return loadAndProcessTemplate("fish.fish", binName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShellType = "bash" | "zsh" | "fish";
|
||||||
|
|
||||||
|
const generators: Record<ShellType, (binName: string) => string> = {
|
||||||
|
bash: generateBashCompletions,
|
||||||
|
zsh: generateZshCompletions,
|
||||||
|
fish: generateFishCompletions,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shell config file paths and startup commands for each shell type.
|
||||||
|
*/
|
||||||
|
const shellConfigs: Record<
|
||||||
|
ShellType,
|
||||||
|
{
|
||||||
|
configFile: string;
|
||||||
|
configDirCommand: string;
|
||||||
|
configDirPattern: RegExp;
|
||||||
|
evalCommand: (binName: string) => string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
bash: {
|
||||||
|
configFile: join(homedir(), ".bashrc"),
|
||||||
|
configDirCommand:
|
||||||
|
'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"',
|
||||||
|
configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m,
|
||||||
|
evalCommand: (binName) => `eval "$(${binName} completions bash)"`,
|
||||||
|
},
|
||||||
|
zsh: {
|
||||||
|
configFile: join(homedir(), ".zshrc"),
|
||||||
|
configDirCommand:
|
||||||
|
'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"',
|
||||||
|
configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m,
|
||||||
|
evalCommand: (binName) => `eval "$(${binName} completions zsh)"`,
|
||||||
|
},
|
||||||
|
fish: {
|
||||||
|
configFile: join(homedir(), ".config", "fish", "config.fish"),
|
||||||
|
configDirCommand:
|
||||||
|
'set -q XO_CONFIG_DIR; or set -gx XO_CONFIG_DIR "$HOME/.config/xo-cli"',
|
||||||
|
configDirPattern: /^\s*set\b[^\n]*\bXO_CONFIG_DIR\b/m,
|
||||||
|
evalCommand: (binName) => `${binName} completions fish | source`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs completions to the user's shell config file.
|
||||||
|
* Adds a default config directory and 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
|
||||||
|
*/
|
||||||
|
export function installCompletions(
|
||||||
|
shell: ShellType,
|
||||||
|
binName: string,
|
||||||
|
configFile: string = shellConfigs[shell].configFile,
|
||||||
|
): boolean {
|
||||||
|
const config = { ...shellConfigs[shell], configFile };
|
||||||
|
const evalCommand = config.evalCommand(binName);
|
||||||
|
|
||||||
|
let existingContent = "";
|
||||||
|
if (existsSync(config.configFile)) {
|
||||||
|
existingContent = readFileSync(config.configFile, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands: string[] = [];
|
||||||
|
if (!config.configDirPattern.test(existingContent)) {
|
||||||
|
commands.push(config.configDirCommand);
|
||||||
|
}
|
||||||
|
if (!existingContent.includes(evalCommand)) {
|
||||||
|
commands.push(evalCommand);
|
||||||
|
}
|
||||||
|
if (commands.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLine =
|
||||||
|
existingContent.endsWith("\n") || existingContent === "" ? "" : "\n";
|
||||||
|
const completionBlock = `${newLine}\n# ${binName} shell completions\n${commands.join("\n")}\n`;
|
||||||
|
|
||||||
|
mkdirSync(dirname(config.configFile), { recursive: true });
|
||||||
|
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));
|
||||||
|
}
|
||||||
102
src/cli/autocomplete/offline-engine.ts
Normal file
102
src/cli/autocomplete/offline-engine.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* 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 } 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);
|
||||||
|
|
||||||
|
// Create a minimal blockchain monitor (no electrum initialization)
|
||||||
|
const blockchainMonitor = new BlockchainMonitor(state);
|
||||||
|
|
||||||
|
// Engine constructor is private; bypass for offline read-only completions.
|
||||||
|
type EngineConstructor = new (
|
||||||
|
mnemonic: string,
|
||||||
|
state: State,
|
||||||
|
blockchainMonitor: BlockchainMonitor,
|
||||||
|
) => Engine;
|
||||||
|
|
||||||
|
const engine = new (Engine as unknown as EngineConstructor)(
|
||||||
|
seed,
|
||||||
|
state,
|
||||||
|
blockchainMonitor,
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
253
src/cli/autocomplete/scripts/bash.sh
Normal file
253
src/cli/autocomplete/scripts/bash.sh
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Bash completion template for {{BIN_NAME}}
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Installation:
|
||||||
|
# eval "$({{BIN_NAME}} completions bash)"
|
||||||
|
#
|
||||||
|
# This file is generated from a template. Placeholders (for example `{{OPTIONS}}`)
|
||||||
|
# are replaced at build/runtime with concrete command data from the CLI.
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Prefer a globally-installed helper, but fall back to a helper co-located with
|
||||||
|
# the CLI binary. This lets completions work in both "installed via PATH" and
|
||||||
|
# "single extracted directory" workflows.
|
||||||
|
__xo_complete_bin=""
|
||||||
|
if command -v xo-complete &>/dev/null; then
|
||||||
|
__xo_complete_bin="xo-complete"
|
||||||
|
elif command -v {{BIN_NAME}} &>/dev/null; then
|
||||||
|
__xo_complete_bin="$(dirname "$(command -v {{BIN_NAME}})")/xo-complete"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# @description
|
||||||
|
# Calls the dynamic completion helper and suppresses helper stderr so the shell
|
||||||
|
# completion menu stays clean even when the helper is unavailable or errors.
|
||||||
|
# @param "$@" Arguments forwarded to xo-complete.
|
||||||
|
__xo_complete() {
|
||||||
|
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# @description
|
||||||
|
# Lists mnemonic aliases directly from the config directory without starting
|
||||||
|
# the dynamic Node helper.
|
||||||
|
__xo_complete_mnemonics() {
|
||||||
|
local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}"
|
||||||
|
local file mnemonic
|
||||||
|
for file in "${config_dir}"/mnemonics/mnemonic-*; do
|
||||||
|
[[ -f "${file}" ]] || continue
|
||||||
|
mnemonic="${file##*/}"
|
||||||
|
[[ "${mnemonic}" == "$1"* ]] && printf '%s\n' "${mnemonic}"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# @description
|
||||||
|
# Main completion dispatcher invoked by bash's `complete -F`.
|
||||||
|
# It determines context (command/subcommand/argument position) and then mixes:
|
||||||
|
# - static completions (known command words)
|
||||||
|
# - dynamic completions (resolved by xo-complete)
|
||||||
|
# - filesystem completions (when a subcommand expects file paths)
|
||||||
|
_{{FUNC_NAME}}_completions() {
|
||||||
|
local cur prev words cword
|
||||||
|
# Populates `cur`, `prev`, `words`, and `cword`.
|
||||||
|
# `_init_completion` is provided by bash-completion.
|
||||||
|
_init_completion || return
|
||||||
|
|
||||||
|
# If the previous token is `-m/--mnemonic-file`, this argument expects a
|
||||||
|
# mnemonic file alias/path. List mnemonic aliases directly from disk.
|
||||||
|
if [[ "${prev}" == "-m" || "${prev}" == "--mnemonic-file" ]]; then
|
||||||
|
local mnemonics
|
||||||
|
mnemonics=$(__xo_complete_mnemonics "${cur}")
|
||||||
|
if [[ -n "${mnemonics}" ]]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
COMPREPLY+=("$line")
|
||||||
|
done <<< "${mnemonics}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Option context: show global options when the current token starts with `-`.
|
||||||
|
if [[ "${cur}" == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "{{OPTIONS}}" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse command/subcommand from non-option tokens before the current cursor.
|
||||||
|
# We track indices so argument-position logic can be computed later.
|
||||||
|
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
|
||||||
|
for ((i=1; i < cword; i++)); do
|
||||||
|
if [[ "${words[i]}" != -* ]]; then
|
||||||
|
if [[ -z "${cmd}" ]]; then
|
||||||
|
cmd="${words[i]}"
|
||||||
|
cmd_idx=$i
|
||||||
|
else
|
||||||
|
subcmd="${words[i]}"
|
||||||
|
subcmd_idx=$i
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# No command selected yet: complete top-level commands.
|
||||||
|
if [[ -z "${cmd}" ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "{{COMMANDS}}" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Command-specific completion rules.
|
||||||
|
case "${cmd}" in
|
||||||
|
mnemonic)
|
||||||
|
if [[ -z "${subcmd}" ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "{{MNEMONIC_SUBS}}" -- "${cur}"))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
template)
|
||||||
|
if [[ -z "${subcmd}" ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "{{TEMPLATE_SUBS}}" -- "${cur}"))
|
||||||
|
elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then
|
||||||
|
# template list/inspect <category> <template> [field]
|
||||||
|
# Position is computed relative to the subcommand token:
|
||||||
|
# 1 => category, 2 => template, 3 => field (inspect only)
|
||||||
|
local pos=$((cword - subcmd_idx))
|
||||||
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "action transaction output lockingscript variable" -- "${cur}"))
|
||||||
|
elif [[ $pos -eq 2 ]]; then
|
||||||
|
local templates
|
||||||
|
templates=$(__xo_complete templates "${cur}")
|
||||||
|
if [[ -n "${templates}" ]]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
COMPREPLY+=("$line")
|
||||||
|
done <<< "${templates}"
|
||||||
|
fi
|
||||||
|
elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then
|
||||||
|
# Field names depend on both selected category and template.
|
||||||
|
local category="${words[subcmd_idx + 1]}"
|
||||||
|
local template_arg="${words[subcmd_idx + 2]}"
|
||||||
|
local fields
|
||||||
|
fields=$(__xo_complete fields "${category}" "${template_arg}" "${cur}")
|
||||||
|
if [[ -n "${fields}" ]]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
COMPREPLY+=("$line")
|
||||||
|
done <<< "${fields}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
elif [[ "${subcmd}" == "set-default" ]]; then
|
||||||
|
# template set-default <template> <output> <role>
|
||||||
|
# We only complete the first positional argument (template) here.
|
||||||
|
local pos=$((cword - subcmd_idx))
|
||||||
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
local templates
|
||||||
|
templates=$(__xo_complete templates "${cur}")
|
||||||
|
if [[ -n "${templates}" ]]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
COMPREPLY+=("$line")
|
||||||
|
done <<< "${templates}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
invitation)
|
||||||
|
if [[ -z "${subcmd}" ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "{{INVITATION_SUBS}}" -- "${cur}"))
|
||||||
|
else
|
||||||
|
case "${subcmd}" in
|
||||||
|
create)
|
||||||
|
# invitation create <template> <action>
|
||||||
|
# The available actions depend on the selected template.
|
||||||
|
local pos=$((cword - subcmd_idx))
|
||||||
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
local templates
|
||||||
|
templates=$(__xo_complete templates "${cur}")
|
||||||
|
if [[ -n "${templates}" ]]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
COMPREPLY+=("$line")
|
||||||
|
done <<< "${templates}"
|
||||||
|
fi
|
||||||
|
elif [[ $pos -eq 2 ]]; then
|
||||||
|
local template_arg="${words[subcmd_idx + 1]}"
|
||||||
|
local actions
|
||||||
|
actions=$(__xo_complete actions "${template_arg}" "${cur}")
|
||||||
|
if [[ -n "${actions}" ]]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
COMPREPLY+=("$line")
|
||||||
|
done <<< "${actions}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
append|sign|broadcast|requirements|export|inspect|delete)
|
||||||
|
# These subcommands expect an invitation identifier as first arg.
|
||||||
|
local pos=$((cword - subcmd_idx))
|
||||||
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
local invitations
|
||||||
|
invitations=$(__xo_complete invitations "${cur}")
|
||||||
|
if [[ -n "${invitations}" ]]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
COMPREPLY+=("$line")
|
||||||
|
done <<< "${invitations}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
import)
|
||||||
|
# File import path: delegate to bash's built-in file completion.
|
||||||
|
COMPREPLY=($(compgen -f -- "${cur}"))
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
resource)
|
||||||
|
if [[ -z "${subcmd}" ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "{{RESOURCE_SUBS}}" -- "${cur}"))
|
||||||
|
elif [[ "${subcmd}" == "unreserve" ]]; then
|
||||||
|
# resource unreserve <txhash:vout>
|
||||||
|
# Suggest known reserved outpoints from the helper.
|
||||||
|
local pos=$((cword - subcmd_idx))
|
||||||
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
local resources
|
||||||
|
resources=$(__xo_complete resources "${cur}")
|
||||||
|
if [[ -n "${resources}" ]]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
COMPREPLY+=("$line")
|
||||||
|
done <<< "${resources}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
settings)
|
||||||
|
if [[ -z "${subcmd}" ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "show get set" -- "${cur}"))
|
||||||
|
elif [[ "${subcmd}" == "get" || "${subcmd}" == "set" ]]; then
|
||||||
|
local pos=$((cword - subcmd_idx))
|
||||||
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "currency default-mnemonic" -- "${cur}"))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
receive)
|
||||||
|
# receive <template> [output]
|
||||||
|
# Template is the first positional argument after `receive`.
|
||||||
|
local pos=$((cword - cmd_idx))
|
||||||
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
local templates
|
||||||
|
templates=$(__xo_complete templates "${cur}")
|
||||||
|
if [[ -n "${templates}" ]]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
COMPREPLY+=("$line")
|
||||||
|
done <<< "${templates}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
completions)
|
||||||
|
# Shell target for generating completion scripts.
|
||||||
|
if [[ -z "${subcmd}" ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "bash zsh fish" -- "${cur}"))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Register the completion function for the CLI binary.
|
||||||
|
complete -F _{{FUNC_NAME}}_completions {{BIN_NAME}}
|
||||||
113
src/cli/autocomplete/scripts/fish.fish
Normal file
113
src/cli/autocomplete/scripts/fish.fish
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Fish completion template for {{BIN_NAME}}
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Installation:
|
||||||
|
# {{BIN_NAME}} completions fish | source
|
||||||
|
#
|
||||||
|
# This file is generated from a template. Placeholders (for example
|
||||||
|
# `{{TOP_LEVEL_COMMANDS}}`) are replaced with concrete completion definitions.
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Fish offers file completion by default. Disable that globally first so command
|
||||||
|
# words are preferred, then selectively re-enable `-F` where paths are expected.
|
||||||
|
complete -c {{BIN_NAME}} -f
|
||||||
|
|
||||||
|
# @description
|
||||||
|
# Resolves and calls `xo-complete` for dynamic values (templates, invitations,
|
||||||
|
# fields, etc.). We first try PATH, then a helper next to `{{BIN_NAME}}`.
|
||||||
|
# @param $argv Arguments forwarded directly to xo-complete.
|
||||||
|
function __{{FUNC_NAME}}_complete_dynamic
|
||||||
|
set -l xo_complete_bin ""
|
||||||
|
if command -q xo-complete
|
||||||
|
set xo_complete_bin xo-complete
|
||||||
|
else if command -q {{BIN_NAME}}
|
||||||
|
set xo_complete_bin (dirname (command -s {{BIN_NAME}}))/xo-complete
|
||||||
|
end
|
||||||
|
if test -n "$xo_complete_bin"
|
||||||
|
$xo_complete_bin $argv 2>/dev/null
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# @description
|
||||||
|
# Lists mnemonic aliases directly from the config directory without starting
|
||||||
|
# the dynamic Node helper.
|
||||||
|
function __{{FUNC_NAME}}_complete_mnemonics
|
||||||
|
set -l config_dir "$XO_CONFIG_DIR"
|
||||||
|
if test -z "$config_dir"
|
||||||
|
set config_dir "$HOME/.config/xo-cli"
|
||||||
|
end
|
||||||
|
for file in $config_dir/mnemonics/mnemonic-*
|
||||||
|
if test -f "$file"
|
||||||
|
string replace -r '.*/' '' "$file"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Global option flags available across top-level command contexts.
|
||||||
|
complete -c {{BIN_NAME}} -s h -d "Show help"
|
||||||
|
complete -c {{BIN_NAME}} -l help -d "Show help"
|
||||||
|
complete -c {{BIN_NAME}} -s v -d "Verbose output"
|
||||||
|
complete -c {{BIN_NAME}} -l verbose -d "Verbose output"
|
||||||
|
complete -c {{BIN_NAME}} -s o -d "Output file"
|
||||||
|
complete -c {{BIN_NAME}} -l output -d "Output file"
|
||||||
|
complete -c {{BIN_NAME}} -l currency -d "Set fiat display currency"
|
||||||
|
|
||||||
|
# Shell-native completion for `-m/--mnemonic-file`.
|
||||||
|
complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_mnemonics)'
|
||||||
|
|
||||||
|
# Top-level command registrations inserted by template expansion.
|
||||||
|
{{TOP_LEVEL_COMMANDS}}
|
||||||
|
|
||||||
|
# Static subcommand registrations inserted by template expansion.
|
||||||
|
{{STATIC_SUBCOMMANDS}}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dynamic completions by command/subcommand.
|
||||||
|
#
|
||||||
|
# Fish condition notes:
|
||||||
|
# - `__fish_seen_subcommand_from <name>` checks whether `<name>` exists in the
|
||||||
|
# current tokenized command line.
|
||||||
|
# - `count (commandline -opc)` returns how many tokens were entered.
|
||||||
|
# We use this to infer positional argument index.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# invitation create <template> <action>
|
||||||
|
# Position 3 => template argument.
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
||||||
|
|
||||||
|
# invitation create <template> <action>
|
||||||
|
# Position 4 => action argument, filtered by selected template token.
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic actions (commandline -opc)[4])'
|
||||||
|
|
||||||
|
# invitation append/sign/broadcast/requirements/inspect <invitation-id>
|
||||||
|
# Position 3 => invitation identifier.
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from append; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from sign; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from broadcast; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from requirements; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from export; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||||
|
|
||||||
|
# invitation import <path>
|
||||||
|
# Re-enable default filesystem completion for path argument.
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from import" -F
|
||||||
|
|
||||||
|
# template list/inspect <category> <template> [field]
|
||||||
|
# Position 3 => category, 4 => template, 5 => field (inspect only).
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 5" -xa '(__{{FUNC_NAME}}_complete_dynamic fields (commandline -opc)[4] (commandline -opc)[5])'
|
||||||
|
|
||||||
|
# template set-default <template> <output> <role>
|
||||||
|
# Position 3 => template argument.
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from set-default; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
||||||
|
|
||||||
|
# resource unreserve <txhash:vout>
|
||||||
|
# Position 3 => outpoint to unreserve.
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from resource; and __fish_seen_subcommand_from unreserve; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic resources)'
|
||||||
|
|
||||||
|
# receive <template> [output]
|
||||||
|
# Position 2 => template argument.
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from receive; and test (count (commandline -opc)) -eq 2" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
||||||
229
src/cli/autocomplete/scripts/zsh.zsh
Normal file
229
src/cli/autocomplete/scripts/zsh.zsh
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Zsh completion template for {{BIN_NAME}}
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Installation:
|
||||||
|
# eval "$({{BIN_NAME}} completions zsh)"
|
||||||
|
#
|
||||||
|
# This file is generated from a template. Placeholders (for example
|
||||||
|
# `{{MNEMONIC_SUBS}}`) are replaced with concrete command values.
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Prefer a helper on PATH; otherwise fall back to helper next to the CLI binary.
|
||||||
|
# This keeps dynamic completion functional in both installed and portable layouts.
|
||||||
|
__xo_complete_bin=""
|
||||||
|
if (( $+commands[xo-complete] )); then
|
||||||
|
__xo_complete_bin="xo-complete"
|
||||||
|
elif (( $+commands[{{BIN_NAME}}] )); then
|
||||||
|
__xo_complete_bin="${commands[{{BIN_NAME}}]:h}/xo-complete"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# @description
|
||||||
|
# Calls the dynamic helper while silencing helper stderr to avoid noisy
|
||||||
|
# completion menus if helper lookup fails.
|
||||||
|
# @param "$@" Arguments forwarded to xo-complete.
|
||||||
|
__xo_complete() {
|
||||||
|
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# @description
|
||||||
|
# Lists mnemonic aliases directly from the config directory without starting
|
||||||
|
# the dynamic Node helper.
|
||||||
|
__xo_complete_mnemonics() {
|
||||||
|
local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}"
|
||||||
|
local file mnemonic
|
||||||
|
for file in "${config_dir}"/mnemonics/mnemonic-*(N); do
|
||||||
|
[[ -f "${file}" ]] || continue
|
||||||
|
mnemonic="${file:t}"
|
||||||
|
[[ "${mnemonic}" == "$1"* ]] && print -r -- "${mnemonic}"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# @description
|
||||||
|
# Main zsh completion dispatcher registered via `compdef`.
|
||||||
|
# It resolves command context from `$words`/`$CURRENT` and serves:
|
||||||
|
# - static command words via `compadd`
|
||||||
|
# - dynamic values from `xo-complete`
|
||||||
|
# - filesystem completions where file paths are expected
|
||||||
|
_{{FUNC_NAME}}_completions() {
|
||||||
|
local -a commands
|
||||||
|
commands=({{COMMANDS}})
|
||||||
|
|
||||||
|
# If previous token is `-m/--mnemonic-file`, complete mnemonic sources.
|
||||||
|
if [[ "${words[CURRENT-1]}" == "-m" || "${words[CURRENT-1]}" == "--mnemonic-file" ]]; then
|
||||||
|
local mnemonics
|
||||||
|
mnemonics=("${(@f)$(__xo_complete_mnemonics "${words[CURRENT]}")}")
|
||||||
|
if [[ ${#mnemonics[@]} -gt 0 ]]; then
|
||||||
|
compadd -- "${mnemonics[@]}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Option context: if current token starts with `-`, complete known options.
|
||||||
|
if [[ "${words[${CURRENT}]}" == -* ]]; then
|
||||||
|
compadd -- {{OPTIONS}}
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find first and second non-option tokens before the cursor.
|
||||||
|
# `cmd_idx` and `subcmd_idx` are used for positional argument calculations.
|
||||||
|
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
|
||||||
|
for ((i=2; i < CURRENT; i++)); do
|
||||||
|
if [[ "${words[i]}" != -* ]]; then
|
||||||
|
if [[ -z "${cmd}" ]]; then
|
||||||
|
cmd="${words[i]}"
|
||||||
|
cmd_idx=$i
|
||||||
|
else
|
||||||
|
subcmd="${words[i]}"
|
||||||
|
subcmd_idx=$i
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# No command token yet: offer top-level commands.
|
||||||
|
if [[ -z "${cmd}" ]]; then
|
||||||
|
compadd -- ${commands[@]}
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Command-specific completion behavior.
|
||||||
|
case "${cmd}" in
|
||||||
|
mnemonic)
|
||||||
|
if [[ -z "${subcmd}" ]]; then
|
||||||
|
compadd -- {{MNEMONIC_SUBS}}
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
template)
|
||||||
|
if [[ -z "${subcmd}" ]]; then
|
||||||
|
compadd -- {{TEMPLATE_SUBS}}
|
||||||
|
elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then
|
||||||
|
# template list/inspect <category> <template> [field]
|
||||||
|
# Relative positions from subcommand:
|
||||||
|
# 1 => category, 2 => template, 3 => field (inspect only)
|
||||||
|
local pos=$((CURRENT - subcmd_idx))
|
||||||
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
compadd -- action transaction output lockingscript variable
|
||||||
|
elif [[ $pos -eq 2 ]]; then
|
||||||
|
local templates
|
||||||
|
templates=("${(@f)$(__xo_complete templates "${words[CURRENT]}")}")
|
||||||
|
if [[ ${#templates[@]} -gt 0 ]]; then
|
||||||
|
compadd -- "${templates[@]}"
|
||||||
|
fi
|
||||||
|
elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then
|
||||||
|
# Field suggestions depend on selected category and template.
|
||||||
|
local category="${words[subcmd_idx + 1]}"
|
||||||
|
local template_arg="${words[subcmd_idx + 2]}"
|
||||||
|
local fields
|
||||||
|
fields=("${(@f)$(__xo_complete fields "${category}" "${template_arg}" "${words[CURRENT]}")}")
|
||||||
|
if [[ ${#fields[@]} -gt 0 ]]; then
|
||||||
|
compadd -- "${fields[@]}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
elif [[ "${subcmd}" == "set-default" ]]; then
|
||||||
|
# template set-default <template> <output> <role>
|
||||||
|
# First positional argument is template name.
|
||||||
|
local pos=$((CURRENT - subcmd_idx))
|
||||||
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
local templates
|
||||||
|
templates=("${(@f)$(__xo_complete templates "${words[CURRENT]}")}")
|
||||||
|
if [[ ${#templates[@]} -gt 0 ]]; then
|
||||||
|
compadd -- "${templates[@]}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
invitation)
|
||||||
|
if [[ -z "${subcmd}" ]]; then
|
||||||
|
compadd -- {{INVITATION_SUBS}}
|
||||||
|
else
|
||||||
|
case "${subcmd}" in
|
||||||
|
create)
|
||||||
|
# invitation create <template> <action>
|
||||||
|
# Action list is template-specific.
|
||||||
|
local pos=$((CURRENT - subcmd_idx))
|
||||||
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
local templates
|
||||||
|
templates=("${(@f)$(__xo_complete templates "${words[CURRENT]}")}")
|
||||||
|
if [[ ${#templates[@]} -gt 0 ]]; then
|
||||||
|
compadd -- "${templates[@]}"
|
||||||
|
fi
|
||||||
|
elif [[ $pos -eq 2 ]]; then
|
||||||
|
local template_arg="${words[subcmd_idx + 1]}"
|
||||||
|
local actions
|
||||||
|
actions=("${(@f)$(__xo_complete actions "${template_arg}" "${words[CURRENT]}")}")
|
||||||
|
if [[ ${#actions[@]} -gt 0 ]]; then
|
||||||
|
compadd -- "${actions[@]}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
append|sign|broadcast|requirements|export|inspect)
|
||||||
|
# These subcommands take invitation ID as first argument.
|
||||||
|
local pos=$((CURRENT - subcmd_idx))
|
||||||
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
local invitations
|
||||||
|
invitations=("${(@f)$(__xo_complete invitations "${words[CURRENT]}")}")
|
||||||
|
if [[ ${#invitations[@]} -gt 0 ]]; then
|
||||||
|
compadd -- "${invitations[@]}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
import)
|
||||||
|
# invitation import <path>: delegate to zsh file completion.
|
||||||
|
_files
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
resource)
|
||||||
|
if [[ -z "${subcmd}" ]]; then
|
||||||
|
compadd -- {{RESOURCE_SUBS}}
|
||||||
|
elif [[ "${subcmd}" == "unreserve" ]]; then
|
||||||
|
# resource unreserve <txhash:vout>
|
||||||
|
local pos=$((CURRENT - subcmd_idx))
|
||||||
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
local resources
|
||||||
|
resources=("${(@f)$(__xo_complete resources "${words[CURRENT]}")}")
|
||||||
|
if [[ ${#resources[@]} -gt 0 ]]; then
|
||||||
|
compadd -- "${resources[@]}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
settings)
|
||||||
|
if [[ -z "${subcmd}" ]]; then
|
||||||
|
compadd -- show get set
|
||||||
|
elif [[ "${subcmd}" == "get" || "${subcmd}" == "set" ]]; then
|
||||||
|
local pos=$((CURRENT - subcmd_idx))
|
||||||
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
compadd -- currency default-mnemonic
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
receive)
|
||||||
|
# receive <template> [output]
|
||||||
|
local pos=$((CURRENT - cmd_idx))
|
||||||
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
local templates
|
||||||
|
templates=("${(@f)$(__xo_complete templates "${words[CURRENT]}")}")
|
||||||
|
if [[ ${#templates[@]} -gt 0 ]]; then
|
||||||
|
compadd -- "${templates[@]}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
completions)
|
||||||
|
# Shell target for completion generation.
|
||||||
|
if [[ -z "${subcmd}" ]]; then
|
||||||
|
compadd -- bash zsh fish
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Register completion function for the executable name.
|
||||||
|
compdef _{{FUNC_NAME}}_completions {{BIN_NAME}}
|
||||||
8
src/cli/commands/index.ts
Normal file
8
src/cli/commands/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { handleMnemonicCommand, printMnemonicHelp } from "./mnemonic.js";
|
||||||
|
export { handleSettingsCommand, printSettingsHelp } from "./settings.js";
|
||||||
|
export { handleTemplateCommand, printTemplateHelp } from "./template.js";
|
||||||
|
export { handleInvitationCommand, printInvitationHelp } from "./invitation.js";
|
||||||
|
export { handleReceiveCommand, printReceiveHelp } from "./receive.js";
|
||||||
|
export { handleResourceCommand, printResourceHelp } from "./resource.js";
|
||||||
|
|
||||||
|
export * from "./types.js";
|
||||||
1045
src/cli/commands/invitation.ts
Normal file
1045
src/cli/commands/invitation.ts
Normal file
File diff suppressed because it is too large
Load Diff
151
src/cli/commands/mnemonic.ts
Normal file
151
src/cli/commands/mnemonic.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { bold, dim } from "../utils.js";
|
||||||
|
import {
|
||||||
|
listMnemonicFiles,
|
||||||
|
createMnemonicFile,
|
||||||
|
createMnemonicSeed,
|
||||||
|
loadMnemonic,
|
||||||
|
} from "../mnemonic.js";
|
||||||
|
import type { BaseCommandDependencies, CommandIO } from "./types.js";
|
||||||
|
import { CommandError } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the help message for the mnemonic command
|
||||||
|
*/
|
||||||
|
export const printMnemonicHelp = (io: CommandIO): void => {
|
||||||
|
io.out(
|
||||||
|
`
|
||||||
|
${bold("Usage:")} xo-cli mnemonic <sub-command>
|
||||||
|
|
||||||
|
${bold("Sub-commands:")}
|
||||||
|
- create <mnemonic-seed> ${dim("Create a new mnemonic file")}
|
||||||
|
- list ${dim("List all mnemonic files")}
|
||||||
|
- import <mnemonic-seed> ${dim("Import a mnemonic seed from a file")}
|
||||||
|
- expose <mnemonic-file> ${dim("Expose a mnemonic file")}
|
||||||
|
|
||||||
|
${bold("Options:")}
|
||||||
|
-o --output <output-filename> ${dim("Output filename for the mnemonic file")}
|
||||||
|
-h --help ${dim("Show this help message")}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the mnemonic command.
|
||||||
|
* Throws CommandError on failure, returns result data on success.
|
||||||
|
* @param deps - The command dependencies.
|
||||||
|
* @param args - Positional args after the command name, e.g. ["create"] or ["import", "page", "pencil", ...].
|
||||||
|
* @param options - Parsed option flags, e.g. { output: "mnemonic.txt" }.
|
||||||
|
*/
|
||||||
|
export const handleMnemonicCommand = async (
|
||||||
|
deps: BaseCommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
options: Record<string, string>,
|
||||||
|
): Promise<{ savedAs?: string; count?: number; mnemonic?: string }> => {
|
||||||
|
// Get the sub-command from the arguments
|
||||||
|
const subCommand = args[0];
|
||||||
|
const { mnemonicsDir } = deps.paths;
|
||||||
|
|
||||||
|
// If no sub-command is provided, print the help message and throw an error
|
||||||
|
if (!subCommand) {
|
||||||
|
deps.io.verbose("No sub-command provided");
|
||||||
|
printMnemonicHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"mnemonic.subcommand.missing",
|
||||||
|
"No sub-command provided",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the sub-command
|
||||||
|
switch (subCommand) {
|
||||||
|
case "create": {
|
||||||
|
// Create a new mnemonic seed
|
||||||
|
const mnemonicSeed = createMnemonicSeed();
|
||||||
|
|
||||||
|
// Create a new mnemonic file
|
||||||
|
const savedAs = createMnemonicFile(
|
||||||
|
mnemonicsDir,
|
||||||
|
mnemonicSeed,
|
||||||
|
options["output"],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the mnemonic file to the user
|
||||||
|
deps.io.out(`Mnemonic file created: ${savedAs} (${mnemonicSeed})`);
|
||||||
|
return { savedAs };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "import": {
|
||||||
|
// Get the mnemonic seed from the arguments
|
||||||
|
const mnemonicSeed = args.slice(1).join(" ");
|
||||||
|
|
||||||
|
// If no mnemonic seed is provided, print the help message and throw an error
|
||||||
|
if (!mnemonicSeed) {
|
||||||
|
deps.io.verbose("No mnemonic seed provided");
|
||||||
|
printMnemonicHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"mnemonic.import.seed_missing",
|
||||||
|
"No mnemonic seed provided",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new mnemonic file
|
||||||
|
deps.io.verbose(`Mnemonic seed: ${mnemonicSeed}`);
|
||||||
|
const savedAs = createMnemonicFile(
|
||||||
|
mnemonicsDir,
|
||||||
|
mnemonicSeed,
|
||||||
|
options["output"],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the mnemonic file to the user
|
||||||
|
deps.io.out(`Mnemonic file created: ${savedAs}`);
|
||||||
|
return { savedAs };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "list": {
|
||||||
|
// List all the mnemonic files
|
||||||
|
const mnemonicFiles = listMnemonicFiles(mnemonicsDir);
|
||||||
|
deps.io.out(mnemonicFiles.join("\n"));
|
||||||
|
|
||||||
|
// Return the number of mnemonic files
|
||||||
|
return { count: mnemonicFiles.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "expose": {
|
||||||
|
// Get the mnemonic file from the arguments
|
||||||
|
const mnemonicFile = args[1];
|
||||||
|
|
||||||
|
// If no mnemonic file is provided, print the help message and throw an error
|
||||||
|
if (!mnemonicFile) {
|
||||||
|
deps.io.verbose("No mnemonic file provided");
|
||||||
|
printMnemonicHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"mnemonic.expose.file_missing",
|
||||||
|
"No mnemonic file provided",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load the mnemonic file
|
||||||
|
try {
|
||||||
|
const mnemonic = loadMnemonic(mnemonicsDir, mnemonicFile);
|
||||||
|
deps.io.out(mnemonic);
|
||||||
|
|
||||||
|
// Return the mnemonic
|
||||||
|
return { mnemonic };
|
||||||
|
} catch (error) {
|
||||||
|
// If the mnemonic file is not found, print an error and throw an error
|
||||||
|
throw new CommandError(
|
||||||
|
"mnemonic.expose.file_not_found",
|
||||||
|
`Mnemonic file not found: ${mnemonicFile}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// If the sub-command is not found, print an error and throw an error
|
||||||
|
deps.io.err(`Unknown sub-command: ${subCommand}`);
|
||||||
|
printMnemonicHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"mnemonic.subcommand.unknown",
|
||||||
|
`Unknown sub-command: ${subCommand}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
96
src/cli/commands/receive.ts
Normal file
96
src/cli/commands/receive.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||||
|
import { hexToBin, lockingBytecodeToCashAddress } from "@bitauth/libauth";
|
||||||
|
|
||||||
|
import { bold, dim } from "../utils.js";
|
||||||
|
import type { CommandDependencies, CommandIO } from "./types.js";
|
||||||
|
import { CommandError } from "./types.js";
|
||||||
|
|
||||||
|
import { resolveTemplate } from "../utils.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the help message for the receive command
|
||||||
|
*/
|
||||||
|
export const printReceiveHelp = (io: CommandIO): void => {
|
||||||
|
io.out(
|
||||||
|
`
|
||||||
|
${bold("Usage:")} xo-cli receive <template-file> <output-identifier> [role-identifier]
|
||||||
|
|
||||||
|
${bold("Description:")}
|
||||||
|
Generate a single-use receiving address from a template.
|
||||||
|
|
||||||
|
${bold("Arguments:")}
|
||||||
|
<template-file> ${dim("Path to the template JSON file")}
|
||||||
|
<output-identifier> ${dim("The output identifier within the template (e.g. 'receiveOutput')")}
|
||||||
|
[role-identifier] ${dim("The role identifier (e.g. 'receiver'). Auto-selects the first role if omitted.")}
|
||||||
|
|
||||||
|
${bold("Options:")}
|
||||||
|
-h --help ${dim("Show this help message")}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command which creates a single-use address/lockingScript for a given template and role.
|
||||||
|
* Throws CommandError on failure, returns address data on success.
|
||||||
|
*
|
||||||
|
* @param deps - The command dependencies.
|
||||||
|
* @param args - Positional args after the command name, e.g. ["template.json", "receiveOutput", "receiver"].
|
||||||
|
* @param options - Parsed option flags.
|
||||||
|
* @returns The address data.
|
||||||
|
* @throws CommandError if the command fails.
|
||||||
|
*/
|
||||||
|
export const handleReceiveCommand = async (
|
||||||
|
deps: CommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
_options: Record<string, string>,
|
||||||
|
): Promise<{ address: string }> => {
|
||||||
|
// Get the template query, output identifier, and role identifier from the arguments
|
||||||
|
const templateQuery = args[0];
|
||||||
|
const outputIdentifier = args[1];
|
||||||
|
const roleIdentifier = args[2];
|
||||||
|
|
||||||
|
// Log the receive args
|
||||||
|
deps.io.verbose(
|
||||||
|
`Receive args - template: ${templateQuery}, output: ${outputIdentifier}, role: ${roleIdentifier}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no template query or output identifier is provided, print the help message and throw an error
|
||||||
|
if (!templateQuery || !outputIdentifier) {
|
||||||
|
deps.io.verbose("Missing required arguments");
|
||||||
|
printReceiveHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"receive.arguments.missing",
|
||||||
|
"Missing required arguments",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve and read the template file
|
||||||
|
const template = await resolveTemplate(deps, templateQuery);
|
||||||
|
const templateIdentifier = generateTemplateIdentifier(template);
|
||||||
|
deps.io.verbose(`Template identifier: ${templateIdentifier}`);
|
||||||
|
|
||||||
|
// Generate the locking bytecode (returned as a hex string)
|
||||||
|
const lockingBytecodeHex = await deps.app.engine.generateLockingBytecode(
|
||||||
|
templateIdentifier,
|
||||||
|
outputIdentifier,
|
||||||
|
roleIdentifier,
|
||||||
|
);
|
||||||
|
deps.io.verbose(`Locking bytecode hex: ${lockingBytecodeHex}`);
|
||||||
|
|
||||||
|
// Convert the locking bytecode to a BCH cash address
|
||||||
|
const result = lockingBytecodeToCashAddress({
|
||||||
|
bytecode: hexToBin(lockingBytecodeHex),
|
||||||
|
prefix: "bitcoincash",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof result === "string") {
|
||||||
|
deps.io.err(`Failed to encode address: ${result}`);
|
||||||
|
throw new CommandError(
|
||||||
|
"receive.address.encode_failed",
|
||||||
|
`Failed to encode address: ${result}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.io.out(result.address);
|
||||||
|
return { address: result.address };
|
||||||
|
};
|
||||||
268
src/cli/commands/resource.ts
Normal file
268
src/cli/commands/resource.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import { hexToBin } from "@bitauth/libauth";
|
||||||
|
|
||||||
|
import { bold, dim } from "../utils.js";
|
||||||
|
import type { CommandDependencies, CommandIO } from "./types.js";
|
||||||
|
import { CommandError } from "./types.js";
|
||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||||
|
import {
|
||||||
|
buildScriptHashDataMap,
|
||||||
|
enrichUnspentOutput,
|
||||||
|
type UnspentOutputWithMetadata,
|
||||||
|
} from "../../utils/utxo-metadata.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: UnspentOutputWithMetadata & { template?: XOTemplate },
|
||||||
|
showReserved = false,
|
||||||
|
): string {
|
||||||
|
// Format the template
|
||||||
|
const template = resource.template
|
||||||
|
? dim(`[${generateTemplateIdentifier(resource.template)}]`)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Format the outpoint
|
||||||
|
const outpoint = bold(
|
||||||
|
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Format the value
|
||||||
|
const value = dim(`${resource.valueSatoshis} sats`);
|
||||||
|
|
||||||
|
// Format the output
|
||||||
|
const output = resource.outputIdentifier
|
||||||
|
? dim(resource.outputIdentifier)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Format the height
|
||||||
|
const height = dim(`(height ${resource.minedAtHeight})`);
|
||||||
|
|
||||||
|
// If the resource is reserved, format the reservation info
|
||||||
|
if (showReserved && resource.reservedBy) {
|
||||||
|
const inv = dim(`reserved for ${resource.reservedBy}`);
|
||||||
|
return `${template} ${outpoint} ${value} ${output} ${height} ${inv}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, format the resource without reservation info
|
||||||
|
return `${template} ${outpoint} ${value} ${output} ${height}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the resource command.
|
||||||
|
* Throws CommandError on failure, returns result data on success.
|
||||||
|
* @param deps - The command dependencies.
|
||||||
|
* @param args - Positional args after the command name, e.g. ["list"].
|
||||||
|
* @param options - Parsed option flags.
|
||||||
|
*/
|
||||||
|
export const handleResourceCommand = async (
|
||||||
|
deps: CommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
_options: Record<string, string>,
|
||||||
|
): Promise<{ count?: number }> => {
|
||||||
|
const subCommand = args[0];
|
||||||
|
deps.io.verbose(`Resource sub-command: ${subCommand}`);
|
||||||
|
|
||||||
|
// If no sub-command is provided, print the help message and throw an error
|
||||||
|
if (!subCommand) {
|
||||||
|
deps.io.verbose("No sub-command provided");
|
||||||
|
printResourceHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"resource.subcommand.missing",
|
||||||
|
"No sub-command provided",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the sub-command
|
||||||
|
switch (subCommand) {
|
||||||
|
case "list": {
|
||||||
|
// Get the qualifier from the arguments - This could be "reserved", "all", or omitted (which defaults to "unreserved")
|
||||||
|
const qualifier = args[1];
|
||||||
|
|
||||||
|
// List all the unspent outputs data
|
||||||
|
const allResources = await deps.app.engine.listUnspentOutputsData();
|
||||||
|
|
||||||
|
let filtered;
|
||||||
|
// If the qualifier is "reserved", return only the reserved resources
|
||||||
|
if (qualifier === "reserved") {
|
||||||
|
filtered = allResources.filter((r) => r.reservedBy);
|
||||||
|
}
|
||||||
|
// If the qualifier is "all", return all the resources
|
||||||
|
else if (qualifier === "all") {
|
||||||
|
filtered = allResources;
|
||||||
|
}
|
||||||
|
// If the qualifier is not "reserved" or "all", return only the unreserved resources
|
||||||
|
else {
|
||||||
|
filtered = allResources.filter((r) => !r.reservedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no resources are found, print a message and return 0
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
deps.io.out(dim("No resources found."));
|
||||||
|
return { count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptHashDataByScriptHash = await buildScriptHashDataMap(
|
||||||
|
deps.app.engine,
|
||||||
|
);
|
||||||
|
|
||||||
|
const resourcesWithTemplateInformation = await Promise.all(
|
||||||
|
filtered.map(async (resource) => {
|
||||||
|
const enriched = enrichUnspentOutput(
|
||||||
|
resource,
|
||||||
|
scriptHashDataByScriptHash,
|
||||||
|
);
|
||||||
|
const template = enriched.templateIdentifier
|
||||||
|
? await deps.app.engine.getTemplate(enriched.templateIdentifier)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...enriched,
|
||||||
|
template,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Format the resources into a list of strings that we can display to the user
|
||||||
|
const showReserved = qualifier === "all" || qualifier === "reserved";
|
||||||
|
const formattedResources = resourcesWithTemplateInformation.map((r) =>
|
||||||
|
formatResource(r, showReserved),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the resources to the user
|
||||||
|
deps.io.out(formattedResources.join("\n"));
|
||||||
|
|
||||||
|
// Display the total satoshis
|
||||||
|
deps.io.out(
|
||||||
|
`Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the total resources
|
||||||
|
deps.io.out(`Total resources: ${filtered.length}`);
|
||||||
|
return { count: filtered.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "unreserve": {
|
||||||
|
// Get the outpoint from the arguments
|
||||||
|
const outpointArg = args[1];
|
||||||
|
|
||||||
|
// If no outpoint is provided, print a message and throw an error
|
||||||
|
if (!outpointArg) {
|
||||||
|
deps.io.err("Please provide a UTXO in <txhash>:<vout> format.");
|
||||||
|
printResourceHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"resource.unreserve.outpoint_missing",
|
||||||
|
"Please provide a UTXO in <txhash>:<vout> format.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the separator index
|
||||||
|
const separatorIndex = outpointArg.lastIndexOf(":");
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
// If the separator index is -1 (not found), print a message and throw an error
|
||||||
|
deps.io.err(
|
||||||
|
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
|
||||||
|
);
|
||||||
|
throw new CommandError(
|
||||||
|
"resource.unreserve.outpoint_invalid",
|
||||||
|
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the tx hash and vout
|
||||||
|
const txHash = outpointArg.substring(0, separatorIndex);
|
||||||
|
const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10);
|
||||||
|
|
||||||
|
// If the tx hash or vout is not a string or isNaN, print a message and throw an error
|
||||||
|
if (!txHash || isNaN(vout)) {
|
||||||
|
deps.io.err(
|
||||||
|
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
|
||||||
|
);
|
||||||
|
throw new CommandError(
|
||||||
|
"resource.unreserve.outpoint_invalid",
|
||||||
|
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather all of our resources
|
||||||
|
const allResources = await deps.app.engine.listUnspentOutputsData();
|
||||||
|
|
||||||
|
// Find the target resource
|
||||||
|
const target = allResources.find(
|
||||||
|
(r) => r.outpointTransactionHash === txHash && r.outpointIndex === vout,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the target resource is not found, print a message and throw an error
|
||||||
|
if (!target) {
|
||||||
|
deps.io.err(`UTXO not found: ${txHash}:${vout}`);
|
||||||
|
throw new CommandError(
|
||||||
|
"resource.unreserve.utxo_missing",
|
||||||
|
`UTXO not found: ${txHash}:${vout}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the target resource is not reserved, print a message and return
|
||||||
|
if (!target.reservedBy) {
|
||||||
|
deps.io.out(dim("UTXO is not reserved. Nothing to do."));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreserve the resources
|
||||||
|
await deps.app.engine.unreserveResources(
|
||||||
|
[{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }],
|
||||||
|
target.reservedBy,
|
||||||
|
);
|
||||||
|
|
||||||
|
deps.io.out(
|
||||||
|
`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: What do I want to return here?
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "unreserve-all": {
|
||||||
|
// Unreserve all the resources
|
||||||
|
const count = await deps.app.unreserveAllResources();
|
||||||
|
|
||||||
|
// If no resources are reserved, print a message and return
|
||||||
|
if (count === 0) {
|
||||||
|
deps.io.out(dim("No reserved resources to unreserve."));
|
||||||
|
}
|
||||||
|
// If some resources were unreserved, print a message and return the count
|
||||||
|
else {
|
||||||
|
deps.io.out(`Unreserved ${bold(String(count))} resource(s).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count };
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
deps.io.verbose(`Unknown resource sub-command: ${subCommand}`);
|
||||||
|
printResourceHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"resource.subcommand.unknown",
|
||||||
|
`Unknown resource sub-command: ${subCommand}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
131
src/cli/commands/settings.ts
Normal file
131
src/cli/commands/settings.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { SettingsService } from "../../services/settings.js";
|
||||||
|
import { formatObject } from "../utils.js";
|
||||||
|
import type { BaseCommandDependencies, CommandIO } from "./types.js";
|
||||||
|
import { CommandError } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints help text for the settings command.
|
||||||
|
*/
|
||||||
|
export const printSettingsHelp = (io: CommandIO): void => {
|
||||||
|
io.out(`Settings Command Help:
|
||||||
|
Commands:
|
||||||
|
settings show
|
||||||
|
settings get <currency|default-mnemonic>
|
||||||
|
settings set <currency|default-mnemonic> <value>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
xo-cli settings show
|
||||||
|
xo-cli settings get currency
|
||||||
|
xo-cli settings set currency AUD
|
||||||
|
xo-cli settings set default-mnemonic mnemonic-main`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported settings keys exposed on the CLI.
|
||||||
|
*/
|
||||||
|
type SettingsKey = "currency" | "default-mnemonic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes user input to one of the supported settings keys.
|
||||||
|
*/
|
||||||
|
function parseSettingsKey(input: string | undefined): SettingsKey | null {
|
||||||
|
if (!input) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = input.trim().toLowerCase();
|
||||||
|
if (normalized === "currency") {
|
||||||
|
return "currency";
|
||||||
|
}
|
||||||
|
if (normalized === "default-mnemonic" || normalized === "defaultMnemonic") {
|
||||||
|
return "default-mnemonic";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles `xo-cli settings` commands.
|
||||||
|
*
|
||||||
|
* This command intentionally does not require wallet initialization so users can
|
||||||
|
* configure currency/default mnemonic without passing `-m`.
|
||||||
|
*/
|
||||||
|
export const handleSettingsCommand = async (
|
||||||
|
deps: BaseCommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
options: Record<string, string>,
|
||||||
|
): Promise<Record<string, unknown>> => {
|
||||||
|
const settings = new SettingsService(deps.paths.walletConfigPath);
|
||||||
|
|
||||||
|
// settings show (default if no subcommand)
|
||||||
|
const subCommand = args[0] ?? "show";
|
||||||
|
if (subCommand === "help" || options["help"] === "true") {
|
||||||
|
printSettingsHelp(deps.io);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (subCommand) {
|
||||||
|
case "show": {
|
||||||
|
const snapshot = settings.getSettings();
|
||||||
|
deps.io.out(formatObject(snapshot));
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "get": {
|
||||||
|
const key = parseSettingsKey(args[1]);
|
||||||
|
if (!key) {
|
||||||
|
printSettingsHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"settings.get.key_missing",
|
||||||
|
'Missing or invalid key. Expected "currency" or "default-mnemonic".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value =
|
||||||
|
key === "currency"
|
||||||
|
? settings.getCurrency()
|
||||||
|
: (settings.getDefaultMnemonic() ?? "");
|
||||||
|
deps.io.out(value);
|
||||||
|
return { key, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "set": {
|
||||||
|
const key = parseSettingsKey(args[1]);
|
||||||
|
if (!key) {
|
||||||
|
printSettingsHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"settings.set.key_missing",
|
||||||
|
'Missing or invalid key. Expected "currency" or "default-mnemonic".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawValue = args.slice(2).join(" ").trim();
|
||||||
|
if (!rawValue) {
|
||||||
|
printSettingsHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"settings.set.value_missing",
|
||||||
|
"Missing value for settings set command.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "currency") {
|
||||||
|
settings.setCurrency(rawValue);
|
||||||
|
const currency = settings.getCurrency();
|
||||||
|
deps.io.out(`Updated currency: ${currency}`);
|
||||||
|
return { key, value: currency };
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.setDefaultMnemonic(rawValue);
|
||||||
|
const defaultMnemonic = settings.getDefaultMnemonic() ?? "";
|
||||||
|
deps.io.out(`Updated default-mnemonic: ${defaultMnemonic}`);
|
||||||
|
return { key, value: defaultMnemonic };
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
printSettingsHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"settings.subcommand.unknown",
|
||||||
|
`Unknown settings command: ${subCommand}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
550
src/cli/commands/template.ts
Normal file
550
src/cli/commands/template.ts
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
import { existsSync, writeFileSync } 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 {
|
||||||
|
loadTemplateFromFile,
|
||||||
|
TemplateLoadError,
|
||||||
|
} from "../../utils/load-template-from-file.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 JSON, JS, or TS 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")}
|
||||||
|
- export <template-identifier> [output-file] ${dim("Export a template to stdout or a file")}
|
||||||
|
|
||||||
|
${bold("Options:")}
|
||||||
|
-o --output <output-filename> ${dim("Output filename for the exported template")}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the template list command.
|
||||||
|
* Throws CommandError on failure, returns result data on success.
|
||||||
|
* @param deps - The command dependencies.
|
||||||
|
* @param args - Positional args after the command name, e.g. ["list", "action"] or ["list", "action", "1234567890"].
|
||||||
|
*/
|
||||||
|
export const handleTemplateListCommand = async (
|
||||||
|
deps: CommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ count?: number }> => {
|
||||||
|
// Get the template category from the arguments - This could be "action", "transaction", "output", "lockingscript", or "variable"
|
||||||
|
const templateCategory = args[0];
|
||||||
|
deps.io.verbose(`Template list category: ${templateCategory}`);
|
||||||
|
|
||||||
|
// If no template category is provided, list all the imported templates
|
||||||
|
if (!templateCategory) {
|
||||||
|
// List all the imported templates
|
||||||
|
const templates = await deps.app.engine.listImportedTemplates();
|
||||||
|
|
||||||
|
// Format the templates into a list of strings that we can display to the user
|
||||||
|
const formattedTemplates = templates.map(
|
||||||
|
(template: XOTemplate) =>
|
||||||
|
`${bold(generateTemplateIdentifier(template))} - ${dim(template.name)} ${dim(template.description)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the templates to the user
|
||||||
|
deps.io.out(formattedTemplates.join("\n"));
|
||||||
|
|
||||||
|
// Return the number of templates
|
||||||
|
return { count: templates.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the template identifier from the arguments
|
||||||
|
const templateIdentifier = args[1];
|
||||||
|
deps.io.verbose(`Template identifier: ${templateIdentifier}`);
|
||||||
|
|
||||||
|
// If no template identifier is provided, print a message and throw an error
|
||||||
|
if (!templateIdentifier) {
|
||||||
|
deps.io.err("No template identifier provided");
|
||||||
|
throw new CommandError(
|
||||||
|
"template.list.identifier_missing",
|
||||||
|
"No template identifier provided",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the raw template from the engine
|
||||||
|
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
|
||||||
|
|
||||||
|
// If the raw template is not found, print a message and throw an error
|
||||||
|
if (!rawTemplate) {
|
||||||
|
deps.io.err(`No template found: ${templateIdentifier}`);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.list.not_found",
|
||||||
|
`No template found: ${templateIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the template deeply - Deeply nested objects instead of shallow objects referencing keys at the top level.
|
||||||
|
// Reduces the load of having to call multiple lookups just to get some resolved value like the outputIdentifer that comes from calling an action.
|
||||||
|
const template = await resolveTemplateReferences(rawTemplate);
|
||||||
|
deps.io.verbose(`Template: ${formatObject(template)}`);
|
||||||
|
|
||||||
|
// Handle the template category
|
||||||
|
switch (templateCategory) {
|
||||||
|
case "action": {
|
||||||
|
// Get the actions from the template
|
||||||
|
const actions = template.actions;
|
||||||
|
|
||||||
|
// Format the actions into a list of strings that we can display to the user
|
||||||
|
const formattedActions = Object.entries(actions).map(
|
||||||
|
([actionIdentifier, action]) =>
|
||||||
|
`${bold(actionIdentifier)} ${dim(action.name)} ${dim(action.description)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the actions to the user
|
||||||
|
deps.io.out(formattedActions.join("\n"));
|
||||||
|
|
||||||
|
// Return the number of actions
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
case "transaction": {
|
||||||
|
// Get the transactions from the template
|
||||||
|
const transactions = template.transactions;
|
||||||
|
|
||||||
|
// Format the transactions into a list of strings that we can display to the user
|
||||||
|
const formattedTransactions = Object.entries(transactions).map(
|
||||||
|
([transactionIdentifier, transaction]) =>
|
||||||
|
`${bold(transactionIdentifier)} ${dim(transaction.name)} ${dim(transaction.description)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the transactions to the user
|
||||||
|
deps.io.out(formattedTransactions.join("\n"));
|
||||||
|
|
||||||
|
// Return the number of transactions
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
case "output": {
|
||||||
|
// Get the outputs from the template
|
||||||
|
const outputs = template.outputs;
|
||||||
|
|
||||||
|
// Format the outputs into a list of strings that we can display to the user
|
||||||
|
const formattedOutputs = Object.entries(outputs).map(
|
||||||
|
([outputIdentifier, output]) =>
|
||||||
|
`${bold(outputIdentifier)} ${dim(output.name)} ${dim(output.description)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the outputs to the user
|
||||||
|
deps.io.out(formattedOutputs.join("\n"));
|
||||||
|
|
||||||
|
// Return the number of outputs
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
case "lockingscript": {
|
||||||
|
// Get the lockingscripts from the template
|
||||||
|
const lockingscripts = template.lockingScripts;
|
||||||
|
|
||||||
|
// Format the lockingscripts into a list of strings that we can display to the user
|
||||||
|
const formattedLockingscripts = Object.entries(lockingscripts).map(
|
||||||
|
([lockingScriptIdentifier, lockingScript]) =>
|
||||||
|
`${bold(lockingScriptIdentifier)} ${dim(lockingScript.name)} ${dim(lockingScript.description)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the lockingscripts to the user
|
||||||
|
deps.io.out(formattedLockingscripts.join("\n"));
|
||||||
|
|
||||||
|
// Return the number of lockingscripts
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
case "variable": {
|
||||||
|
// Get the variables from the template
|
||||||
|
const variables = template.variables || {};
|
||||||
|
|
||||||
|
// Format the variables into a list of strings that we can display to the user
|
||||||
|
const formattedVariables = Object.entries(variables).map(
|
||||||
|
([variableIdentifier, variable]) =>
|
||||||
|
`${bold(variableIdentifier)} ${dim(variable.name)} ${dim(variable.description)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the variables to the user
|
||||||
|
deps.io.out(formattedVariables.join("\n"));
|
||||||
|
|
||||||
|
// Return the number of variables
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
deps.io.verbose(`Unknown template category: ${templateCategory}`);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.list.category_unknown",
|
||||||
|
`Unknown template category: ${templateCategory}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the help message for the template inspect command
|
||||||
|
*/
|
||||||
|
export const printTemplateInspectHelp = (io: CommandIO): void => {
|
||||||
|
io.out(
|
||||||
|
`
|
||||||
|
${bold("Usage:")} xo-cli template inspect <category> <identifier> <field>
|
||||||
|
|
||||||
|
${bold("Arguments:")}
|
||||||
|
<category> ${dim("The category of the template to inspect")}
|
||||||
|
<identifier> ${dim("The identifier of the template to inspect")}
|
||||||
|
<field> ${dim("The field of the template to inspect")}
|
||||||
|
|
||||||
|
${bold("Categories:")}
|
||||||
|
- action <action-identifier> ${dim("Inspect an action")}
|
||||||
|
- transaction <transaction-identifier> ${dim("Inspect a transaction")}
|
||||||
|
- output <output-identifier> ${dim("Inspect an output")}
|
||||||
|
- lockingscript <lockingscript-identifier> ${dim("Inspect a lockingscript")}
|
||||||
|
- variable <variable-identifier> ${dim("Inspect a variable")}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the template inspect command.
|
||||||
|
* Throws CommandError on failure, returns empty object on success.
|
||||||
|
* @param deps - The command dependencies.
|
||||||
|
* @param args - Positional args after the command name, e.g. ["inspect", "transaction", "1234567890"].
|
||||||
|
*/
|
||||||
|
export const handleTemplateInspectCommand = async (
|
||||||
|
deps: CommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
): Promise<Record<string, never>> => {
|
||||||
|
// Get the template category, identifier, and field from the arguments
|
||||||
|
const templateCategory = args[0];
|
||||||
|
const templateQuery = args[1];
|
||||||
|
const templateField = args[2];
|
||||||
|
|
||||||
|
deps.io.verbose(
|
||||||
|
`Template inspect args - category: ${templateCategory}, identifier: ${templateQuery}, field: ${templateField}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no template category, identifier, or field is provided, print a message and throw an error
|
||||||
|
if (!templateCategory || !templateQuery || !templateField) {
|
||||||
|
deps.io.err("No template category, identifier, or field provided");
|
||||||
|
printTemplateInspectHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.inspect.arguments_missing",
|
||||||
|
"No template category, identifier, or field provided",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the template
|
||||||
|
const originalTemplate = await resolveTemplate(deps, templateQuery);
|
||||||
|
deps.io.verbose(`Original Template: ${formatObject(originalTemplate)}`);
|
||||||
|
|
||||||
|
// Resolve the template references
|
||||||
|
const template = await resolveTemplateReferences(originalTemplate);
|
||||||
|
deps.io.verbose(`Extended Template: ${formatObject(template)}`);
|
||||||
|
|
||||||
|
// Handle the template category
|
||||||
|
switch (templateCategory) {
|
||||||
|
case "action": {
|
||||||
|
// Get the action from the template
|
||||||
|
const action = template.actions[templateField];
|
||||||
|
|
||||||
|
// If the action is not found, print a message and throw an error
|
||||||
|
if (!action) {
|
||||||
|
deps.io.err(`No action found: ${templateField}`);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.inspect.action_missing",
|
||||||
|
`No action found: ${templateField}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display the action to the user
|
||||||
|
deps.io.out(formatObject(action));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
case "transaction": {
|
||||||
|
// Get the transaction from the template
|
||||||
|
const transaction = template.transactions?.[templateField];
|
||||||
|
|
||||||
|
// If the transaction is not found, print a message and throw an error
|
||||||
|
if (!transaction) {
|
||||||
|
deps.io.err(`No transaction found: ${templateField}`);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.inspect.transaction_missing",
|
||||||
|
`No transaction found: ${templateField}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display the transaction to the user
|
||||||
|
deps.io.out(formatObject(transaction));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
case "output": {
|
||||||
|
// Get the output from the template
|
||||||
|
const output = template.outputs[templateField];
|
||||||
|
|
||||||
|
// If the output is not found, print a message and throw an error
|
||||||
|
if (!output) {
|
||||||
|
deps.io.err(`No output found: ${templateField}`);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.inspect.output_missing",
|
||||||
|
`No output found: ${templateField}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display the output to the user
|
||||||
|
deps.io.out(formatObject(output));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
case "lockingscript": {
|
||||||
|
// Get the lockingscript from the template
|
||||||
|
const lockingscript = template.lockingScripts[templateField];
|
||||||
|
|
||||||
|
// If the lockingscript is not found, print a message and throw an error
|
||||||
|
if (!lockingscript) {
|
||||||
|
deps.io.err(`No lockingscript found: ${templateField}`);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.inspect.lockingscript_missing",
|
||||||
|
`No lockingscript found: ${templateField}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display the lockingscript to the user
|
||||||
|
deps.io.out(formatObject(lockingscript));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
case "variable": {
|
||||||
|
// Get the variable from the template
|
||||||
|
const variable = template.variables?.[templateField];
|
||||||
|
|
||||||
|
// If the variable is not found, print a message and throw an error
|
||||||
|
if (!variable) {
|
||||||
|
deps.io.err(`No variable found: ${templateField}`);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.inspect.variable_missing",
|
||||||
|
`No variable found: ${templateField}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display the variable to the user
|
||||||
|
deps.io.out(formatObject(variable));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
deps.io.verbose(`Unknown template category: ${templateCategory}`);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.inspect.category_unknown",
|
||||||
|
`Unknown template category: ${templateCategory}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the template export command.
|
||||||
|
* Throws CommandError on failure, returns result data on success.
|
||||||
|
* @param deps - The command dependencies.
|
||||||
|
* @param args - Positional args after "export", e.g. ["template-id"] or ["template-id", "template.json"].
|
||||||
|
* @param options - Parsed option flags.
|
||||||
|
*/
|
||||||
|
export const handleTemplateExportCommand = async (
|
||||||
|
deps: CommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
options: Record<string, string>,
|
||||||
|
): Promise<{ outputFile?: string }> => {
|
||||||
|
// Get the template identifier from the arguments
|
||||||
|
const templateIdentifier = args[0];
|
||||||
|
|
||||||
|
// If no template identifier is provided, print a message and throw an error
|
||||||
|
if (!templateIdentifier) {
|
||||||
|
deps.io.err("No template identifier provided");
|
||||||
|
printTemplateHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.export.identifier_missing",
|
||||||
|
"No template identifier provided",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the raw template from the engine.
|
||||||
|
// Do not resolve references or pretty-print the template.
|
||||||
|
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
|
||||||
|
|
||||||
|
// If the raw template is not found, print a message and throw an error
|
||||||
|
if (!rawTemplate) {
|
||||||
|
deps.io.err(`No template found: ${templateIdentifier}`);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.export.not_found",
|
||||||
|
`No template found: ${templateIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize the template without indentation to preserve the engine output shape.
|
||||||
|
const serializedTemplate = JSON.stringify(rawTemplate);
|
||||||
|
|
||||||
|
// Resolve output file from --output (or -o), then fallback to optional positional output file
|
||||||
|
const outputFile = options["output"] ?? args[1];
|
||||||
|
|
||||||
|
// If no output file is provided, print the template to stdout
|
||||||
|
if (!outputFile) {
|
||||||
|
deps.io.out(serializedTemplate);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve output file path and write the template to disk
|
||||||
|
const outputPath = path.resolve(process.cwd(), outputFile);
|
||||||
|
try {
|
||||||
|
writeFileSync(outputPath, serializedTemplate);
|
||||||
|
} catch (error) {
|
||||||
|
throw new CommandError(
|
||||||
|
"template.export.write_failed",
|
||||||
|
`Failed to export template to file: ${outputPath} (${error instanceof Error ? error.message : "unknown error"})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.io.out(`Template exported to: ${outputPath}`);
|
||||||
|
return { outputFile: outputPath };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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; outputFile?: string }> => {
|
||||||
|
// Get the sub-command from the arguments
|
||||||
|
const subCommand = args[0];
|
||||||
|
|
||||||
|
// If no sub-command is provided, print a message and throw an error
|
||||||
|
if (!subCommand) {
|
||||||
|
deps.io.verbose("No sub-command provided");
|
||||||
|
printTemplateHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.subcommand.missing",
|
||||||
|
"No sub-command provided",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the sub-command
|
||||||
|
switch (subCommand) {
|
||||||
|
case "import": {
|
||||||
|
// Get the template file from the arguments
|
||||||
|
const templateFile = args[1];
|
||||||
|
|
||||||
|
// If no template file is provided, print a message and throw an error
|
||||||
|
deps.io.verbose(`Template file: ${templateFile}`);
|
||||||
|
|
||||||
|
if (!templateFile) {
|
||||||
|
deps.io.verbose("No template file provided");
|
||||||
|
printTemplateHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.import.file_missing",
|
||||||
|
"No template file provided",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the template path
|
||||||
|
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`);
|
||||||
|
deps.io.verbose(`Template path: ${templatePath}`);
|
||||||
|
|
||||||
|
// If the template file does not exist, print a message and throw an error
|
||||||
|
if (!existsSync(templatePath)) {
|
||||||
|
deps.io.err(`Template file does not exist: ${templatePath}`);
|
||||||
|
printTemplateHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.import.file_not_found",
|
||||||
|
`Template file does not exist: ${templatePath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and load the template file (JSON directly, TS/JS via child process).
|
||||||
|
let templateContents: string;
|
||||||
|
try {
|
||||||
|
templateContents = await loadTemplateFromFile(templatePath);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof TemplateLoadError
|
||||||
|
? error.message
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: String(error);
|
||||||
|
deps.io.err(message);
|
||||||
|
printTemplateHelp(deps.io);
|
||||||
|
throw new CommandError("template.import.load_failed", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.io.verbose(`Importing template: ${templateFile}`);
|
||||||
|
|
||||||
|
// Import the template
|
||||||
|
await deps.app.engine.importTemplate(templateContents);
|
||||||
|
deps.io.verbose(`Template imported: ${templateFile}`);
|
||||||
|
|
||||||
|
// Return the template file
|
||||||
|
return { templateFile };
|
||||||
|
}
|
||||||
|
case "list": {
|
||||||
|
// Handle the template list command, We offload here as it has lots of arguments and is quite long
|
||||||
|
return handleTemplateListCommand(deps, args.slice(1));
|
||||||
|
}
|
||||||
|
case "inspect": {
|
||||||
|
// Handle the template inspect command, We offload here as it has lots of arguments and is quite long
|
||||||
|
return handleTemplateInspectCommand(deps, args.slice(1));
|
||||||
|
}
|
||||||
|
case "export": {
|
||||||
|
// Handle the template export command
|
||||||
|
return handleTemplateExportCommand(deps, args.slice(1), options);
|
||||||
|
}
|
||||||
|
case "set-default": {
|
||||||
|
// Get the template file, output identifier, and role identifier from the arguments
|
||||||
|
const templateFile = args[1];
|
||||||
|
const outputIdentifier = args[2];
|
||||||
|
const roleIdentifier = args[3];
|
||||||
|
|
||||||
|
// If no template file, output identifier, or role identifier is provided, print a message and throw an error
|
||||||
|
if (!templateFile || !outputIdentifier || !roleIdentifier) {
|
||||||
|
deps.io.verbose(
|
||||||
|
"No template file, output identifier, or role identifier provided",
|
||||||
|
);
|
||||||
|
printTemplateHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.default.arguments_missing",
|
||||||
|
"No template file, output identifier, or role identifier provided",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the default locking parameters
|
||||||
|
deps.io.verbose(
|
||||||
|
`Template file: ${templateFile}, output identifier: ${outputIdentifier}, role identifier: ${roleIdentifier}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set the default locking parameters
|
||||||
|
await deps.app.engine.setDefaultLockingParameters(
|
||||||
|
templateFile,
|
||||||
|
outputIdentifier,
|
||||||
|
roleIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return an empty object
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// If the sub-command is not found, print a message and throw an error
|
||||||
|
deps.io.verbose(`Unknown template sub-command: ${subCommand}`);
|
||||||
|
printTemplateHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.subcommand.unknown",
|
||||||
|
`Unknown template sub-command: ${subCommand}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
59
src/cli/commands/types.ts
Normal file
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
300
src/cli/index.ts
Normal file
300
src/cli/index.ts
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* CLI entry point.
|
||||||
|
*
|
||||||
|
* TODO: Decide the best way to handle CLI arguments. We have the option of:
|
||||||
|
* - Handling it in the `bin` folder
|
||||||
|
* - Switch / if statements in here
|
||||||
|
* - Dedicated command parser
|
||||||
|
* - Separate files?
|
||||||
|
*
|
||||||
|
* What kind of commands do we want to support?
|
||||||
|
* Worth noting that we shouldn't need to list invitations? Maybe we will though? If we do, then we will need to reuse the storage + xo-invitations.db file. I think this is fine to do though?
|
||||||
|
* Nah, lets use the storage + xo-invitations.db file. Will allow us to persist invitations.
|
||||||
|
* How do we want to import invitations though? Should we just take in the ID still? Probably makes more sense to allow for reading from a file though...
|
||||||
|
* But thats an entirely different flow to what we have already. And how would we handle writing the invitation? Do we just overwrite the file? Probably... Just take in an -o option; default to overwrite?
|
||||||
|
*
|
||||||
|
* Commands:
|
||||||
|
* xo-cli mnemonic create [mnemonic seed]
|
||||||
|
* xo-cli mnemonic list
|
||||||
|
*
|
||||||
|
* xo-cli template import <template-file>
|
||||||
|
* xo-cli template list
|
||||||
|
* xo-cli template set-default <template-file> <output-identifier> <role-identifier>
|
||||||
|
*
|
||||||
|
* xo-cli invitation list
|
||||||
|
* xo-cli invitation create <template-file> <action-id> [-o Output file, var-${action-variable-name}=${value}, role=${value}]
|
||||||
|
* xo-cli invitation import <invitation-file>
|
||||||
|
* xo-cli invitation sign <invitation-file>
|
||||||
|
* xo-cli invitation broadcast <invitation-file>
|
||||||
|
*
|
||||||
|
* xo-cli resource list
|
||||||
|
*
|
||||||
|
* universal Args:
|
||||||
|
* -h --help
|
||||||
|
* -m --mnemonic-file <mnemonic-file>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
import { AppService } from "../services/app.js";
|
||||||
|
import { SettingsService } from "../services/settings.js";
|
||||||
|
import { convertArgsToObject } from "./arguments.js";
|
||||||
|
import { bold, dim, formatObject } from "./utils.js";
|
||||||
|
import { listGlobalMnemonicFiles, loadMnemonic } from "./mnemonic.js";
|
||||||
|
import {
|
||||||
|
getDataDir,
|
||||||
|
getMnemonicsDir,
|
||||||
|
getWalletConfigPath,
|
||||||
|
} from "../utils/paths.js";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CommandDependencies,
|
||||||
|
type CommandIO,
|
||||||
|
type CommandPaths,
|
||||||
|
CommandError,
|
||||||
|
handleMnemonicCommand,
|
||||||
|
handleSettingsCommand,
|
||||||
|
handleTemplateCommand,
|
||||||
|
handleInvitationCommand,
|
||||||
|
handleReceiveCommand,
|
||||||
|
handleResourceCommand,
|
||||||
|
} from "./commands/index.js";
|
||||||
|
|
||||||
|
import { handleCompletionsCommand } from "./autocomplete/completions.js";
|
||||||
|
|
||||||
|
const createCommandIO = (verbose: boolean): CommandIO => ({
|
||||||
|
out: (message: string) => {
|
||||||
|
console.log(message);
|
||||||
|
},
|
||||||
|
err: (message: string) => {
|
||||||
|
console.error(message);
|
||||||
|
},
|
||||||
|
verbose: (message: string) => {
|
||||||
|
if (verbose) console.log(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entry point.
|
||||||
|
* We will:
|
||||||
|
* - Initialize the app service?
|
||||||
|
* - Extract the command being called
|
||||||
|
* - Extract CLI Args (Depends on the command being called. Eww. But we can probably use Zod to validate the args in a decent way?)
|
||||||
|
* - Execute the command
|
||||||
|
* - Export if configured?
|
||||||
|
* - Exit with the appropriate code
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
// Initialize the app service
|
||||||
|
// NOTE: We are going to assume that they are using a mnemonic file for now
|
||||||
|
const { args, options } = convertArgsToObject(process.argv.slice(2));
|
||||||
|
|
||||||
|
// Create a verbose logger if the user set the verbose flag
|
||||||
|
const io = createCommandIO(options["verbose"] === "true");
|
||||||
|
|
||||||
|
// Log the parsed app args
|
||||||
|
io.verbose(`Parsed args: ${formatObject(args)}`);
|
||||||
|
io.verbose(`Parsed options: ${formatObject(options)}`);
|
||||||
|
|
||||||
|
// Handle the command
|
||||||
|
const command = args[0];
|
||||||
|
io.verbose(`Command: ${command}`);
|
||||||
|
if (!command) {
|
||||||
|
// TODO: Print help, probably...
|
||||||
|
io.err("No command provided");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positional args after the command name (sub-command, files, etc.)
|
||||||
|
const subArgs = args.slice(1);
|
||||||
|
|
||||||
|
// Build paths object from global path functions
|
||||||
|
const paths: CommandPaths = {
|
||||||
|
mnemonicsDir: getMnemonicsDir(),
|
||||||
|
dataDir: getDataDir(),
|
||||||
|
walletConfigPath: getWalletConfigPath(),
|
||||||
|
workingDir: process.cwd(),
|
||||||
|
};
|
||||||
|
const settings = new SettingsService(paths.walletConfigPath);
|
||||||
|
|
||||||
|
// Early handling for completions command
|
||||||
|
if (command === "completions") {
|
||||||
|
handleCompletionsCommand(subArgs, options);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "mnemonic") {
|
||||||
|
try {
|
||||||
|
await handleMnemonicCommand({ io, paths }, subArgs, options);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CommandError) {
|
||||||
|
process.exit(error.code);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "settings") {
|
||||||
|
try {
|
||||||
|
await handleSettingsCommand({ io, paths }, subArgs, options);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CommandError) {
|
||||||
|
process.exit(error.code);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve mnemonic file: explicit flag > persisted settings > error.
|
||||||
|
let mnemonicFile = options["mnemonicFile"];
|
||||||
|
let didUsePersistedMnemonic = false;
|
||||||
|
if (!mnemonicFile) {
|
||||||
|
mnemonicFile = settings.getDefaultMnemonic();
|
||||||
|
didUsePersistedMnemonic = Boolean(mnemonicFile);
|
||||||
|
}
|
||||||
|
if (didUsePersistedMnemonic && mnemonicFile) {
|
||||||
|
io.verbose(`Using persisted wallet: ${mnemonicFile}`);
|
||||||
|
}
|
||||||
|
if (!mnemonicFile) {
|
||||||
|
io.err("No mnemonic file provided");
|
||||||
|
io.out(
|
||||||
|
`You can create a mnemonic file with the following command: xo-cli mnemonic create <mnemonic-seed> or use one of the following files: \n${listGlobalMnemonicFiles().join("\n")}`,
|
||||||
|
);
|
||||||
|
io.out(
|
||||||
|
`\nTip: pass -m <file> once and it will be remembered in ${paths.walletConfigPath}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the choice so subsequent commands can omit -m.
|
||||||
|
settings.setDefaultMnemonic(mnemonicFile);
|
||||||
|
if (options["currency"]) {
|
||||||
|
settings.setCurrency(options["currency"]);
|
||||||
|
io.verbose(`Using configured currency: ${settings.getCurrency()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mnemonic = loadMnemonic(paths.mnemonicsDir, mnemonicFile);
|
||||||
|
io.verbose(`Loaded mnemonic from file: ${mnemonicFile} ${mnemonic}`);
|
||||||
|
|
||||||
|
// Create an App instance
|
||||||
|
io.verbose("Creating app instance...");
|
||||||
|
const app = await AppService.create(
|
||||||
|
mnemonic,
|
||||||
|
{
|
||||||
|
syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000",
|
||||||
|
engineConfig: {
|
||||||
|
databasePath: options["databasePath"] ?? paths.dataDir,
|
||||||
|
databaseFilename: options["databaseFilename"] ?? "xo-wallet.db",
|
||||||
|
},
|
||||||
|
invitationStoragePath:
|
||||||
|
options["invitationStoragePath"] ??
|
||||||
|
join(paths.dataDir, "xo-invitations.db"),
|
||||||
|
},
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
io.verbose("App instance created");
|
||||||
|
|
||||||
|
// Start the app
|
||||||
|
// TODO: Rethink this. Do we really want to start the app here? It just slows it down if we dont actually have to have it started for the command
|
||||||
|
io.verbose("Starting app...");
|
||||||
|
await app.start();
|
||||||
|
io.verbose("App started");
|
||||||
|
|
||||||
|
const commandDependencies: CommandDependencies = {
|
||||||
|
io,
|
||||||
|
paths,
|
||||||
|
app,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle the command
|
||||||
|
try {
|
||||||
|
let result: unknown;
|
||||||
|
switch (command) {
|
||||||
|
case "template":
|
||||||
|
result = await handleTemplateCommand(
|
||||||
|
commandDependencies,
|
||||||
|
subArgs,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "invitation":
|
||||||
|
result = await handleInvitationCommand(
|
||||||
|
commandDependencies,
|
||||||
|
subArgs,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "receive":
|
||||||
|
result = await handleReceiveCommand(
|
||||||
|
commandDependencies,
|
||||||
|
subArgs,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "resource":
|
||||||
|
result = await handleResourceCommand(
|
||||||
|
commandDependencies,
|
||||||
|
subArgs,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "help":
|
||||||
|
result = await handleHelpCommand(commandDependencies, subArgs, options);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
io.err(`Unknown command: ${command}`);
|
||||||
|
throw new CommandError(
|
||||||
|
"cli.command.unknown",
|
||||||
|
`Unknown command: ${command}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log(result);
|
||||||
|
|
||||||
|
// objectPrint(result);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CommandError) {
|
||||||
|
io.err(error.message);
|
||||||
|
process.exit(error.code);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHelpCommand = async (
|
||||||
|
deps: CommandDependencies,
|
||||||
|
_args: string[],
|
||||||
|
_options: Record<string, string>,
|
||||||
|
): Promise<Record<string, never>> => {
|
||||||
|
deps.io.out(
|
||||||
|
`${bold("XO-CLI Help:")}
|
||||||
|
|
||||||
|
${bold("Usage:")} xo-cli <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
mnemonic ${dim("Manage mnemonic files")}
|
||||||
|
template ${dim("Manage templates")}
|
||||||
|
invitation ${dim("Manage invitations")}
|
||||||
|
receive ${dim("Generate a single-use receiving address")}
|
||||||
|
resource ${dim("Manage resources")}
|
||||||
|
settings ${dim("Manage persisted wallet settings")}
|
||||||
|
completions ${dim("Generate shell completion scripts (bash, zsh, fish)")}
|
||||||
|
help ${dim("Show this help message")}
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help ${dim("Show this help message")}
|
||||||
|
-m, --mnemonic-file <mnemonic-file> ${dim("Use a specific mnemonic file")}
|
||||||
|
--currency <currency-code> ${dim("Set fiat display currency (e.g. USD, AUD)")}
|
||||||
|
-v, --verbose ${dim("Show verbose output")}`,
|
||||||
|
);
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
139
src/cli/mnemonic.ts
Normal file
139
src/cli/mnemonic.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { basename, isAbsolute, join, resolve } from "node:path";
|
||||||
|
import { encodeBip39Mnemonic, generateBip39Mnemonic } from "@bitauth/libauth";
|
||||||
|
|
||||||
|
import { BCHMnemonicURL } from "../utils/bch-mnemonic-url.js";
|
||||||
|
import { getMnemonicsDir as getGlobalMnemonicsDir } from "../utils/paths.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new mnemonic seed phrase
|
||||||
|
*/
|
||||||
|
export const createMnemonicSeed = (): string => {
|
||||||
|
return generateBip39Mnemonic();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mnemonic file from a mnemonic seed
|
||||||
|
* @param mnemonicsDir - Directory to store mnemonic files
|
||||||
|
* @param mnemonic - The mnemonic seed
|
||||||
|
* @param outputFilename - The filename to write the mnemonic to. If not provided, the first word from the mnemonic will be used as the filename
|
||||||
|
* @returns The filename of the created mnemonic file
|
||||||
|
*/
|
||||||
|
export const createMnemonicFile = (
|
||||||
|
mnemonicsDir: string,
|
||||||
|
mnemonic: string,
|
||||||
|
outputFilename?: string,
|
||||||
|
): string => {
|
||||||
|
const mnemonicUrl = BCHMnemonicURL.fromSeed(mnemonic);
|
||||||
|
|
||||||
|
let fileName = outputFilename;
|
||||||
|
if (!fileName) {
|
||||||
|
const firstWord = mnemonic.split(" ")[0]?.toLowerCase();
|
||||||
|
if (!firstWord) {
|
||||||
|
throw new Error(
|
||||||
|
"Failed to create mnemonic file: Unable to extract first word from the mnemonic",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fileName = `mnemonic-${firstWord}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeName = basename(fileName);
|
||||||
|
const outPath = join(mnemonicsDir, safeName);
|
||||||
|
writeFileSync(outPath, mnemonicUrl.toURL());
|
||||||
|
|
||||||
|
return safeName;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a mnemonic reference to an absolute path.
|
||||||
|
* Order: absolute path if it exists → path relative to cwd → mnemonicsDir/<basename>.
|
||||||
|
*
|
||||||
|
* @param mnemonicsDir - Directory containing mnemonic files
|
||||||
|
* @param mnemonicRef - Path or basename (e.g. `mnemonic-nuclear`)
|
||||||
|
* @returns Absolute path to the mnemonic file
|
||||||
|
* @throws If no matching file exists
|
||||||
|
*/
|
||||||
|
export const resolveMnemonicFilePath = (
|
||||||
|
mnemonicsDir: string,
|
||||||
|
mnemonicRef: string,
|
||||||
|
): string => {
|
||||||
|
// Try to resolve the mnemonic file as an absolute path
|
||||||
|
if (isAbsolute(mnemonicRef) && existsSync(mnemonicRef)) {
|
||||||
|
return mnemonicRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resolve the mnemonic file relative to the current working directory
|
||||||
|
const relativeToCwd = resolve(process.cwd(), mnemonicRef);
|
||||||
|
if (existsSync(relativeToCwd)) {
|
||||||
|
return relativeToCwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resolve the mnemonic file in the mnemonics directory
|
||||||
|
const inMnemonics = join(mnemonicsDir, basename(mnemonicRef));
|
||||||
|
if (existsSync(inMnemonics)) {
|
||||||
|
return inMnemonics;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the mnemonic file is not found, throw an error
|
||||||
|
throw new Error(
|
||||||
|
`Mnemonic file not found: ${mnemonicRef}. Run "xo-cli mnemonic list" to see available files.`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a mnemonic from a mnemonic file
|
||||||
|
* @param mnemonicsDir - Directory containing mnemonic files
|
||||||
|
* @param mnemonicFile - The filename of the mnemonic file
|
||||||
|
* @returns The mnemonic seed
|
||||||
|
*/
|
||||||
|
export const loadMnemonic = (
|
||||||
|
mnemonicsDir: string,
|
||||||
|
mnemonicFile: string,
|
||||||
|
): string => {
|
||||||
|
// Resolve the mnemonic file path
|
||||||
|
const resolvedPath = resolveMnemonicFilePath(mnemonicsDir, mnemonicFile);
|
||||||
|
|
||||||
|
// Read the mnemonic file
|
||||||
|
const mnemonicUrl = BCHMnemonicURL.fromURL(
|
||||||
|
readFileSync(resolvedPath, "utf8"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the entropy from the mnemonic url
|
||||||
|
const { entropy } = mnemonicUrl.toObject();
|
||||||
|
|
||||||
|
// Encode the entropy to a mnemonic
|
||||||
|
const mnemonic = encodeBip39Mnemonic(entropy);
|
||||||
|
|
||||||
|
// If the mnemonic is not a string, throw an error
|
||||||
|
if (typeof mnemonic === "string") {
|
||||||
|
throw new Error(`Failed to convert entropy to mnemonic: ${mnemonic}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the mnemonic phrase
|
||||||
|
return mnemonic.phrase;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists mnemonic files in the given directory.
|
||||||
|
* @param mnemonicsDir - Directory containing mnemonic files
|
||||||
|
* @returns Basenames suitable for `-m <name>`
|
||||||
|
*/
|
||||||
|
export const listMnemonicFiles = (mnemonicsDir: string): string[] => {
|
||||||
|
// List the mnemonic files in the given directory
|
||||||
|
const filenames = readdirSync(mnemonicsDir).filter((f: string) =>
|
||||||
|
f.startsWith("mnemonic-"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return the mnemonic files
|
||||||
|
return filenames;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists mnemonic files using the global mnemonics directory.
|
||||||
|
* Convenience function for use in the CLI entry point before paths are resolved.
|
||||||
|
* @returns Basenames suitable for `-m <name>`
|
||||||
|
*/
|
||||||
|
export const listGlobalMnemonicFiles = (): string[] => {
|
||||||
|
// List the mnemonic files in the global mnemonics directory
|
||||||
|
return listMnemonicFiles(getGlobalMnemonicsDir());
|
||||||
|
};
|
||||||
114
src/cli/utils.ts
Normal file
114
src/cli/utils.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import util from "node:util";
|
||||||
|
|
||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||||
|
|
||||||
|
import type { CommandDependencies } from "./commands/types.js";
|
||||||
|
import { CommandError } from "./commands/types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterate through the templates, trying to match the id or the name with the given input.
|
||||||
|
* Use multiple for-loops.
|
||||||
|
* First, check the id of every template
|
||||||
|
* Then, check the name of every template. If multiple names match, throw an error.
|
||||||
|
* If no match is found, throw an error.
|
||||||
|
*
|
||||||
|
* @param deps - The command dependencies.
|
||||||
|
* @param query - The id or name of the template to resolve.
|
||||||
|
* @returns The template object.
|
||||||
|
* @throws CommandError if no template is found.
|
||||||
|
* @throws CommandError if multiple templates are found.
|
||||||
|
*/
|
||||||
|
export const resolveTemplate = async (
|
||||||
|
deps: CommandDependencies,
|
||||||
|
query: string,
|
||||||
|
): Promise<XOTemplate> => {
|
||||||
|
// Gather all of our imported templates
|
||||||
|
const templates = await deps.app.engine.listImportedTemplates();
|
||||||
|
|
||||||
|
// Create a set to store the matches
|
||||||
|
const matches = new Set<XOTemplate>();
|
||||||
|
|
||||||
|
// Iterate through the templates and check if the identifier matches the query
|
||||||
|
for (const template of templates) {
|
||||||
|
if (generateTemplateIdentifier(template) === query) {
|
||||||
|
// Return early if we got a match since identifiers are always unique by content
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate through the templates and check if the name matches the query
|
||||||
|
for (const template of templates) {
|
||||||
|
if (template.name === query) {
|
||||||
|
matches.add(template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there are multiple matches, throw an error
|
||||||
|
if (matches.size > 1) {
|
||||||
|
throw new CommandError(
|
||||||
|
"template.resolve.multiple_matches",
|
||||||
|
`Multiple templates found for "${query}": ${Array.from(matches)
|
||||||
|
.map(
|
||||||
|
(template) =>
|
||||||
|
`${template.name} (${generateTemplateIdentifier(template)})`,
|
||||||
|
)
|
||||||
|
.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there is one match, return the match
|
||||||
|
if (matches.size === 1) {
|
||||||
|
return matches.values().next().value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there are no matches, throw an error
|
||||||
|
throw new CommandError(
|
||||||
|
"template.resolve.not_found",
|
||||||
|
`Template not found: ${query}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text formatting utilities for the CLI.
|
||||||
|
*
|
||||||
|
* Uses ANSI escape codes to format text.
|
||||||
|
*
|
||||||
|
* AI Generated links:
|
||||||
|
* @see https://en.wikipedia.org/wiki/ANSI_escape_code
|
||||||
|
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters
|
||||||
|
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
|
||||||
|
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Formatting
|
||||||
|
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Cursor_movement
|
||||||
|
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Screen_manipulation
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BOLD = "\x1b[1m";
|
||||||
|
export const bold = (text: string) => `${BOLD}${text}${RESET}`;
|
||||||
|
|
||||||
|
const DIM = "\x1b[2m";
|
||||||
|
export const dim = (text: string) => `${DIM}${text}${RESET}`;
|
||||||
|
|
||||||
|
const UNDERLINE = "\x1b[4m";
|
||||||
|
export const underline = (text: string) => `${UNDERLINE}${text}${RESET}`;
|
||||||
|
|
||||||
|
const INVERSE = "\x1b[7m";
|
||||||
|
export const inverse = (text: string) => `${INVERSE}${text}${RESET}`;
|
||||||
|
|
||||||
|
const HIDDEN = "\x1b[8m";
|
||||||
|
export const hidden = (text: string) => `${HIDDEN}${text}${RESET}`;
|
||||||
|
|
||||||
|
const STRIKETHROUGH = "\x1b[9m";
|
||||||
|
export const strikethrough = (text: string) =>
|
||||||
|
`${STRIKETHROUGH}${text}${RESET}`;
|
||||||
|
|
||||||
|
const RESET = "\x1b[0m";
|
||||||
|
export const reset = (text: string) => `${RESET}${text}${RESET}`;
|
||||||
|
|
||||||
|
export const formatObject = (obj: unknown) => {
|
||||||
|
return util.inspect(obj, {
|
||||||
|
depth: null,
|
||||||
|
colors: true,
|
||||||
|
compact: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
18
src/index.ts
18
src/index.ts
@@ -1,3 +1,4 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* XO Wallet CLI - Terminal User Interface for XO crypto wallet.
|
* XO Wallet CLI - Terminal User Interface for XO crypto wallet.
|
||||||
*
|
*
|
||||||
@@ -9,21 +10,28 @@
|
|||||||
* 5. Real-time updates via SSE
|
* 5. Real-time updates via SSE
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { App } from './app.js';
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { App } from "./app.js";
|
||||||
|
import { getDataDir } from "./utils/paths.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main entry point.
|
* Main entry point.
|
||||||
*/
|
*/
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const dataDir = getDataDir();
|
||||||
// Create and start the application
|
// Create and start the application
|
||||||
await App.create({
|
await App.create({
|
||||||
syncServerUrl: process.env['SYNC_SERVER_URL'] ?? 'http://localhost:3000',
|
syncServerUrl: process.env["SYNC_SERVER_URL"] ?? "http://localhost:3000",
|
||||||
databasePath: process.env['DB_PATH'] ?? './',
|
databasePath: process.env["DB_PATH"] ?? dataDir,
|
||||||
databaseFilename: process.env['DB_FILENAME'] ?? 'xo-wallet.db',
|
databaseFilename: process.env["DB_FILENAME"] ?? "xo-wallet.db",
|
||||||
|
invitationStoragePath:
|
||||||
|
process.env["INVITATION_STORAGE_PATH"] ??
|
||||||
|
join(dataDir, "xo-invitations.db"),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start XO Wallet CLI:', error);
|
console.error("Failed to start XO Wallet CLI:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,43 +3,82 @@ import {
|
|||||||
type XOEngineOptions,
|
type XOEngineOptions,
|
||||||
// This is temporary. Will likely be moved to where we import templates in the cli. I think that makes more sense as this is a library thing
|
// This is temporary. Will likely be moved to where we import templates in the cli. I think that makes more sense as this is a library thing
|
||||||
generateTemplateIdentifier,
|
generateTemplateIdentifier,
|
||||||
} from '@xo-cash/engine';
|
} from "@xo-cash/engine";
|
||||||
import type { XOInvitation } from '@xo-cash/types';
|
import type { XOInvitation } from "@xo-cash/types";
|
||||||
|
|
||||||
import { Invitation } from './invitation.js';
|
import { Invitation } from "./invitation.js";
|
||||||
import { Storage } from './storage.js';
|
import { BaseStorage, Storage } from "./storage.js";
|
||||||
import { SyncServer } from '../utils/sync-server.js';
|
import { SyncServer } from "../utils/sync-server.js";
|
||||||
import { HistoryService } from './history.js';
|
import { HistoryService } from "./history.js";
|
||||||
|
import { type BlockchainService, ElectrumService } from "./electrum.js";
|
||||||
|
import { RatesService } from "./rates.js";
|
||||||
|
import { SettingsService } from "./settings.js";
|
||||||
|
|
||||||
import { EventEmitter } from '../utils/event-emitter.js';
|
import { EventEmitter } from "../utils/event-emitter.js";
|
||||||
|
|
||||||
// TODO: Remove this. Exists to hash the seed for database namespace.
|
// TODO: Remove this. Exists to hash the seed for database namespace.
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from "crypto";
|
||||||
import { p2pkhTemplate } from '@xo-cash/templates';
|
import { hexToBin } from "@bitauth/libauth";
|
||||||
|
import { parseTemplate } from "@xo-cash/engine";
|
||||||
|
|
||||||
|
import { p2pkhTemplate } from "@xo-cash/templates";
|
||||||
|
import { vendingMachineTemplate } from "../templates/vending-machine.js";
|
||||||
|
import { wrapBCHTemplate } from "../templates/wrap-template.js";
|
||||||
|
|
||||||
export type AppEventMap = {
|
export type AppEventMap = {
|
||||||
'invitation-added': Invitation;
|
"invitation-added": Invitation;
|
||||||
'invitation-removed': Invitation;
|
"invitation-removed": Invitation;
|
||||||
}
|
"wallet-state-changed": {
|
||||||
|
reason:
|
||||||
|
| "invitation-added"
|
||||||
|
| "invitation-removed"
|
||||||
|
| "invitation-updated"
|
||||||
|
| "invitation-status-changed";
|
||||||
|
invitationIdentifier: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
syncServerUrl: string;
|
syncServerUrl: string;
|
||||||
engineConfig: XOEngineOptions;
|
engineConfig: XOEngineOptions;
|
||||||
invitationStoragePath: string;
|
invitationStoragePath: string;
|
||||||
|
electrumHost?: string;
|
||||||
|
electrumApplicationIdentifier?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AppService extends EventEmitter<AppEventMap> {
|
export class AppService extends EventEmitter<AppEventMap> {
|
||||||
public engine: Engine;
|
public engine: Engine;
|
||||||
public storage: Storage;
|
public storage: BaseStorage;
|
||||||
public config: AppConfig;
|
public config: AppConfig;
|
||||||
public history: HistoryService;
|
public history: HistoryService;
|
||||||
|
public electrum: BlockchainService;
|
||||||
|
public rates: RatesService;
|
||||||
|
public settings: SettingsService;
|
||||||
|
|
||||||
public invitations: Invitation[] = [];
|
public invitations: Invitation[] = [];
|
||||||
|
/**
|
||||||
|
* Incremented whenever the invitation list or any invitation's data/status changes.
|
||||||
|
* Used by TUI hooks so useSyncExternalStore snapshots change on in-place mutations.
|
||||||
|
*/
|
||||||
|
public invitationsRevision = 0;
|
||||||
|
private invitationRevisions = new Map<string, number>();
|
||||||
|
private invitationEventCleanup = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
onUpdated: (invitation: XOInvitation) => void;
|
||||||
|
onStatusChanged: (status: string) => void;
|
||||||
|
onRemoved: () => void;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
static async create(seed: string, config: AppConfig): Promise<AppService> {
|
static async create(
|
||||||
|
seed: string,
|
||||||
|
config: AppConfig,
|
||||||
|
settings: SettingsService = new SettingsService(),
|
||||||
|
): Promise<AppService> {
|
||||||
// Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app.
|
// Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app.
|
||||||
// We are going to do this by computing a hash of the seed and prefixing the storage paths with it.
|
// We are going to do this by computing a hash of the seed and prefixing the storage paths with it.
|
||||||
const seedHash = createHash('sha256').update(seed).digest('hex');
|
const seedHash = createHash("sha256").update(seed).digest("hex");
|
||||||
|
|
||||||
// We want to only prefix the file name
|
// We want to only prefix the file name
|
||||||
const prefixedStoragePath = `${seedHash.slice(0, 8)}-${config.engineConfig.databaseFilename}`;
|
const prefixedStoragePath = `${seedHash.slice(0, 8)}-${config.engineConfig.databaseFilename}`;
|
||||||
@@ -53,75 +92,255 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
// TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here
|
// TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here
|
||||||
// Import the default P2PKH template
|
// Import the default P2PKH template
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
|
await engine.importTemplate(vendingMachineTemplate);
|
||||||
|
await engine.importTemplate(wrapBCHTemplate);
|
||||||
|
|
||||||
|
// Update all the unspents for every template, and subscribe to the locking bytecodes for changes
|
||||||
|
// TODO: Remove the above lines that do the same thing. Minimising changes for BLISS.
|
||||||
|
const updateTemplates = async () => {
|
||||||
|
const templates = await engine.listImportedTemplates();
|
||||||
|
|
||||||
|
templates.forEach(async (template) => {
|
||||||
|
engine.updateUnspentOutputsForTemplate(
|
||||||
|
generateTemplateIdentifier(template),
|
||||||
|
);
|
||||||
|
engine.subscribeToScriptHashForTemplate(
|
||||||
|
generateTemplateIdentifier(template),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateTemplates();
|
||||||
|
|
||||||
// Set default locking parameters for P2PKH
|
// Set default locking parameters for P2PKH
|
||||||
|
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
|
||||||
|
// TODO: Add discovery for funds in the first index? Or until we return 0 TXs?
|
||||||
await engine.setDefaultLockingParameters(
|
await engine.setDefaultLockingParameters(
|
||||||
generateTemplateIdentifier(p2pkhTemplate),
|
generateTemplateIdentifier(parseTemplate(p2pkhTemplate)),
|
||||||
'receiveOutput',
|
"receiveOutput",
|
||||||
'receiver',
|
"receiver",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create our own storage for the invitations
|
// Create our own storage for the invitations
|
||||||
const storage = await Storage.create(config.invitationStoragePath);
|
const storage = await Storage.create(config.invitationStoragePath);
|
||||||
|
const walletStorage = await storage.child(seedHash.slice(0, 8));
|
||||||
|
|
||||||
// Create the app service
|
// Create the app service
|
||||||
return new AppService(engine, storage, config);
|
const electrum = new ElectrumService({
|
||||||
|
host: config.electrumHost,
|
||||||
|
applicationIdentifier: config.electrumApplicationIdentifier,
|
||||||
|
});
|
||||||
|
const rates = await RatesService.create(settings);
|
||||||
|
|
||||||
|
return new AppService(
|
||||||
|
engine,
|
||||||
|
walletStorage,
|
||||||
|
config,
|
||||||
|
electrum,
|
||||||
|
rates,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(engine: Engine, storage: Storage, config: AppConfig) {
|
constructor(
|
||||||
|
engine: Engine,
|
||||||
|
storage: BaseStorage,
|
||||||
|
config: AppConfig,
|
||||||
|
electrum: BlockchainService,
|
||||||
|
rates: RatesService,
|
||||||
|
settings: SettingsService,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.engine = engine;
|
this.engine = engine;
|
||||||
this.storage = storage;
|
this.storage = storage;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.electrum = electrum;
|
||||||
|
this.rates = rates;
|
||||||
|
this.settings = settings;
|
||||||
this.history = new HistoryService(engine, this.invitations);
|
this.history = new HistoryService(engine, this.invitations);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createInvitation(invitation: XOInvitation | string): Promise<Invitation> {
|
async createInvitation(
|
||||||
|
invitation: XOInvitation | string,
|
||||||
|
): Promise<Invitation> {
|
||||||
// Make sure the engine has the template imported
|
// Make sure the engine has the template imported
|
||||||
const invitationStorage = this.storage.child('invitations')
|
const invitationStorage = this.storage.child("invitations");
|
||||||
const invitationSyncServer = new SyncServer(this.config.syncServerUrl, typeof invitation === 'string' ? invitation : invitation.invitationIdentifier);
|
const invitationSyncServer = new SyncServer(
|
||||||
|
this.config.syncServerUrl,
|
||||||
|
typeof invitation === "string"
|
||||||
|
? invitation
|
||||||
|
: invitation.invitationIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
const deps = {
|
const deps = {
|
||||||
engine: this.engine,
|
engine: this.engine,
|
||||||
syncServer: invitationSyncServer,
|
syncServer: invitationSyncServer,
|
||||||
storage: invitationStorage,
|
storage: invitationStorage,
|
||||||
|
electrum: this.electrum,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the invitation
|
// Create the invitation
|
||||||
const invitationInstance = await Invitation.create(invitation, deps);
|
const invitationInstance = await Invitation.create(invitation, deps);
|
||||||
|
|
||||||
// Add the invitation to the invitations array
|
// Attach listeners before SSE connects so updates are not missed.
|
||||||
await this.addInvitation(invitationInstance);
|
await this.addInvitation(invitationInstance);
|
||||||
|
invitationInstance.start();
|
||||||
|
|
||||||
return invitationInstance;
|
return invitationInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
async addInvitation(invitation: Invitation): Promise<void> {
|
async addInvitation(invitation: Invitation): Promise<void> {
|
||||||
|
this.attachInvitationListeners(invitation);
|
||||||
|
|
||||||
// Add the invitation to the invitations array
|
// Add the invitation to the invitations array
|
||||||
this.invitations.push(invitation);
|
this.invitations.push(invitation);
|
||||||
|
this.bumpInvitationRevision(invitation.data.invitationIdentifier);
|
||||||
|
|
||||||
// Emit the invitation-added event
|
// Emit the invitation-added event
|
||||||
this.emit('invitation-added', invitation);
|
this.emit("invitation-added", invitation);
|
||||||
|
this.emit("wallet-state-changed", {
|
||||||
|
reason: "invitation-added",
|
||||||
|
invitationIdentifier: invitation.data.invitationIdentifier,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeInvitation(invitation: Invitation): Promise<void> {
|
async removeInvitation(invitation: Invitation): Promise<void> {
|
||||||
// Remove the invitation from the invitations array
|
const invitationIdentifier = invitation.data.invitationIdentifier;
|
||||||
this.invitations = this.invitations.filter(i => i !== invitation);
|
this.detachInvitationListeners(invitationIdentifier);
|
||||||
|
|
||||||
|
// Remove the invitation from the invitations array while preserving the array reference.
|
||||||
|
const invitationIndex = this.invitations.indexOf(invitation);
|
||||||
|
if (invitationIndex >= 0) {
|
||||||
|
this.invitations.splice(invitationIndex, 1);
|
||||||
|
}
|
||||||
|
this.bumpInvitationRevision(invitationIdentifier);
|
||||||
|
|
||||||
// Emit the invitation-removed event
|
// Emit the invitation-removed event
|
||||||
this.emit('invitation-removed', invitation);
|
this.emit("invitation-removed", invitation);
|
||||||
|
this.emit("wallet-state-changed", {
|
||||||
|
reason: "invitation-removed",
|
||||||
|
invitationIdentifier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachInvitationListeners(invitation: Invitation): void {
|
||||||
|
const invitationIdentifier = invitation.data.invitationIdentifier;
|
||||||
|
if (this.invitationEventCleanup.has(invitationIdentifier)) return;
|
||||||
|
|
||||||
|
const onUpdated = () => {
|
||||||
|
this.bumpInvitationRevision(invitationIdentifier);
|
||||||
|
this.emit("wallet-state-changed", {
|
||||||
|
reason: "invitation-updated",
|
||||||
|
invitationIdentifier,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onStatusChanged = () => {
|
||||||
|
this.bumpInvitationRevision(invitationIdentifier);
|
||||||
|
this.emit("wallet-state-changed", {
|
||||||
|
reason: "invitation-status-changed",
|
||||||
|
invitationIdentifier,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onRemoved = () => {
|
||||||
|
this.detachInvitationListeners(invitationIdentifier);
|
||||||
|
this.invitations.splice(this.invitations.indexOf(invitation), 1);
|
||||||
|
this.bumpInvitationRevision(invitationIdentifier);
|
||||||
|
this.emit("invitation-removed", invitation);
|
||||||
|
this.emit("wallet-state-changed", {
|
||||||
|
reason: "invitation-removed",
|
||||||
|
invitationIdentifier: invitationIdentifier,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
invitation.on("invitation-updated", onUpdated);
|
||||||
|
invitation.on("invitation-status-changed", onStatusChanged);
|
||||||
|
invitation.on("invitation-removed", onRemoved);
|
||||||
|
|
||||||
|
this.invitationEventCleanup.set(invitationIdentifier, {
|
||||||
|
onUpdated,
|
||||||
|
onStatusChanged,
|
||||||
|
onRemoved,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getInvitationRevision(invitationIdentifier: string): number {
|
||||||
|
return this.invitationRevisions.get(invitationIdentifier) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bumpInvitationRevision(invitationIdentifier: string): void {
|
||||||
|
this.invitationsRevision += 1;
|
||||||
|
this.invitationRevisions.set(
|
||||||
|
invitationIdentifier,
|
||||||
|
this.getInvitationRevision(invitationIdentifier) + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private detachInvitationListeners(invitationIdentifier: string): void {
|
||||||
|
const trackedInvitation = this.invitations.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.data.invitationIdentifier === invitationIdentifier,
|
||||||
|
);
|
||||||
|
const cleanup = this.invitationEventCleanup.get(invitationIdentifier);
|
||||||
|
if (!trackedInvitation || !cleanup) return;
|
||||||
|
|
||||||
|
trackedInvitation.off("invitation-updated", cleanup.onUpdated);
|
||||||
|
trackedInvitation.off("invitation-status-changed", cleanup.onStatusChanged);
|
||||||
|
this.invitationEventCleanup.delete(invitationIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unreserves all reserved UTXOs across every invitation.
|
||||||
|
* Useful when stale reservations from previous sessions block spending.
|
||||||
|
*
|
||||||
|
* @returns The number of UTXOs that were unreserved.
|
||||||
|
*/
|
||||||
|
async unreserveAllResources(): Promise<number> {
|
||||||
|
const allUnspentOutputs = await this.engine.listUnspentOutputsData();
|
||||||
|
const reserved = allUnspentOutputs.filter((o) => o.reservedBy);
|
||||||
|
|
||||||
|
// Group by invitation identifier so the engine can clear them properly.
|
||||||
|
const byInvitation = new Map<string, typeof reserved>();
|
||||||
|
for (const output of reserved) {
|
||||||
|
const existing = byInvitation.get(output.reservedBy!) ?? [];
|
||||||
|
existing.push(output);
|
||||||
|
byInvitation.set(output.reservedBy!, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [invitationIdentifier, outputs] of byInvitation) {
|
||||||
|
await this.engine.unreserveResources(
|
||||||
|
outputs.map((o) => ({
|
||||||
|
outpointTransactionHash: hexToBin(o.outpointTransactionHash),
|
||||||
|
outpointIndex: o.outpointIndex,
|
||||||
|
})),
|
||||||
|
invitationIdentifier,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reserved.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
|
// Start rates in the background so BCH -> fiat conversions become reactive in the TUI.
|
||||||
|
this.rates
|
||||||
|
.start()
|
||||||
|
.catch((err) => console.error("Error starting rates service:", err));
|
||||||
|
|
||||||
// Get the invitations db
|
// Get the invitations db
|
||||||
const invitationsDb = this.storage.child('invitations');
|
const invitationsDb = this.storage.child("invitations");
|
||||||
|
|
||||||
// Load invitations from storage
|
// Load invitations from storage
|
||||||
const invitations = await invitationsDb.all() as { key: string; value: XOInvitation }[];
|
const invitations = (await invitationsDb.all()) as {
|
||||||
|
key: string;
|
||||||
|
value: XOInvitation;
|
||||||
|
}[];
|
||||||
|
|
||||||
await Promise.all(invitations.map(async ({ key }) => {
|
await Promise.all(
|
||||||
await this.createInvitation(key);
|
invitations.map(async ({ key }) => {
|
||||||
}));
|
await this.createInvitation(key).catch((err) =>
|
||||||
|
console.error(`Error creating invitation ${key}: ${err}`),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
57
src/services/electrum.ts
Normal file
57
src/services/electrum.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
fetchTransactionBlockHeight,
|
||||||
|
initializeElectrumClient,
|
||||||
|
} from "@electrum-cash/protocol";
|
||||||
|
|
||||||
|
export interface ElectrumServiceConfig {
|
||||||
|
host?: string;
|
||||||
|
applicationIdentifier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class BlockchainService {
|
||||||
|
abstract hasSeenTransaction(transactionHash: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small Electrum adapter used by CLI services.
|
||||||
|
* Keeps connection logic in one place and exposes a tiny API.
|
||||||
|
*/
|
||||||
|
export class ElectrumService {
|
||||||
|
private readonly host: string;
|
||||||
|
private readonly applicationIdentifier: string;
|
||||||
|
private clientPromise?: ReturnType<typeof initializeElectrumClient>;
|
||||||
|
|
||||||
|
constructor(config: ElectrumServiceConfig = {}) {
|
||||||
|
this.host =
|
||||||
|
config.host ?? process.env["ELECTRUM_HOST"] ?? "bch.imaginary.cash";
|
||||||
|
this.applicationIdentifier = "xo-cli";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getClient() {
|
||||||
|
if (!this.clientPromise) {
|
||||||
|
this.clientPromise = initializeElectrumClient(
|
||||||
|
this.applicationIdentifier,
|
||||||
|
this.host,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.clientPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the transaction is known by Electrum
|
||||||
|
* (confirmed or currently in mempool).
|
||||||
|
*/
|
||||||
|
async hasSeenTransaction(transactionHash: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const client = await this.getClient();
|
||||||
|
const height = await fetchTransactionBlockHeight(client, transactionHash);
|
||||||
|
|
||||||
|
// Electrum returns numbers for known transactions
|
||||||
|
// (e.g. >0 confirmed, 0/-1 unconfirmed variants).
|
||||||
|
return typeof height === "number";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,252 +1,727 @@
|
|||||||
/**
|
import { binToHex, hexToBin, sha256 } from "@bitauth/libauth";
|
||||||
* History Service - Derives wallet history from invitations and UTXOs.
|
import { compileCashAssemblyString, type Engine } from "@xo-cash/engine";
|
||||||
*
|
import type { ScriptHashData, State, UnspentOutputData } from "@xo-cash/state";
|
||||||
* Provides a unified view of wallet activity including:
|
import type {
|
||||||
* - UTXO reservations (from invitation commits that reference our UTXOs as inputs)
|
XOInvitation,
|
||||||
* - UTXOs we own (with descriptions derived from template outputs)
|
XOInvitationInput,
|
||||||
*/
|
XOInvitationOutput,
|
||||||
|
XOInvitationVariableValue,
|
||||||
|
XOTemplate,
|
||||||
|
} from "@xo-cash/types";
|
||||||
|
import type { Invitation } from "./invitation.js";
|
||||||
|
|
||||||
import type { Engine } from '@xo-cash/engine';
|
export type WalletHistorySource = "invitation" | "utxo";
|
||||||
import type { XOInvitation, XOTemplate } from '@xo-cash/types';
|
|
||||||
import type { UnspentOutputData } from '@xo-cash/state';
|
|
||||||
import type { Invitation } from './invitation.js';
|
|
||||||
import { binToHex } from '@bitauth/libauth';
|
|
||||||
|
|
||||||
/**
|
export type WalletHistoryInput = {
|
||||||
* Types of history events.
|
|
||||||
*/
|
|
||||||
export type HistoryItemType =
|
|
||||||
| 'utxo_received'
|
|
||||||
| 'utxo_reserved'
|
|
||||||
| 'invitation_created';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single item in the wallet history.
|
|
||||||
*/
|
|
||||||
export interface HistoryItem {
|
|
||||||
/** Unique identifier for this history item. */
|
|
||||||
id: string;
|
id: string;
|
||||||
|
commitIdentifier?: string;
|
||||||
/** Unix timestamp of when the event occurred (if available). */
|
inputIdentifier?: string;
|
||||||
timestamp?: number;
|
role?: string;
|
||||||
|
|
||||||
/** The type of history event. */
|
|
||||||
type: HistoryItemType;
|
|
||||||
|
|
||||||
/** Human-readable description derived from the template. */
|
|
||||||
description: string;
|
description: string;
|
||||||
|
|
||||||
/** The value in satoshis (for UTXO-related events). */
|
|
||||||
valueSatoshis?: bigint;
|
valueSatoshis?: bigint;
|
||||||
|
outpoint: {
|
||||||
|
txid: string;
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
scriptHash?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** The invitation identifier this event relates to (if applicable). */
|
export type WalletHistoryOutput = {
|
||||||
invitationIdentifier?: string;
|
id: string;
|
||||||
|
commitIdentifier?: string;
|
||||||
/** The template identifier for reference. */
|
outputIdentifier?: string;
|
||||||
templateIdentifier?: string;
|
role?: string;
|
||||||
|
description: string;
|
||||||
/** The UTXO outpoint (for UTXO-related events). */
|
valueSatoshis?: bigint;
|
||||||
outpoint?: {
|
outpoint?: {
|
||||||
txid: string;
|
txid: string;
|
||||||
index: number;
|
index: number;
|
||||||
};
|
};
|
||||||
|
lockingBytecode?: string;
|
||||||
/** Whether this UTXO is reserved. */
|
scriptHash?: string;
|
||||||
reserved?: boolean;
|
reserved?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WalletHistoryItem = {
|
||||||
|
id: string;
|
||||||
|
source: WalletHistorySource;
|
||||||
|
invitationIdentifier?: string;
|
||||||
|
createdAtTimestamp?: number;
|
||||||
|
templateIdentifier: string;
|
||||||
|
template: string;
|
||||||
|
action?: string;
|
||||||
|
roles: string[];
|
||||||
|
description: string;
|
||||||
|
valueSatoshis: bigint;
|
||||||
|
inputs: WalletHistoryInput[];
|
||||||
|
outputs: WalletHistoryOutput[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HistoryItem = WalletHistoryItem;
|
||||||
|
|
||||||
|
interface InvitationContext {
|
||||||
|
invitation: Invitation;
|
||||||
|
template: XOTemplate | null;
|
||||||
|
variables: Record<string, XOInvitationVariableValue>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
interface UtxoContext {
|
||||||
* Service for deriving wallet history from invitations and UTXOs.
|
utxo: UnspentOutputData;
|
||||||
|
scriptHashData?: ScriptHashData;
|
||||||
|
template: XOTemplate | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WalletMetadataIndex {
|
||||||
|
scriptHashDataByScriptHash: Map<string, ScriptHashData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This needs a thorough and significant rewrite and design.
|
||||||
|
* I've tried to fundamental approaches so far:
|
||||||
|
* - UTXO first
|
||||||
|
* - Invitation first
|
||||||
*
|
*
|
||||||
* This service takes the engine and invitations array as dependencies
|
* The issue is that neither of these end up being simple or effective
|
||||||
* and derives history events from them. Since invitations is passed
|
* UTXO first makes tracking utxos across invitations extremely difficult. So if you receive a UTXO from an invitation and then spend it on another, you wont even see that old invitation.
|
||||||
* by reference, getHistory() always sees the current data.
|
* Invitation first makes fitting UTXOs that dont have an invitation (say if someone sent directly to your address) extremely difficult. You end up having to run a UTXO first pass anyway, and then end up with conflicts around resolved roles.
|
||||||
|
* Inferring roles is also extremely difficult. We cant just say "does this have an output for our P2PKH receiving roll? it does? Ok, we are a receiver" because this would match `true` because of our change outputs.
|
||||||
|
* If anyone has any idea of how to address this without tying knots of spaghetti, please let me know.
|
||||||
|
* This has been rewritten multiple times to try and simplify it, but its still extremely hard to follow and understand, while not even providing information that we want.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class HistoryService {
|
export class HistoryService {
|
||||||
/**
|
|
||||||
* Creates a new HistoryService.
|
|
||||||
*
|
|
||||||
* @param engine - The XO engine instance for querying UTXOs and templates.
|
|
||||||
* @param invitations - The array of invitations to derive history from.
|
|
||||||
*/
|
|
||||||
constructor(
|
constructor(
|
||||||
private engine: Engine,
|
private engine: Engine,
|
||||||
private invitations: Invitation[]
|
private invitations: Invitation[],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the wallet history derived from invitations and UTXOs.
|
* I Might swap this over to invitation based history before the event to make it a bit more evident... Really not happy with the UTXO for demo purposes
|
||||||
*
|
* But for the actual usage, UTXO is easier to follow - just not good for demo
|
||||||
* @returns Array of history items sorted by timestamp (newest first), then UTXOs without timestamps.
|
* Long term, this is intended to be in the Engine, so we will just be a consumer of history state.
|
||||||
*/
|
*/
|
||||||
async getHistory(): Promise<HistoryItem[]> {
|
async getHistory(): Promise<WalletHistoryItem[]> {
|
||||||
const items: HistoryItem[] = [];
|
|
||||||
|
|
||||||
// 1. Get all our UTXOs
|
|
||||||
const allUtxos = await this.engine.listUnspentOutputsData();
|
const allUtxos = await this.engine.listUnspentOutputsData();
|
||||||
|
const metadataIndex = await this.buildWalletMetadataIndex(allUtxos);
|
||||||
|
const invitationContexts = await this.buildInvitationContextIndex();
|
||||||
|
const utxoContexts = await Promise.all(
|
||||||
|
allUtxos.map((utxo) => this.buildUtxoContext(utxo, metadataIndex)),
|
||||||
|
);
|
||||||
|
|
||||||
// Create a map for quick UTXO lookup by outpoint
|
const reservedUtxosByInvitation = new Map<string, UtxoContext[]>();
|
||||||
const utxoMap = new Map<string, UnspentOutputData>();
|
const standaloneUtxos: UtxoContext[] = [];
|
||||||
for (const utxo of allUtxos) {
|
|
||||||
const key = `${utxo.outpointTransactionHash}:${utxo.outpointIndex}`;
|
for (const context of utxoContexts) {
|
||||||
utxoMap.set(key, utxo);
|
const invitationIdentifier = context.utxo.reservedBy;
|
||||||
|
if (
|
||||||
|
invitationIdentifier &&
|
||||||
|
invitationContexts.has(invitationIdentifier)
|
||||||
|
) {
|
||||||
|
const group = reservedUtxosByInvitation.get(invitationIdentifier) ?? [];
|
||||||
|
group.push(context);
|
||||||
|
reservedUtxosByInvitation.set(invitationIdentifier, group);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Process invitations to find UTXO reservations from commits
|
standaloneUtxos.push(context);
|
||||||
for (const invitation of this.invitations) {
|
}
|
||||||
const invData = invitation.data;
|
|
||||||
|
|
||||||
// Add invitation created event
|
const invitationItems = [...reservedUtxosByInvitation.entries()].map(
|
||||||
const template = await this.engine.getTemplate(invData.templateIdentifier);
|
([invitationIdentifier, reservedContexts]) =>
|
||||||
const invDescription = template
|
this.projectInvitationHistory(
|
||||||
? this.deriveInvitationDescription(invData, template)
|
invitationContexts.get(invitationIdentifier)!,
|
||||||
: 'Unknown action';
|
reservedContexts,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const standaloneItems = standaloneUtxos.map((context) =>
|
||||||
|
this.projectStandaloneUtxo(context),
|
||||||
|
);
|
||||||
|
|
||||||
items.push({
|
return [...standaloneItems, ...invitationItems].sort((a, b) => {
|
||||||
id: `inv-${invData.invitationIdentifier}`,
|
if (a.source !== b.source) return a.source === "utxo" ? -1 : 1;
|
||||||
timestamp: invData.createdAtTimestamp,
|
return (b.createdAtTimestamp ?? 0) - (a.createdAtTimestamp ?? 0);
|
||||||
type: 'invitation_created',
|
|
||||||
description: invDescription,
|
|
||||||
invitationIdentifier: invData.invitationIdentifier,
|
|
||||||
templateIdentifier: invData.templateIdentifier,
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Check each commit for inputs that reference our UTXOs
|
private async buildInvitationContextIndex(): Promise<
|
||||||
for (const commit of invData.commits) {
|
Map<string, InvitationContext>
|
||||||
const commitInputs = commit.data.inputs ?? [];
|
> {
|
||||||
|
const contexts = new Map<string, InvitationContext>();
|
||||||
|
|
||||||
for (const input of commitInputs) {
|
for (const invitation of this.invitations) {
|
||||||
// Input's outpointTransactionHash could be Uint8Array or string
|
const templateIdentifier = invitation.data.templateIdentifier;
|
||||||
const txHash = input.outpointTransactionHash
|
const template = templateIdentifier
|
||||||
? (input.outpointTransactionHash instanceof Uint8Array
|
? ((await this.engine.getTemplate(templateIdentifier)) ?? null)
|
||||||
? binToHex(input.outpointTransactionHash)
|
: null;
|
||||||
: String(input.outpointTransactionHash))
|
contexts.set(invitation.data.invitationIdentifier, {
|
||||||
: undefined;
|
invitation,
|
||||||
|
template,
|
||||||
|
variables: this.extractInvitationVariables(invitation.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!txHash || input.outpointIndex === undefined) continue;
|
return contexts;
|
||||||
|
}
|
||||||
|
|
||||||
const utxoKey = `${txHash}:${input.outpointIndex}`;
|
private async buildWalletMetadataIndex(
|
||||||
const matchingUtxo = utxoMap.get(utxoKey);
|
allUtxos: UnspentOutputData[],
|
||||||
|
): Promise<WalletMetadataIndex> {
|
||||||
|
const scriptHashDataByScriptHash = new Map<string, ScriptHashData>();
|
||||||
|
const templateIdentifiers = new Set<string>();
|
||||||
|
|
||||||
// If this input references one of our UTXOs, it's a reservation event
|
for (const invitation of this.invitations) {
|
||||||
if (matchingUtxo) {
|
if (invitation.data.templateIdentifier) {
|
||||||
const utxoTemplate = await this.engine.getTemplate(matchingUtxo.templateIdentifier);
|
templateIdentifiers.add(invitation.data.templateIdentifier);
|
||||||
const utxoDescription = utxoTemplate
|
}
|
||||||
? this.deriveUtxoDescription(matchingUtxo, utxoTemplate)
|
}
|
||||||
: 'Unknown UTXO';
|
|
||||||
|
|
||||||
items.push({
|
const uniqueScriptHashes = new Set(allUtxos.map((utxo) => utxo.scriptHash));
|
||||||
id: `reserved-${commit.commitIdentifier}-${utxoKey}`,
|
for (const scriptHash of uniqueScriptHashes) {
|
||||||
timestamp: invData.createdAtTimestamp, // Use invitation timestamp as proxy
|
const scriptHashData = await this.getScriptHashData(scriptHash);
|
||||||
type: 'utxo_reserved',
|
if (scriptHashData === undefined) continue;
|
||||||
description: `Reserved for: ${invDescription}`,
|
|
||||||
valueSatoshis: BigInt(matchingUtxo.valueSatoshis),
|
scriptHashDataByScriptHash.set(scriptHash, scriptHashData);
|
||||||
invitationIdentifier: invData.invitationIdentifier,
|
templateIdentifiers.add(scriptHashData.templateIdentifier);
|
||||||
templateIdentifier: matchingUtxo.templateIdentifier,
|
}
|
||||||
|
|
||||||
|
for (const templateIdentifier of templateIdentifiers) {
|
||||||
|
const scriptHashDataList =
|
||||||
|
await this.engine.listScriptHashesForTemplate(templateIdentifier);
|
||||||
|
for (const scriptHashData of scriptHashDataList) {
|
||||||
|
scriptHashDataByScriptHash.set(
|
||||||
|
scriptHashData.scriptHash,
|
||||||
|
scriptHashData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scriptHashDataByScriptHash };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildUtxoContext(
|
||||||
|
utxo: UnspentOutputData,
|
||||||
|
metadataIndex: WalletMetadataIndex,
|
||||||
|
): Promise<UtxoContext> {
|
||||||
|
const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get(
|
||||||
|
utxo.scriptHash,
|
||||||
|
);
|
||||||
|
const templateIdentifier = scriptHashData?.templateIdentifier;
|
||||||
|
const template = templateIdentifier
|
||||||
|
? ((await this.engine.getTemplate(templateIdentifier)) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
utxo,
|
||||||
|
scriptHashData,
|
||||||
|
template,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private projectInvitationHistory(
|
||||||
|
context: InvitationContext,
|
||||||
|
reservedContexts: UtxoContext[],
|
||||||
|
): WalletHistoryItem {
|
||||||
|
const invitation = context.invitation.data;
|
||||||
|
const entityRoles = this.deriveInvitationEntityRoles(context);
|
||||||
|
const inputs = this.projectInvitationInputs(
|
||||||
|
context,
|
||||||
|
reservedContexts,
|
||||||
|
entityRoles,
|
||||||
|
);
|
||||||
|
const inputUtxoIds = this.listInvitationInputUtxoIds(
|
||||||
|
context,
|
||||||
|
reservedContexts,
|
||||||
|
);
|
||||||
|
const outputs = this.projectInvitationOutputs(
|
||||||
|
context,
|
||||||
|
reservedContexts,
|
||||||
|
entityRoles,
|
||||||
|
inputUtxoIds,
|
||||||
|
);
|
||||||
|
const roles = this.deriveRoles(inputs, outputs);
|
||||||
|
const valueSatoshis = this.calculateValueSatoshis(inputs, outputs);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `inv-${invitation.invitationIdentifier}`,
|
||||||
|
source: "invitation",
|
||||||
|
invitationIdentifier: invitation.invitationIdentifier,
|
||||||
|
createdAtTimestamp: invitation.createdAtTimestamp,
|
||||||
|
templateIdentifier: invitation.templateIdentifier,
|
||||||
|
template: context.template?.name ?? "UnknownTemplate",
|
||||||
|
action: invitation.actionIdentifier,
|
||||||
|
roles,
|
||||||
|
description: this.describeInvitation(context, roles[0]),
|
||||||
|
valueSatoshis,
|
||||||
|
inputs,
|
||||||
|
outputs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private projectInvitationInputs(
|
||||||
|
context: InvitationContext,
|
||||||
|
reservedContexts: UtxoContext[],
|
||||||
|
entityRoles: Map<string, string[]>,
|
||||||
|
): WalletHistoryInput[] {
|
||||||
|
const invitation = context.invitation.data;
|
||||||
|
const inputs: WalletHistoryInput[] = [];
|
||||||
|
const reservedByOutpoint = new Map(
|
||||||
|
reservedContexts.map((context) => [
|
||||||
|
this.getOutpointKey(
|
||||||
|
context.utxo.outpointTransactionHash,
|
||||||
|
context.utxo.outpointIndex,
|
||||||
|
),
|
||||||
|
context,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const commit of invitation.commits) {
|
||||||
|
for (const [index, input] of (commit.data.inputs ?? []).entries()) {
|
||||||
|
const txid = this.getInputTxid(input);
|
||||||
|
const outpointIndex = input.outpointIndex;
|
||||||
|
if (txid === undefined || outpointIndex === undefined) continue;
|
||||||
|
|
||||||
|
const utxoContext = reservedByOutpoint.get(
|
||||||
|
this.getOutpointKey(txid, outpointIndex),
|
||||||
|
);
|
||||||
|
// TODO: Remove this reservation-based filter once Engine/library cleanup releases stale invitation reservations internally.
|
||||||
|
if (!utxoContext) continue;
|
||||||
|
|
||||||
|
const inputIdentifier = input.inputIdentifier;
|
||||||
|
const role =
|
||||||
|
input.roleIdentifier ??
|
||||||
|
this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ??
|
||||||
|
utxoContext.scriptHashData?.roleIdentifier;
|
||||||
|
|
||||||
|
inputs.push({
|
||||||
|
id: `input-${invitation.invitationIdentifier}-${commit.commitIdentifier}-${index}`,
|
||||||
|
commitIdentifier: commit.commitIdentifier,
|
||||||
|
inputIdentifier,
|
||||||
|
role,
|
||||||
|
description: this.describeInput(inputIdentifier, context),
|
||||||
|
valueSatoshis: -BigInt(utxoContext.utxo.valueSatoshis),
|
||||||
|
outpoint: { txid, index: outpointIndex },
|
||||||
|
scriptHash: utxoContext.utxo.scriptHash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private projectInvitationOutputs(
|
||||||
|
context: InvitationContext,
|
||||||
|
reservedContexts: UtxoContext[],
|
||||||
|
entityRoles: Map<string, string[]>,
|
||||||
|
inputUtxoIds: Set<string>,
|
||||||
|
): WalletHistoryOutput[] {
|
||||||
|
const invitation = context.invitation.data;
|
||||||
|
const outputs: WalletHistoryOutput[] = [];
|
||||||
|
const usedUtxoIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const commit of invitation.commits) {
|
||||||
|
for (const [index, output] of (commit.data.outputs ?? []).entries()) {
|
||||||
|
const matchingContext = this.findReservedOutputContext(
|
||||||
|
output,
|
||||||
|
reservedContexts,
|
||||||
|
usedUtxoIds,
|
||||||
|
);
|
||||||
|
// UTXO-first: committed outputs only matter here if they resolve to a wallet UTXO currently reserved by this invitation.
|
||||||
|
if (!matchingContext) continue;
|
||||||
|
|
||||||
|
const lockingBytecode =
|
||||||
|
this.getOutputLockingBytecodeHex(output) ??
|
||||||
|
matchingContext.scriptHashData?.lockingBytecode;
|
||||||
|
const outputIdentifier =
|
||||||
|
output.outputIdentifier ??
|
||||||
|
matchingContext.scriptHashData?.outputIdentifier;
|
||||||
|
const role =
|
||||||
|
output.roleIdentifier ??
|
||||||
|
this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ??
|
||||||
|
matchingContext.scriptHashData?.roleIdentifier;
|
||||||
|
const valueSatoshis =
|
||||||
|
output.valueSatoshis !== undefined
|
||||||
|
? BigInt(output.valueSatoshis)
|
||||||
|
: BigInt(matchingContext.utxo.valueSatoshis);
|
||||||
|
|
||||||
|
usedUtxoIds.add(this.getUtxoId(matchingContext.utxo));
|
||||||
|
|
||||||
|
outputs.push({
|
||||||
|
id: `output-${invitation.invitationIdentifier}-${commit.commitIdentifier}-${index}`,
|
||||||
|
commitIdentifier: commit.commitIdentifier,
|
||||||
|
outputIdentifier,
|
||||||
|
role,
|
||||||
|
description: this.describeOutput(outputIdentifier, context),
|
||||||
|
valueSatoshis,
|
||||||
outpoint: {
|
outpoint: {
|
||||||
txid: txHash,
|
txid: matchingContext.utxo.outpointTransactionHash,
|
||||||
index: input.outpointIndex,
|
index: matchingContext.utxo.outpointIndex,
|
||||||
},
|
},
|
||||||
|
lockingBytecode,
|
||||||
|
scriptHash: matchingContext.utxo.scriptHash,
|
||||||
reserved: true,
|
reserved: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const reservedContext of reservedContexts) {
|
||||||
|
if (usedUtxoIds.has(this.getUtxoId(reservedContext.utxo))) continue;
|
||||||
|
if (inputUtxoIds.has(this.getUtxoId(reservedContext.utxo))) continue;
|
||||||
|
outputs.push(this.projectUtxoOutput(reservedContext));
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private listInvitationInputUtxoIds(
|
||||||
|
context: InvitationContext,
|
||||||
|
reservedContexts: UtxoContext[],
|
||||||
|
): Set<string> {
|
||||||
|
const invitationInputUtxoIds = new Set<string>();
|
||||||
|
const reservedByOutpoint = new Map(
|
||||||
|
reservedContexts.map((context) => [
|
||||||
|
this.getOutpointKey(
|
||||||
|
context.utxo.outpointTransactionHash,
|
||||||
|
context.utxo.outpointIndex,
|
||||||
|
),
|
||||||
|
context,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const commit of context.invitation.data.commits) {
|
||||||
|
for (const input of commit.data.inputs ?? []) {
|
||||||
|
const txid = this.getInputTxid(input);
|
||||||
|
const outpointIndex = input.outpointIndex;
|
||||||
|
if (txid === undefined || outpointIndex === undefined) continue;
|
||||||
|
|
||||||
|
const utxoContext = reservedByOutpoint.get(
|
||||||
|
this.getOutpointKey(txid, outpointIndex),
|
||||||
|
);
|
||||||
|
if (utxoContext)
|
||||||
|
invitationInputUtxoIds.add(this.getUtxoId(utxoContext.utxo));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Add all UTXOs as "received" events (without timestamps)
|
return invitationInputUtxoIds;
|
||||||
for (const utxo of allUtxos) {
|
}
|
||||||
const template = await this.engine.getTemplate(utxo.templateIdentifier);
|
|
||||||
const description = template
|
|
||||||
? this.deriveUtxoDescription(utxo, template)
|
|
||||||
: 'Unknown output';
|
|
||||||
|
|
||||||
items.push({
|
private findReservedOutputContext(
|
||||||
id: `utxo-${utxo.outpointTransactionHash}:${utxo.outpointIndex}`,
|
output: XOInvitationOutput,
|
||||||
// No timestamp available for UTXOs
|
reservedContexts: UtxoContext[],
|
||||||
type: 'utxo_received',
|
usedUtxoIds: Set<string>,
|
||||||
description,
|
): UtxoContext | undefined {
|
||||||
valueSatoshis: BigInt(utxo.valueSatoshis),
|
const lockingBytecode = this.getOutputLockingBytecodeHex(output);
|
||||||
templateIdentifier: utxo.templateIdentifier,
|
const scriptHash = lockingBytecode
|
||||||
|
? this.lockingBytecodeToScriptHash(lockingBytecode)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return reservedContexts.find((context) => {
|
||||||
|
if (usedUtxoIds.has(this.getUtxoId(context.utxo))) return false;
|
||||||
|
if (scriptHash && context.utxo.scriptHash === scriptHash) return true;
|
||||||
|
if (
|
||||||
|
lockingBytecode &&
|
||||||
|
context.scriptHashData?.lockingBytecode === lockingBytecode
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (
|
||||||
|
output.outputIdentifier &&
|
||||||
|
context.scriptHashData?.outputIdentifier === output.outputIdentifier
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private projectStandaloneUtxo(context: UtxoContext): WalletHistoryItem {
|
||||||
|
const output = this.projectUtxoOutput(context);
|
||||||
|
const templateIdentifier = context.scriptHashData?.templateIdentifier;
|
||||||
|
const role = output.role;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `utxo-${context.utxo.outpointTransactionHash}:${context.utxo.outpointIndex}`,
|
||||||
|
source: "utxo",
|
||||||
|
templateIdentifier: templateIdentifier ?? "",
|
||||||
|
template: context.template?.name ?? "UnknownTemplate",
|
||||||
|
roles: role ? [role] : ["unknown"],
|
||||||
|
description: output.description,
|
||||||
|
valueSatoshis: output.valueSatoshis ?? 0n,
|
||||||
|
inputs: [],
|
||||||
|
outputs: [output],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private projectUtxoOutput(context: UtxoContext): WalletHistoryOutput {
|
||||||
|
const outputIdentifier = context.scriptHashData?.outputIdentifier;
|
||||||
|
const role = context.scriptHashData?.roleIdentifier;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: this.getUtxoId(context.utxo),
|
||||||
|
outputIdentifier,
|
||||||
|
role,
|
||||||
|
description: this.describeOutputFromTemplate(
|
||||||
|
outputIdentifier,
|
||||||
|
context.template,
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
valueSatoshis: BigInt(context.utxo.valueSatoshis),
|
||||||
outpoint: {
|
outpoint: {
|
||||||
txid: utxo.outpointTransactionHash,
|
txid: context.utxo.outpointTransactionHash,
|
||||||
index: utxo.outpointIndex,
|
index: context.utxo.outpointIndex,
|
||||||
},
|
},
|
||||||
reserved: utxo.reserved,
|
lockingBytecode: context.scriptHashData?.lockingBytecode,
|
||||||
invitationIdentifier: utxo.invitationIdentifier || undefined,
|
scriptHash: context.utxo.scriptHash,
|
||||||
});
|
reserved: context.utxo.reservedBy !== undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort: items with timestamps first (newest first), then items without timestamps
|
private deriveInvitationEntityRoles(
|
||||||
return items.sort((a, b) => {
|
context: InvitationContext,
|
||||||
// Both have timestamps: sort by timestamp descending
|
): Map<string, string[]> {
|
||||||
if (a.timestamp !== undefined && b.timestamp !== undefined) {
|
const invitation = context.invitation.data;
|
||||||
return b.timestamp - a.timestamp;
|
const rolesByEntity = new Map<string, Set<string>>();
|
||||||
}
|
const allEntities = new Set(
|
||||||
// Only a has timestamp: a comes first
|
invitation.commits.map((commit) => commit.entityIdentifier),
|
||||||
if (a.timestamp !== undefined) return -1;
|
);
|
||||||
// Only b has timestamp: b comes first
|
|
||||||
if (b.timestamp !== undefined) return 1;
|
for (const entityIdentifier of allEntities) {
|
||||||
// Neither has timestamp: maintain order
|
rolesByEntity.set(entityIdentifier, new Set());
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
for (const commit of invitation.commits) {
|
||||||
* Derives a human-readable description for a UTXO from its template output definition.
|
const roles =
|
||||||
*
|
rolesByEntity.get(commit.entityIdentifier) ?? new Set<string>();
|
||||||
* @param utxo - The UTXO data.
|
for (const input of commit.data.inputs ?? []) {
|
||||||
* @param template - The template definition.
|
if (input.roleIdentifier) roles.add(input.roleIdentifier);
|
||||||
* @returns Human-readable description string.
|
}
|
||||||
*/
|
for (const output of commit.data.outputs ?? []) {
|
||||||
private deriveUtxoDescription(utxo: UnspentOutputData, template: XOTemplate): string {
|
if (output.roleIdentifier) roles.add(output.roleIdentifier);
|
||||||
const outputDef = template.outputs?.[utxo.outputIdentifier];
|
}
|
||||||
|
for (const variable of commit.data.variables ?? []) {
|
||||||
if (!outputDef) {
|
if (variable.roleIdentifier) roles.add(variable.roleIdentifier);
|
||||||
return `${utxo.outputIdentifier} output`;
|
}
|
||||||
|
rolesByEntity.set(commit.entityIdentifier, roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start with the output name or identifier
|
const action = context.template?.actions?.[invitation.actionIdentifier];
|
||||||
let description = outputDef.name || utxo.outputIdentifier;
|
const participantRoles =
|
||||||
|
action?.requirements?.participants
|
||||||
|
?.map((participant) => participant.role)
|
||||||
|
.filter((role): role is string => typeof role === "string") ?? [];
|
||||||
|
const explicitlyFilledRoles = new Set<string>();
|
||||||
|
for (const roles of rolesByEntity.values()) {
|
||||||
|
for (const role of roles) explicitlyFilledRoles.add(role);
|
||||||
|
}
|
||||||
|
const unfilledParticipantRoles = participantRoles.filter(
|
||||||
|
(role) => !explicitlyFilledRoles.has(role),
|
||||||
|
);
|
||||||
|
const entitiesWithoutRoles = [...rolesByEntity.entries()]
|
||||||
|
.filter(([, roles]) => roles.size === 0)
|
||||||
|
.map(([entityIdentifier]) => entityIdentifier);
|
||||||
|
|
||||||
// If there's a description, parse it and replace variable placeholders
|
if (
|
||||||
if (outputDef.description) {
|
unfilledParticipantRoles.length === 1 &&
|
||||||
description = outputDef.description
|
entitiesWithoutRoles.length >= 1
|
||||||
// Replace <variableName> placeholders (we don't have variable values here, so just clean up)
|
) {
|
||||||
.replace(/<([^>]+)>/g, (_, varId) => varId)
|
const inferredRole = unfilledParticipantRoles[0];
|
||||||
// Remove $() wrappers
|
if (inferredRole !== undefined) {
|
||||||
.replace(/\$\(([^)]+)\)/g, '$1');
|
for (const entityIdentifier of entitiesWithoutRoles) {
|
||||||
|
rolesByEntity.get(entityIdentifier)?.add(inferredRole);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return description;
|
return new Map(
|
||||||
|
[...rolesByEntity.entries()].map(([entityIdentifier, roles]) => [
|
||||||
|
entityIdentifier,
|
||||||
|
[...roles],
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private getFirstEntityRole(
|
||||||
* Derives a human-readable description from an invitation and its template.
|
entityRoles: Map<string, string[]>,
|
||||||
* Parses the transaction description and replaces variable placeholders.
|
entityIdentifier: string,
|
||||||
*
|
): string | undefined {
|
||||||
* @param invitation - The invitation data.
|
return entityRoles.get(entityIdentifier)?.[0];
|
||||||
* @param template - The template definition.
|
}
|
||||||
* @returns Human-readable description string.
|
|
||||||
*/
|
private deriveRoles(
|
||||||
private deriveInvitationDescription(invitation: XOInvitation, template: XOTemplate): string {
|
inputs: WalletHistoryInput[],
|
||||||
|
outputs: WalletHistoryOutput[],
|
||||||
|
): string[] {
|
||||||
|
const roles = new Set<string>();
|
||||||
|
|
||||||
|
for (const input of inputs) {
|
||||||
|
if (input.role) roles.add(input.role);
|
||||||
|
}
|
||||||
|
for (const output of outputs) {
|
||||||
|
if (output.role) roles.add(output.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
return roles.size > 0 ? [...roles] : ["unknown"];
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateValueSatoshis(
|
||||||
|
inputs: WalletHistoryInput[],
|
||||||
|
outputs: WalletHistoryOutput[],
|
||||||
|
): bigint {
|
||||||
|
const inputTotal = inputs.reduce(
|
||||||
|
(total, input) => total + (input.valueSatoshis ?? 0n),
|
||||||
|
0n,
|
||||||
|
);
|
||||||
|
const outputTotal = outputs.reduce(
|
||||||
|
(total, output) => total + (output.valueSatoshis ?? 0n),
|
||||||
|
0n,
|
||||||
|
);
|
||||||
|
return inputTotal + outputTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
private describeInvitation(
|
||||||
|
context: InvitationContext,
|
||||||
|
role?: string,
|
||||||
|
): string {
|
||||||
|
const invitation = context.invitation.data;
|
||||||
|
const template = context.template;
|
||||||
|
if (!template) return invitation.actionIdentifier;
|
||||||
|
|
||||||
const action = template.actions?.[invitation.actionIdentifier];
|
const action = template.actions?.[invitation.actionIdentifier];
|
||||||
const transactionName = action?.transaction;
|
const transaction = action?.transaction
|
||||||
const transaction = transactionName ? template.transactions?.[transactionName] : null;
|
? template.transactions?.[action.transaction]
|
||||||
|
: undefined;
|
||||||
|
const roleData = role ? transaction?.roles?.[role] : undefined;
|
||||||
|
const descriptionTemplate =
|
||||||
|
roleData?.description ??
|
||||||
|
transaction?.description ??
|
||||||
|
roleData?.name ??
|
||||||
|
transaction?.name ??
|
||||||
|
action?.description ??
|
||||||
|
action?.name ??
|
||||||
|
invitation.actionIdentifier;
|
||||||
|
|
||||||
if (!transaction?.description) {
|
return this.compileDescription(descriptionTemplate, context.variables);
|
||||||
return action?.name ?? invitation.actionIdentifier;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const committedVariables = invitation.commits.flatMap(c => c.data.variables ?? []);
|
private describeInput(
|
||||||
|
inputIdentifier: string | undefined,
|
||||||
|
context: InvitationContext,
|
||||||
|
): string {
|
||||||
|
if (!inputIdentifier) return "Input";
|
||||||
|
const input = context.template?.inputs?.[inputIdentifier];
|
||||||
|
return this.compileDescription(
|
||||||
|
input?.description ?? input?.name ?? inputIdentifier,
|
||||||
|
context.variables,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return transaction.description
|
private describeOutput(
|
||||||
// Replace <variableName> with actual values
|
outputIdentifier: string | undefined,
|
||||||
.replace(/<([^>]+)>/g, (match, varId) => {
|
context: InvitationContext,
|
||||||
const variable = committedVariables.find(v => v.variableIdentifier === varId);
|
): string {
|
||||||
return variable ? String(variable.value) : match;
|
return this.describeOutputFromTemplate(
|
||||||
})
|
outputIdentifier,
|
||||||
// Remove the $() wrapper around variable expressions
|
context.template,
|
||||||
.replace(/\$\(([^)]+)\)/g, '$1');
|
context.variables,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private describeOutputFromTemplate(
|
||||||
|
outputIdentifier: string | undefined,
|
||||||
|
template: XOTemplate | null,
|
||||||
|
variables: Record<string, XOInvitationVariableValue>,
|
||||||
|
): string {
|
||||||
|
if (!outputIdentifier) return "Output";
|
||||||
|
const output = template?.outputs?.[outputIdentifier];
|
||||||
|
return this.compileDescription(
|
||||||
|
output?.description ?? output?.name ?? outputIdentifier,
|
||||||
|
variables,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private compileDescription(
|
||||||
|
description: string,
|
||||||
|
variables: Record<string, XOInvitationVariableValue>,
|
||||||
|
): string {
|
||||||
|
try {
|
||||||
|
return compileCashAssemblyString({
|
||||||
|
cashAssemblyText: description,
|
||||||
|
variables,
|
||||||
|
evaluationDecodeMode: "utf8",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return this.interpolateSimpleCashAssemblyVariables(
|
||||||
|
description,
|
||||||
|
variables,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractInvitationVariables(
|
||||||
|
invitation: XOInvitation,
|
||||||
|
): Record<string, XOInvitationVariableValue> {
|
||||||
|
const committedVariables = invitation.commits.flatMap(
|
||||||
|
(c) => c.data.variables ?? [],
|
||||||
|
);
|
||||||
|
return committedVariables.reduce(
|
||||||
|
(acc, variable) => {
|
||||||
|
if (!variable.variableIdentifier) return acc;
|
||||||
|
acc[variable.variableIdentifier] = variable.value;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, XOInvitationVariableValue>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInputTxid(input: XOInvitationInput): string | undefined {
|
||||||
|
if (!input.outpointTransactionHash) return undefined;
|
||||||
|
return input.outpointTransactionHash instanceof Uint8Array
|
||||||
|
? binToHex(input.outpointTransactionHash)
|
||||||
|
: String(input.outpointTransactionHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOutputLockingBytecodeHex(
|
||||||
|
output: XOInvitationOutput,
|
||||||
|
): string | undefined {
|
||||||
|
if (output.lockingBytecode === undefined) return undefined;
|
||||||
|
return typeof output.lockingBytecode === "string"
|
||||||
|
? output.lockingBytecode
|
||||||
|
: binToHex(output.lockingBytecode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getScriptHashData(
|
||||||
|
scriptHash: string,
|
||||||
|
): Promise<ScriptHashData | undefined> {
|
||||||
|
return (this.engine as unknown as { state: State }).state.getScriptHashData(
|
||||||
|
scriptHash,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOutpointKey(txid: string, index: number): string {
|
||||||
|
return `${txid}:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUtxoId(utxo: UnspentOutputData): string {
|
||||||
|
return `utxo-${utxo.outpointTransactionHash}:${utxo.outpointIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private lockingBytecodeToScriptHash(lockingBytecode: string): string {
|
||||||
|
const hash = sha256.hash(hexToBin(lockingBytecode));
|
||||||
|
return binToHex(hash.reverse());
|
||||||
|
}
|
||||||
|
|
||||||
|
private interpolateSimpleCashAssemblyVariables(
|
||||||
|
text: string,
|
||||||
|
variables: Record<string, XOInvitationVariableValue>,
|
||||||
|
): string {
|
||||||
|
return text.replace(
|
||||||
|
/\$\(<([^>]+)>\)/g,
|
||||||
|
(match, variableIdentifier: string) => {
|
||||||
|
if (
|
||||||
|
!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)
|
||||||
|
) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
return String(variables[variableIdentifier]);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,152 @@
|
|||||||
import type { AcceptInvitationParameters, AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
|
import type {
|
||||||
import { hasInvitationExpired } from '@xo-cash/engine';
|
InvitationParameters,
|
||||||
import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable } from '@xo-cash/types';
|
Engine,
|
||||||
import type { UnspentOutputData } from '@xo-cash/state';
|
GetSpendableResourcesParameters,
|
||||||
|
} from "@xo-cash/engine";
|
||||||
|
import {
|
||||||
|
generateTemplateIdentifier,
|
||||||
|
hasInvitationExpired,
|
||||||
|
mergeInvitationCommits,
|
||||||
|
serializeInvitation,
|
||||||
|
deserializeInvitation,
|
||||||
|
} from "@xo-cash/engine";
|
||||||
|
import type {
|
||||||
|
XOInvitation,
|
||||||
|
XOInvitationCommit,
|
||||||
|
XOInvitationInput,
|
||||||
|
XOInvitationOutput,
|
||||||
|
XOInvitationVariable,
|
||||||
|
XOInvitationVariableValue,
|
||||||
|
XOTemplate,
|
||||||
|
} from "@xo-cash/types";
|
||||||
|
import type { UnspentOutputData } from "@xo-cash/state";
|
||||||
|
import {
|
||||||
|
binToHex,
|
||||||
|
encodeTransaction,
|
||||||
|
generateTransaction,
|
||||||
|
hashTransaction,
|
||||||
|
hexToBin,
|
||||||
|
} from "@bitauth/libauth";
|
||||||
|
|
||||||
import type { SSEvent } from '../utils/sse-client.js';
|
import type { SSEvent } from "../utils/sse-client.js";
|
||||||
import type { SyncServer } from '../utils/sync-server.js';
|
import type { SyncServer } from "../utils/sync-server.js";
|
||||||
import type { Storage } from './storage.js';
|
import type { BaseStorage } from "./storage.js";
|
||||||
|
import type { BlockchainService } from "./electrum.js";
|
||||||
|
|
||||||
import { EventEmitter } from '../utils/event-emitter.js'
|
import { EventEmitter } from "../utils/event-emitter.js";
|
||||||
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
|
import { decodeExtendedJsonObject } from "../utils/ext-json.js";
|
||||||
|
import {
|
||||||
|
resolveCommitReferences,
|
||||||
|
type ResolvedInvitationData,
|
||||||
|
} from "../utils/resolve-invitation-data.js";
|
||||||
|
import { compileCashAssemblyString } from "@xo-cash/engine";
|
||||||
|
|
||||||
|
export type { ResolvedInvitationData } from "../utils/resolve-invitation-data.js";
|
||||||
|
|
||||||
export type InvitationEventMap = {
|
export type InvitationEventMap = {
|
||||||
'invitation-updated': XOInvitation;
|
"invitation-updated": XOInvitation;
|
||||||
'invitation-status-changed': string;
|
"invitation-status-changed": string;
|
||||||
}
|
"invitation-removed": void;
|
||||||
|
error: Error;
|
||||||
|
};
|
||||||
|
|
||||||
export type InvitationDependencies = {
|
export type InvitationDependencies = {
|
||||||
syncServer: SyncServer;
|
syncServer: SyncServer;
|
||||||
storage: Storage;
|
storage: BaseStorage;
|
||||||
engine: Engine;
|
engine: Engine;
|
||||||
|
electrum: BlockchainService;
|
||||||
|
};
|
||||||
|
|
||||||
|
function stripLocalInvitationMetadata(invitation: XOInvitation): XOInvitation {
|
||||||
|
const { entityIdentifier: _entityIdentifier, ...sharedInvitation } =
|
||||||
|
invitation as XOInvitation & { entityIdentifier?: string };
|
||||||
|
|
||||||
|
return sharedInvitation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Invitation extends EventEmitter<InvitationEventMap> {
|
export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||||
/**
|
/**
|
||||||
* Create an invitation and start the SSE Session required for it.
|
* Create an invitation and start the SSE Session required for it.
|
||||||
*/
|
*/
|
||||||
static async create(invitation: XOInvitation | string, dependencies: InvitationDependencies): Promise<Invitation> {
|
static async create(
|
||||||
|
invitation: XOInvitation | string,
|
||||||
|
dependencies: InvitationDependencies,
|
||||||
|
): Promise<Invitation> {
|
||||||
// If the invitation is a string, its probably an invitation identifier.
|
// If the invitation is a string, its probably an invitation identifier.
|
||||||
// We will try to find the data then just call the create method again, but this time with the data.
|
// We will try to find the data then just call the create method again, but this time with the data.
|
||||||
if(typeof invitation === 'string') {
|
if (typeof invitation === "string") {
|
||||||
// Try to get the invitation from the storage
|
// Try to get the invitation from the storage
|
||||||
const invitationFromStorage = await dependencies.storage.get(invitation);
|
const invitationFromStorage = await dependencies.storage.get(invitation);
|
||||||
if (invitationFromStorage) {
|
if (invitationFromStorage) {
|
||||||
console.log(`Invitation found in storage: ${invitation}`);
|
|
||||||
return this.create(invitationFromStorage, dependencies);
|
return this.create(invitationFromStorage, dependencies);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get the invitation from the sync server
|
// Try to get the invitation from the sync server
|
||||||
const invitationFromSyncServer = await dependencies.syncServer.getInvitation(invitation);
|
const invitationFromSyncServer =
|
||||||
if (invitationFromSyncServer && invitationFromSyncServer.invitationIdentifier === invitation) {
|
await dependencies.syncServer.getInvitation(invitation);
|
||||||
console.log(`Invitation found in sync server: ${invitation}`);
|
if (
|
||||||
|
invitationFromSyncServer &&
|
||||||
|
invitationFromSyncServer.invitationIdentifier === invitation
|
||||||
|
) {
|
||||||
return this.create(invitationFromSyncServer, dependencies);
|
return this.create(invitationFromSyncServer, dependencies);
|
||||||
}
|
}
|
||||||
|
|
||||||
// We cant find it. Throw an error.
|
// We cant find it. Throw an error.
|
||||||
throw new Error(`Invitation not found in local or remote storage: ${invitation}`);
|
throw new Error(
|
||||||
|
`Invitation not found in local or remote storage: ${invitation}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = await dependencies.engine.getTemplate(invitation.templateIdentifier);
|
const template = await dependencies.engine.getTemplate(
|
||||||
|
invitation.templateIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
|
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the invitation
|
// engine invitation (I have no idea if this is required)
|
||||||
const invitationInstance = new Invitation(invitation, dependencies);
|
const engineInvitation = await dependencies.engine.importInvitation(
|
||||||
|
serializeInvitation(invitation),
|
||||||
|
);
|
||||||
|
|
||||||
// Start the invitation and its tracking
|
// Create the invitation
|
||||||
await invitationInstance.start();
|
const invitationInstance = new Invitation(
|
||||||
|
engineInvitation,
|
||||||
|
dependencies,
|
||||||
|
template,
|
||||||
|
);
|
||||||
|
|
||||||
return invitationInstance;
|
return invitationInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattened, template-enriched view of {@link Invitation.data}.
|
||||||
|
* Updated automatically whenever invitation data changes.
|
||||||
|
*/
|
||||||
|
public resolvedData: ResolvedInvitationData = {
|
||||||
|
invitationIdentifier: "",
|
||||||
|
templateIdentifier: "",
|
||||||
|
actionIdentifier: "",
|
||||||
|
variables: [],
|
||||||
|
inputs: [],
|
||||||
|
outputs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The template used to enrich {@link resolvedData}.
|
||||||
|
*/
|
||||||
|
private template: XOTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The invitation data.
|
* The invitation data.
|
||||||
*/
|
*/
|
||||||
public data: XOInvitation = {
|
public data: XOInvitation = {
|
||||||
invitationIdentifier: '',
|
invitationIdentifier: "",
|
||||||
commits: [],
|
commits: [],
|
||||||
createdAtTimestamp: 0,
|
createdAtTimestamp: 0,
|
||||||
templateIdentifier: '',
|
templateIdentifier: "",
|
||||||
actionIdentifier: '',
|
actionIdentifier: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,98 +163,213 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
* The storage instance.
|
* The storage instance.
|
||||||
* TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid)
|
* TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid)
|
||||||
*/
|
*/
|
||||||
private storage: Storage;
|
private storage: BaseStorage;
|
||||||
|
private electrum: BlockchainService;
|
||||||
/**
|
private sseUpdateQueue: Promise<void> = Promise.resolve();
|
||||||
* True after we have successfully called sign() on this invitation (session-only, not persisted).
|
|
||||||
*/
|
|
||||||
private _weHaveSigned = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True after we have successfully called broadcast() on this invitation (session-only, not persisted).
|
|
||||||
*/
|
|
||||||
private _broadcasted = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
|
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
|
||||||
*/
|
*/
|
||||||
public status: string = 'unknown';
|
public status: string = "unknown";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an invitation and start the SSE Session required for it.
|
* Create an invitation and start the SSE Session required for it.
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
invitation: XOInvitation,
|
invitation: XOInvitation,
|
||||||
dependencies: InvitationDependencies
|
dependencies: InvitationDependencies,
|
||||||
|
template: XOTemplate,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.data = invitation;
|
this.template = template;
|
||||||
this.engine = dependencies.engine;
|
this.engine = dependencies.engine;
|
||||||
this.syncServer = dependencies.syncServer;
|
this.syncServer = dependencies.syncServer;
|
||||||
this.storage = dependencies.storage;
|
this.storage = dependencies.storage;
|
||||||
|
this.electrum = dependencies.electrum;
|
||||||
|
this.updateInvitationData(invitation);
|
||||||
|
|
||||||
// I cannot express this enough, but the event handler does not need a clean up.
|
// Apply SSE updates serially so each engine update sees the latest history.
|
||||||
// There is this beautiful thing called a "garbage collector". Once this class is removed from scope (removed from the invitations array) all the references
|
this.syncServer.on("message", (event) => {
|
||||||
// will be removed, including the SSE Session (and therefore this handler).
|
this.enqueueSyncUpdate(() => this.handleSSEMessage(event)).catch(
|
||||||
this.syncServer.on('message', this.handleSSEMessage.bind(this));
|
(error) => {
|
||||||
|
this.emit(
|
||||||
|
"error",
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates raw invitation data and recomputes {@link resolvedData}.
|
||||||
|
*/
|
||||||
|
private updateInvitationData(invitation: XOInvitation): void {
|
||||||
|
this.data = invitation;
|
||||||
|
this.resolvedData = resolveCommitReferences(invitation, this.template);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueueSyncUpdate(update: () => Promise<void>): Promise<void> {
|
||||||
|
const queuedUpdate = this.sseUpdateQueue.then(update);
|
||||||
|
this.sseUpdateQueue = queuedUpdate.catch(() => {});
|
||||||
|
return queuedUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the invitation - Connect sync server and download latest invitation data.
|
||||||
|
*/
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
|
// Persist immediately so imports survive sync-server outages and appear in the TUI
|
||||||
|
// after a CLI import or app restart.
|
||||||
|
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||||
|
|
||||||
|
try {
|
||||||
// Connect to the sync server and get the invitation (in parallel)
|
// Connect to the sync server and get the invitation (in parallel)
|
||||||
const [_, invitation] = await Promise.all([
|
const [_, invitation] = await Promise.all([
|
||||||
this.syncServer.connect(),
|
this.syncServer.connect(),
|
||||||
this.syncServer.getInvitation(this.data.invitationIdentifier),
|
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
|
await this.enqueueSyncUpdate(async () => {
|
||||||
const sseCommits = this.data.commits;
|
// SSE messages can arrive before the GET request completes.
|
||||||
|
const combinedCommits = this.mergeCommits(
|
||||||
|
this.data.commits,
|
||||||
|
invitation?.commits ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
// Merge the commits
|
try {
|
||||||
const combinedCommits = this.mergeCommits(sseCommits, invitation?.commits ?? []);
|
// Prefer keeping the engine's local invitation state in sync.
|
||||||
|
this.updateInvitationData(
|
||||||
|
stripLocalInvitationMetadata(
|
||||||
|
await this.engine.updateInvitation({
|
||||||
|
...this.data,
|
||||||
|
...invitation,
|
||||||
|
commits: combinedCommits,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.emit(
|
||||||
|
"error",
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
);
|
||||||
|
this.updateInvitationData({ ...this.data, 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);
|
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||||
|
});
|
||||||
|
|
||||||
// Publish the invitation to the sync server
|
// Publish the invitation to the sync server
|
||||||
this.syncServer.publishInvitation(this.data);
|
this.publishInvitation(this.data);
|
||||||
|
|
||||||
// Compute and emit initial status
|
// Compute and emit initial status
|
||||||
await this.updateStatus();
|
await this.updateStatus();
|
||||||
|
} catch (err) {
|
||||||
|
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an SSE message.
|
* Handle an SSE message.
|
||||||
*
|
*
|
||||||
* TODO: Invitation should sync up the initial data (top level) then everything after that should be the commits. This makes it easier to merge as we go instead of just having to overwrite the entire invitation.
|
* TODO: Invitation should sync up the initial data (top level) then everything after that should be the commits. This makes it easier to merge as we go instead of just having to overwrite the entire invitation.
|
||||||
* Why this level of thought is required is beyond me. We should be given a `mergeCommits` method or "something" that lets us take whole invitation and merge commits into it.
|
|
||||||
* NOTE: signInvitation does merge the commits... But we want to be able to add commits in *before* signing the invitation. So, we are just going to receive a single commit at a time, then just invitation.commits.push(commit); to get around this.
|
|
||||||
* I hope we dont end up with duplicate commits :/... We also dont have a way to list invitiations, which is an... interesting choice.
|
|
||||||
*/
|
*/
|
||||||
private handleSSEMessage(event: SSEvent): void {
|
private async handleSSEMessage(event: SSEvent): Promise<void> {
|
||||||
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
const invitation = this.parseInvitationFromSSEMessage(event);
|
||||||
if (data.topic === 'invitation-updated') {
|
if (
|
||||||
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
|
!invitation ||
|
||||||
|
invitation.invitationIdentifier !== this.data.invitationIdentifier
|
||||||
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out commits that already exist (probably a faster way to do this. This is n^2)
|
// Filter out commits that already exist
|
||||||
const newCommits = this.mergeCommits(this.data.commits, invitation.commits);
|
const newCommits = this.mergeCommits(this.data.commits, invitation.commits);
|
||||||
|
|
||||||
// Set the new commits
|
try {
|
||||||
this.data = { ...this.data, commits: newCommits };
|
this.updateInvitationData(
|
||||||
|
stripLocalInvitationMetadata(
|
||||||
// Calculate the new status of the invitation (fire-and-forget; handler is sync)
|
await this.engine.updateInvitation({
|
||||||
this.updateStatus().catch(() => {});
|
...this.data,
|
||||||
|
...invitation,
|
||||||
// Emit the updated event
|
commits: newCommits,
|
||||||
this.emit('invitation-updated', this.data);
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.emit(
|
||||||
|
"error",
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
);
|
||||||
|
this.updateInvitationData({ ...this.data, commits: newCommits });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||||
|
await this.updateStatus();
|
||||||
|
this.emit("invitation-updated", this.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseInvitationFromSSEMessage(event: SSEvent): XOInvitation | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.data) as unknown;
|
||||||
|
const payload =
|
||||||
|
event.event === "invitation-updated"
|
||||||
|
? this.unwrapInvitationUpdatedPayload(parsed)
|
||||||
|
: this.unwrapLegacyInvitationUpdatedPayload(parsed);
|
||||||
|
|
||||||
|
if (!payload) return null;
|
||||||
|
|
||||||
|
const decoded = decodeExtendedJsonObject(payload) as XOInvitation;
|
||||||
|
return stripLocalInvitationMetadata(
|
||||||
|
deserializeInvitation(serializeInvitation(decoded)),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private unwrapInvitationUpdatedPayload(payload: unknown): unknown | null {
|
||||||
|
if (
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
"topic" in payload &&
|
||||||
|
"data" in payload
|
||||||
|
) {
|
||||||
|
return this.unwrapLegacyInvitationUpdatedPayload(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unwrapLegacyInvitationUpdatedPayload(
|
||||||
|
payload: unknown,
|
||||||
|
): unknown | null {
|
||||||
|
if (
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
"topic" in payload &&
|
||||||
|
"data" in payload &&
|
||||||
|
payload.topic === "invitation-updated"
|
||||||
|
) {
|
||||||
|
return payload.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish the invitation to the sync server
|
||||||
|
*/
|
||||||
|
private async publishInvitation(
|
||||||
|
invitation: XOInvitation = this.data,
|
||||||
|
): Promise<void> {
|
||||||
|
this.syncServer.publishInvitation(invitation).catch((error) => {
|
||||||
|
this.emit(
|
||||||
|
"error",
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,7 +378,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
* @param additional - The additional commits
|
* @param additional - The additional commits
|
||||||
* @returns The merged commits
|
* @returns The merged commits
|
||||||
*/
|
*/
|
||||||
private mergeCommits(initial: XOInvitationCommit[], additional: XOInvitationCommit[]): XOInvitationCommit[] {
|
private mergeCommits(
|
||||||
|
initial: XOInvitationCommit[],
|
||||||
|
additional: XOInvitationCommit[],
|
||||||
|
): XOInvitationCommit[] {
|
||||||
// Create a map of the initial commits
|
// Create a map of the initial commits
|
||||||
const initialMap = new Map<string, XOInvitationCommit>();
|
const initialMap = new Map<string, XOInvitationCommit>();
|
||||||
for (const commit of initial) {
|
for (const commit of initial) {
|
||||||
@@ -218,99 +412,193 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
/**
|
/**
|
||||||
* Internal status computation: returns a single word.
|
* Internal status computation: returns a single word.
|
||||||
* NOTE: This could be a Enum-like object as well. May be a nice improvement. - DO NOT USE TS ENUM, THEY ARENT NATIVELY SUPPORTED IN NODE.JS
|
* NOTE: This could be a Enum-like object as well. May be a nice improvement. - DO NOT USE TS ENUM, THEY ARENT NATIVELY SUPPORTED IN NODE.JS
|
||||||
* - expired: any commit has expired
|
|
||||||
* - complete: we have broadcast this invitation
|
* - complete: we have broadcast this invitation
|
||||||
|
* - expired: any commit has expired
|
||||||
* - ready: no missing requirements and we have signed (ready to broadcast)
|
* - ready: no missing requirements and we have signed (ready to broadcast)
|
||||||
* - signed: we have signed but there are still missing parts (waiting for others)
|
* - signed: we have signed but there are still missing parts (waiting for others)
|
||||||
* - actionable: you can provide data (missing requirements and/or you can sign)
|
* - actionable: you can provide data (missing requirements and/or you can sign)
|
||||||
* - unknown: template/action not found or error
|
* - unknown: template/action not found or error
|
||||||
*/
|
*/
|
||||||
private async computeStatusInternal(): Promise<string> {
|
private async computeStatusInternal(): Promise<string> {
|
||||||
if (hasInvitationExpired(this.data)) {
|
|
||||||
return 'expired';
|
|
||||||
}
|
|
||||||
if (this._broadcasted) {
|
|
||||||
return 'complete';
|
|
||||||
}
|
|
||||||
|
|
||||||
let missingReqs;
|
let missingReqs;
|
||||||
try {
|
try {
|
||||||
missingReqs = await this.engine.listMissingRequirements(this.data);
|
const missingRequirements = await this.engine.listMissingRequirements(
|
||||||
|
this.data.invitationIdentifier,
|
||||||
|
);
|
||||||
|
missingReqs = missingRequirements.templateRequirements;
|
||||||
} catch {
|
} catch {
|
||||||
return 'unknown';
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMissing =
|
const hasMissing =
|
||||||
(missingReqs.variables?.length ?? 0) > 0 ||
|
(missingReqs.variables?.length ?? 0) > 0 ||
|
||||||
(missingReqs.inputs?.length ?? 0) > 0 ||
|
(missingReqs.inputs?.length ?? 0) > 0 ||
|
||||||
(missingReqs.outputs?.length ?? 0) > 0 ||
|
(missingReqs.outputs?.length ?? 0) > 0 ||
|
||||||
(missingReqs.roles !== undefined && Object.keys(missingReqs.roles).length > 0);
|
(missingReqs.roles !== undefined &&
|
||||||
|
Object.keys(missingReqs.roles).length > 0);
|
||||||
|
|
||||||
if (!hasMissing && this._weHaveSigned) {
|
const hasSignedCommit = this.hasSignedCommitInInvitation();
|
||||||
return 'ready';
|
|
||||||
|
if (!hasMissing) {
|
||||||
|
const transactionHash = await this.deriveTransactionHash();
|
||||||
|
if (
|
||||||
|
transactionHash &&
|
||||||
|
(await this.electrum.hasSeenTransaction(transactionHash))
|
||||||
|
) {
|
||||||
|
return "complete";
|
||||||
}
|
}
|
||||||
if (hasMissing && this._weHaveSigned) {
|
|
||||||
return 'signed';
|
|
||||||
}
|
}
|
||||||
return 'actionable';
|
|
||||||
|
if (hasInvitationExpired(this.data)) {
|
||||||
|
return "expired";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMissing && hasSignedCommit) {
|
||||||
|
return "ready";
|
||||||
|
}
|
||||||
|
if (hasMissing && hasSignedCommit) {
|
||||||
|
return "signed";
|
||||||
|
}
|
||||||
|
return "actionable";
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasSignedCommitInInvitation(): boolean {
|
||||||
|
for (const commit of this.data.commits) {
|
||||||
|
for (const input of commit.data.inputs ?? []) {
|
||||||
|
if (!input.mergesWith) continue;
|
||||||
|
if (input.unlockingBytecode === undefined) continue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the transaction to get the TX hash, this is so we can check its status on the blockchain.
|
||||||
|
* TODO: Remove this. This should be part of the engine. The code is virtually identical to `executeAction` except it doesnt throw if the invitation is expired
|
||||||
|
* @returns txHash or undefined if the transaction could not be built
|
||||||
|
*/
|
||||||
|
private async deriveTransactionHash(): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const template = await this.engine.getTemplate(
|
||||||
|
this.data.templateIdentifier,
|
||||||
|
);
|
||||||
|
if (!template) return undefined;
|
||||||
|
|
||||||
|
const mergedCommit = mergeInvitationCommits(this.data, template);
|
||||||
|
if (!mergedCommit) return undefined;
|
||||||
|
|
||||||
|
const transactionResult = generateTransaction({
|
||||||
|
version: mergedCommit.transactionVersion,
|
||||||
|
locktime: mergedCommit.transactionLocktime,
|
||||||
|
// @ts-expect-error merged inputs include additional invitation metadata.
|
||||||
|
inputs: mergedCommit.inputs,
|
||||||
|
// @ts-expect-error merged outputs include additional invitation metadata.
|
||||||
|
outputs: mergedCommit.outputs,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!transactionResult.success) return undefined;
|
||||||
|
|
||||||
|
const transactionHex = binToHex(
|
||||||
|
encodeTransaction(transactionResult.transaction),
|
||||||
|
);
|
||||||
|
const rawHash: unknown = hashTransaction(hexToBin(transactionHex));
|
||||||
|
if (typeof rawHash === "string") return rawHash;
|
||||||
|
if (rawHash instanceof Uint8Array) return binToHex(rawHash);
|
||||||
|
return undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the status of the invitation and emit the new single-word status.
|
* Update the status of the invitation and emit the new single-word status.
|
||||||
*/
|
*/
|
||||||
private async updateStatus(): Promise<void> {
|
private async updateStatus(): Promise<void> {
|
||||||
const status = await this.computeStatus();
|
this.computeStatus()
|
||||||
|
.then((status) => {
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.emit('invitation-status-changed', status);
|
this.emit("invitation-status-changed", status);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.status = `error (${error instanceof Error ? error.message : String(error)})`;
|
||||||
|
this.emit(
|
||||||
|
"error",
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept the invitation
|
* Accept the invitation
|
||||||
*/
|
*/
|
||||||
async accept(acceptParams?: AcceptInvitationParameters): Promise<void> {
|
async accept(acceptParams?: InvitationParameters): Promise<void> {
|
||||||
// Accept the invitation
|
// Accept the invitation
|
||||||
this.data = await this.engine.acceptInvitation(this.data, acceptParams);
|
this.updateInvitationData(
|
||||||
|
await this.engine.acceptInvitation(this.data, acceptParams),
|
||||||
|
);
|
||||||
|
|
||||||
// Sync the invitation to the sync server
|
// Sync the invitation to the sync server
|
||||||
this.syncServer.publishInvitation(this.data);
|
await this.publishInvitation(this.data);
|
||||||
|
|
||||||
|
// Store the accepted invitation and notify reactive consumers.
|
||||||
|
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||||
|
this.emit("invitation-updated", this.data);
|
||||||
|
|
||||||
// Update the status of the invitation
|
// Update the status of the invitation
|
||||||
await this.updateStatus();
|
await this.updateStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept the invitation once for this engine entity so future appends have a root commit.
|
||||||
|
*/
|
||||||
|
async ensureAccepted(): Promise<void> {
|
||||||
|
const ownCommits = await this.engine.findOwnCommits(
|
||||||
|
this.data.invitationIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ownCommits.length === 0) {
|
||||||
|
await this.accept();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign the invitation
|
* Sign the invitation
|
||||||
*/
|
*/
|
||||||
async sign(): Promise<void> {
|
async sign(): Promise<void> {
|
||||||
// Sign the invitation
|
// Sign the invitation
|
||||||
const signedInvitation = await this.engine.signInvitation(this.data);
|
const signedInvitation = await this.engine.signInvitation(
|
||||||
|
this.data.invitationIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
// Publish the signed invitation to the sync server
|
// Publish the signed invitation to the sync server
|
||||||
this.syncServer.publishInvitation(signedInvitation);
|
this.publishInvitation(signedInvitation);
|
||||||
|
|
||||||
// Store the signed invitation in the storage
|
// Store the signed invitation in the storage
|
||||||
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
|
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
|
||||||
|
|
||||||
this.data = signedInvitation;
|
this.updateInvitationData(signedInvitation);
|
||||||
this._weHaveSigned = true;
|
|
||||||
|
|
||||||
// Update the status of the invitation
|
// Update the status of the invitation
|
||||||
await this.updateStatus();
|
await this.updateStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcast the invitation
|
* Broadcast the invitation.
|
||||||
|
* @returns The transaction hash returned by the network after broadcast.
|
||||||
*/
|
*/
|
||||||
async broadcast(): Promise<void> {
|
async broadcast(): Promise<string> {
|
||||||
// Broadcast the transaction (executeAction returns transaction hash when broadcastTransaction: true)
|
const txHash = await this.engine.executeAction(
|
||||||
await this.engine.executeAction(this.data, {
|
this.data.invitationIdentifier,
|
||||||
|
{
|
||||||
broadcastTransaction: true,
|
broadcastTransaction: true,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
this._broadcasted = true;
|
|
||||||
|
|
||||||
// Update the status of the invitation
|
|
||||||
await this.updateStatus();
|
await this.updateStatus();
|
||||||
|
|
||||||
|
return String(txHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -320,12 +608,19 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
/**
|
/**
|
||||||
* Append a commit to the invitation
|
* Append a commit to the invitation
|
||||||
*/
|
*/
|
||||||
async append(data: AppendInvitationParameters): Promise<void> {
|
async append(data: InvitationParameters): Promise<void> {
|
||||||
|
await this.ensureAccepted();
|
||||||
|
|
||||||
// Append the commit to the invitation
|
// Append the commit to the invitation
|
||||||
this.data = await this.engine.appendInvitation(this.data, data);
|
this.updateInvitationData(
|
||||||
|
await this.engine.appendInvitation(
|
||||||
|
this.data.invitationIdentifier,
|
||||||
|
data,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Sync the invitation to the sync server
|
// Sync the invitation to the sync server
|
||||||
await this.syncServer.publishInvitation(this.data);
|
await this.publishInvitation(this.data);
|
||||||
|
|
||||||
// Store the invitation in the storage
|
// Store the invitation in the storage
|
||||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||||
@@ -342,7 +637,22 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
await this.append({ inputs });
|
await this.append({ inputs });
|
||||||
|
|
||||||
// Sync the invitation to the sync server
|
// Sync the invitation to the sync server
|
||||||
await this.syncServer.publishInvitation(this.data);
|
await this.publishInvitation(this.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the locking bytecode for the invitation
|
||||||
|
* TODO: Find out if this has side-effects or needs special handling
|
||||||
|
*/
|
||||||
|
async generateLockingBytecode(
|
||||||
|
outputIdentifier: string,
|
||||||
|
roleIdentifier?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return this.engine.generateLockingBytecode(
|
||||||
|
this.data.templateIdentifier,
|
||||||
|
outputIdentifier,
|
||||||
|
roleIdentifier,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addOutputs(outputs: XOInvitationOutput[]): Promise<void> {
|
async addOutputs(outputs: XOInvitationOutput[]): Promise<void> {
|
||||||
@@ -350,7 +660,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
await this.append({ outputs });
|
await this.append({ outputs });
|
||||||
|
|
||||||
// Sync the invitation to the sync server
|
// Sync the invitation to the sync server
|
||||||
await this.syncServer.publishInvitation(this.data);
|
await this.publishInvitation(this.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addVariables(variables: XOInvitationVariable[]): Promise<void> {
|
async addVariables(variables: XOInvitationVariable[]): Promise<void> {
|
||||||
@@ -358,12 +668,58 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
await this.append({ variables });
|
await this.append({ variables });
|
||||||
|
|
||||||
// Sync the invitation to the sync server
|
// Sync the invitation to the sync server
|
||||||
await this.syncServer.publishInvitation(this.data);
|
await this.publishInvitation(this.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findSuitableResources(options: FindSuitableResourcesParameters): Promise<UnspentOutputData[]> {
|
async findSuitableResources(
|
||||||
// Find the suitable resources
|
options: Partial<GetSpendableResourcesParameters> = {},
|
||||||
const { unspentOutputs } = await this.engine.findSuitableResources(this.data, options);
|
): Promise<UnspentOutputData[]> {
|
||||||
|
const templateIdentifier =
|
||||||
|
options.templateIdentifier ?? this.data.templateIdentifier;
|
||||||
|
const template = await this.engine.getTemplate(templateIdentifier);
|
||||||
|
const fallbackOutputIdentifier = Object.keys(template?.outputs ?? {})[0];
|
||||||
|
if (!fallbackOutputIdentifier && !options.outputIdentifier) {
|
||||||
|
throw new Error(
|
||||||
|
`No output identifiers found for template: ${templateIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// const resolvedOptions: GetSpendableResourcesParameters = {
|
||||||
|
// templateIdentifier,
|
||||||
|
// outputIdentifier: options.outputIdentifier ?? fallbackOutputIdentifier ?? "",
|
||||||
|
// };
|
||||||
|
|
||||||
|
// There are disagreements around whether all spendables should be returned from getSpendableResources.
|
||||||
|
// I had a fix merged in, but it got overwritten. So, im just going to get all of them manually and go around
|
||||||
|
// The engine's expectations.
|
||||||
|
// To do this, we are going to grab all out templates
|
||||||
|
const templates = await this.engine.listImportedTemplates();
|
||||||
|
|
||||||
|
// For each template, we need to create a 2d array of all the outputs
|
||||||
|
const outputs = templates.map((template) => {
|
||||||
|
return Object.keys(template.outputs).map((output) => {
|
||||||
|
const templateIdentifier = generateTemplateIdentifier(template);
|
||||||
|
|
||||||
|
return {
|
||||||
|
templateIdentifier,
|
||||||
|
outputIdentifier: output,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// then, for each output, we need to get the spendable resources
|
||||||
|
const spendableResources = await Promise.all(
|
||||||
|
outputs.flat().map((output) => {
|
||||||
|
return this.engine.getSpendableResources(this.data, {
|
||||||
|
templateIdentifier: output.templateIdentifier,
|
||||||
|
outputIdentifier: output.outputIdentifier,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unspentOutputs = spendableResources.flatMap(
|
||||||
|
(resource) => resource.unspentOutputs,
|
||||||
|
);
|
||||||
|
|
||||||
// Update the status of the invitation
|
// Update the status of the invitation
|
||||||
await this.updateStatus();
|
await this.updateStatus();
|
||||||
@@ -380,7 +736,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
* Get the missing requirements for the invitation
|
* Get the missing requirements for the invitation
|
||||||
*/
|
*/
|
||||||
async getMissingRequirements() {
|
async getMissingRequirements() {
|
||||||
return this.engine.listMissingRequirements(this.data);
|
return this.engine.listMissingRequirements(this.data.invitationIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -407,7 +763,148 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
/**
|
/**
|
||||||
* Get the locking bytecode for the invitation
|
* Get the locking bytecode for the invitation
|
||||||
*/
|
*/
|
||||||
async getLockingBytecode(outputIdentifier: string, roleIdentifier?: string): Promise<string> {
|
async getLockingBytecode(
|
||||||
return this.engine.generateLockingBytecode(this.data.templateIdentifier, outputIdentifier, roleIdentifier);
|
outputIdentifier: string,
|
||||||
|
roleIdentifier?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return this.engine.generateLockingBytecode(
|
||||||
|
this.data.templateIdentifier,
|
||||||
|
outputIdentifier,
|
||||||
|
roleIdentifier,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the sats out for the invitation
|
||||||
|
* TODO: Clean up this function. Why is it so big? Can obviously make it 2 functions instead of recursive, but still...
|
||||||
|
*/
|
||||||
|
async getSatsOut(outputIdentifier?: string): Promise<bigint> {
|
||||||
|
// If an output identifier is provided, find all outputs with that identifier, and its valueSatoshis identifier back to the variables
|
||||||
|
if (outputIdentifier) {
|
||||||
|
// Get the valueSatoshis identifier from the template
|
||||||
|
const template = await this.engine.getTemplate(
|
||||||
|
this.data.templateIdentifier,
|
||||||
|
);
|
||||||
|
if (!template) {
|
||||||
|
throw new Error(
|
||||||
|
`Template not found: ${this.data.templateIdentifier} when trying to get sats out for output: ${outputIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = template.outputs[outputIdentifier];
|
||||||
|
if (!output) {
|
||||||
|
throw new Error(
|
||||||
|
`Output not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueSatoshisExpression = output.valueSatoshis;
|
||||||
|
if (!valueSatoshisExpression) {
|
||||||
|
throw new Error(
|
||||||
|
`Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a list of all the variables from the commits
|
||||||
|
const variables = this.data.commits.flatMap(
|
||||||
|
(c) => c.data?.variables ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a dictionary of the variables
|
||||||
|
const formattedVariables = variables.reduce(
|
||||||
|
(acc, v) => {
|
||||||
|
const { variableIdentifier, value } = v;
|
||||||
|
acc[variableIdentifier ?? ""] = value;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, XOInvitationVariableValue>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us)
|
||||||
|
const valueSatoshis = compileCashAssemblyString({
|
||||||
|
cashAssemblyText: String(valueSatoshisExpression),
|
||||||
|
variables: formattedVariables,
|
||||||
|
evaluationDecodeMode: "bigint",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the value satoshis as a bigint
|
||||||
|
// TODO: Check this of a vulnerability or crash - I assume there might be one if someone made the `valueSatoshis` a malicious expression
|
||||||
|
return BigInt(valueSatoshis);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we didnt get an output identifier, go through the action outputs and sum the valueSatoshis
|
||||||
|
const action = this.data.actionIdentifier;
|
||||||
|
if (!action) {
|
||||||
|
throw new Error(
|
||||||
|
`Action not found: ${this.data.actionIdentifier} when trying to get sats out for output: ${outputIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the template
|
||||||
|
const template = await this.engine.getTemplate(
|
||||||
|
this.data.templateIdentifier,
|
||||||
|
);
|
||||||
|
if (!template) {
|
||||||
|
throw new Error(
|
||||||
|
`Template not found: ${this.data.templateIdentifier} when trying to get sats out for action: ${action}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the transaction ID from the action
|
||||||
|
const transactionID = template.actions[action]?.transaction;
|
||||||
|
if (!transactionID) {
|
||||||
|
throw new Error(
|
||||||
|
`Transactions not found: ${action} in template: ${this.data.templateIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the transaction from the template
|
||||||
|
const transaction = template.transactions?.[transactionID];
|
||||||
|
if (!transaction) {
|
||||||
|
throw new Error(
|
||||||
|
`Transaction not found: ${transactionID} in template: ${this.data.templateIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the outputs from the transaction
|
||||||
|
const outputs = transaction.outputs;
|
||||||
|
if (!outputs) {
|
||||||
|
throw new Error(
|
||||||
|
`Outputs not found: ${transactionID} in template: ${this.data.templateIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a value to store the cummulative total of the outputs
|
||||||
|
let totalSats = 0n;
|
||||||
|
|
||||||
|
// Iterate through the outputs and sum the valueSatoshis
|
||||||
|
for (const output of outputs) {
|
||||||
|
if (typeof output === "string") {
|
||||||
|
const sats = await this.getSatsOut(output);
|
||||||
|
totalSats += sats;
|
||||||
|
} else {
|
||||||
|
const sats = await this.getSatsOut(output.output);
|
||||||
|
totalSats += sats;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the invitation from the Local SQLite db as well as the Engine's internal DB
|
||||||
|
* NOTE: This uses methods that are marked "DANGEROUSLY" inside the engine and behaviour may change
|
||||||
|
*/
|
||||||
|
public async delete() {
|
||||||
|
// Remove the invitation from our local db
|
||||||
|
this.storage.remove(this.data.invitationIdentifier);
|
||||||
|
|
||||||
|
// Remove the invitation from the engine's internal db
|
||||||
|
await this.engine.DANGEROUS_deleteStoredInvitation(this.data.invitationIdentifier);
|
||||||
|
|
||||||
|
this.emit("invitation-removed", this.data.invitationIdentifier);
|
||||||
|
|
||||||
|
// Update the status of the invitation
|
||||||
|
await this.updateStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
225
src/services/rates.ts
Normal file
225
src/services/rates.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { OracleClient } from "@generalprotocols/oracle-client";
|
||||||
|
import { EventEmitter } from "../utils/event-emitter.js";
|
||||||
|
import { type RatesEventMap } from "../utils/rates/base-rates.js";
|
||||||
|
import { RatesOracle } from "../utils/rates/rates-oracles.js";
|
||||||
|
import { SettingsService } from "./settings.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event map emitted by {@link RatesService}.
|
||||||
|
*/
|
||||||
|
export type RatesServiceEventMap = {
|
||||||
|
"rate-updated": {
|
||||||
|
numeratorUnitCode: string;
|
||||||
|
denominatorUnitCode: string;
|
||||||
|
price: number;
|
||||||
|
pair: string;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory representation of a market rate.
|
||||||
|
*/
|
||||||
|
type CachedRate = {
|
||||||
|
price: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal adapter contract that RatesService depends on.
|
||||||
|
*
|
||||||
|
* Using a small interface keeps the service decoupled and avoids inheriting
|
||||||
|
* implementation-specific type constraints from concrete adapters.
|
||||||
|
*/
|
||||||
|
export interface RatesAdapter {
|
||||||
|
start(): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
listPairs(): Promise<Set<string>>;
|
||||||
|
formatCurrency(amount: number, targetCurrency: string): string;
|
||||||
|
on(
|
||||||
|
type: "rateUpdated",
|
||||||
|
listener: (detail: RatesEventMap["rateUpdated"]) => void,
|
||||||
|
): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrates the rates adapter lifecycle and provides BCH -> fiat helpers
|
||||||
|
* for the TUI.
|
||||||
|
*
|
||||||
|
* This service keeps a small in-memory snapshot of the latest prices and emits
|
||||||
|
* a normalized event whenever a pair changes. React components can subscribe
|
||||||
|
* through `useSyncExternalStore` for clean and predictable reactivity.
|
||||||
|
*/
|
||||||
|
export class RatesService extends EventEmitter<RatesServiceEventMap> {
|
||||||
|
private readonly adapter: RatesAdapter;
|
||||||
|
private readonly settings: SettingsService;
|
||||||
|
private readonly ratesByPair = new Map<string, CachedRate>();
|
||||||
|
private unsubscribeFromAdapter: (() => void) | null = null;
|
||||||
|
private started = false;
|
||||||
|
|
||||||
|
constructor(adapter: RatesAdapter, settings: SettingsService) {
|
||||||
|
super();
|
||||||
|
this.adapter = adapter;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a rates service.
|
||||||
|
*
|
||||||
|
* If no adapter is passed, this defaults to the Oracle-backed adapter.
|
||||||
|
*/
|
||||||
|
public static async create(
|
||||||
|
settings: SettingsService,
|
||||||
|
adapter?: RatesAdapter,
|
||||||
|
): Promise<RatesService> {
|
||||||
|
if (adapter) {
|
||||||
|
return new RatesService(adapter, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const oracleClient = new OracleClient();
|
||||||
|
oracleClient.start();
|
||||||
|
|
||||||
|
const ratesOracle = new RatesOracle(oracleClient, settings);
|
||||||
|
ratesOracle.start();
|
||||||
|
|
||||||
|
return new RatesService(ratesOracle, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the underlying adapter and begins collecting live updates.
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
if (this.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.started = true;
|
||||||
|
|
||||||
|
this.unsubscribeFromAdapter = this.adapter.on("rateUpdated", (event) => {
|
||||||
|
this.handleRateUpdated(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.adapter.start();
|
||||||
|
} catch (error) {
|
||||||
|
this.unsubscribeFromAdapter?.();
|
||||||
|
this.unsubscribeFromAdapter = null;
|
||||||
|
this.started = false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops live rate collection.
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (!this.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.started = false;
|
||||||
|
|
||||||
|
this.unsubscribeFromAdapter?.();
|
||||||
|
this.unsubscribeFromAdapter = null;
|
||||||
|
|
||||||
|
await this.adapter.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the latest price for a pair in NUMERATOR/DENOMINATOR form.
|
||||||
|
*
|
||||||
|
* Example: `getRate("USD", "BCH")`.
|
||||||
|
*/
|
||||||
|
public getRate(
|
||||||
|
numeratorUnitCode: string,
|
||||||
|
denominatorUnitCode: string,
|
||||||
|
): number | null {
|
||||||
|
const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode);
|
||||||
|
return this.ratesByPair.get(pair)?.price ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts satoshis to fiat using the latest BCH/fiat rate.
|
||||||
|
*
|
||||||
|
* Example: `convertBchToFiat(1234n, "USD")`.
|
||||||
|
*/
|
||||||
|
public convertBchToFiat(
|
||||||
|
satoshis: bigint,
|
||||||
|
targetCurrency: string = "USD",
|
||||||
|
): number | null {
|
||||||
|
const rate = this.getRate(targetCurrency, "BCH");
|
||||||
|
if (rate === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountInBch = Number(satoshis) / 100_000_000;
|
||||||
|
return amountInBch * rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a BCH -> fiat converted amount using the adapter formatter.
|
||||||
|
*/
|
||||||
|
public formatBchToFiat(
|
||||||
|
satoshis: bigint,
|
||||||
|
targetCurrency: string = "USD",
|
||||||
|
): string | null {
|
||||||
|
const normalizedCurrency = targetCurrency.toUpperCase();
|
||||||
|
const amount = this.convertBchToFiat(satoshis, normalizedCurrency);
|
||||||
|
if (amount === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.adapter.formatCurrency(amount, normalizedCurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats an arbitrary fiat amount in a currency-aware way.
|
||||||
|
*/
|
||||||
|
public formatCurrency(amount: number, currencyCode: string): string {
|
||||||
|
return this.adapter.formatCurrency(amount, currencyCode.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists available market pairs in NUMERATOR/DENOMINATOR format.
|
||||||
|
*/
|
||||||
|
public async listPairs(): Promise<Set<string>> {
|
||||||
|
return this.adapter.listPairs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the fiat currency currently configured in settings.
|
||||||
|
*/
|
||||||
|
public getConfiguredCurrency(): string {
|
||||||
|
return this.settings.getCurrency();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles normalized updates from the underlying adapter.
|
||||||
|
*/
|
||||||
|
private handleRateUpdated(event: RatesEventMap["rateUpdated"]): void {
|
||||||
|
const numeratorUnitCode = event.numeratorUnitCode.toUpperCase();
|
||||||
|
const denominatorUnitCode = event.denominatorUnitCode.toUpperCase();
|
||||||
|
const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode);
|
||||||
|
const updatedAt = Date.now();
|
||||||
|
|
||||||
|
this.ratesByPair.set(pair, {
|
||||||
|
price: event.price,
|
||||||
|
updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emit("rate-updated", {
|
||||||
|
numeratorUnitCode,
|
||||||
|
denominatorUnitCode,
|
||||||
|
price: event.price,
|
||||||
|
pair,
|
||||||
|
updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a stable key for pair lookups.
|
||||||
|
*/
|
||||||
|
private getPairKey(
|
||||||
|
numeratorUnitCode: string,
|
||||||
|
denominatorUnitCode: string,
|
||||||
|
): string {
|
||||||
|
return `${numeratorUnitCode.toUpperCase()}/${denominatorUnitCode.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/services/settings.ts
Normal file
196
src/services/settings.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { EventEmitter } from "../utils/event-emitter.js";
|
||||||
|
import { getSettingsPath } from "../utils/paths.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported persisted settings keys.
|
||||||
|
*/
|
||||||
|
export type SettingsData = {
|
||||||
|
"default-mnemonic"?: string;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event payloads emitted by {@link SettingsService}.
|
||||||
|
*/
|
||||||
|
export type SettingsServiceEventMap = {
|
||||||
|
"settings-updated": {
|
||||||
|
key: keyof SettingsData;
|
||||||
|
value: string | undefined;
|
||||||
|
settings: SettingsData;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime defaults for settings that should always exist in memory.
|
||||||
|
*/
|
||||||
|
const DEFAULT_SETTINGS: SettingsData = {
|
||||||
|
currency: "USD",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles loading, migrating, and persisting wallet settings.
|
||||||
|
*
|
||||||
|
* The backing file is `<XO_CONFIG_DIR>/.wallet`. Historically it stored a raw
|
||||||
|
* mnemonic reference string. This service migrates that legacy format to JSON:
|
||||||
|
* `{ "default-mnemonic": "<value>", "currency": "USD" }`.
|
||||||
|
*/
|
||||||
|
export class SettingsService extends EventEmitter<SettingsServiceEventMap> {
|
||||||
|
private readonly settingsPath: string;
|
||||||
|
private settings: SettingsData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new settings service instance.
|
||||||
|
*
|
||||||
|
* @param settingsPath - Optional custom settings file path (useful for tests)
|
||||||
|
*/
|
||||||
|
constructor(settingsPath: string = getSettingsPath()) {
|
||||||
|
super();
|
||||||
|
this.settingsPath = settingsPath;
|
||||||
|
this.settings = this.loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current settings snapshot.
|
||||||
|
*/
|
||||||
|
public getSettings(): SettingsData {
|
||||||
|
return { ...this.settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the currently selected default mnemonic reference.
|
||||||
|
*/
|
||||||
|
public getDefaultMnemonic(): string | undefined {
|
||||||
|
return this.settings["default-mnemonic"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the default mnemonic reference and persists it to disk.
|
||||||
|
*/
|
||||||
|
public setDefaultMnemonic(mnemonicRef: string): void {
|
||||||
|
const normalizedMnemonicRef = mnemonicRef.trim();
|
||||||
|
if (normalizedMnemonicRef.length === 0) {
|
||||||
|
throw new Error("default-mnemonic cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.settings["default-mnemonic"] = normalizedMnemonicRef;
|
||||||
|
this.persistSettings();
|
||||||
|
|
||||||
|
this.emit("settings-updated", {
|
||||||
|
key: "default-mnemonic",
|
||||||
|
value: normalizedMnemonicRef,
|
||||||
|
settings: this.getSettings(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the selected fiat currency code (ISO-like uppercase).
|
||||||
|
*/
|
||||||
|
public getCurrency(): string {
|
||||||
|
return this.settings.currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the selected fiat currency and persists it to disk.
|
||||||
|
*/
|
||||||
|
public setCurrency(currencyCode: string): void {
|
||||||
|
const normalizedCurrency = this.normalizeCurrency(currencyCode);
|
||||||
|
if (this.settings.currency === normalizedCurrency) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.settings.currency = normalizedCurrency;
|
||||||
|
this.persistSettings();
|
||||||
|
|
||||||
|
this.emit("settings-updated", {
|
||||||
|
key: "currency",
|
||||||
|
value: normalizedCurrency,
|
||||||
|
settings: this.getSettings(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads and normalizes the settings file from disk.
|
||||||
|
*
|
||||||
|
* If the file contains the old legacy format (raw mnemonic string), the
|
||||||
|
* migrated JSON shape is written back immediately.
|
||||||
|
*/
|
||||||
|
private loadSettings(): SettingsData {
|
||||||
|
if (!existsSync(this.settingsPath)) {
|
||||||
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawContents = readFileSync(this.settingsPath, "utf8").trim();
|
||||||
|
if (rawContents.length === 0) {
|
||||||
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawContents);
|
||||||
|
const normalized = this.normalizeSettings(parsed);
|
||||||
|
return normalized;
|
||||||
|
} catch {
|
||||||
|
const migrated = this.normalizeSettings({
|
||||||
|
"default-mnemonic": rawContents,
|
||||||
|
});
|
||||||
|
this.persistSettings(migrated);
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the given settings object to disk as pretty JSON.
|
||||||
|
*
|
||||||
|
* @param nextSettings - Optional explicit value, defaults to in-memory state
|
||||||
|
*/
|
||||||
|
private persistSettings(nextSettings?: SettingsData): void {
|
||||||
|
if (nextSettings) {
|
||||||
|
this.settings = nextSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
this.settingsPath,
|
||||||
|
`${JSON.stringify(this.settings, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerces unknown input into a safe settings object.
|
||||||
|
*/
|
||||||
|
private normalizeSettings(input: unknown): SettingsData {
|
||||||
|
const normalized: SettingsData = {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!input || typeof input !== "object") {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeMnemonic = (input as Record<string, unknown>)[
|
||||||
|
"default-mnemonic"
|
||||||
|
];
|
||||||
|
if (typeof maybeMnemonic === "string" && maybeMnemonic.trim().length > 0) {
|
||||||
|
normalized["default-mnemonic"] = maybeMnemonic.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeCurrency = (input as Record<string, unknown>).currency;
|
||||||
|
if (typeof maybeCurrency === "string" && maybeCurrency.trim().length > 0) {
|
||||||
|
normalized.currency = this.normalizeCurrency(maybeCurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures currency values stay uppercase and non-empty.
|
||||||
|
*/
|
||||||
|
private normalizeCurrency(currencyCode: string): string {
|
||||||
|
const normalizedCurrency = currencyCode.trim().toUpperCase();
|
||||||
|
if (normalizedCurrency.length === 0) {
|
||||||
|
throw new Error("currency cannot be empty");
|
||||||
|
}
|
||||||
|
return normalizedCurrency;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,45 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from "better-sqlite3";
|
||||||
import { decodeExtendedJson, encodeExtendedJson } from '../utils/ext-json.js';
|
import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine";
|
||||||
|
import type { XOInvitation } from "@xo-cash/types";
|
||||||
|
|
||||||
export class Storage {
|
/**
|
||||||
|
* This is not an actual storage adapter that we want to make use of. This storage adapter is a stop-gap while the engine is under development.
|
||||||
|
* At the time of writing the storage adapter, the engine provided no way to read data about your currenty invitations, so that is where this is coming in.
|
||||||
|
* Its providing a Developer facing way to store/read the invitation data and then we can just import them into the engine whenever we want to interact with an invitation.
|
||||||
|
*/
|
||||||
|
export abstract class BaseStorage {
|
||||||
|
abstract all(): Promise<{ key: string; value: any }[]>;
|
||||||
|
abstract set(key: string, value: any): Promise<void>;
|
||||||
|
abstract get(key: string): Promise<any>;
|
||||||
|
abstract remove(key: string): Promise<void>;
|
||||||
|
abstract clear(): Promise<void>;
|
||||||
|
abstract child(key: string): BaseStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite Database Storage Adapter.
|
||||||
|
*/
|
||||||
|
export class Storage extends BaseStorage {
|
||||||
static async create(dbPath: string): Promise<Storage> {
|
static async create(dbPath: string): Promise<Storage> {
|
||||||
// Create the database
|
// Create the database
|
||||||
const database = new Database(dbPath);
|
const database = new Database(dbPath);
|
||||||
|
|
||||||
// Create the storage table if it doesn't exist
|
// Create the storage table if it doesn't exist
|
||||||
database.prepare('CREATE TABLE IF NOT EXISTS storage (key TEXT PRIMARY KEY, value TEXT)').run();
|
database
|
||||||
|
.prepare(
|
||||||
|
"CREATE TABLE IF NOT EXISTS storage (key TEXT PRIMARY KEY, value TEXT)",
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
return new Storage(database, '');
|
return new Storage(database, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly database: Database.Database,
|
private readonly database: Database.Database,
|
||||||
private readonly basePath: string,
|
private readonly basePath: string,
|
||||||
) {}
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the full key with basePath prefix
|
* Get the full key with basePath prefix
|
||||||
@@ -33,70 +57,78 @@ export class Storage {
|
|||||||
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
|
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(key: string, value: any): Promise<void> {
|
async set(key: string, value: XOInvitation): Promise<void> {
|
||||||
// Encode the extended json object
|
const encodedValue = serializeInvitation(value);
|
||||||
const encodedValue = encodeExtendedJson(value);
|
|
||||||
|
|
||||||
// Insert or replace the value into the database with full key (including basePath)
|
// Insert or replace the value into the database with full key (including basePath)
|
||||||
const fullKey = this.getFullKey(key);
|
const fullKey = this.getFullKey(key);
|
||||||
this.database.prepare('INSERT OR REPLACE INTO storage (key, value) VALUES (?, ?)').run(fullKey, encodedValue);
|
this.database
|
||||||
|
.prepare("INSERT OR REPLACE INTO storage (key, value) VALUES (?, ?)")
|
||||||
|
.run(fullKey, encodedValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all key-value pairs from this storage namespace (shallow only - no nested children)
|
* Get all key-value pairs from this storage namespace (shallow only - no nested children)
|
||||||
*/
|
*/
|
||||||
async all(): Promise<{ key: string; value: any }[]> {
|
async all(): Promise<{ key: string; value: any }[]> {
|
||||||
let query = 'SELECT key, value FROM storage';
|
let query = "SELECT key, value FROM storage";
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
|
|
||||||
if (this.basePath) {
|
if (this.basePath) {
|
||||||
// Filter by basePath prefix
|
// Filter by basePath prefix
|
||||||
query += ' WHERE key LIKE ?';
|
query += " WHERE key LIKE ?";
|
||||||
params.push(`${this.basePath}.%`);
|
params.push(`${this.basePath}.%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all the rows from the database
|
// Get all the rows from the database
|
||||||
const rows = await this.database.prepare(query).all(...params) as { key: string; value: any }[];
|
const rows = (await this.database.prepare(query).all(...params)) as {
|
||||||
|
key: string;
|
||||||
|
value: any;
|
||||||
|
}[];
|
||||||
|
|
||||||
// Filter for shallow results (only direct children)
|
// Filter for shallow results (only direct children)
|
||||||
const filteredRows = rows.filter(row => {
|
const filteredRows = rows.filter((row) => {
|
||||||
const strippedKey = this.stripBasePath(row.key);
|
const strippedKey = this.stripBasePath(row.key);
|
||||||
// Only include keys that don't have additional dots (no deeper nesting)
|
// Only include keys that don't have additional dots (no deeper nesting)
|
||||||
return !strippedKey.includes('.');
|
return !strippedKey.includes(".");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Decode the extended json objects and strip basePath from keys
|
// Deserialize invitations and strip basePath from keys
|
||||||
return filteredRows.map(row => ({
|
return filteredRows.map((row) => ({
|
||||||
key: this.stripBasePath(row.key),
|
key: this.stripBasePath(row.key),
|
||||||
value: decodeExtendedJson(row.value)
|
value: deserializeInvitation(row.value),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(key: string): Promise<any> {
|
async get(key: string): Promise<any> {
|
||||||
// Get the row from the database using full key
|
// Get the row from the database using full key
|
||||||
const fullKey = this.getFullKey(key);
|
const fullKey = this.getFullKey(key);
|
||||||
const row = await this.database.prepare('SELECT value FROM storage WHERE key = ?').get(fullKey) as { value: any };
|
const row = (await this.database
|
||||||
|
.prepare("SELECT value FROM storage WHERE key = ?")
|
||||||
|
.get(fullKey)) as { value: any };
|
||||||
|
|
||||||
// Return null if not found
|
// Return null if not found
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|
||||||
// Decode the extended json object
|
// Decode the extended json object
|
||||||
return decodeExtendedJson(row.value);
|
return deserializeInvitation(row.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(key: string): Promise<void> {
|
async remove(key: string): Promise<void> {
|
||||||
// Delete using full key
|
// Delete using full key
|
||||||
const fullKey = this.getFullKey(key);
|
const fullKey = this.getFullKey(key);
|
||||||
this.database.prepare('DELETE FROM storage WHERE key = ?').run(fullKey);
|
this.database.prepare("DELETE FROM storage WHERE key = ?").run(fullKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear(): Promise<void> {
|
async clear(): Promise<void> {
|
||||||
if (this.basePath) {
|
if (this.basePath) {
|
||||||
// Clear only items under this namespace
|
// Clear only items under this namespace
|
||||||
this.database.prepare('DELETE FROM storage WHERE key LIKE ?').run(`${this.basePath}.%`);
|
this.database
|
||||||
|
.prepare("DELETE FROM storage WHERE key LIKE ?")
|
||||||
|
.run(`${this.basePath}.%`);
|
||||||
} else {
|
} else {
|
||||||
// Clear everything
|
// Clear everything
|
||||||
this.database.prepare('DELETE FROM storage').run();
|
this.database.prepare("DELETE FROM storage").run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,3 +136,107 @@ export class Storage {
|
|||||||
return new Storage(this.database, this.getFullKey(key));
|
return new Storage(this.database, this.getFullKey(key));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory storage adapter with the same namespaced API as {@link Storage}.
|
||||||
|
*
|
||||||
|
* This adapter is useful for tests and short-lived sessions where persisted
|
||||||
|
* SQLite state is not needed.
|
||||||
|
*
|
||||||
|
* TODO: Move this somewhere else. There is no reason for this to be in the main codebase. We should put this stricly in the tests beacuse that were its actually being used.
|
||||||
|
* Ideally, we would provide these kind of generic fills as part of our packages somewhere, but these interfaces dont fit our current design.
|
||||||
|
*/
|
||||||
|
export class InMemoryStorage extends BaseStorage {
|
||||||
|
static async create(): Promise<InMemoryStorage> {
|
||||||
|
return new InMemoryStorage(new Map<string, string>(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly store: Map<string, string>,
|
||||||
|
private readonly basePath: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full key with basePath prefix.
|
||||||
|
*/
|
||||||
|
private getFullKey(key: string): string {
|
||||||
|
return this.basePath ? `${this.basePath}.${key}` : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip the basePath prefix from a key.
|
||||||
|
*/
|
||||||
|
private stripBasePath(fullKey: string): string {
|
||||||
|
if (!this.basePath) return fullKey;
|
||||||
|
const prefix = `${this.basePath}.`;
|
||||||
|
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: XOInvitation): Promise<void> {
|
||||||
|
const fullKey = this.getFullKey(key);
|
||||||
|
const encodedValue = serializeInvitation(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: deserializeInvitation(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 deserializeInvitation(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
283
src/templates/vending-machine.ts
Normal file
283
src/templates/vending-machine.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vending machine payment template.
|
||||||
|
*
|
||||||
|
* Merchant creates a purchaseItems invitation with receipt variables;
|
||||||
|
* customer funds and signs the composable transaction.
|
||||||
|
*/
|
||||||
|
export const vendingMachineTemplate: XOTemplate = {
|
||||||
|
$schema: "https://libauth.org/schemas/wallet-template-v0.schema.json",
|
||||||
|
name: "Vending Machine",
|
||||||
|
description:
|
||||||
|
"Purchase items from a vending machine with an itemized receipt.",
|
||||||
|
icon: "wallet",
|
||||||
|
version: "1",
|
||||||
|
supported: ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"],
|
||||||
|
|
||||||
|
defaults: {
|
||||||
|
change: {
|
||||||
|
output: "changeOutput",
|
||||||
|
role: "merchant",
|
||||||
|
generate: ["merchantKey"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
merchant: {
|
||||||
|
name: "Merchant",
|
||||||
|
description: "The vending machine operator receiving payment.",
|
||||||
|
icon: "owner",
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
name: "Customer",
|
||||||
|
description: "The customer paying for items.",
|
||||||
|
icon: "sender",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
start: [
|
||||||
|
{
|
||||||
|
action: "purchaseItems",
|
||||||
|
role: "merchant",
|
||||||
|
generate: ["merchantKey"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
purchaseItems: {
|
||||||
|
name: "Purchase Items",
|
||||||
|
description: "Purchase: $(<receiptSummary>) for $(<totalSatoshis>) sats",
|
||||||
|
icon: "request",
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
merchant: {
|
||||||
|
name: "Sell Items",
|
||||||
|
description: "Receive payment for $(<receiptSummary>)",
|
||||||
|
icon: "request",
|
||||||
|
requirements: {
|
||||||
|
secrets: ["merchantKey"],
|
||||||
|
variables: [
|
||||||
|
"totalSatoshis",
|
||||||
|
"orderId",
|
||||||
|
"merchantName",
|
||||||
|
"receiptSummary",
|
||||||
|
"lineItemsJson",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
name: "Pay",
|
||||||
|
description: "Pay $(<totalSatoshis>) sats for $(<receiptSummary>)",
|
||||||
|
icon: "send",
|
||||||
|
requirements: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
requirements: {
|
||||||
|
participants: [
|
||||||
|
{ role: "merchant", slots: { min: 1, max: 1 } },
|
||||||
|
{ role: "customer", slots: { min: 1 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
transaction: "purchaseItemsTransaction",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
transactions: {
|
||||||
|
purchaseItemsTransaction: {
|
||||||
|
name: "Vending Purchase",
|
||||||
|
description: "Order $(<orderId>): $(<receiptSummary>)",
|
||||||
|
icon: "request",
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
merchant: {
|
||||||
|
name: "Received Payment",
|
||||||
|
description:
|
||||||
|
"Received $(<totalSatoshis>) sats from $(<merchantName>) sale",
|
||||||
|
icon: "receive",
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
name: "Sent Payment",
|
||||||
|
description: "Paid $(<totalSatoshis>) sats for $(<receiptSummary>)",
|
||||||
|
icon: "send",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
inputs: [],
|
||||||
|
outputs: [{ output: "purchaseOutput" }],
|
||||||
|
version: 2,
|
||||||
|
locktime: 0,
|
||||||
|
composable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/** No custom input templates — customer UTXOs are selected at funding time. */
|
||||||
|
inputs: {},
|
||||||
|
|
||||||
|
outputs: {
|
||||||
|
changeOutput: {
|
||||||
|
name: "Change",
|
||||||
|
description: "Funds returned as change.",
|
||||||
|
icon: "receive",
|
||||||
|
lockingScript: "merchantReceivingLockingScript",
|
||||||
|
},
|
||||||
|
purchaseOutput: {
|
||||||
|
name: "Purchase Payment",
|
||||||
|
description: "$(<totalSatoshis>) sats to $(<merchantName>)",
|
||||||
|
icon: "request",
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
merchant: {
|
||||||
|
name: "Payment Received",
|
||||||
|
description:
|
||||||
|
"Received $(<totalSatoshis>) sats for $(<receiptSummary>)",
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
name: "Payment Sent",
|
||||||
|
description: "Sent $(<totalSatoshis>) sats for $(<receiptSummary>)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
lockingScript: "merchantReceivingLockingScript",
|
||||||
|
valueSatoshis: "$(<totalSatoshis>)",
|
||||||
|
token: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
lockingScripts: {
|
||||||
|
merchantReceivingLockingScript: {
|
||||||
|
name: "Merchant Receive",
|
||||||
|
description: "Funds received by the vending machine merchant.",
|
||||||
|
icon: "address",
|
||||||
|
lockingType: "p2pkh",
|
||||||
|
lockingBytecode: "lockMerchantP2PKH",
|
||||||
|
unlockingBytecode: "unlockMerchantP2PKH",
|
||||||
|
actions: [],
|
||||||
|
state: { variables: [], secrets: [] },
|
||||||
|
balance: {},
|
||||||
|
roles: {
|
||||||
|
merchant: {
|
||||||
|
state: {
|
||||||
|
variables: [],
|
||||||
|
secrets: ["merchantKey"],
|
||||||
|
},
|
||||||
|
actions: [],
|
||||||
|
balance: {
|
||||||
|
satoshis: true,
|
||||||
|
fungibleTokens: true,
|
||||||
|
nonfungibleTokens: true,
|
||||||
|
},
|
||||||
|
selectable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
scripts: {
|
||||||
|
lockMerchantP2PKH:
|
||||||
|
"OP_DUP OP_HASH160 <$(<merchantKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG",
|
||||||
|
unlockMerchantP2PKH:
|
||||||
|
"<merchantKey.schnorr_signature.all_outputs> <merchantKey.public_key>",
|
||||||
|
},
|
||||||
|
|
||||||
|
constants: {
|
||||||
|
dustLimit: {
|
||||||
|
name: "Dust Limit",
|
||||||
|
description: "Minimum satoshis for P2PKH outputs.",
|
||||||
|
type: "integer",
|
||||||
|
value: 546,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
variables: {
|
||||||
|
merchantKey: {
|
||||||
|
name: "Merchant Private Key",
|
||||||
|
description: "Private key for the vending machine merchant wallet.",
|
||||||
|
type: "bytes",
|
||||||
|
hint: "private_key",
|
||||||
|
},
|
||||||
|
totalSatoshis: {
|
||||||
|
name: "Total Price",
|
||||||
|
description: "Total purchase price in satoshis",
|
||||||
|
type: "integer",
|
||||||
|
hint: "satoshis",
|
||||||
|
},
|
||||||
|
orderId: {
|
||||||
|
name: "Order ID",
|
||||||
|
description: "Unique order identifier",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
merchantName: {
|
||||||
|
name: "Merchant Name",
|
||||||
|
description: "Display name of the vending machine",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
receiptSummary: {
|
||||||
|
name: "Receipt Summary",
|
||||||
|
description: "Human-readable list of purchased items",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
lineItemsJson: {
|
||||||
|
name: "Line Items",
|
||||||
|
description: "JSON-encoded line items for the purchase",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
icons: [
|
||||||
|
{ name: "wallet", hash: "0000000000000000000000" },
|
||||||
|
{ name: "owner", hash: "0000000000000000000000" },
|
||||||
|
{ name: "sender", hash: "0000000000000000000000" },
|
||||||
|
{ name: "request", hash: "0000000000000000000000" },
|
||||||
|
{ name: "receive", hash: "0000000000000000000000" },
|
||||||
|
{ name: "send", hash: "0000000000000000000000" },
|
||||||
|
],
|
||||||
|
|
||||||
|
scenarios: [
|
||||||
|
{
|
||||||
|
name: "purchase items happy path",
|
||||||
|
description: "Merchant requests payment for vending machine items.",
|
||||||
|
action: "purchaseItems",
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
role: "merchant",
|
||||||
|
values: {
|
||||||
|
generated: {
|
||||||
|
merchantKey:
|
||||||
|
"KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8",
|
||||||
|
},
|
||||||
|
variables: {
|
||||||
|
totalSatoshis: 3500,
|
||||||
|
orderId: "order-demo-1",
|
||||||
|
merchantName: "XO Snack Machine",
|
||||||
|
receiptSummary: "2× Cola, 1× Chips",
|
||||||
|
lineItemsJson:
|
||||||
|
'[{"name":"Cola","qty":2},{"name":"Chips","qty":1}]',
|
||||||
|
},
|
||||||
|
secrets: {},
|
||||||
|
inputs: [],
|
||||||
|
outputs: [
|
||||||
|
{
|
||||||
|
lockingBytecode:
|
||||||
|
"76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac",
|
||||||
|
valueSatoshis: 3500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "customer",
|
||||||
|
values: {
|
||||||
|
generated: {},
|
||||||
|
variables: {},
|
||||||
|
secrets: {},
|
||||||
|
inputs: [],
|
||||||
|
outputs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
270
src/templates/wrap-template.ts
Normal file
270
src/templates/wrap-template.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
|
||||||
|
export const wrapBCHTemplate: XOTemplate = {
|
||||||
|
$schema: "https://libauth.org/schemas/wallet-template-v0.schema.json",
|
||||||
|
|
||||||
|
name: "Wrapped BCH",
|
||||||
|
description: "Convert between BCH and wBCH tokens.",
|
||||||
|
icon: "wrap",
|
||||||
|
|
||||||
|
version: "1",
|
||||||
|
supported: ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"],
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
user: {
|
||||||
|
name: "User",
|
||||||
|
description: "The person wrapping or unwrapping BCH.",
|
||||||
|
icon: "user",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
start: [
|
||||||
|
{
|
||||||
|
action: "wrap",
|
||||||
|
role: "user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "unwrap",
|
||||||
|
role: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
wrap: {
|
||||||
|
name: "Wrap BCH",
|
||||||
|
description: "Convert BCH into wBCH tokens.",
|
||||||
|
icon: "wrap",
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
user: {
|
||||||
|
requirements: {
|
||||||
|
variables: ["amountToWrap", "recipientLockingScript"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
requirements: {
|
||||||
|
participants: [{ role: "user", slots: { min: 1, max: 1 } }],
|
||||||
|
},
|
||||||
|
|
||||||
|
transaction: "wrapTransaction",
|
||||||
|
},
|
||||||
|
|
||||||
|
unwrap: {
|
||||||
|
name: "Unwrap wBCH",
|
||||||
|
description: "Convert wBCH tokens back into BCH.",
|
||||||
|
icon: "unwrap",
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
user: {
|
||||||
|
requirements: {
|
||||||
|
variables: ["amountToUnwrap", "recipientLockingScript"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
requirements: {
|
||||||
|
participants: [{ role: "user", slots: { min: 1, max: 1 } }],
|
||||||
|
},
|
||||||
|
|
||||||
|
transaction: "unwrapTransaction",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
transactions: {
|
||||||
|
wrapTransaction: {
|
||||||
|
name: "Wrapped BCH",
|
||||||
|
description:
|
||||||
|
"Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) BCH into wBCH tokens.",
|
||||||
|
icon: "wrap",
|
||||||
|
|
||||||
|
inputs: [{ input: "covenantInput", inputIndex: 0 }],
|
||||||
|
outputs: [
|
||||||
|
{ output: "covenantOutput", outputIndex: 0 },
|
||||||
|
{ output: "wrappedTokensOutput", outputIndex: undefined },
|
||||||
|
],
|
||||||
|
|
||||||
|
version: 2,
|
||||||
|
locktime: 0,
|
||||||
|
composable: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
unwrapTransaction: {
|
||||||
|
name: "Unwrapped wBCH",
|
||||||
|
description:
|
||||||
|
"Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) wBCH tokens back into BCH.",
|
||||||
|
icon: "unwrap",
|
||||||
|
|
||||||
|
inputs: [{ input: "covenantInput", inputIndex: 0 }],
|
||||||
|
outputs: [
|
||||||
|
{ output: "covenantOutput", outputIndex: 0 },
|
||||||
|
{ output: "unwrappedSatoshisOutput", outputIndex: undefined },
|
||||||
|
],
|
||||||
|
|
||||||
|
version: 2,
|
||||||
|
locktime: 0,
|
||||||
|
composable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
outputs: {
|
||||||
|
covenantOutput: {
|
||||||
|
name: "wBCH Covenant",
|
||||||
|
description: "Holds BCH and wBCH tokens that can be freely converted.",
|
||||||
|
icon: "contract",
|
||||||
|
|
||||||
|
lockingScript: "wrapBCHLockingScript",
|
||||||
|
},
|
||||||
|
|
||||||
|
wrappedTokensOutput: {
|
||||||
|
name: "Wrapped wBCH",
|
||||||
|
description:
|
||||||
|
"Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) wBCH tokens.",
|
||||||
|
icon: "receive",
|
||||||
|
|
||||||
|
valueSatoshis: "$(<amountToWrap>)",
|
||||||
|
token: {
|
||||||
|
category: "$(<wbchTokenCategory>)",
|
||||||
|
amount: "$(<amountToWrap>)",
|
||||||
|
nft: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
user: {
|
||||||
|
balance: {
|
||||||
|
satoshis: true,
|
||||||
|
fungibleTokens: true,
|
||||||
|
nonfungibleTokens: true,
|
||||||
|
},
|
||||||
|
selectable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
lockingScript: "$(<recipientLockingScript>)",
|
||||||
|
},
|
||||||
|
|
||||||
|
unwrappedSatoshisOutput: {
|
||||||
|
name: "Unwrapped BCH",
|
||||||
|
description:
|
||||||
|
"Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) BCH.",
|
||||||
|
icon: "receive",
|
||||||
|
|
||||||
|
valueSatoshis: "$(<amountToUnwrap>)",
|
||||||
|
token: null,
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
user: {
|
||||||
|
balance: {
|
||||||
|
satoshis: true,
|
||||||
|
fungibleTokens: true,
|
||||||
|
nonfungibleTokens: true,
|
||||||
|
},
|
||||||
|
selectable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
lockingScript: "$(<recipientLockingScript>)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
inputs: {
|
||||||
|
covenantInput: {
|
||||||
|
name: "wBCH Covenant",
|
||||||
|
description: "The covenant being updated.",
|
||||||
|
icon: "contract",
|
||||||
|
|
||||||
|
unlockingScript: "unlockCovenant",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
lockingScripts: {
|
||||||
|
wrapBCHLockingScript: {
|
||||||
|
name: "wBCH Covenant",
|
||||||
|
description: "Holds BCH and wBCH tokens that can be freely converted.",
|
||||||
|
icon: "contract",
|
||||||
|
|
||||||
|
lockingType: "p2sh",
|
||||||
|
lockingBytecode: "wrapBCHLockingBytecode",
|
||||||
|
|
||||||
|
actions: [
|
||||||
|
{ action: "wrap", role: "user" },
|
||||||
|
{ action: "unwrap", role: "user" },
|
||||||
|
],
|
||||||
|
|
||||||
|
state: {
|
||||||
|
variables: [],
|
||||||
|
secrets: [],
|
||||||
|
},
|
||||||
|
balance: {
|
||||||
|
satoshis: 0n,
|
||||||
|
fungibleTokens: 0n,
|
||||||
|
},
|
||||||
|
selectable: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
scripts: {
|
||||||
|
enforceCovenantPersists:
|
||||||
|
"OP_INPUTINDEX OP_DUP OP_OUTPUTBYTECODE OP_SWAP OP_UTXOBYTECODE OP_EQUAL OP_VERIFY",
|
||||||
|
enforceTokenCategoryPreserved:
|
||||||
|
"OP_INPUTINDEX OP_DUP OP_OUTPUTTOKENCATEGORY OP_SWAP OP_UTXOTOKENCATEGORY OP_EQUAL OP_VERIFY",
|
||||||
|
enforceValueTokenSumConserved:
|
||||||
|
"OP_INPUTINDEX OP_UTXOVALUE OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_ADD OP_INPUTINDEX OP_OUTPUTVALUE OP_INPUTINDEX OP_OUTPUTTOKENAMOUNT OP_ADD OP_EQUAL OP_VERIFY",
|
||||||
|
|
||||||
|
// Direct script references — introspection opcodes must not use $(...) evaluations
|
||||||
|
// because those are evaluated at compile time without transaction context.
|
||||||
|
wrapBCHLockingBytecode:
|
||||||
|
"enforceCovenantPersists enforceTokenCategoryPreserved enforceValueTokenSumConserved",
|
||||||
|
unlockCovenant: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
constants: {
|
||||||
|
wbchTokenCategory: {
|
||||||
|
name: "wBCH Token Category",
|
||||||
|
description: "The official token category for Wrapped BCH.",
|
||||||
|
type: "bytes",
|
||||||
|
value: "ff4d6e4b90aa8158d39c5dc874fd9411af1ac3b5ed6f354755e8362a0d02c6b3",
|
||||||
|
},
|
||||||
|
satoshisPerBCH: {
|
||||||
|
name: "Satoshis per BCH",
|
||||||
|
description: "Used to display amounts in BCH with decimals.",
|
||||||
|
type: "integer",
|
||||||
|
value: 100000000,
|
||||||
|
},
|
||||||
|
tokenDust: {
|
||||||
|
name: "Token Dust Limit",
|
||||||
|
description: "Minimal satoshis required for a token-bearing output.",
|
||||||
|
type: "integer",
|
||||||
|
value: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
variables: {
|
||||||
|
amountToWrap: {
|
||||||
|
name: "Amount to Wrap",
|
||||||
|
description: "How much BCH to convert to wBCH (in satoshis).",
|
||||||
|
type: "integer",
|
||||||
|
hint: "satoshis",
|
||||||
|
},
|
||||||
|
amountToUnwrap: {
|
||||||
|
name: "Amount to Unwrap",
|
||||||
|
description: "How much wBCH to convert back to BCH (in satoshis).",
|
||||||
|
type: "integer",
|
||||||
|
hint: "satoshis",
|
||||||
|
},
|
||||||
|
recipientLockingScript: {
|
||||||
|
name: "Destination",
|
||||||
|
description: "Where to receive your BCH or wBCH tokens.",
|
||||||
|
type: "bytes",
|
||||||
|
hint: "lockingScript",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
icons: [
|
||||||
|
{ name: "wrap", hash: "0000000000000000000000" },
|
||||||
|
{ name: "unwrap", hash: "0000000000000000000000" },
|
||||||
|
{ name: "user", hash: "0000000000000000000000" },
|
||||||
|
{ name: "contract", hash: "0000000000000000000000" },
|
||||||
|
{ name: "receive", hash: "0000000000000000000000" },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -4,9 +4,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text, useApp, useInput } from 'ink';
|
import { Box, Text, useApp } from 'ink';
|
||||||
import { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
|
import { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
|
||||||
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
|
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
|
||||||
|
import { InputLayerProvider, useBlockableInput } from './hooks/useInputLayer.js';
|
||||||
import type { AppConfig } from '../app.js';
|
import type { AppConfig } from '../app.js';
|
||||||
import { colors, logoSmall } from './theme.js';
|
import { colors, logoSmall } from './theme.js';
|
||||||
|
|
||||||
@@ -15,8 +16,7 @@ import { SeedInputScreen } from './screens/SeedInput.js';
|
|||||||
import { WalletStateScreen } from './screens/WalletState.js';
|
import { WalletStateScreen } from './screens/WalletState.js';
|
||||||
import { TemplateListScreen } from './screens/TemplateList.js';
|
import { TemplateListScreen } from './screens/TemplateList.js';
|
||||||
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
|
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
|
||||||
import { InvitationScreen } from './screens/Invitation.js';
|
import { InvitationScreen } from './screens/invitations/InvitationScreen.js';
|
||||||
import { TransactionScreen } from './screens/Transaction.js';
|
|
||||||
|
|
||||||
import { MessageDialog } from './components/Dialog.js';
|
import { MessageDialog } from './components/Dialog.js';
|
||||||
|
|
||||||
@@ -44,8 +44,6 @@ function Router(): React.ReactElement {
|
|||||||
return <ActionWizardScreen />;
|
return <ActionWizardScreen />;
|
||||||
case 'invitations':
|
case 'invitations':
|
||||||
return <InvitationScreen />;
|
return <InvitationScreen />;
|
||||||
case 'transaction':
|
|
||||||
return <TransactionScreen />;
|
|
||||||
default:
|
default:
|
||||||
return <Text color={colors.error}>Unknown screen: {screen}</Text>;
|
return <Text color={colors.error}>Unknown screen: {screen}</Text>;
|
||||||
}
|
}
|
||||||
@@ -78,21 +76,7 @@ function StatusBar(): React.ReactElement {
|
|||||||
* Dialog overlay component for modals.
|
* Dialog overlay component for modals.
|
||||||
*/
|
*/
|
||||||
function DialogOverlay(): React.ReactElement | null {
|
function DialogOverlay(): React.ReactElement | null {
|
||||||
const { dialog, setDialog } = useDialog();
|
const { dialog } = useDialog();
|
||||||
|
|
||||||
useInput((input, key) => {
|
|
||||||
if (!dialog?.visible) return;
|
|
||||||
|
|
||||||
if (key.return || input === 'y' || input === 'Y') {
|
|
||||||
if (dialog.type === 'confirm' && dialog.onConfirm) {
|
|
||||||
dialog.onConfirm();
|
|
||||||
} else {
|
|
||||||
dialog.onCancel?.();
|
|
||||||
}
|
|
||||||
} else if (key.escape || input === 'n' || input === 'N') {
|
|
||||||
dialog.onCancel?.();
|
|
||||||
}
|
|
||||||
}, { isActive: dialog?.visible ?? false });
|
|
||||||
|
|
||||||
if (!dialog?.visible) return null;
|
if (!dialog?.visible) return null;
|
||||||
|
|
||||||
@@ -128,16 +112,12 @@ function MainContent(): React.ReactElement {
|
|||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const { goBack, canGoBack } = useNavigation();
|
const { goBack, canGoBack } = useNavigation();
|
||||||
const { screen } = useNavigation();
|
const { screen } = useNavigation();
|
||||||
const { dialog } = useDialog();
|
|
||||||
const appContext = useAppContext();
|
const appContext = useAppContext();
|
||||||
|
|
||||||
// Global keybindings (disabled when dialog is shown)
|
// Global keybindings — auto-blocked when any dialog/overlay is capturing input.
|
||||||
useInput((input, key) => {
|
useBlockableInput((input, key) => {
|
||||||
// Don't handle global keys when dialog is shown
|
// Quit on Ctrl+C
|
||||||
if (dialog?.visible) return;
|
if (key.ctrl && input === 'c') {
|
||||||
|
|
||||||
// Quit on 'q' or Ctrl+C
|
|
||||||
if (input === 'q' || (key.ctrl && input === 'c')) {
|
|
||||||
appContext.exit();
|
appContext.exit();
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
@@ -179,8 +159,8 @@ function MainContent(): React.ReactElement {
|
|||||||
export function App({ config }: AppProps): React.ReactElement {
|
export function App({ config }: AppProps): React.ReactElement {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
|
|
||||||
const handleExit = () => {
|
|
||||||
// Cleanup will be handled by React when components unmount
|
// Cleanup will be handled by React when components unmount
|
||||||
|
const handleExit = () => {
|
||||||
exit();
|
exit();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,9 +169,11 @@ export function App({ config }: AppProps): React.ReactElement {
|
|||||||
config={config}
|
config={config}
|
||||||
onExit={handleExit}
|
onExit={handleExit}
|
||||||
>
|
>
|
||||||
|
<InputLayerProvider>
|
||||||
<NavigationProvider initialScreen="seed-input">
|
<NavigationProvider initialScreen="seed-input">
|
||||||
<MainContent />
|
<MainContent />
|
||||||
</NavigationProvider>
|
</NavigationProvider>
|
||||||
|
</InputLayerProvider>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
188
src/tui/components/CurrencySelectionDialog.tsx
Normal file
188
src/tui/components/CurrencySelectionDialog.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import React, { useEffect, useId, useMemo, useState } from "react";
|
||||||
|
import { Box, Text } from "ink";
|
||||||
|
|
||||||
|
import { ScrollableList, type ListItemData } from "./List.js";
|
||||||
|
import TextInput from "./TextInput.js";
|
||||||
|
import { DialogWrapper } from "./Dialog.js";
|
||||||
|
import { useInputLayer, useLayeredInput } from "../hooks/useInputLayer.js";
|
||||||
|
import { colors } from "../theme.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the currency selection dialog.
|
||||||
|
*/
|
||||||
|
interface CurrencySelectionDialogProps {
|
||||||
|
/** Current wallet currency from persisted settings. */
|
||||||
|
currentCurrency: string;
|
||||||
|
/** Available fiat numerator symbols that can be paired with BCH. */
|
||||||
|
currencies: string[];
|
||||||
|
/** True while the dialog is loading available pairs. */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Optional loading/error message for pair discovery. */
|
||||||
|
errorMessage: string | null;
|
||||||
|
/** Called when the user chooses a currency and confirms. */
|
||||||
|
onSelectCurrency: (currencyCode: string) => void;
|
||||||
|
/** Called when the dialog should close without applying changes. */
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currency picker dialog.
|
||||||
|
*
|
||||||
|
* UX requirements:
|
||||||
|
* - Arrow keys move the highlighted item.
|
||||||
|
* - Typing immediately filters results.
|
||||||
|
* - Enter applies current selection.
|
||||||
|
* - Escape closes without saving.
|
||||||
|
*/
|
||||||
|
export function CurrencySelectionDialog({
|
||||||
|
currentCurrency,
|
||||||
|
currencies,
|
||||||
|
isLoading,
|
||||||
|
errorMessage,
|
||||||
|
onSelectCurrency,
|
||||||
|
onCancel,
|
||||||
|
}: CurrencySelectionDialogProps): React.ReactElement {
|
||||||
|
const layerId = useId();
|
||||||
|
const [filterText, setFilterText] = useState("");
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
// Mount this as a capturing input layer so background screens stop handling keys.
|
||||||
|
useInputLayer(layerId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the currently selected filtered result.
|
||||||
|
*/
|
||||||
|
const applySelection = (): void => {
|
||||||
|
const selectedCurrency = filteredCurrencies[selectedIndex];
|
||||||
|
if (!selectedCurrency) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelectCurrency(selectedCurrency);
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayeredInput(layerId, (_input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelectedIndex((prev) =>
|
||||||
|
prev <= 0 ? Math.max(filteredCurrencies.length - 1, 0) : prev - 1,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.downArrow) {
|
||||||
|
setSelectedIndex((prev) =>
|
||||||
|
filteredCurrencies.length === 0
|
||||||
|
? 0
|
||||||
|
: prev >= filteredCurrencies.length - 1
|
||||||
|
? 0
|
||||||
|
: prev + 1,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter currencies as the user types.
|
||||||
|
*/
|
||||||
|
const filteredCurrencies = useMemo(() => {
|
||||||
|
const normalizedFilter = filterText.trim().toUpperCase();
|
||||||
|
if (!normalizedFilter) {
|
||||||
|
return currencies;
|
||||||
|
}
|
||||||
|
return currencies.filter((currencyCode) =>
|
||||||
|
currencyCode.toUpperCase().includes(normalizedFilter),
|
||||||
|
);
|
||||||
|
}, [currencies, filterText]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep selected index valid whenever filtering shrinks the result set.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (filteredCurrencies.length === 0) {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedIndex >= filteredCurrencies.length) {
|
||||||
|
setSelectedIndex(filteredCurrencies.length - 1);
|
||||||
|
}
|
||||||
|
}, [filteredCurrencies, selectedIndex]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the dialog opens or the list updates, default to current currency.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (filterText.trim().length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = filteredCurrencies.findIndex(
|
||||||
|
(currencyCode) => currencyCode.toUpperCase() === currentCurrency.toUpperCase(),
|
||||||
|
);
|
||||||
|
if (currentIndex >= 0) {
|
||||||
|
setSelectedIndex(currentIndex);
|
||||||
|
}
|
||||||
|
}, [filteredCurrencies, currentCurrency, filterText]);
|
||||||
|
|
||||||
|
const listItems: ListItemData<string>[] = filteredCurrencies.map(
|
||||||
|
(currencyCode) => ({
|
||||||
|
key: currencyCode,
|
||||||
|
label: currencyCode,
|
||||||
|
description:
|
||||||
|
currencyCode.toUpperCase() === currentCurrency.toUpperCase()
|
||||||
|
? "(current)"
|
||||||
|
: undefined,
|
||||||
|
value: currencyCode,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogWrapper title="Select Fiat Currency" borderColor={colors.info} width={64}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Available BCH quote pairs are loaded from the live rates adapter.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.primary}>Filter:</Text>
|
||||||
|
</Box>
|
||||||
|
<Box borderStyle="single" borderColor={colors.focus} paddingX={1}>
|
||||||
|
<TextInput
|
||||||
|
value={filterText}
|
||||||
|
onChange={setFilterText}
|
||||||
|
onSubmit={() => applySelection()}
|
||||||
|
placeholder="Type currency code (e.g. USD, AUD)..."
|
||||||
|
focus
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{isLoading ? (
|
||||||
|
<Text color={colors.textMuted}>Loading available pairs...</Text>
|
||||||
|
) : errorMessage ? (
|
||||||
|
<Text color={colors.error}>{errorMessage}</Text>
|
||||||
|
) : (
|
||||||
|
<ScrollableList
|
||||||
|
items={listItems}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onSelect={setSelectedIndex}
|
||||||
|
onActivate={() => applySelection()}
|
||||||
|
focus={false}
|
||||||
|
maxVisible={8}
|
||||||
|
emptyMessage="No BCH quote pairs match this filter."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Type to filter • ↑↓ navigate • Enter apply • Esc cancel
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</DialogWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,10 +2,17 @@
|
|||||||
* Dialog components for modals, confirmations, and input dialogs.
|
* Dialog components for modals, confirmations, and input dialogs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useRef, useState } from 'react';
|
import React, { useId, useMemo, useRef, useState } from 'react';
|
||||||
import { Box, Text, useInput, measureElement } from 'ink';
|
import { Box, Text, measureElement, useStdout } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import TextInput from './TextInput.js';
|
||||||
import { colors } from '../theme.js';
|
import { colors } from '../theme.js';
|
||||||
|
import { useInputLayer, useLayeredInput } from '../hooks/useInputLayer.js';
|
||||||
|
import {
|
||||||
|
formatDialogMessageLines,
|
||||||
|
getMessageContentWidth,
|
||||||
|
getMessageDialogWidth,
|
||||||
|
MAX_MESSAGE_DIALOG_LINES,
|
||||||
|
} from '../utils/format-dialog-message.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base dialog wrapper props.
|
* Base dialog wrapper props.
|
||||||
@@ -24,11 +31,12 @@ interface DialogWrapperProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function DialogWrapper({
|
export function DialogWrapper({
|
||||||
title,
|
title,
|
||||||
borderColor = colors.primary,
|
borderColor = colors.primary,
|
||||||
children,
|
children,
|
||||||
width = 60,
|
width = 60,
|
||||||
|
backgroundColor = colors.bg,
|
||||||
}: DialogWrapperProps): React.ReactElement {
|
}: DialogWrapperProps): React.ReactElement {
|
||||||
const ref = useRef<any>(null);
|
const ref = useRef<any>(null);
|
||||||
const [height, setHeight] = useState<number | null>(null);
|
const [height, setHeight] = useState<number | null>(null);
|
||||||
@@ -51,9 +59,12 @@ function DialogWrapper({
|
|||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
|
backgroundColor={backgroundColor}
|
||||||
>
|
>
|
||||||
{Array.from({ length: height }).map((_, i) => (
|
{Array.from({ length: height }).map((_, i) => (
|
||||||
<Text key={i}>{' '.repeat(width)}</Text>
|
<Text key={i} backgroundColor={backgroundColor}>
|
||||||
|
{' '.repeat(width)}
|
||||||
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -67,6 +78,7 @@ function DialogWrapper({
|
|||||||
paddingX={2}
|
paddingX={2}
|
||||||
paddingY={1}
|
paddingY={1}
|
||||||
width={width}
|
width={width}
|
||||||
|
backgroundColor={backgroundColor}
|
||||||
>
|
>
|
||||||
<Text color={borderColor} bold>
|
<Text color={borderColor} bold>
|
||||||
{title}
|
{title}
|
||||||
@@ -113,15 +125,17 @@ export function InputDialog({
|
|||||||
onCancel,
|
onCancel,
|
||||||
isActive = true,
|
isActive = true,
|
||||||
}: InputDialogProps): React.ReactElement {
|
}: InputDialogProps): React.ReactElement {
|
||||||
|
const layerId = useId();
|
||||||
const [value, setValue] = useState(initialValue);
|
const [value, setValue] = useState(initialValue);
|
||||||
|
|
||||||
useInput((input, key) => {
|
// Auto-capture input when this dialog is mounted.
|
||||||
if (!isActive) return;
|
useInputLayer(layerId);
|
||||||
|
|
||||||
|
useLayeredInput(layerId, (_input, key) => {
|
||||||
if (key.escape) {
|
if (key.escape) {
|
||||||
onCancel();
|
onCancel();
|
||||||
}
|
}
|
||||||
}, { isActive });
|
});
|
||||||
|
|
||||||
const handleSubmit = (val: string) => {
|
const handleSubmit = (val: string) => {
|
||||||
onSubmit(val);
|
onSubmit(val);
|
||||||
@@ -178,11 +192,13 @@ export function ConfirmDialog({
|
|||||||
confirmLabel = 'Yes',
|
confirmLabel = 'Yes',
|
||||||
cancelLabel = 'No',
|
cancelLabel = 'No',
|
||||||
}: ConfirmDialogProps): React.ReactElement {
|
}: ConfirmDialogProps): React.ReactElement {
|
||||||
|
const layerId = useId();
|
||||||
const [selected, setSelected] = useState<'confirm' | 'cancel'>('confirm');
|
const [selected, setSelected] = useState<'confirm' | 'cancel'>('confirm');
|
||||||
|
|
||||||
useInput((input, key) => {
|
// Auto-capture input when this dialog is mounted.
|
||||||
if (!isActive) return;
|
useInputLayer(layerId);
|
||||||
|
|
||||||
|
useLayeredInput(layerId, (input, key) => {
|
||||||
if (key.leftArrow || key.rightArrow || key.tab) {
|
if (key.leftArrow || key.rightArrow || key.tab) {
|
||||||
setSelected(prev => prev === 'confirm' ? 'cancel' : 'confirm');
|
setSelected(prev => prev === 'confirm' ? 'cancel' : 'confirm');
|
||||||
} else if (key.return) {
|
} else if (key.return) {
|
||||||
@@ -196,7 +212,7 @@ export function ConfirmDialog({
|
|||||||
} else if (input === 'y' || input === 'Y') {
|
} else if (input === 'y' || input === 'Y') {
|
||||||
onConfirm();
|
onConfirm();
|
||||||
}
|
}
|
||||||
}, { isActive });
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogWrapper title={title} borderColor={colors.warning}>
|
<DialogWrapper title={title} borderColor={colors.warning}>
|
||||||
@@ -250,9 +266,29 @@ export function MessageDialog({
|
|||||||
type = 'info',
|
type = 'info',
|
||||||
isActive = true,
|
isActive = true,
|
||||||
}: MessageDialogProps): React.ReactElement {
|
}: MessageDialogProps): React.ReactElement {
|
||||||
useInput((input, key) => {
|
const layerId = useId();
|
||||||
if (!isActive) return;
|
const { stdout } = useStdout();
|
||||||
|
const dialogWidth = getMessageDialogWidth(stdout.columns ?? 80);
|
||||||
|
const contentWidth = getMessageContentWidth(dialogWidth);
|
||||||
|
|
||||||
|
const messageLines = useMemo(() => {
|
||||||
|
const formattedLines = formatDialogMessageLines(message, contentWidth);
|
||||||
|
|
||||||
|
if (formattedLines.length <= MAX_MESSAGE_DIALOG_LINES) {
|
||||||
|
return formattedLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiddenLineCount = formattedLines.length - MAX_MESSAGE_DIALOG_LINES;
|
||||||
|
return [
|
||||||
|
...formattedLines.slice(0, MAX_MESSAGE_DIALOG_LINES),
|
||||||
|
`... and ${hiddenLineCount} more line(s)`,
|
||||||
|
];
|
||||||
|
}, [contentWidth, message]);
|
||||||
|
|
||||||
|
// Auto-capture input when this dialog is mounted.
|
||||||
|
useInputLayer(layerId);
|
||||||
|
|
||||||
|
useLayeredInput(layerId, (_input, key) => {
|
||||||
if (key.return || key.escape) {
|
if (key.return || key.escape) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
@@ -267,8 +303,16 @@ export function MessageDialog({
|
|||||||
'ℹ';
|
'ℹ';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogWrapper title={`${icon} ${title}`} borderColor={borderColor}>
|
<DialogWrapper
|
||||||
<Text wrap="wrap">{message}</Text>
|
title={`${icon} ${title}`}
|
||||||
|
borderColor={borderColor}
|
||||||
|
width={dialogWidth}
|
||||||
|
>
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{messageLines.map((line, index) => (
|
||||||
|
<Text key={`${index}-${line.slice(0, 24)}`}>{line}</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
|
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
273
src/tui/components/FilePicker.tsx
Normal file
273
src/tui/components/FilePicker.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* Terminal file picker for browsing directories and selecting files.
|
||||||
|
*
|
||||||
|
* This component does not include a dialog wrapper — consumers wrap it in
|
||||||
|
* {@link DialogWrapper} when needed. When used inside a dialog overlay, pass
|
||||||
|
* `layerId` so keyboard input is routed through the input-layer stack instead
|
||||||
|
* of conflicting with background {@link ScrollableList} handlers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Box, Text } from "ink";
|
||||||
|
|
||||||
|
import { ScrollableList, type ListItemData } from "./List.js";
|
||||||
|
import { useLayeredInput } from "../hooks/useInputLayer.js";
|
||||||
|
import { colors } from "../theme.js";
|
||||||
|
import {
|
||||||
|
listDirectoryEntries,
|
||||||
|
type DirectoryEntry,
|
||||||
|
} from "../utils/list-directory-entries.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for {@link FilePicker}.
|
||||||
|
*/
|
||||||
|
export interface FilePickerProps {
|
||||||
|
/** Starting directory. Defaults to `process.cwd()`. */
|
||||||
|
initialDirectory?: string;
|
||||||
|
/**
|
||||||
|
* Allowed file extensions without a leading dot (e.g. `['json']`).
|
||||||
|
* Omit to show all files. Directories are always shown.
|
||||||
|
*/
|
||||||
|
extensions?: string[];
|
||||||
|
/**
|
||||||
|
* Input-layer id for dialog use. When set, this component handles ↑↓/Enter
|
||||||
|
* via {@link useLayeredInput} and disables {@link ScrollableList} focus.
|
||||||
|
*/
|
||||||
|
layerId?: string;
|
||||||
|
/** Whether the list receives keyboard focus when `layerId` is not set. */
|
||||||
|
focus?: boolean;
|
||||||
|
/** Maximum visible rows in the scroll window. */
|
||||||
|
maxVisible?: number;
|
||||||
|
/** Called when the user confirms a file with Enter. */
|
||||||
|
onSelectFile: (absolutePath: string) => void;
|
||||||
|
/** Optional callback whenever the browsed directory changes. */
|
||||||
|
onDirectoryChange?: (absolutePath: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncates a long path for display, keeping the end visible.
|
||||||
|
*/
|
||||||
|
function formatDirectoryPath(directoryPath: string, maxLength = 56): string {
|
||||||
|
if (directoryPath.length <= maxLength) {
|
||||||
|
return directoryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `...${directoryPath.slice(-(maxLength - 3))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds list row metadata for a directory entry.
|
||||||
|
*/
|
||||||
|
function toListItem(entry: DirectoryEntry): ListItemData<DirectoryEntry> {
|
||||||
|
if (entry.kind === "parent") {
|
||||||
|
return {
|
||||||
|
key: "__parent__",
|
||||||
|
label: "..",
|
||||||
|
description: "Parent directory",
|
||||||
|
value: entry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.kind === "directory") {
|
||||||
|
return {
|
||||||
|
key: `dir:${entry.absolutePath}`,
|
||||||
|
label: entry.name,
|
||||||
|
description: "Directory",
|
||||||
|
value: entry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: `file:${entry.absolutePath}`,
|
||||||
|
label: entry.name,
|
||||||
|
value: entry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic terminal file picker with optional extension filtering.
|
||||||
|
*/
|
||||||
|
export function FilePicker({
|
||||||
|
initialDirectory = process.cwd(),
|
||||||
|
extensions,
|
||||||
|
layerId,
|
||||||
|
focus = true,
|
||||||
|
maxVisible = 10,
|
||||||
|
onSelectFile,
|
||||||
|
onDirectoryChange,
|
||||||
|
}: FilePickerProps): React.ReactElement {
|
||||||
|
const [currentDirectory, setCurrentDirectory] = useState(() =>
|
||||||
|
initialDirectory,
|
||||||
|
);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [loadError, setLoadError] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const { entries, error } = useMemo(
|
||||||
|
() => listDirectoryEntries(currentDirectory, { extensions }),
|
||||||
|
[currentDirectory, extensions],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadError(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [currentDirectory, extensions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (entries.length === 0) {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedIndex >= entries.length) {
|
||||||
|
setSelectedIndex(entries.length - 1);
|
||||||
|
}
|
||||||
|
}, [entries, selectedIndex]);
|
||||||
|
|
||||||
|
const listItems = useMemo(
|
||||||
|
(): ListItemData<DirectoryEntry>[] => entries.map(toListItem),
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves selection to the previous visible row, wrapping at the top.
|
||||||
|
*/
|
||||||
|
const selectPrevious = useCallback((): void => {
|
||||||
|
setSelectedIndex((previous) =>
|
||||||
|
previous <= 0 ? Math.max(entries.length - 1, 0) : previous - 1,
|
||||||
|
);
|
||||||
|
}, [entries.length]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves selection to the next visible row, wrapping at the bottom.
|
||||||
|
*/
|
||||||
|
const selectNext = useCallback((): void => {
|
||||||
|
setSelectedIndex((previous) =>
|
||||||
|
entries.length === 0
|
||||||
|
? 0
|
||||||
|
: previous >= entries.length - 1
|
||||||
|
? 0
|
||||||
|
: previous + 1,
|
||||||
|
);
|
||||||
|
}, [entries.length]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the current row: navigate for parent/directory, select for files.
|
||||||
|
*/
|
||||||
|
const activateSelectedEntry = useCallback((): void => {
|
||||||
|
const entry = entries[selectedIndex];
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.kind === "parent" || entry.kind === "directory") {
|
||||||
|
setCurrentDirectory(entry.absolutePath);
|
||||||
|
onDirectoryChange?.(entry.absolutePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectFile(entry.absolutePath);
|
||||||
|
}, [entries, onDirectoryChange, onSelectFile, selectedIndex]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog overlays must pass `layerId` because ScrollableList uses raw ink
|
||||||
|
* `useInput`, which does not respect the input capture stack.
|
||||||
|
*/
|
||||||
|
useLayeredInput(
|
||||||
|
layerId ?? "file-picker-standalone",
|
||||||
|
(_input, key) => {
|
||||||
|
if (!layerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.upArrow) {
|
||||||
|
selectPrevious();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.downArrow) {
|
||||||
|
selectNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.return) {
|
||||||
|
activateSelectedEntry();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: Boolean(layerId) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
(
|
||||||
|
item: ListItemData<DirectoryEntry>,
|
||||||
|
isSelected: boolean,
|
||||||
|
isFocused: boolean,
|
||||||
|
): React.ReactNode => {
|
||||||
|
const entry = item.value;
|
||||||
|
/**
|
||||||
|
* Inside dialogs, ScrollableList focus is disabled (input comes from layerId).
|
||||||
|
* Treat the selected row as highlighted so it matches other focused lists.
|
||||||
|
*/
|
||||||
|
const isHighlighted = layerId ? isSelected : isFocused;
|
||||||
|
const textColor = isHighlighted ? colors.focus : colors.text;
|
||||||
|
const indicator = isHighlighted ? "▸ " : " ";
|
||||||
|
|
||||||
|
if (entry?.kind === "parent") {
|
||||||
|
return (
|
||||||
|
<Text color={textColor} bold={isSelected}>
|
||||||
|
{indicator}
|
||||||
|
⬆ ..
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry?.kind === "directory") {
|
||||||
|
return (
|
||||||
|
<Text color={textColor} bold={isSelected}>
|
||||||
|
{indicator}
|
||||||
|
📁 {item.label}/
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text color={textColor} bold={isSelected}>
|
||||||
|
{indicator}
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[layerId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const listFocus = layerId ? false : focus;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Directory: {formatDirectoryPath(currentDirectory)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{loadError ? (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.error}>{loadError}</Text>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<ScrollableList
|
||||||
|
items={listItems}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onSelect={setSelectedIndex}
|
||||||
|
onActivate={() => activateSelectedEntry()}
|
||||||
|
focus={listFocus}
|
||||||
|
maxVisible={maxVisible}
|
||||||
|
emptyMessage="No matching files or folders"
|
||||||
|
renderItem={renderItem}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import TextInput from './TextInput.js';
|
||||||
import { colors } from '../theme.js';
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import React, { useState, useMemo, useCallback } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text, useInput } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import TextInput from './TextInput.js';
|
||||||
import { colors } from '../theme.js';
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -116,8 +116,14 @@ interface LoadingProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Loading({ message = 'Loading...' }: LoadingProps): React.ReactElement {
|
export function Loading({ message = 'Loading...' }: LoadingProps): React.ReactElement {
|
||||||
// Simple spinner using Ink's spinner component
|
|
||||||
const Spinner = require('ink-spinner').default;
|
// Was using ink-spinner, but its not updated for react 19.
|
||||||
|
// Just putting nothing here for now
|
||||||
|
const Spinner = (props: any) => {
|
||||||
|
return (
|
||||||
|
<></>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
202
src/tui/components/QRCode.tsx
Normal file
202
src/tui/components/QRCode.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* QR Code component for displaying scannable QR codes in the terminal.
|
||||||
|
*
|
||||||
|
* Uses the lower-half-block character (▄) exclusively for rendering. The top
|
||||||
|
* half of each cell is controlled via backgroundColor and the bottom half via
|
||||||
|
* the foreground color. This avoids the sub-pixel seams that occur when mixing
|
||||||
|
* different Unicode block characters (█, ▀, ▄, space) across adjacent rows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import QRCodeLib from 'qrcode';
|
||||||
|
import { DialogWrapper } from './Dialog.js';
|
||||||
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
|
/** Color used for light (background) QR modules. */
|
||||||
|
const LIGHT = 'white';
|
||||||
|
|
||||||
|
/** Color used for dark (data) QR modules. Must match the dialog/terminal bg. */
|
||||||
|
const DARK = colors.bg as string;
|
||||||
|
|
||||||
|
/** Default quiet zone size in modules (QR spec recommends 4, 2 is usually sufficient). */
|
||||||
|
const QUIET_ZONE = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A run of consecutive characters in a rendered QR row that share the
|
||||||
|
* same foreground/background color pair.
|
||||||
|
*/
|
||||||
|
interface ColorSpan {
|
||||||
|
/** The repeated ▄ characters for this span. */
|
||||||
|
chars: string;
|
||||||
|
/** Foreground color (controls the bottom half of each cell). */
|
||||||
|
fg: string;
|
||||||
|
/** Background color (controls the top half of each cell). */
|
||||||
|
bg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the QRCode component.
|
||||||
|
*/
|
||||||
|
interface QRCodeProps {
|
||||||
|
/** The data to encode in the QR code. */
|
||||||
|
value: string;
|
||||||
|
/** Whether to wrap the QR code in a DialogWrapper. */
|
||||||
|
dialog?: boolean;
|
||||||
|
/** Dialog title (only used when dialog is true). Defaults to "QR Code". */
|
||||||
|
dialogTitle?: string;
|
||||||
|
/** Whether to display the raw encoded value as copyable text above the QR code. */
|
||||||
|
showValue?: boolean;
|
||||||
|
/** Optional subtitle to display below the QR code. */
|
||||||
|
subtitle?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the QR code module matrix with a quiet zone border.
|
||||||
|
*
|
||||||
|
* @param value - The string to encode.
|
||||||
|
* @param quietZone - Number of light-module rows/columns to add around the QR data.
|
||||||
|
* @returns A 2D array where `true` means dark module and `false` means light module.
|
||||||
|
*/
|
||||||
|
function generateMatrix(value: string, quietZone: number = QUIET_ZONE): boolean[][] {
|
||||||
|
const qr = QRCodeLib.create(value, { errorCorrectionLevel: 'M' });
|
||||||
|
const { size, data } = qr.modules;
|
||||||
|
const totalSize = size + quietZone * 2;
|
||||||
|
|
||||||
|
const matrix: boolean[][] = [];
|
||||||
|
|
||||||
|
for (let row = 0; row < totalSize; row++) {
|
||||||
|
const matrixRow: boolean[] = [];
|
||||||
|
|
||||||
|
for (let col = 0; col < totalSize; col++) {
|
||||||
|
const qrRow = row - quietZone;
|
||||||
|
const qrCol = col - quietZone;
|
||||||
|
const insideData = qrRow >= 0 && qrRow < size && qrCol >= 0 && qrCol < size;
|
||||||
|
|
||||||
|
// Quiet zone modules are always light (false).
|
||||||
|
matrixRow.push(insideData ? data[qrRow * size + qrCol] === 1 : false);
|
||||||
|
}
|
||||||
|
|
||||||
|
matrix.push(matrixRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a pair of module rows into an array of {@link ColorSpan}s.
|
||||||
|
*
|
||||||
|
* Every cell uses the `▄` (lower half block) character. The foreground color
|
||||||
|
* paints the bottom half and the backgroundColor paints the top half, giving
|
||||||
|
* us artifact-free rendering with a single glyph.
|
||||||
|
*
|
||||||
|
* Consecutive cells that share the same color pair are merged into one span
|
||||||
|
* to keep the element count low.
|
||||||
|
*
|
||||||
|
* @param matrix - The full module matrix.
|
||||||
|
* @param row - The index of the top row in the pair (the bottom row is row + 1).
|
||||||
|
* @returns An array of color spans for this terminal line.
|
||||||
|
*/
|
||||||
|
function buildRowSpans(matrix: boolean[][], row: number): ColorSpan[] {
|
||||||
|
const width = matrix[0]?.length ?? 0;
|
||||||
|
const spans: ColorSpan[] = [];
|
||||||
|
|
||||||
|
for (let col = 0; col < width; col++) {
|
||||||
|
const topDark = matrix[row]?.[col] ?? false;
|
||||||
|
const bottomDark = matrix[row + 1]?.[col] ?? false;
|
||||||
|
|
||||||
|
// ▄ lower-half block: foreground = bottom color, backgroundColor = top color
|
||||||
|
const fg = bottomDark ? DARK : LIGHT;
|
||||||
|
const bg = topDark ? DARK : LIGHT;
|
||||||
|
|
||||||
|
const last = spans[spans.length - 1];
|
||||||
|
if (last && last.fg === fg && last.bg === bg) {
|
||||||
|
last.chars += '▄';
|
||||||
|
} else {
|
||||||
|
spans.push({ chars: '▄', fg, bg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the full module matrix into an array of span-arrays, one per
|
||||||
|
* terminal row (each covering two QR module rows).
|
||||||
|
*
|
||||||
|
* @param matrix - The 2D dark/light module matrix from {@link generateMatrix}.
|
||||||
|
*/
|
||||||
|
function renderMatrix(matrix: boolean[][]): ColorSpan[][] {
|
||||||
|
const rows: ColorSpan[][] = [];
|
||||||
|
const height = matrix.length;
|
||||||
|
|
||||||
|
for (let row = 0; row < height; row += 2) {
|
||||||
|
rows.push(buildRowSpans(matrix, row));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a scannable QR code in the terminal.
|
||||||
|
*
|
||||||
|
* Supports optional dialog wrapping via the `dialog` prop and an optional
|
||||||
|
* copyable text display of the encoded value via `showValue`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // Minimal usage
|
||||||
|
* <QRCode value="bitcoincash:qr..." />
|
||||||
|
*
|
||||||
|
* // Inside a dialog with the raw value shown
|
||||||
|
* <QRCode value="bitcoincash:qr..." dialog dialogTitle="Receive Address" showValue />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function QRCode({
|
||||||
|
value,
|
||||||
|
dialog = false,
|
||||||
|
dialogTitle = 'QR Code',
|
||||||
|
showValue = false,
|
||||||
|
subtitle = null,
|
||||||
|
}: QRCodeProps): React.ReactElement {
|
||||||
|
const { rows, moduleCount } = useMemo(() => {
|
||||||
|
const matrix = generateMatrix(value);
|
||||||
|
return {
|
||||||
|
rows: renderMatrix(matrix),
|
||||||
|
moduleCount: matrix[0]?.length ?? 0,
|
||||||
|
};
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const qrContent = (
|
||||||
|
<Box flexDirection="column" alignItems="center">
|
||||||
|
{showValue && (
|
||||||
|
<Box marginBottom={1} width={moduleCount}>
|
||||||
|
<Text color={colors.textMuted} wrap="wrap">{value}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{rows.map((spans, i) => (
|
||||||
|
<Text key={i}>
|
||||||
|
{spans.map((span, j) => (
|
||||||
|
<Text key={j} color={span.fg} backgroundColor={span.bg}>
|
||||||
|
{span.chars}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dialog) {
|
||||||
|
const dialogWidth = Math.max(moduleCount + 8, 40);
|
||||||
|
return (
|
||||||
|
<DialogWrapper title={dialogTitle} borderColor={colors.primary} width={dialogWidth}>
|
||||||
|
{qrContent}
|
||||||
|
{subtitle}
|
||||||
|
</DialogWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return qrContent;
|
||||||
|
}
|
||||||
216
src/tui/components/TextInput.tsx
Normal file
216
src/tui/components/TextInput.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import React, {useState, useEffect} from 'react';
|
||||||
|
import {Text, useInput} from 'ink';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import type {Except} from 'type-fest';
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
/**
|
||||||
|
* Text to display when `value` is empty.
|
||||||
|
*/
|
||||||
|
readonly placeholder?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen to user's input. Useful in case there are multiple input components
|
||||||
|
* at the same time and input must be "routed" to a specific component.
|
||||||
|
*/
|
||||||
|
readonly focus?: boolean; // eslint-disable-line react/boolean-prop-naming
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace all chars and mask the value. Useful for password inputs.
|
||||||
|
*/
|
||||||
|
readonly mask?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show cursor and allow navigation inside text input with arrow keys.
|
||||||
|
*/
|
||||||
|
readonly showCursor?: boolean; // eslint-disable-line react/boolean-prop-naming
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight pasted text
|
||||||
|
*/
|
||||||
|
readonly highlightPastedText?: boolean; // eslint-disable-line react/boolean-prop-naming
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value to display in a text input.
|
||||||
|
*/
|
||||||
|
readonly value: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to call when value updates.
|
||||||
|
*/
|
||||||
|
readonly onChange: (value: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to call when `Enter` is pressed, where first argument is a value of the input.
|
||||||
|
*/
|
||||||
|
readonly onSubmit?: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TextInput({
|
||||||
|
value: originalValue,
|
||||||
|
placeholder = '',
|
||||||
|
focus = true,
|
||||||
|
mask,
|
||||||
|
highlightPastedText = false,
|
||||||
|
showCursor = true,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
}: Props) {
|
||||||
|
const [state, setState] = useState({
|
||||||
|
cursorOffset: (originalValue || '').length,
|
||||||
|
cursorWidth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {cursorOffset, cursorWidth} = state;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState(previousState => {
|
||||||
|
if (!focus || !showCursor) {
|
||||||
|
return previousState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValue = originalValue || '';
|
||||||
|
|
||||||
|
if (previousState.cursorOffset > newValue.length - 1) {
|
||||||
|
return {
|
||||||
|
cursorOffset: newValue.length,
|
||||||
|
cursorWidth: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return previousState;
|
||||||
|
});
|
||||||
|
}, [originalValue, focus, showCursor]);
|
||||||
|
|
||||||
|
const cursorActualWidth = highlightPastedText ? cursorWidth : 0;
|
||||||
|
|
||||||
|
const value = mask ? mask.repeat(originalValue.length) : originalValue;
|
||||||
|
let renderedValue = value;
|
||||||
|
let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined;
|
||||||
|
|
||||||
|
// Fake mouse cursor, because it's too inconvenient to deal with actual cursor and ansi escapes
|
||||||
|
if (showCursor && focus) {
|
||||||
|
renderedPlaceholder =
|
||||||
|
placeholder.length > 0
|
||||||
|
? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1))
|
||||||
|
: chalk.inverse(' ');
|
||||||
|
|
||||||
|
renderedValue = value.length > 0 ? '' : chalk.inverse(' ');
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
for (const char of value) {
|
||||||
|
renderedValue +=
|
||||||
|
i >= cursorOffset - cursorActualWidth && i <= cursorOffset
|
||||||
|
? chalk.inverse(char)
|
||||||
|
: char;
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length > 0 && cursorOffset === value.length) {
|
||||||
|
renderedValue += chalk.inverse(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (
|
||||||
|
key.upArrow ||
|
||||||
|
key.downArrow ||
|
||||||
|
(key.ctrl && input === 'c') ||
|
||||||
|
key.tab ||
|
||||||
|
(key.shift && key.tab)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.return) {
|
||||||
|
if (onSubmit) {
|
||||||
|
onSubmit(originalValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextCursorOffset = cursorOffset;
|
||||||
|
let nextValue = originalValue;
|
||||||
|
let nextCursorWidth = 0;
|
||||||
|
|
||||||
|
if (key.leftArrow) {
|
||||||
|
if (showCursor) {
|
||||||
|
nextCursorOffset--;
|
||||||
|
}
|
||||||
|
} else if (key.rightArrow) {
|
||||||
|
if (showCursor) {
|
||||||
|
nextCursorOffset++;
|
||||||
|
}
|
||||||
|
} else if (key.backspace || key.delete) {
|
||||||
|
if (cursorOffset > 0) {
|
||||||
|
nextValue =
|
||||||
|
originalValue.slice(0, cursorOffset - 1) +
|
||||||
|
originalValue.slice(cursorOffset, originalValue.length);
|
||||||
|
|
||||||
|
nextCursorOffset--;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextValue =
|
||||||
|
originalValue.slice(0, cursorOffset) +
|
||||||
|
input +
|
||||||
|
originalValue.slice(cursorOffset, originalValue.length);
|
||||||
|
|
||||||
|
nextCursorOffset += input.length;
|
||||||
|
|
||||||
|
if (input.length > 1) {
|
||||||
|
nextCursorWidth = input.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursorOffset < 0) {
|
||||||
|
nextCursorOffset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursorOffset > originalValue.length) {
|
||||||
|
nextCursorOffset = originalValue.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({
|
||||||
|
cursorOffset: nextCursorOffset,
|
||||||
|
cursorWidth: nextCursorWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextValue !== originalValue) {
|
||||||
|
onChange(nextValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{isActive: focus},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
{placeholder
|
||||||
|
? value.length > 0
|
||||||
|
? renderedValue
|
||||||
|
: renderedPlaceholder
|
||||||
|
: renderedValue}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextInput;
|
||||||
|
|
||||||
|
type UncontrolledProps = {
|
||||||
|
/**
|
||||||
|
* Initial value.
|
||||||
|
*/
|
||||||
|
readonly initialValue?: string;
|
||||||
|
} & Except<Props, 'value' | 'onChange'>;
|
||||||
|
|
||||||
|
export function UncontrolledTextInput({
|
||||||
|
initialValue = '',
|
||||||
|
...props
|
||||||
|
}: UncontrolledProps) {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
|
||||||
|
return <TextInput {...props} value={value} onChange={setValue} />;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import { Box, Text } from "ink";
|
import { Box, Text } from "ink";
|
||||||
import TextInput from "ink-text-input";
|
import TextInput from "./TextInput.js";
|
||||||
import { formatSatoshis } from "../theme.js";
|
import { useSatoshisConversion } from "../hooks/useSatoshisConversion.js";
|
||||||
|
|
||||||
interface VariableInputFieldProps {
|
interface VariableInputFieldProps {
|
||||||
variable: {
|
variable: {
|
||||||
@@ -19,6 +19,45 @@ interface VariableInputFieldProps {
|
|||||||
focusColor: string;
|
focusColor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SATOSHIS_PER_BCH = 100_000_000n;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the variable is an integer satoshis field.
|
||||||
|
*/
|
||||||
|
function isSatoshisVariable(variable: VariableInputFieldProps["variable"]): boolean {
|
||||||
|
return (
|
||||||
|
variable.type === "integer" &&
|
||||||
|
variable.hint?.toLowerCase().includes("satoshi") === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a strict integer string into bigint.
|
||||||
|
*/
|
||||||
|
function parseSatoshis(value: string): bigint | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!/^[-]?\d+$/.test(trimmed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return BigInt(trimmed);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format satoshis as BCH with fixed 8 decimals, preserving bigint precision.
|
||||||
|
*/
|
||||||
|
function formatBchFromSatoshis(satoshis: bigint): string {
|
||||||
|
const sign = satoshis < 0n ? "-" : "";
|
||||||
|
const absolute = satoshis < 0n ? satoshis * -1n : satoshis;
|
||||||
|
const whole = absolute / SATOSHIS_PER_BCH;
|
||||||
|
const fractional = absolute % SATOSHIS_PER_BCH;
|
||||||
|
return `${sign}${whole.toString()}.${fractional.toString().padStart(8, "0")} BCH`;
|
||||||
|
}
|
||||||
|
|
||||||
export function VariableInputField({
|
export function VariableInputField({
|
||||||
variable,
|
variable,
|
||||||
index,
|
index,
|
||||||
@@ -28,6 +67,26 @@ export function VariableInputField({
|
|||||||
borderColor,
|
borderColor,
|
||||||
focusColor,
|
focusColor,
|
||||||
}: VariableInputFieldProps): React.ReactElement {
|
}: VariableInputFieldProps): React.ReactElement {
|
||||||
|
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
|
||||||
|
useSatoshisConversion();
|
||||||
|
const satoshisValue = useMemo(
|
||||||
|
() => parseSatoshis(variable.value),
|
||||||
|
[variable.value],
|
||||||
|
);
|
||||||
|
const formattedBch = useMemo(() => {
|
||||||
|
if (satoshisValue === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return formatBchFromSatoshis(satoshisValue);
|
||||||
|
}, [satoshisValue]);
|
||||||
|
const formattedFiat = useMemo(() => {
|
||||||
|
if (satoshisValue === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return formatSatoshisToFiat(satoshisValue);
|
||||||
|
}, [satoshisValue, formatSatoshisToFiat]);
|
||||||
|
const shouldShowSatoshisConversion = isSatoshisVariable(variable);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
<Text color={focusColor}>{variable.name}</Text>
|
<Text color={focusColor}>{variable.name}</Text>
|
||||||
@@ -55,12 +114,29 @@ export function VariableInputField({
|
|||||||
<Text color={borderColor} dimColor>{variable.hint}</Text>
|
<Text color={borderColor} dimColor>{variable.hint}</Text>
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
{variable.type === 'integer' && variable.hint === 'satoshis' && (
|
{shouldShowSatoshisConversion && (
|
||||||
<Box>
|
<Box flexDirection="column">
|
||||||
|
{formattedBch ? (
|
||||||
|
<>
|
||||||
<Text color={borderColor} dimColor>
|
<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*/}
|
{formattedBch}
|
||||||
{(Number(variable.value) / 100_000_000).toFixed(8)} BCH
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text color={borderColor} dimColor>
|
||||||
|
{formattedFiat
|
||||||
|
? `Approx. ${currencyCode}: ${formattedFiat}`
|
||||||
|
: `Approx. ${currencyCode}: waiting for live rate...`}
|
||||||
|
</Text>
|
||||||
|
{formattedFiatPerBchRate && (
|
||||||
|
<Text color={borderColor} dimColor>
|
||||||
|
1 BCH = {formattedFiatPerBchRate}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text color={borderColor} dimColor>
|
||||||
|
Enter a whole satoshi amount to preview BCH/{currencyCode} conversion.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
* Export all shared components.
|
* Export all shared components.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { Screen } from './Screen.js';
|
export { Screen } from "./Screen.js";
|
||||||
export { Input, TextDisplay } from './Input.js';
|
export { Input, TextDisplay } from "./Input.js";
|
||||||
export { Button, ButtonRow } from './Button.js';
|
export { Button, ButtonRow } from "./Button.js";
|
||||||
export {
|
export {
|
||||||
List,
|
List,
|
||||||
SimpleList,
|
SimpleList,
|
||||||
@@ -13,6 +13,12 @@ export {
|
|||||||
type ListItemData,
|
type ListItemData,
|
||||||
type ListGroup,
|
type ListGroup,
|
||||||
type ScrollableListProps,
|
type ScrollableListProps,
|
||||||
} from './List.js';
|
} from "./List.js";
|
||||||
export { InputDialog, ConfirmDialog, MessageDialog } from './Dialog.js';
|
export { InputDialog, ConfirmDialog, MessageDialog } from "./Dialog.js";
|
||||||
export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js';
|
export {
|
||||||
|
ProgressBar,
|
||||||
|
StepIndicator,
|
||||||
|
Loading,
|
||||||
|
type Step,
|
||||||
|
} from "./ProgressBar.js";
|
||||||
|
export { QRCode } from "./QRCode.js";
|
||||||
|
|||||||
@@ -2,12 +2,26 @@
|
|||||||
* Export all hooks.
|
* Export all hooks.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { NavigationProvider, useNavigation } from './useNavigation.js';
|
export { NavigationProvider, useNavigation } from "./useNavigation.js";
|
||||||
export { AppProvider, useAppContext, useDialog, useStatus } from './useAppContext.js';
|
export {
|
||||||
|
AppProvider,
|
||||||
|
useAppContext,
|
||||||
|
useDialog,
|
||||||
|
useStatus,
|
||||||
|
} from "./useAppContext.js";
|
||||||
export {
|
export {
|
||||||
useInvitations,
|
useInvitations,
|
||||||
useInvitation,
|
useInvitation,
|
||||||
useInvitationData,
|
useInvitationData,
|
||||||
useCreateInvitation,
|
useCreateInvitation,
|
||||||
useInvitationIds,
|
useInvitationIds,
|
||||||
} from './useInvitations.js';
|
} from "./useInvitations.js";
|
||||||
|
export {
|
||||||
|
InputLayerProvider,
|
||||||
|
useInputLayer,
|
||||||
|
useLayeredInput,
|
||||||
|
useBlockableInput,
|
||||||
|
useIsInputCaptured,
|
||||||
|
} from "./useInputLayer.js";
|
||||||
|
export { useRate, useBchToFiatRate } from "./useRates.js";
|
||||||
|
export { useSatoshisConversion } from "./useSatoshisConversion.js";
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function AppProvider({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Start the AppService (loads existing invitations)
|
// Start the AppService (loads existing invitations)
|
||||||
await service.start();
|
service.start();
|
||||||
|
|
||||||
// Set the service and mark as initialized
|
// Set the service and mark as initialized
|
||||||
setAppService(service);
|
setAppService(service);
|
||||||
|
|||||||
169
src/tui/hooks/useInputLayer.tsx
Normal file
169
src/tui/hooks/useInputLayer.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Input Layer System — stack-based keyboard input capture for dialogs and overlays.
|
||||||
|
*
|
||||||
|
* Only "capturing" components (dialogs, overlays, import flows) register layers.
|
||||||
|
* When any layer exists on the stack, all non-capturing input handlers are blocked.
|
||||||
|
*
|
||||||
|
* Hooks:
|
||||||
|
* - `useInputLayer(id)` — push a capturing layer (dialogs/overlays).
|
||||||
|
* - `useLayeredInput(id, …)` — handle input for a specific capturing layer.
|
||||||
|
* - `useBlockableInput(…)` — handle input for screens / global keys; auto-blocked
|
||||||
|
* when any capturing layer is on the stack.
|
||||||
|
* - `useIsInputCaptured()` — returns true when a capturing layer is present
|
||||||
|
* (useful for disabling `focus` on child components).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { useInput } from 'ink';
|
||||||
|
|
||||||
|
// ── Context ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InputLayerContextType {
|
||||||
|
/** Push a capturing layer. Returns a cleanup that pops it. */
|
||||||
|
push: (layerId: string) => () => void;
|
||||||
|
/** True when `layerId` is the topmost entry in the stack. */
|
||||||
|
isTop: (layerId: string) => boolean;
|
||||||
|
/** True when the stack has no entries (no dialog/overlay is capturing). */
|
||||||
|
isStackEmpty: () => boolean;
|
||||||
|
/** Monotonic counter — bumped on every push/pop so consumers re-render. */
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InputLayerContext = createContext<InputLayerContextType | null>(null);
|
||||||
|
|
||||||
|
// ── Provider ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InputLayerProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the component tree and provides the input-layer stack.
|
||||||
|
*
|
||||||
|
* Place this inside your outermost providers but above any component
|
||||||
|
* that calls the input-layer hooks.
|
||||||
|
*/
|
||||||
|
export function InputLayerProvider({ children }: InputLayerProviderProps): React.ReactElement {
|
||||||
|
const stackRef = useRef<string[]>([]);
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
|
||||||
|
const bump = useCallback(() => setVersion((v) => v + 1), []);
|
||||||
|
|
||||||
|
const push = useCallback(
|
||||||
|
(layerId: string): (() => void) => {
|
||||||
|
stackRef.current = [...stackRef.current, layerId];
|
||||||
|
bump();
|
||||||
|
return () => {
|
||||||
|
stackRef.current = stackRef.current.filter((id) => id !== layerId);
|
||||||
|
bump();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[bump],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isTop = useCallback(
|
||||||
|
(layerId: string): boolean => {
|
||||||
|
const s = stackRef.current;
|
||||||
|
return s.length > 0 && s[s.length - 1] === layerId;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isStackEmpty = useCallback(
|
||||||
|
(): boolean => stackRef.current.length === 0,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo<InputLayerContextType>(
|
||||||
|
() => ({ push, isTop, isStackEmpty, version }),
|
||||||
|
[push, isTop, isStackEmpty, version],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputLayerContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</InputLayerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hooks ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a **capturing** layer (dialog / overlay / import flow).
|
||||||
|
*
|
||||||
|
* Pushes on mount, pops on unmount. While this layer is present every
|
||||||
|
* `useBlockableInput` handler in the tree is automatically disabled.
|
||||||
|
*
|
||||||
|
* @returns `{ isActive }` — true only when this layer is the topmost.
|
||||||
|
*/
|
||||||
|
export function useInputLayer(layerId: string): { isActive: boolean } {
|
||||||
|
const ctx = useContext(InputLayerContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useInputLayer must be used within an InputLayerProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { push } = ctx;
|
||||||
|
useEffect(() => {
|
||||||
|
const pop = push(layerId);
|
||||||
|
return pop;
|
||||||
|
}, [push, layerId]);
|
||||||
|
|
||||||
|
return { isActive: ctx.isTop(layerId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input handler for a **capturing** layer.
|
||||||
|
*
|
||||||
|
* Only fires when `layerId` is the topmost entry in the stack.
|
||||||
|
*/
|
||||||
|
export function useLayeredInput(
|
||||||
|
layerId: string,
|
||||||
|
handler: (input: string, key: any) => void,
|
||||||
|
options?: { isActive?: boolean },
|
||||||
|
): void {
|
||||||
|
const ctx = useContext(InputLayerContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useLayeredInput must be used within an InputLayerProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTopLayer = ctx.isTop(layerId);
|
||||||
|
const externalActive = options?.isActive !== false;
|
||||||
|
useInput(handler, { isActive: isTopLayer && externalActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input handler for **non-capturing** components (screens, global keys).
|
||||||
|
*
|
||||||
|
* Fires only when the capture stack is empty (no dialog/overlay is open).
|
||||||
|
* This is the hook screens should use instead of raw `useInput`.
|
||||||
|
*/
|
||||||
|
export function useBlockableInput(
|
||||||
|
handler: (input: string, key: any) => void,
|
||||||
|
options?: { isActive?: boolean },
|
||||||
|
): void {
|
||||||
|
const ctx = useContext(InputLayerContext);
|
||||||
|
|
||||||
|
const nothingCapturing = ctx ? ctx.isStackEmpty() : true;
|
||||||
|
const externalActive = options?.isActive !== false;
|
||||||
|
useInput(handler, { isActive: nothingCapturing && externalActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `true` when any capturing layer is on the stack.
|
||||||
|
*
|
||||||
|
* Use this to disable `focus` props on child components (e.g. ScrollableList)
|
||||||
|
* so their internal `useInput` handlers don't fire while a dialog is open.
|
||||||
|
*/
|
||||||
|
export function useIsInputCaptured(): boolean {
|
||||||
|
const ctx = useContext(InputLayerContext);
|
||||||
|
return ctx ? !ctx.isStackEmpty() : false;
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { useAppContext } from './useAppContext.js';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all invitations reactively.
|
* Get all invitations reactively.
|
||||||
* Re-renders when invitations are added or removed.
|
* Re-renders when invitations are added, removed, or updated.
|
||||||
*/
|
*/
|
||||||
export function useInvitations(): Invitation[] {
|
export function useInvitations(): Invitation[] {
|
||||||
const { appService } = useAppContext();
|
const { appService } = useAppContext();
|
||||||
@@ -21,26 +21,22 @@ export function useInvitations(): Invitation[] {
|
|||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to invitation list changes
|
appService.on('wallet-state-changed', callback);
|
||||||
const onAdded = () => callback();
|
|
||||||
const onRemoved = () => callback();
|
|
||||||
|
|
||||||
appService.on('invitation-added', onAdded);
|
|
||||||
appService.on('invitation-removed', onRemoved);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
appService.off('invitation-added', onAdded);
|
appService.off('wallet-state-changed', callback);
|
||||||
appService.off('invitation-removed', onRemoved);
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[appService]
|
[appService]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSnapshot = useCallback(() => {
|
const getSnapshot = useCallback(() => {
|
||||||
return appService?.invitations ?? [];
|
return appService?.invitationsRevision ?? 0;
|
||||||
}, [appService]);
|
}, [appService]);
|
||||||
|
|
||||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
const revision = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
|
|
||||||
|
return useMemo(() => [...(appService?.invitations ?? [])], [appService, revision]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,36 +52,32 @@ export function useInvitation(invitationId: string | null): Invitation | null {
|
|||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the invitation instance
|
const onWalletStateChanged = ({
|
||||||
const invitation = appService.invitations.find(
|
invitationIdentifier,
|
||||||
(inv) => inv.data.invitationIdentifier === invitationId
|
}: {
|
||||||
);
|
invitationIdentifier: string;
|
||||||
|
}) => {
|
||||||
if (!invitation) {
|
if (invitationIdentifier === invitationId) {
|
||||||
return () => {};
|
callback();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
// Subscribe to this specific invitation's updates
|
appService.on('wallet-state-changed', onWalletStateChanged);
|
||||||
const onUpdated = () => callback();
|
|
||||||
const onStatusChanged = () => callback();
|
|
||||||
|
|
||||||
invitation.on('invitation-updated', onUpdated);
|
|
||||||
invitation.on('invitation-status-changed', onStatusChanged);
|
|
||||||
|
|
||||||
// Also subscribe to list changes in case the invitation is removed
|
|
||||||
const onRemoved = () => callback();
|
|
||||||
appService.on('invitation-removed', onRemoved);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
invitation.off('invitation-updated', onUpdated);
|
appService.off('wallet-state-changed', onWalletStateChanged);
|
||||||
invitation.off('invitation-status-changed', onStatusChanged);
|
|
||||||
appService.off('invitation-removed', onRemoved);
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[appService, invitationId]
|
[appService, invitationId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSnapshot = useCallback(() => {
|
const getSnapshot = useCallback(() => {
|
||||||
|
return appService && invitationId
|
||||||
|
? appService.getInvitationRevision(invitationId)
|
||||||
|
: 0;
|
||||||
|
}, [appService, invitationId]);
|
||||||
|
|
||||||
|
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
|
|
||||||
if (!appService || !invitationId) {
|
if (!appService || !invitationId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -95,9 +87,6 @@ export function useInvitation(invitationId: string | null): Invitation | null {
|
|||||||
(inv) => inv.data.invitationIdentifier === invitationId
|
(inv) => inv.data.invitationIdentifier === invitationId
|
||||||
) ?? null
|
) ?? null
|
||||||
);
|
);
|
||||||
}, [appService, invitationId]);
|
|
||||||
|
|
||||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,7 +98,7 @@ export function useInvitationData(invitationId: string | null): XOInvitation | n
|
|||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return invitation?.data ?? null;
|
return invitation?.data ?? null;
|
||||||
}, [invitation?.data.invitationIdentifier, invitation?.data.commits?.length]);
|
}, [invitation?.data]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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');
|
||||||
|
}
|
||||||
73
src/tui/hooks/useSatoshisConversion.tsx
Normal file
73
src/tui/hooks/useSatoshisConversion.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
||||||
|
import { useAppContext } from './useAppContext.js';
|
||||||
|
import { useBchToFiatRate } from './useRates.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive BCH satoshis -> fiat conversion helpers for TUI screens.
|
||||||
|
*
|
||||||
|
* This hook subscribes to rate updates through `useBchToFiatRate`, so any
|
||||||
|
* component using it will re-render automatically when the selected pair
|
||||||
|
* receives a new quote.
|
||||||
|
*/
|
||||||
|
export function useSatoshisConversion(targetCurrency?: string) {
|
||||||
|
const { appService } = useAppContext();
|
||||||
|
const subscribeToCurrency = useCallback(
|
||||||
|
(callback: () => void) => {
|
||||||
|
if (!appService || targetCurrency) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return appService.settings.on('settings-updated', (event) => {
|
||||||
|
if (event.key === 'currency') {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[appService, targetCurrency],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCurrencySnapshot = useCallback(() => {
|
||||||
|
if (targetCurrency) {
|
||||||
|
return targetCurrency.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!appService) {
|
||||||
|
return 'USD';
|
||||||
|
}
|
||||||
|
|
||||||
|
return appService.settings.getCurrency();
|
||||||
|
}, [appService, targetCurrency]);
|
||||||
|
|
||||||
|
const currencyCode = useSyncExternalStore(
|
||||||
|
subscribeToCurrency,
|
||||||
|
getCurrencySnapshot,
|
||||||
|
getCurrencySnapshot,
|
||||||
|
);
|
||||||
|
const fiatPerBchRate = useBchToFiatRate(currencyCode);
|
||||||
|
|
||||||
|
const formattedFiatPerBchRate = useMemo(() => {
|
||||||
|
if (!appService || fiatPerBchRate === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return appService.rates.formatCurrency(fiatPerBchRate, currencyCode);
|
||||||
|
}, [appService, fiatPerBchRate, currencyCode]);
|
||||||
|
|
||||||
|
const formatSatoshisToFiat = useCallback(
|
||||||
|
(satoshis: bigint): string | null => {
|
||||||
|
if (!appService || fiatPerBchRate === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return appService.rates.formatBchToFiat(satoshis, currencyCode);
|
||||||
|
},
|
||||||
|
[appService, fiatPerBchRate, currencyCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currencyCode,
|
||||||
|
fiatPerBchRate,
|
||||||
|
formattedFiatPerBchRate,
|
||||||
|
formatSatoshisToFiat,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
@@ -1,25 +1,90 @@
|
|||||||
/**
|
/**
|
||||||
* Seed Input Screen - Initial screen for wallet seed phrase entry.
|
* Seed Input Screen - Initial screen for wallet seed phrase entry.
|
||||||
*
|
*
|
||||||
* Allows users to enter their BIP39 seed phrase to initialize the wallet.
|
* Allows users to enter their BIP39 seed phrase to initialize the wallet,
|
||||||
|
* or select from previously saved mnemonic files on disk.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import TextInput from '../components/TextInput.js';
|
||||||
import { Button } from '../components/Button.js';
|
import { Button } from '../components/Button.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
|
import { useBlockableInput } from '../hooks/useInputLayer.js';
|
||||||
import { colors, logo } from '../theme.js';
|
import { colors, logo } from '../theme.js';
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { createMnemonicFile } from '../../cli/mnemonic.js';
|
||||||
|
import { getMnemonicsDir } from '../../utils/paths.js';
|
||||||
|
import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js';
|
||||||
|
import { encodeBip39Mnemonic, generateBip39Mnemonic } from '@bitauth/libauth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Status message type.
|
* Status message type.
|
||||||
*/
|
*/
|
||||||
type StatusType = 'idle' | 'loading' | 'error' | 'success';
|
type StatusType = 'idle' | 'loading' | 'error' | 'success';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed mnemonic file entry with the derived seed phrase ready for wallet init.
|
||||||
|
*/
|
||||||
|
interface MnemonicFileEntry {
|
||||||
|
filename: string;
|
||||||
|
/** Friendly label derived from filename or the URL comment field. */
|
||||||
|
label: string;
|
||||||
|
/** The BIP39 mnemonic phrase derived from the file's entropy. */
|
||||||
|
mnemonic: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus sections the user can tab between.
|
||||||
|
* When saved wallets exist the file list is shown first.
|
||||||
|
*/
|
||||||
|
type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'generateRandomSeed' | 'button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads mnemonic-* files from the configured mnemonics directory (same as xo-cli),
|
||||||
|
* then from cwd for legacy installs. Parses each as a BCHMnemonicURL.
|
||||||
|
*/
|
||||||
|
function loadMnemonicFiles(): MnemonicFileEntry[] {
|
||||||
|
const dirs = [getMnemonicsDir(), process.cwd()];
|
||||||
|
const seenBasenames = new Set<string>();
|
||||||
|
const entries: MnemonicFileEntry[] = [];
|
||||||
|
|
||||||
|
for (const dir of dirs) {
|
||||||
|
if (!fs.existsSync(dir)) continue;
|
||||||
|
const filenames = fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((f) => f.startsWith('mnemonic-'));
|
||||||
|
for (const filename of filenames) {
|
||||||
|
if (seenBasenames.has(filename)) continue;
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(path.join(dir, filename), 'utf-8').trim();
|
||||||
|
const parsed = BCHMnemonicURL.fromURL(content);
|
||||||
|
const raw = parsed.toObject();
|
||||||
|
|
||||||
|
const mnemonicResult = encodeBip39Mnemonic(raw.entropy);
|
||||||
|
if (typeof mnemonicResult === 'string') continue;
|
||||||
|
|
||||||
|
/** Use the URL comment as the label, falling back to a cleaned-up filename. */
|
||||||
|
const label = raw.comment
|
||||||
|
?? filename.replace(/^mnemonic-/, '').replace(/\.[^.]+$/, '');
|
||||||
|
|
||||||
|
entries.push({ filename, label, mnemonic: mnemonicResult.phrase });
|
||||||
|
seenBasenames.add(filename);
|
||||||
|
} catch {
|
||||||
|
// Skip files that can't be parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed Input Screen Component.
|
* Seed Input Screen Component.
|
||||||
* Provides seed phrase entry for wallet initialization.
|
* Provides seed phrase entry for wallet initialization and a selectable
|
||||||
|
* list of previously saved mnemonic files.
|
||||||
*/
|
*/
|
||||||
export function SeedInputScreen(): React.ReactElement {
|
export function SeedInputScreen(): React.ReactElement {
|
||||||
const { navigate } = useNavigation();
|
const { navigate } = useNavigation();
|
||||||
@@ -30,9 +95,31 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
const [seedPhrase, setSeedPhrase] = useState('');
|
const [seedPhrase, setSeedPhrase] = useState('');
|
||||||
const [statusMessage, setStatusMessage] = useState('');
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
const [statusType, setStatusType] = useState<StatusType>('idle');
|
const [statusType, setStatusType] = useState<StatusType>('idle');
|
||||||
const [focusedElement, setFocusedElement] = useState<'input' | 'button'>('input');
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Mnemonic file list state
|
||||||
|
const [mnemonicFiles, setMnemonicFiles] = useState<MnemonicFileEntry[]>([]);
|
||||||
|
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
|
||||||
|
|
||||||
|
/** When set, manual seed is written to the configured mnemonics directory after a successful unlock. */
|
||||||
|
const [saveMnemonicChecked, setSaveMnemonicChecked] = useState(false);
|
||||||
|
|
||||||
|
// Focus: when saved wallets exist default to the file list, otherwise the input.
|
||||||
|
const [focusedSection, setFocusedSection] = useState<FocusSection>('input');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const entries = loadMnemonicFiles();
|
||||||
|
setMnemonicFiles(entries);
|
||||||
|
if (entries.length > 0) setFocusedSection('files');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ordered list of focusable sections (files section only when entries exist).
|
||||||
|
*/
|
||||||
|
const focusSections: FocusSection[] = mnemonicFiles.length > 0
|
||||||
|
? ['files', 'input', 'generateRandomSeed', 'saveCheckbox', 'button']
|
||||||
|
: ['input', 'generateRandomSeed', 'saveCheckbox', 'button'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a status message with the given type.
|
* Shows a status message with the given type.
|
||||||
*/
|
*/
|
||||||
@@ -42,12 +129,53 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles seed phrase submission.
|
* Shared wallet initialization handler used by both manual entry and file selection.
|
||||||
|
*/
|
||||||
|
const doInitialize = useCallback(
|
||||||
|
async (seed: string, options?: { saveMnemonic?: boolean }) => {
|
||||||
|
showStatus('Initializing wallet...', 'loading');
|
||||||
|
setStatus('Initializing wallet...');
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initializeWallet(seed);
|
||||||
|
|
||||||
|
let statusText = 'Wallet initialized successfully!';
|
||||||
|
if (options?.saveMnemonic) {
|
||||||
|
try {
|
||||||
|
const savedAs = createMnemonicFile(getMnemonicsDir(), seed);
|
||||||
|
setMnemonicFiles(loadMnemonicFiles());
|
||||||
|
statusText = `Wallet initialized! Mnemonic saved as ${savedAs}`;
|
||||||
|
} catch (saveErr) {
|
||||||
|
const saveMsg =
|
||||||
|
saveErr instanceof Error ? saveErr.message : String(saveErr);
|
||||||
|
statusText = `Wallet initialized, but could not save mnemonic: ${saveMsg}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showStatus(statusText, 'success');
|
||||||
|
setStatus('Wallet ready');
|
||||||
|
setSeedPhrase('');
|
||||||
|
setSaveMnemonicChecked(false);
|
||||||
|
|
||||||
|
navigate('wallet');
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Failed to initialize wallet';
|
||||||
|
showStatus(message, 'error');
|
||||||
|
setStatus('Initialization failed');
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[initializeWallet, navigate, showStatus, setStatus],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles manual seed phrase submission with validation.
|
||||||
*/
|
*/
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
const seed = seedPhrase.trim();
|
const seed = seedPhrase.trim();
|
||||||
|
|
||||||
// Basic validation
|
|
||||||
if (!seed) {
|
if (!seed) {
|
||||||
showStatus('Please enter your seed phrase', 'error');
|
showStatus('Please enter your seed phrase', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -59,60 +187,86 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading status
|
await doInitialize(seed, { saveMnemonic: saveMnemonicChecked });
|
||||||
showStatus('Initializing wallet...', 'loading');
|
}, [seedPhrase, saveMnemonicChecked, doInitialize, showStatus]);
|
||||||
setStatus('Initializing wallet...');
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
try {
|
/**
|
||||||
// Initialize wallet and create AppService
|
* Handles selecting a mnemonic file from the list.
|
||||||
await initializeWallet(seed);
|
*/
|
||||||
|
const handleFileSelect = useCallback(async (index: number) => {
|
||||||
|
const entry = mnemonicFiles[index];
|
||||||
|
if (!entry) return;
|
||||||
|
await doInitialize(entry.mnemonic);
|
||||||
|
}, [mnemonicFiles, doInitialize]);
|
||||||
|
|
||||||
showStatus('Wallet initialized successfully!', 'success');
|
// Keyboard navigation
|
||||||
setStatus('Wallet ready');
|
useBlockableInput((input, key) => {
|
||||||
|
|
||||||
// Clear sensitive data before navigating
|
|
||||||
setSeedPhrase('');
|
|
||||||
|
|
||||||
// Navigate to wallet state screen
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate('wallet');
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to initialize wallet';
|
|
||||||
showStatus(message, 'error');
|
|
||||||
setStatus('Initialization failed');
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
}, [seedPhrase, initializeWallet, navigate, showStatus, setStatus]);
|
|
||||||
|
|
||||||
// Handle keyboard navigation
|
|
||||||
useInput((input, key) => {
|
|
||||||
if (isSubmitting) return;
|
if (isSubmitting) return;
|
||||||
|
|
||||||
// Tab to switch focus
|
// Tab / Shift-Tab to cycle focus sections
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
setFocusedElement(prev => prev === 'input' ? 'button' : 'input');
|
setFocusedSection((prev) => {
|
||||||
|
const idx = focusSections.indexOf(prev);
|
||||||
|
const next = key.shift
|
||||||
|
? (idx - 1 + focusSections.length) % focusSections.length
|
||||||
|
: (idx + 1) % focusSections.length;
|
||||||
|
return focusSections[next]!;
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter on button submits
|
// Space or Enter toggles "save mnemonic" when that row is focused
|
||||||
if (key.return && focusedElement === 'button') {
|
if (focusedSection === 'saveCheckbox') {
|
||||||
|
if (input === ' ' || key.return) {
|
||||||
|
setSaveMnemonicChecked((v) => !v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow keys inside the file list
|
||||||
|
if (focusedSection === 'files' && mnemonicFiles.length > 0) {
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelectedFileIndex((prev) => Math.max(0, prev - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.downArrow) {
|
||||||
|
setSelectedFileIndex((prev) => Math.min(mnemonicFiles.length - 1, prev + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.return) {
|
||||||
|
handleFileSelect(selectedFileIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl-R generates a random seed phrase and fills it in the input
|
||||||
|
if (key.ctrl && input === 'r') {
|
||||||
|
setSeedPhrase(generateBip39Mnemonic());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pressing enter while the generate random seed section is focused, generate a random seed and fill it in the input
|
||||||
|
if (key.return && focusedSection === 'generateRandomSeed') {
|
||||||
|
setSeedPhrase(generateBip39Mnemonic());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter on button submits manual seed
|
||||||
|
if (key.return && focusedSection === 'button') {
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get status color
|
// Derived style helpers
|
||||||
const statusColor = statusType === 'error' ? colors.error :
|
const statusColor = statusType === 'error' ? colors.error :
|
||||||
statusType === 'success' ? colors.success :
|
statusType === 'success' ? colors.success :
|
||||||
statusType === 'loading' ? colors.info :
|
statusType === 'loading' ? colors.info :
|
||||||
colors.textMuted;
|
colors.textMuted;
|
||||||
|
|
||||||
// Get border color based on status
|
|
||||||
const inputBorderColor = statusType === 'error' ? colors.error :
|
const inputBorderColor = statusType === 'error' ? colors.error :
|
||||||
statusType === 'success' ? colors.success :
|
statusType === 'success' ? colors.success :
|
||||||
focusedElement === 'input' ? colors.focus :
|
focusedSection === 'input' ? colors.focus :
|
||||||
colors.border;
|
colors.borderMuted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection='column' alignItems='center' paddingY={1}>
|
<Box flexDirection='column' alignItems='center' paddingY={1}>
|
||||||
@@ -123,14 +277,82 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text color={colors.text} bold>Welcome to XO Wallet CLI</Text>
|
<Text color={colors.text} bold>Welcome to XO Wallet CLI</Text>
|
||||||
<Text color={colors.textMuted}>Enter your seed phrase to get started</Text>
|
<Text color={colors.textMuted}>Enter your seed phrase or select a saved wallet</Text>
|
||||||
|
|
||||||
{/* Spacer */}
|
|
||||||
<Box marginY={1} />
|
<Box marginY={1} />
|
||||||
|
|
||||||
{/* Input section */}
|
|
||||||
<Box flexDirection='column' width={64}>
|
<Box flexDirection='column' width={64}>
|
||||||
<Text color={colors.text} bold>Seed Phrase (12 or 24 words):</Text>
|
{/* ── Saved Wallets ─────────────────────────────────── */}
|
||||||
|
{mnemonicFiles.length > 0 && (
|
||||||
|
<Box flexDirection='column' marginBottom={1}>
|
||||||
|
<Box marginBottom={1}>
|
||||||
|
<Text color={colors.primary} bold>
|
||||||
|
{'▸ '}Saved Wallets
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}> ({mnemonicFiles.length})</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
flexDirection='column'
|
||||||
|
borderStyle='single'
|
||||||
|
borderColor={focusedSection === 'files' ? colors.focus : colors.borderMuted}
|
||||||
|
paddingX={1}
|
||||||
|
>
|
||||||
|
{mnemonicFiles.map((entry, idx) => {
|
||||||
|
const isHighlighted = focusedSection === 'files' && idx === selectedFileIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={entry.filename} paddingY={0}>
|
||||||
|
<Text
|
||||||
|
color={isHighlighted ? colors.bg : colors.textMuted}
|
||||||
|
backgroundColor={isHighlighted ? colors.focus : undefined}
|
||||||
|
bold={isHighlighted}
|
||||||
|
>
|
||||||
|
{isHighlighted ? ' ▶ ' : ' '}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
color={isHighlighted ? colors.bg : colors.text}
|
||||||
|
backgroundColor={isHighlighted ? colors.focus : undefined}
|
||||||
|
bold={isHighlighted}
|
||||||
|
>
|
||||||
|
{` ${entry.label} `}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
color={isHighlighted ? colors.bg : colors.textMuted}
|
||||||
|
backgroundColor={isHighlighted ? colors.focus : undefined}
|
||||||
|
dimColor={!isHighlighted}
|
||||||
|
>
|
||||||
|
{` (${entry.filename})`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{focusedSection === 'files' && (
|
||||||
|
<Box marginTop={0} paddingX={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
↑↓ navigate • Enter: load wallet
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Divider between sections ──────────────────────── */}
|
||||||
|
{mnemonicFiles.length > 0 && (
|
||||||
|
<Box marginBottom={1} justifyContent='center'>
|
||||||
|
<Text color={colors.borderMuted}>{'─'.repeat(20)} or {'─'.repeat(20)}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Manual Seed Entry ─────────────────────────────── */}
|
||||||
|
<Text color={colors.primary} bold>
|
||||||
|
{'▸ '}Manual Entry
|
||||||
|
</Text>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.text}>Seed Phrase (12 or 24 words):</Text>
|
||||||
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
borderStyle='single'
|
borderStyle='single'
|
||||||
borderColor={inputBorderColor}
|
borderColor={inputBorderColor}
|
||||||
@@ -142,10 +364,49 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
onChange={setSeedPhrase}
|
onChange={setSeedPhrase}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
placeholder='Enter your seed phrase...'
|
placeholder='Enter your seed phrase...'
|
||||||
focus={focusedElement === 'input' && !isSubmitting}
|
focus={focusedSection === 'input' && !isSubmitting}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Generate random seed phrase and fill in the input */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Box
|
||||||
|
paddingX={1}
|
||||||
|
paddingY={0}
|
||||||
|
backgroundColor={focusedSection === 'generateRandomSeed' ? colors.focus : colors.bgSelected}
|
||||||
|
>
|
||||||
|
<Text color={focusedSection === 'generateRandomSeed' ? colors.bg : colors.text} bold>Generate Random Seed</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Text color={colors.textMuted}> (Ctrl-R)</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Save mnemonic checkbox (manual entry only; applies on Continue) */}
|
||||||
|
<Box
|
||||||
|
marginTop={1}
|
||||||
|
paddingX={1}
|
||||||
|
borderStyle='single'
|
||||||
|
borderColor={
|
||||||
|
focusedSection === 'saveCheckbox' ? colors.focus : colors.borderMuted
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
color={focusedSection === 'saveCheckbox' ? colors.focus : colors.text}
|
||||||
|
bold={focusedSection === 'saveCheckbox'}
|
||||||
|
>
|
||||||
|
{saveMnemonicChecked ? '[x] ' : '[ ] '}
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.text}>Save this mnemonic</Text>
|
||||||
|
<Text color={colors.textMuted}> ({getMnemonicsDir()}/)</Text>
|
||||||
|
</Box>
|
||||||
|
{focusedSection === 'saveCheckbox' && (
|
||||||
|
<Box marginTop={0} paddingX={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
Space / Enter: toggle
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Status message */}
|
{/* Status message */}
|
||||||
<Box marginTop={1} height={1}>
|
<Box marginTop={1} height={1}>
|
||||||
{statusMessage && (
|
{statusMessage && (
|
||||||
@@ -162,7 +423,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
<Box justifyContent='center' marginTop={1}>
|
<Box justifyContent='center' marginTop={1}>
|
||||||
<Button
|
<Button
|
||||||
label='Continue'
|
label='Continue'
|
||||||
focused={focusedElement === 'button'}
|
focused={focusedSection === 'button'}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
shortcut='Enter'
|
shortcut='Enter'
|
||||||
/>
|
/>
|
||||||
@@ -172,7 +433,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
{/* Help text */}
|
{/* Help text */}
|
||||||
<Box marginTop={2}>
|
<Box marginTop={2}>
|
||||||
<Text color={colors.textMuted} dimColor>
|
<Text color={colors.textMuted} dimColor>
|
||||||
Tab: navigate • Enter: submit • q: quit
|
Tab: navigate • Enter: submit, load wallet, or toggle save • Space: toggle save • Esc: back
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -7,10 +7,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
|
import path from 'node:path';
|
||||||
import { ScrollableList, type ListItemData } from '../components/List.js';
|
import { ScrollableList, type ListItemData } from '../components/List.js';
|
||||||
|
import { FilePicker } from '../components/FilePicker.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
|
import { useBlockableInput, useInputLayer, useIsInputCaptured, useLayeredInput } from '../hooks/useInputLayer.js';
|
||||||
import { colors, logoSmall } from '../theme.js';
|
import { colors, logoSmall } from '../theme.js';
|
||||||
|
|
||||||
// XO Imports
|
// XO Imports
|
||||||
@@ -21,11 +24,11 @@ import type { XOTemplate } from '@xo-cash/types';
|
|||||||
import {
|
import {
|
||||||
formatTemplateListItem,
|
formatTemplateListItem,
|
||||||
formatActionListItem,
|
formatActionListItem,
|
||||||
deduplicateStartingActions,
|
|
||||||
getTemplateRoles,
|
getTemplateRoles,
|
||||||
getRolesForAction,
|
|
||||||
type UniqueStartingAction,
|
|
||||||
} from '../../utils/template-utils.js';
|
} from '../../utils/template-utils.js';
|
||||||
|
import { buildScriptHashDataMap } from '../../utils/utxo-metadata.js';
|
||||||
|
import { loadTemplateFromFile } from '../../utils/load-template-from-file.js';
|
||||||
|
import { ConfirmDialog, DialogWrapper } from '../components/Dialog.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template item with metadata.
|
* Template item with metadata.
|
||||||
@@ -33,7 +36,7 @@ import {
|
|||||||
interface TemplateItem {
|
interface TemplateItem {
|
||||||
template: XOTemplate;
|
template: XOTemplate;
|
||||||
templateIdentifier: string;
|
templateIdentifier: string;
|
||||||
startingActions: UniqueStartingAction[];
|
availableActions: TemplateActionItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,9 +45,66 @@ interface TemplateItem {
|
|||||||
type TemplateListItem = ListItemData<TemplateItem>;
|
type TemplateListItem = ListItemData<TemplateItem>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Action list item with UniqueStartingAction value.
|
* Action list item with available action value.
|
||||||
*/
|
*/
|
||||||
type ActionListItem = ListItemData<UniqueStartingAction>;
|
type ActionListItem = ListItemData<TemplateActionItem>;
|
||||||
|
|
||||||
|
interface TemplateActionItem {
|
||||||
|
actionIdentifier: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
roles: string[];
|
||||||
|
source: 'starting' | 'next' | 'starting+next';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List item key for the synthetic import row. */
|
||||||
|
const IMPORT_TEMPLATE_KEY = 'import-template';
|
||||||
|
|
||||||
|
/** Input layer id shared by the import dialog and its file picker. */
|
||||||
|
const IMPORT_TEMPLATE_DIALOG_LAYER_ID = 'import-template-dialog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import template dialog overlay.
|
||||||
|
* Captures keyboard input and wraps the generic {@link FilePicker}.
|
||||||
|
*/
|
||||||
|
function ImportTemplateDialogOverlay({
|
||||||
|
onClose,
|
||||||
|
onSelectFile,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onSelectFile: (filePath: string) => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
useInputLayer(IMPORT_TEMPLATE_DIALOG_LAYER_ID);
|
||||||
|
|
||||||
|
useLayeredInput(IMPORT_TEMPLATE_DIALOG_LAYER_ID, (_input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogWrapper title="Import Template" borderColor={colors.primary} width={72}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Select a JSON, JavaScript, or TypeScript template file from disk.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<FilePicker
|
||||||
|
layerId={IMPORT_TEMPLATE_DIALOG_LAYER_ID}
|
||||||
|
extensions={['json', 'js', 'mjs', 'cjs', 'ts', 'mts', 'cts']}
|
||||||
|
onSelectFile={onSelectFile}
|
||||||
|
maxVisible={8}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
↑↓ navigate • Enter open/select • Esc cancel
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</DialogWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template List Screen Component.
|
* Template List Screen Component.
|
||||||
@@ -61,6 +121,10 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
||||||
const [focusedPanel, setFocusedPanel] = useState<'templates' | 'actions'>('templates');
|
const [focusedPanel, setFocusedPanel] = useState<'templates' | 'actions'>('templates');
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [templateToDelete, setTemplateToDelete] = useState<TemplateItem | null>(null);
|
||||||
|
const isCaptured = useIsInputCaptured();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads templates from the engine.
|
* Loads templates from the engine.
|
||||||
@@ -76,19 +140,99 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
setStatus('Loading templates...');
|
setStatus('Loading templates...');
|
||||||
|
|
||||||
const templateList = await appService.engine.listImportedTemplates();
|
const templateList = await appService.engine.listImportedTemplates();
|
||||||
|
const allUtxos = await appService.engine.listUnspentOutputsData();
|
||||||
|
const scriptHashDataByScriptHash =
|
||||||
|
await buildScriptHashDataMap(appService.engine);
|
||||||
|
|
||||||
|
const ownedOutputsByTemplate = new Map<string, Set<string>>();
|
||||||
|
for (const utxo of allUtxos) {
|
||||||
|
const scriptRow = scriptHashDataByScriptHash.get(utxo.scriptHash);
|
||||||
|
if (scriptRow === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing =
|
||||||
|
ownedOutputsByTemplate.get(scriptRow.templateIdentifier) ??
|
||||||
|
new Set<string>();
|
||||||
|
existing.add(scriptRow.outputIdentifier);
|
||||||
|
ownedOutputsByTemplate.set(scriptRow.templateIdentifier, existing);
|
||||||
|
}
|
||||||
|
|
||||||
const loadedTemplates = await Promise.all(
|
const loadedTemplates = await Promise.all(
|
||||||
templateList.map(async (template) => {
|
templateList.map(async (template) => {
|
||||||
const templateIdentifier = generateTemplateIdentifier(template);
|
const templateIdentifier = generateTemplateIdentifier(template);
|
||||||
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
|
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
|
||||||
|
const actionMap = new Map<string, TemplateActionItem>();
|
||||||
|
|
||||||
// Use utility function to deduplicate actions
|
for (const startingAction of rawStartingActions) {
|
||||||
const startingActions = deduplicateStartingActions(template, rawStartingActions);
|
const existing = actionMap.get(startingAction.action);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.roles.includes(startingAction.role ?? '')) {
|
||||||
|
existing.roles.push(startingAction.role ?? '');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionDef = template.actions?.[startingAction.action];
|
||||||
|
actionMap.set(startingAction.action, {
|
||||||
|
actionIdentifier: startingAction.action,
|
||||||
|
name: actionDef?.name || startingAction.action,
|
||||||
|
description: actionDef?.description,
|
||||||
|
roles: [startingAction.role ?? ''],
|
||||||
|
source: 'starting',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>();
|
||||||
|
for (const outputIdentifier of ownedOutputIdentifiers) {
|
||||||
|
const outputDef = template.outputs?.[outputIdentifier];
|
||||||
|
if (!outputDef || typeof outputDef.lockingScript !== 'string') continue;
|
||||||
|
|
||||||
|
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockingScript] as
|
||||||
|
| { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> }
|
||||||
|
| undefined;
|
||||||
|
if (!lockingScriptDefinition?.roles) continue;
|
||||||
|
|
||||||
|
for (const [lockscriptRoleId, lockscriptRoleDef] of Object.entries(lockingScriptDefinition.roles)) {
|
||||||
|
for (const actionSpec of lockscriptRoleDef.actions ?? []) {
|
||||||
|
const actionIdentifier = typeof actionSpec === 'string'
|
||||||
|
? actionSpec
|
||||||
|
: actionSpec.action;
|
||||||
|
if (!actionIdentifier) continue;
|
||||||
|
|
||||||
|
const roleIdentifier = typeof actionSpec === 'string'
|
||||||
|
? lockscriptRoleId
|
||||||
|
: (actionSpec.role ?? lockscriptRoleId);
|
||||||
|
|
||||||
|
const existing = actionMap.get(actionIdentifier);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.roles.includes(roleIdentifier)) {
|
||||||
|
existing.roles.push(roleIdentifier);
|
||||||
|
}
|
||||||
|
if (existing.source === 'starting') {
|
||||||
|
existing.source = 'starting+next';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionDef = template.actions?.[actionIdentifier];
|
||||||
|
actionMap.set(actionIdentifier, {
|
||||||
|
actionIdentifier,
|
||||||
|
name: actionDef?.name || actionIdentifier,
|
||||||
|
description: actionDef?.description,
|
||||||
|
roles: [roleIdentifier],
|
||||||
|
source: 'next',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableActions = Array.from(actionMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
template,
|
template,
|
||||||
templateIdentifier,
|
templateIdentifier,
|
||||||
startingActions,
|
availableActions,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -109,15 +253,23 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
loadTemplates();
|
loadTemplates();
|
||||||
}, [loadTemplates]);
|
}, [loadTemplates]);
|
||||||
|
|
||||||
// Get current template and its actions
|
|
||||||
const currentTemplate = templates[selectedTemplateIndex];
|
|
||||||
const currentActions = currentTemplate?.startingActions ?? [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build template list items for ScrollableList.
|
* Build template list items for ScrollableList.
|
||||||
*/
|
*/
|
||||||
const templateListItems = useMemo((): TemplateListItem[] => {
|
const templateListItems = useMemo((): TemplateListItem[] => {
|
||||||
return templates.map((item, index) => {
|
const importTemplateItem: TemplateListItem = {
|
||||||
|
key: IMPORT_TEMPLATE_KEY,
|
||||||
|
label: 'Import Template',
|
||||||
|
description: 'Import a template from a file',
|
||||||
|
value: {
|
||||||
|
templateIdentifier: IMPORT_TEMPLATE_KEY,
|
||||||
|
template: {} as XOTemplate,
|
||||||
|
availableActions: [],
|
||||||
|
},
|
||||||
|
hidden: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...templates.map((item, index) => {
|
||||||
const formatted = formatTemplateListItem(item.template, index);
|
const formatted = formatTemplateListItem(item.template, index);
|
||||||
return {
|
return {
|
||||||
key: item.templateIdentifier,
|
key: item.templateIdentifier,
|
||||||
@@ -126,9 +278,16 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
value: item,
|
value: item,
|
||||||
hidden: !formatted.isValid,
|
hidden: !formatted.isValid,
|
||||||
};
|
};
|
||||||
});
|
}), importTemplateItem];
|
||||||
}, [templates]);
|
}, [templates]);
|
||||||
|
|
||||||
|
const selectedTemplateListItem = templateListItems[selectedTemplateIndex];
|
||||||
|
const isImportRowSelected = selectedTemplateListItem?.key === IMPORT_TEMPLATE_KEY;
|
||||||
|
const currentTemplate = isImportRowSelected
|
||||||
|
? undefined
|
||||||
|
: selectedTemplateListItem?.value;
|
||||||
|
const currentActions = currentTemplate?.availableActions ?? [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build action list items for ScrollableList.
|
* Build action list items for ScrollableList.
|
||||||
*/
|
*/
|
||||||
@@ -137,12 +296,13 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const formatted = formatActionListItem(
|
const formatted = formatActionListItem(
|
||||||
action.actionIdentifier,
|
action.actionIdentifier,
|
||||||
currentTemplate?.template?.actions?.[action.actionIdentifier],
|
currentTemplate?.template?.actions?.[action.actionIdentifier],
|
||||||
action.roleCount,
|
action.roles.length,
|
||||||
index
|
index
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: action.actionIdentifier,
|
key: action.actionIdentifier,
|
||||||
label: formatted.label,
|
label: `${formatted.label}`,
|
||||||
description: formatted.description,
|
description: formatted.description,
|
||||||
value: action,
|
value: action,
|
||||||
hidden: !formatted.isValid,
|
hidden: !formatted.isValid,
|
||||||
@@ -158,6 +318,86 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
setSelectedActionIndex(0); // Reset action selection when template changes
|
setSelectedActionIndex(0); // Reset action selection when template changes
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the import file picker.
|
||||||
|
*/
|
||||||
|
const openImportDialog = useCallback(() => {
|
||||||
|
setIsImportDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the import file picker when the synthetic import row is activated.
|
||||||
|
*/
|
||||||
|
const handleTemplateActivate = useCallback((item: TemplateListItem) => {
|
||||||
|
if (item.key === IMPORT_TEMPLATE_KEY) {
|
||||||
|
openImportDialog();
|
||||||
|
}
|
||||||
|
}, [openImportDialog]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens delete confirmation for the currently selected template.
|
||||||
|
*/
|
||||||
|
const openDeleteDialog = useCallback(() => {
|
||||||
|
if (!currentTemplate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTemplateToDelete(currentTemplate);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
}, [currentTemplate]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the confirmed template from local storage.
|
||||||
|
*/
|
||||||
|
const handleConfirmDelete = useCallback(async () => {
|
||||||
|
if (!appService || !templateToDelete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedName =
|
||||||
|
templateToDelete.template.name || templateToDelete.templateIdentifier;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setStatus('Deleting template...');
|
||||||
|
await appService.engine.DANGEROUS_deleteImportedTemplate(
|
||||||
|
templateToDelete.templateIdentifier,
|
||||||
|
);
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setTemplateToDelete(null);
|
||||||
|
await loadTemplates();
|
||||||
|
setStatus(`Deleted ${deletedName}`);
|
||||||
|
} catch (error) {
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setTemplateToDelete(null);
|
||||||
|
showError(
|
||||||
|
`Failed to delete template: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [appService, loadTemplates, setStatus, showError, templateToDelete]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the selected template file and imports it through the engine.
|
||||||
|
*/
|
||||||
|
const handleImportFile = useCallback(async (filePath: string) => {
|
||||||
|
if (!appService) {
|
||||||
|
showError('AppService not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setStatus('Importing template...');
|
||||||
|
const content = await loadTemplateFromFile(filePath);
|
||||||
|
await appService.engine.importTemplate(content);
|
||||||
|
await loadTemplates();
|
||||||
|
setIsImportDialogOpen(false);
|
||||||
|
setStatus(`Imported ${path.basename(filePath)}`);
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to import template: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [appService, loadTemplates, setStatus, showError]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles action selection.
|
* Handles action selection.
|
||||||
* Navigates to the Action Wizard where the user will choose their role.
|
* Navigates to the Action Wizard where the user will choose their role.
|
||||||
@@ -171,17 +411,30 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
navigate('wizard', {
|
navigate('wizard', {
|
||||||
templateIdentifier: currentTemplate.templateIdentifier,
|
templateIdentifier: currentTemplate.templateIdentifier,
|
||||||
actionIdentifier: action.actionIdentifier,
|
actionIdentifier: action.actionIdentifier,
|
||||||
|
actionRoles: action.roles,
|
||||||
template: currentTemplate.template,
|
template: currentTemplate.template,
|
||||||
});
|
});
|
||||||
}, [currentTemplate, navigate]);
|
}, [currentTemplate, navigate]);
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation and template shortcuts
|
||||||
useInput((input, key) => {
|
useBlockableInput((input, key) => {
|
||||||
// Tab to switch panels
|
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
|
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoading || focusedPanel !== 'templates') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input === 'a' || input === 'A') {
|
||||||
|
openImportDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((input === 'd' || input === 'D') && currentTemplate) {
|
||||||
|
openDeleteDialog();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -250,7 +503,8 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
items={templateListItems}
|
items={templateListItems}
|
||||||
selectedIndex={selectedTemplateIndex}
|
selectedIndex={selectedTemplateIndex}
|
||||||
onSelect={handleTemplateSelect}
|
onSelect={handleTemplateSelect}
|
||||||
focus={focusedPanel === 'templates'}
|
onActivate={handleTemplateActivate}
|
||||||
|
focus={focusedPanel === 'templates' && !isCaptured}
|
||||||
emptyMessage="No templates imported"
|
emptyMessage="No templates imported"
|
||||||
renderItem={renderTemplateItem}
|
renderItem={renderTemplateItem}
|
||||||
/>
|
/>
|
||||||
@@ -267,11 +521,17 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
paddingX={1}
|
paddingX={1}
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Starting Actions </Text>
|
<Text color={colors.primary} bold> Available Actions </Text>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>Loading...</Text>
|
<Text color={colors.textMuted}>Loading...</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : isImportRowSelected ? (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Import a template to see available actions
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
) : !currentTemplate ? (
|
) : !currentTemplate ? (
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>Select a template...</Text>
|
<Text color={colors.textMuted}>Select a template...</Text>
|
||||||
@@ -282,8 +542,8 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
selectedIndex={selectedActionIndex}
|
selectedIndex={selectedActionIndex}
|
||||||
onSelect={setSelectedActionIndex}
|
onSelect={setSelectedActionIndex}
|
||||||
onActivate={handleActionActivate}
|
onActivate={handleActionActivate}
|
||||||
focus={focusedPanel === 'actions'}
|
focus={focusedPanel === 'actions' && !isCaptured}
|
||||||
emptyMessage="No starting actions available"
|
emptyMessage="No actions available"
|
||||||
renderItem={renderActionItem}
|
renderItem={renderActionItem}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -304,7 +564,15 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
<Text color={colors.primary} bold> Description </Text>
|
<Text color={colors.primary} bold> Description </Text>
|
||||||
|
|
||||||
{/* Show template description when templates panel is focused */}
|
{/* Show template description when templates panel is focused */}
|
||||||
{focusedPanel === 'templates' && currentTemplate ? (
|
{focusedPanel === 'templates' && isImportRowSelected ? (
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.text} bold>Import Template</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Import a template file (JSON, JavaScript, or TypeScript) from the directory where the TUI was launched.
|
||||||
|
Press Enter or a to open the file picker.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : focusedPanel === 'templates' && currentTemplate ? (
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={colors.text} bold>
|
<Text color={colors.text} bold>
|
||||||
{currentTemplate.template.name || 'Unnamed Template'}
|
{currentTemplate.template.name || 'Unnamed Template'}
|
||||||
@@ -339,9 +607,6 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const action = currentActions[selectedActionIndex];
|
const action = currentActions[selectedActionIndex];
|
||||||
if (!action) return null;
|
if (!action) return null;
|
||||||
|
|
||||||
// Get roles that can start this action using utility function
|
|
||||||
const availableRoles = getRolesForAction(currentTemplate.template, action.actionIdentifier);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text color={colors.text} bold>
|
<Text color={colors.text} bold>
|
||||||
@@ -351,16 +616,24 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
{action.description || 'No description available'}
|
{action.description || 'No description available'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* List available roles for this action */}
|
{/* List roles available for this action in current context */}
|
||||||
{availableRoles.length > 0 && (
|
{action.roles.length > 0 && (
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={colors.text}>Available Roles:</Text>
|
<Text color={colors.text}>Available Roles:</Text>
|
||||||
{availableRoles.map((role) => (
|
{action.roles.map((roleId) => {
|
||||||
<Text key={role.roleId} color={colors.textMuted}>
|
const roleDef = currentTemplate.template.roles?.[roleId];
|
||||||
{' '}- {role.name}
|
const roleName = typeof roleDef === 'object' ? roleDef?.name ?? roleId : roleId;
|
||||||
{role.description ? `: ${role.description}` : ''}
|
const roleDescription = typeof roleDef === 'object' ? roleDef?.description : undefined;
|
||||||
|
return (
|
||||||
|
<Text key={roleId} color={colors.textMuted}>
|
||||||
|
{' '}- {roleName}
|
||||||
|
{roleDescription ? `: ${roleDescription}` : ''}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}Source: {action.source}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -370,7 +643,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
) : focusedPanel === 'actions' && !currentTemplate ? (
|
) : focusedPanel === 'actions' && !currentTemplate ? (
|
||||||
<Text color={colors.textMuted}>Select a template first</Text>
|
<Text color={colors.textMuted}>Select a template first</Text>
|
||||||
) : focusedPanel === 'actions' && currentActions.length === 0 ? (
|
) : focusedPanel === 'actions' && currentActions.length === 0 ? (
|
||||||
<Text color={colors.textMuted}>No starting actions available</Text>
|
<Text color={colors.textMuted}>No actions available</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -378,9 +651,57 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
{/* Help text */}
|
{/* Help text */}
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted} dimColor>
|
<Text color={colors.textMuted} dimColor>
|
||||||
Tab: Switch list • Enter: Select action • ↑↓: Navigate • Esc: Back
|
{focusedPanel === 'templates' && isImportRowSelected
|
||||||
|
? 'Tab: Switch list • a/Enter: Import • ↑↓: Navigate • Esc: Back'
|
||||||
|
: focusedPanel === 'templates' && currentTemplate
|
||||||
|
? 'Tab: Switch list • a: Import • d: Delete • ↑↓: Navigate • Esc: Back'
|
||||||
|
: 'Tab: Switch list • Enter: Select action • ↑↓: Navigate • Esc: Back'}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Import template dialog overlay */}
|
||||||
|
{isImportDialogOpen && (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<ImportTemplateDialogOverlay
|
||||||
|
onClose={() => setIsImportDialogOpen(false)}
|
||||||
|
onSelectFile={handleImportFile}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete template confirmation dialog */}
|
||||||
|
{isDeleteDialogOpen && templateToDelete && (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Delete Template"
|
||||||
|
message={
|
||||||
|
`Delete "${templateToDelete.template.name || templateToDelete.templateIdentifier}"?\n\n` +
|
||||||
|
'This removes the template from local storage. Invitations that use it may become unusable.'
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setTemplateToDelete(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,416 +0,0 @@
|
|||||||
/**
|
|
||||||
* Transaction Screen - Reviews and broadcasts transactions.
|
|
||||||
*
|
|
||||||
* Provides:
|
|
||||||
* - Transaction details review
|
|
||||||
* - Input/output inspection
|
|
||||||
* - Fee calculation display
|
|
||||||
* - Broadcast confirmation
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { Box, Text, useInput } from 'ink';
|
|
||||||
import { ConfirmDialog } from '../components/Dialog.js';
|
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.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.
|
|
||||||
*/
|
|
||||||
const actionItems = [
|
|
||||||
{ label: 'Broadcast Transaction', value: 'broadcast' },
|
|
||||||
{ label: 'Sign Transaction', value: 'sign' },
|
|
||||||
{ label: 'Copy Transaction Hex', value: 'copy' },
|
|
||||||
{ label: 'Back to Invitation', value: 'back' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transaction Screen Component.
|
|
||||||
*/
|
|
||||||
export function TransactionScreen(): React.ReactElement {
|
|
||||||
const { navigate, goBack, data: navData } = useNavigation();
|
|
||||||
const { showError, showInfo } = useAppContext();
|
|
||||||
const { setStatus } = useStatus();
|
|
||||||
|
|
||||||
// Extract invitation ID from navigation data
|
|
||||||
const invitationId = navData.invitationId as string | undefined;
|
|
||||||
|
|
||||||
// Use hook to get invitation reactively
|
|
||||||
const invitationInstance = useInvitation(invitationId ?? null);
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [focusedPanel, setFocusedPanel] = useState<'inputs' | 'outputs' | 'actions'>('actions');
|
|
||||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [showBroadcastConfirm, setShowBroadcastConfirm] = useState(false);
|
|
||||||
|
|
||||||
// Check if invitation exists
|
|
||||||
useEffect(() => {
|
|
||||||
if (!invitationId) {
|
|
||||||
showError('No invitation ID provided');
|
|
||||||
goBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (invitationId && !invitationInstance) {
|
|
||||||
showError('Invitation not found');
|
|
||||||
goBack();
|
|
||||||
}
|
|
||||||
}, [invitationId, invitationInstance, showError, goBack]);
|
|
||||||
|
|
||||||
const invitation = invitationInstance?.data ?? null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcast transaction.
|
|
||||||
*/
|
|
||||||
const broadcastTransaction = useCallback(async () => {
|
|
||||||
if (!invitationInstance) return;
|
|
||||||
|
|
||||||
setShowBroadcastConfirm(false);
|
|
||||||
setIsLoading(true);
|
|
||||||
setStatus('Broadcasting transaction...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await invitationInstance.broadcast();
|
|
||||||
showInfo(
|
|
||||||
`Transaction Broadcast Successful!\n\n` +
|
|
||||||
`The transaction has been submitted to the network.`
|
|
||||||
);
|
|
||||||
navigate('wallet');
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to broadcast: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
setStatus('Ready');
|
|
||||||
}
|
|
||||||
}, [invitationInstance, showInfo, showError, navigate, setStatus]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign transaction.
|
|
||||||
*/
|
|
||||||
const signTransaction = useCallback(async () => {
|
|
||||||
if (!invitationInstance) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setStatus('Signing transaction...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await invitationInstance.sign();
|
|
||||||
showInfo('Transaction signed successfully!');
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
setStatus('Ready');
|
|
||||||
}
|
|
||||||
}, [invitationInstance, showInfo, showError, setStatus]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy transaction hex.
|
|
||||||
*/
|
|
||||||
const copyTransactionHex = useCallback(async () => {
|
|
||||||
if (!invitation) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await copyToClipboard(invitation.invitationIdentifier);
|
|
||||||
showInfo(
|
|
||||||
`Copied Invitation ID!\n\n` +
|
|
||||||
`ID: ${invitation.invitationIdentifier}\n` +
|
|
||||||
`Commits: ${invitation.commits.length}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}, [invitation, showInfo, showError]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle action selection.
|
|
||||||
*/
|
|
||||||
const handleAction = useCallback((action: string) => {
|
|
||||||
switch (action) {
|
|
||||||
case 'broadcast':
|
|
||||||
setShowBroadcastConfirm(true);
|
|
||||||
break;
|
|
||||||
case 'sign':
|
|
||||||
signTransaction();
|
|
||||||
break;
|
|
||||||
case 'copy':
|
|
||||||
copyTransactionHex();
|
|
||||||
break;
|
|
||||||
case 'back':
|
|
||||||
goBack();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}, [signTransaction, copyTransactionHex, goBack]);
|
|
||||||
|
|
||||||
// Handle keyboard navigation
|
|
||||||
useInput((input, key) => {
|
|
||||||
if (showBroadcastConfirm) return;
|
|
||||||
|
|
||||||
// Tab to switch panels
|
|
||||||
if (key.tab) {
|
|
||||||
setFocusedPanel(prev => {
|
|
||||||
if (prev === 'inputs') return 'outputs';
|
|
||||||
if (prev === 'outputs') return 'actions';
|
|
||||||
return 'inputs';
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Up/Down in actions
|
|
||||||
if (focusedPanel === 'actions') {
|
|
||||||
if (key.upArrow || input === 'k') {
|
|
||||||
setSelectedActionIndex(prev => Math.max(0, prev - 1));
|
|
||||||
} else if (key.downArrow || input === 'j') {
|
|
||||||
setSelectedActionIndex(prev => Math.min(actionItems.length - 1, prev + 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter to select
|
|
||||||
if (key.return && focusedPanel === 'actions') {
|
|
||||||
const action = actionItems[selectedActionIndex];
|
|
||||||
if (action) {
|
|
||||||
handleAction(action.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, { isActive: !showBroadcastConfirm });
|
|
||||||
|
|
||||||
// Extract transaction data from invitation
|
|
||||||
const commits = invitation?.commits ?? [];
|
|
||||||
const inputs: Array<{ txid: string; index: number; value?: bigint; inputIdentifier?: string }> = [];
|
|
||||||
const outputs: Array<{ value?: bigint; lockingBytecode: string; outputIdentifier?: string; isTemplate: boolean }> = [];
|
|
||||||
const variables: Array<{ id: string; value: string }> = [];
|
|
||||||
|
|
||||||
// Parse commits for inputs, outputs, and variables
|
|
||||||
for (const commit of commits) {
|
|
||||||
// Extract variables (to help understand output values)
|
|
||||||
if (commit.data?.variables) {
|
|
||||||
for (const variable of commit.data.variables) {
|
|
||||||
variables.push({
|
|
||||||
id: variable.variableIdentifier,
|
|
||||||
value: String(variable.value),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (commit.data?.inputs) {
|
|
||||||
for (const input of commit.data.inputs) {
|
|
||||||
// Convert Uint8Array to hex string if needed
|
|
||||||
const txidHex = input.outpointTransactionHash
|
|
||||||
? typeof input.outpointTransactionHash === 'string'
|
|
||||||
? input.outpointTransactionHash
|
|
||||||
: Buffer.from(input.outpointTransactionHash).toString('hex')
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Skip inputs that are just placeholders (no txid)
|
|
||||||
if (txidHex) {
|
|
||||||
inputs.push({
|
|
||||||
txid: txidHex,
|
|
||||||
index: input.outpointIndex ?? 0,
|
|
||||||
value: undefined, // Will be looked up from UTXO data
|
|
||||||
inputIdentifier: (input as any).inputIdentifier,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (commit.data?.outputs) {
|
|
||||||
for (const output of commit.data.outputs) {
|
|
||||||
// Convert Uint8Array to hex string if needed
|
|
||||||
const lockingBytecodeHex = output.lockingBytecode
|
|
||||||
? typeof output.lockingBytecode === 'string'
|
|
||||||
? output.lockingBytecode
|
|
||||||
: Buffer.from(output.lockingBytecode).toString('hex')
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Check if this is a template-defined output (has outputIdentifier but no direct value)
|
|
||||||
const isTemplateOutput = !!(output as any).outputIdentifier && !output.valueSatoshis;
|
|
||||||
|
|
||||||
outputs.push({
|
|
||||||
value: output.valueSatoshis,
|
|
||||||
lockingBytecode: lockingBytecodeHex ?? '(pending)',
|
|
||||||
outputIdentifier: (output as any).outputIdentifier,
|
|
||||||
isTemplate: isTemplateOutput,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to resolve template output values from variables
|
|
||||||
const resolvedOutputs = outputs.map(output => {
|
|
||||||
if (output.isTemplate && output.outputIdentifier) {
|
|
||||||
// Look for a matching variable (e.g., requestSatoshisOutput -> requestedSatoshis)
|
|
||||||
const satoshiVar = variables.find(v =>
|
|
||||||
v.id.toLowerCase().includes('satoshi') ||
|
|
||||||
v.id.toLowerCase().includes('amount')
|
|
||||||
);
|
|
||||||
if (satoshiVar) {
|
|
||||||
return {
|
|
||||||
...output,
|
|
||||||
value: BigInt(satoshiVar.value),
|
|
||||||
resolvedFrom: satoshiVar.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate totals (only for resolved values)
|
|
||||||
const totalOut = resolvedOutputs.reduce((sum, o) => sum + (o.value ?? 0n), 0n);
|
|
||||||
// Note: We can't calculate totalIn without UTXO lookup, so fee is unknown
|
|
||||||
const hasUnresolvedOutputs = resolvedOutputs.some(o => o.value === undefined);
|
|
||||||
const hasUnresolvedInputs = inputs.length > 0; // Input values are always unknown from commit data
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flexDirection='column' flexGrow={1}>
|
|
||||||
{/* Header */}
|
|
||||||
<Box borderStyle='single' borderColor={colors.secondary} paddingX={1}>
|
|
||||||
<Text color={colors.primary} bold>{logoSmall} - Transaction Review</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Summary box */}
|
|
||||||
<Box
|
|
||||||
borderStyle='single'
|
|
||||||
borderColor={colors.primary}
|
|
||||||
marginTop={1}
|
|
||||||
marginX={1}
|
|
||||||
paddingX={1}
|
|
||||||
flexDirection='column'
|
|
||||||
>
|
|
||||||
<Text color={colors.primary} bold> Transaction Summary </Text>
|
|
||||||
{invitation ? (
|
|
||||||
<Box flexDirection='column' marginTop={1}>
|
|
||||||
<Text color={colors.text}>Inputs: {inputs.length} | Outputs: {resolvedOutputs.length} | Commits: {commits.length}</Text>
|
|
||||||
{hasUnresolvedInputs && (
|
|
||||||
<Text color={colors.textMuted}>Total In: (requires UTXO lookup)</Text>
|
|
||||||
)}
|
|
||||||
<Text color={colors.warning}>Total Out: {formatSatoshis(totalOut)}{hasUnresolvedOutputs ? ' (partial)' : ''}</Text>
|
|
||||||
{hasUnresolvedInputs ? (
|
|
||||||
<Text color={colors.textMuted}>Fee: (calculated at broadcast)</Text>
|
|
||||||
) : (
|
|
||||||
<Text color={colors.info}>Fee: {formatSatoshis(0n)}</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Text color={colors.textMuted}>Loading...</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Inputs and Outputs */}
|
|
||||||
<Box flexDirection='row' marginTop={1} marginX={1} flexGrow={1}>
|
|
||||||
{/* Inputs */}
|
|
||||||
<Box
|
|
||||||
borderStyle='single'
|
|
||||||
borderColor={focusedPanel === 'inputs' ? colors.focus : colors.border}
|
|
||||||
width='50%'
|
|
||||||
flexDirection='column'
|
|
||||||
paddingX={1}
|
|
||||||
>
|
|
||||||
<Text color={colors.primary} bold> Inputs </Text>
|
|
||||||
<Box flexDirection='column' marginTop={1}>
|
|
||||||
{inputs.length === 0 ? (
|
|
||||||
<Text color={colors.textMuted}>No inputs</Text>
|
|
||||||
) : (
|
|
||||||
inputs.map((input, index) => (
|
|
||||||
<Box key={`${input.txid}-${input.index}`} flexDirection='column' marginBottom={1}>
|
|
||||||
<Text color={colors.text}>
|
|
||||||
{index + 1}. {formatHex(input.txid, 12)}:{input.index}
|
|
||||||
</Text>
|
|
||||||
{input.value !== undefined && (
|
|
||||||
<Text color={colors.textMuted}> {formatSatoshis(input.value)}</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Outputs */}
|
|
||||||
<Box
|
|
||||||
borderStyle='single'
|
|
||||||
borderColor={focusedPanel === 'outputs' ? colors.focus : colors.border}
|
|
||||||
width='50%'
|
|
||||||
flexDirection='column'
|
|
||||||
paddingX={1}
|
|
||||||
marginLeft={1}
|
|
||||||
>
|
|
||||||
<Text color={colors.primary} bold> Outputs </Text>
|
|
||||||
<Box flexDirection='column' marginTop={1}>
|
|
||||||
{resolvedOutputs.length === 0 ? (
|
|
||||||
<Text color={colors.textMuted}>No outputs</Text>
|
|
||||||
) : (
|
|
||||||
resolvedOutputs.map((output, index) => (
|
|
||||||
<Box key={index} flexDirection='column' marginBottom={1}>
|
|
||||||
<Text color={colors.text}>
|
|
||||||
{index + 1}. {output.value !== undefined ? formatSatoshis(output.value) : '(pending)'}
|
|
||||||
{output.outputIdentifier && (
|
|
||||||
<Text color={colors.info}> [{output.outputIdentifier}]</Text>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Text color={colors.textMuted}> {output.lockingBytecode !== '(pending)' ? formatHex(output.lockingBytecode, 20) : '(pending)'}</Text>
|
|
||||||
{(output as any).resolvedFrom && (
|
|
||||||
<Text color={colors.textMuted} dimColor> (from ${(output as any).resolvedFrom})</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<Box
|
|
||||||
borderStyle='single'
|
|
||||||
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
|
||||||
marginTop={1}
|
|
||||||
marginX={1}
|
|
||||||
paddingX={1}
|
|
||||||
flexDirection='column'
|
|
||||||
>
|
|
||||||
<Text color={colors.primary} bold> Actions </Text>
|
|
||||||
<Box flexDirection='column' marginTop={1}>
|
|
||||||
{actionItems.map((item, index) => (
|
|
||||||
<Text
|
|
||||||
key={item.value}
|
|
||||||
color={index === selectedActionIndex && focusedPanel === 'actions' ? colors.focus : colors.text}
|
|
||||||
bold={index === selectedActionIndex && focusedPanel === 'actions'}
|
|
||||||
>
|
|
||||||
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
|
|
||||||
{item.label}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Help text */}
|
|
||||||
<Box marginTop={1} marginX={1}>
|
|
||||||
<Text color={colors.textMuted} dimColor>
|
|
||||||
Tab: Switch focus • Enter: Select • Esc: Back
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Broadcast confirmation dialog */}
|
|
||||||
{showBroadcastConfirm && (
|
|
||||||
<Box
|
|
||||||
position='absolute'
|
|
||||||
flexDirection='column'
|
|
||||||
alignItems='center'
|
|
||||||
justifyContent='center'
|
|
||||||
width='100%'
|
|
||||||
height='100%'
|
|
||||||
>
|
|
||||||
<ConfirmDialog
|
|
||||||
title='Broadcast Transaction'
|
|
||||||
message='Are you sure you want to broadcast this transaction? This action cannot be undone.'
|
|
||||||
onConfirm={broadcastTransaction}
|
|
||||||
onCancel={() => setShowBroadcastConfirm(false)}
|
|
||||||
isActive={showBroadcastConfirm}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,20 +8,29 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { ScrollableList, type ListItemData } from '../components/List.js';
|
import { ScrollableList, type ListItemData } from '../components/List.js';
|
||||||
|
import { QRCode } from '../components/QRCode.js';
|
||||||
|
import { CurrencySelectionDialog } from '../components/CurrencySelectionDialog.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
|
import { useSatoshisConversion } from '../hooks/useSatoshisConversion.js';
|
||||||
|
import { useInputLayer, useLayeredInput, useBlockableInput, useIsInputCaptured } from '../hooks/useInputLayer.js';
|
||||||
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
||||||
import type { HistoryItem } from '../../services/history.js';
|
import type { HistoryItem } from '../../services/history.js';
|
||||||
|
|
||||||
|
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
||||||
|
import { hexToBin, lockingBytecodeToCashAddress } from '@bitauth/libauth';
|
||||||
|
|
||||||
// Import utility functions
|
// Import utility functions
|
||||||
import {
|
import {
|
||||||
formatHistoryListItem,
|
buildHistoryDisplayRows,
|
||||||
getHistoryItemColorName,
|
getHistoryItemColorName,
|
||||||
formatHistoryDate,
|
formatHistoryDate,
|
||||||
|
type HistoryDisplayRow,
|
||||||
type HistoryColorName,
|
type HistoryColorName,
|
||||||
} from '../../utils/history-utils.js';
|
} from '../../utils/history-utils.js';
|
||||||
|
import { copyToClipboard } from '../utils/clipboard.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map history color name to theme color.
|
* Map history color name to theme color.
|
||||||
@@ -52,13 +61,54 @@ const menuItems: ListItemData<string>[] = [
|
|||||||
{ key: 'import', label: 'Import Invitation', value: 'import' },
|
{ key: 'import', label: 'Import Invitation', value: 'import' },
|
||||||
{ key: 'invitations', label: 'View Invitations', value: 'invitations' },
|
{ key: 'invitations', label: 'View Invitations', value: 'invitations' },
|
||||||
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' },
|
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' },
|
||||||
|
{ key: 'set-currency', label: 'Set Fiat Currency', value: 'set-currency' },
|
||||||
|
{ key: 'unreserve-all', label: 'Unreserve All Resources', value: 'unreserve-all' },
|
||||||
{ key: 'refresh', label: 'Refresh', value: 'refresh' },
|
{ key: 'refresh', label: 'Refresh', value: 'refresh' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* History list item with HistoryItem value.
|
* History list item with display row value.
|
||||||
*/
|
*/
|
||||||
type HistoryListItem = ListItemData<HistoryItem>;
|
type HistoryListItem = ListItemData<HistoryDisplayRow>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QR code dialog overlay — auto-captures input via the layer system.
|
||||||
|
* Rendered only while a QR address is visible; closes on Enter/Esc.
|
||||||
|
*/
|
||||||
|
function QRDialogOverlay({ address, onClose }: { address: string; onClose: () => void }): React.ReactElement {
|
||||||
|
useInputLayer('qr-dialog');
|
||||||
|
|
||||||
|
useLayeredInput('qr-dialog', (input, key) => {
|
||||||
|
if (input === 'c' || input === 'C') {
|
||||||
|
copyToClipboard(address);
|
||||||
|
}
|
||||||
|
if (key.escape || key.return) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<QRCode
|
||||||
|
value={address}
|
||||||
|
dialog
|
||||||
|
dialogTitle="Receive Address"
|
||||||
|
showValue
|
||||||
|
subtitle={
|
||||||
|
<Box flexDirection="column" justifyContent="center" marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Press C to copy to clipboard</Text>
|
||||||
|
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wallet State Screen Component.
|
* Wallet State Screen Component.
|
||||||
@@ -68,6 +118,12 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
const { navigate } = useNavigation();
|
const { navigate } = useNavigation();
|
||||||
const { appService, showError, showInfo } = useAppContext();
|
const { appService, showError, showInfo } = useAppContext();
|
||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
const {
|
||||||
|
currencyCode,
|
||||||
|
fiatPerBchRate,
|
||||||
|
formattedFiatPerBchRate,
|
||||||
|
formatSatoshisToFiat,
|
||||||
|
} = useSatoshisConversion();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
||||||
@@ -77,6 +133,17 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0);
|
const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
/** Cash address to display in the QR code dialog (null when dialog is hidden). */
|
||||||
|
const [qrAddress, setQrAddress] = useState<string | null>(null);
|
||||||
|
/** Whether the fiat currency selection dialog is open. */
|
||||||
|
const [isCurrencyDialogOpen, setCurrencyDialogOpen] = useState(false);
|
||||||
|
/** Loading state for rates pair discovery. */
|
||||||
|
const [isLoadingCurrencyPairs, setLoadingCurrencyPairs] = useState(false);
|
||||||
|
/** Optional error message shown in the currency dialog. */
|
||||||
|
const [currencyPairsError, setCurrencyPairsError] = useState<string | null>(null);
|
||||||
|
/** Available fiat currencies derived from rates pairs in X/BCH format. */
|
||||||
|
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refreshes wallet state.
|
* Refreshes wallet state.
|
||||||
*/
|
*/
|
||||||
@@ -118,8 +185,23 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
refresh();
|
refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
// Keep wallet state in sync with invitation lifecycle and updates.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!appService) return;
|
||||||
|
|
||||||
|
const onWalletStateChanged = () => {
|
||||||
|
void refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
appService.on('wallet-state-changed', onWalletStateChanged);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
appService.off('wallet-state-changed', onWalletStateChanged);
|
||||||
|
};
|
||||||
|
}, [appService, refresh]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a new receiving address.
|
* Generates a new receiving address and displays it as a QR code.
|
||||||
*/
|
*/
|
||||||
const generateNewAddress = useCallback(async () => {
|
const generateNewAddress = useCallback(async () => {
|
||||||
if (!appService) {
|
if (!appService) {
|
||||||
@@ -139,24 +221,137 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a new locking bytecode
|
// Generate the template identifier
|
||||||
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
|
|
||||||
const templateId = generateTemplateIdentifier(p2pkhTemplate);
|
const templateId = generateTemplateIdentifier(p2pkhTemplate);
|
||||||
|
|
||||||
const lockingBytecode = await appService.engine.generateLockingBytecode(
|
// Generate the locking bytecode (returned as a hex string)
|
||||||
|
const lockingBytecodeHex = await appService.engine.generateLockingBytecode(
|
||||||
templateId,
|
templateId,
|
||||||
'receiveOutput',
|
'receiveOutput',
|
||||||
'receiver',
|
'receiver',
|
||||||
);
|
);
|
||||||
|
|
||||||
showInfo(`New address generated!\n\nLocking bytecode:\n${formatHex(lockingBytecode, 40)}`);
|
// Convert the locking bytecode to a BCH cash address for display and QR encoding.
|
||||||
|
const result = lockingBytecodeToCashAddress({ bytecode: hexToBin(lockingBytecodeHex), prefix: 'bitcoincash' });
|
||||||
|
|
||||||
|
if (typeof result === 'string') {
|
||||||
|
showError(`Failed to encode address: ${result}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(result);
|
||||||
|
|
||||||
|
setQrAddress(result.address);
|
||||||
|
setStatus('Address generated');
|
||||||
|
|
||||||
// Refresh to show updated state
|
// Refresh to show updated state
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Failed to generate address: ${error instanceof Error ? error.message : String(error)}`);
|
showError(`Failed to generate address: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
}, [appService, setStatus, showInfo, showError, refresh]);
|
}, [appService, setStatus, showError, refresh]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unreserves all reserved UTXOs and refreshes the wallet state.
|
||||||
|
*/
|
||||||
|
const unreserveAll = useCallback(async () => {
|
||||||
|
if (!appService) {
|
||||||
|
showError('AppService not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setStatus('Unreserving all resources...');
|
||||||
|
const count = await appService.unreserveAllResources();
|
||||||
|
showInfo(`Unreserved ${count} resource(s)`);
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Failed to unreserve resources: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}, [appService, setStatus, showError, showInfo, refresh]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads all available rates pairs, then extracts fiat numerator symbols from
|
||||||
|
* pairs shaped like X/BCH.
|
||||||
|
*
|
||||||
|
* We retry briefly because rates startup is asynchronous and metadata can take
|
||||||
|
* a moment to hydrate right after wallet initialization.
|
||||||
|
*/
|
||||||
|
const loadAvailableCurrencies = useCallback(async (): Promise<void> => {
|
||||||
|
if (!appService) {
|
||||||
|
setCurrencyPairsError("AppService not initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingCurrencyPairs(true);
|
||||||
|
setCurrencyPairsError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let pairs = new Set<string>();
|
||||||
|
|
||||||
|
// Retry a few times so we can catch late metadata initialization.
|
||||||
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||||
|
pairs = await appService.rates.listPairs();
|
||||||
|
if (pairs.size > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencies = Array.from(pairs)
|
||||||
|
.map((pair) => pair.toUpperCase())
|
||||||
|
.filter((pair) => pair.endsWith("/BCH"))
|
||||||
|
.map((pair) => pair.split("/")[0] ?? "")
|
||||||
|
.filter((currency) => currency.length > 0)
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
const uniqueCurrencies = Array.from(new Set(currencies));
|
||||||
|
setAvailableCurrencies(uniqueCurrencies);
|
||||||
|
|
||||||
|
if (uniqueCurrencies.length === 0) {
|
||||||
|
setCurrencyPairsError(
|
||||||
|
"No X/BCH rates are currently available. Try again in a moment.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setCurrencyPairsError(
|
||||||
|
`Failed to load currency pairs: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingCurrencyPairs(false);
|
||||||
|
}
|
||||||
|
}, [appService]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the fiat currency dialog and triggers pair discovery.
|
||||||
|
*/
|
||||||
|
const openCurrencyDialog = useCallback(() => {
|
||||||
|
setCurrencyDialogOpen(true);
|
||||||
|
void loadAvailableCurrencies();
|
||||||
|
}, [loadAvailableCurrencies]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the selected fiat currency to persisted settings.
|
||||||
|
*/
|
||||||
|
const applyCurrencySelection = useCallback(
|
||||||
|
(currencyCode: string) => {
|
||||||
|
if (!appService) {
|
||||||
|
showError("AppService not initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
appService.settings.setCurrency(currencyCode);
|
||||||
|
setStatus(`Fiat currency updated to ${currencyCode}`);
|
||||||
|
setCurrencyDialogOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to update currency: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appService, setStatus, showError],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles menu action.
|
* Handles menu action.
|
||||||
@@ -175,11 +370,17 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
case 'new-address':
|
case 'new-address':
|
||||||
generateNewAddress();
|
generateNewAddress();
|
||||||
break;
|
break;
|
||||||
|
case 'set-currency':
|
||||||
|
openCurrencyDialog();
|
||||||
|
break;
|
||||||
|
case 'unreserve-all':
|
||||||
|
unreserveAll();
|
||||||
|
break;
|
||||||
case 'refresh':
|
case 'refresh':
|
||||||
refresh();
|
refresh();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [navigate, generateNewAddress, refresh]);
|
}, [navigate, generateNewAddress, openCurrencyDialog, unreserveAll, refresh]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle menu item activation.
|
* Handle menu item activation.
|
||||||
@@ -194,21 +395,42 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
* Build history list items for ScrollableList.
|
* Build history list items for ScrollableList.
|
||||||
*/
|
*/
|
||||||
const historyListItems = useMemo((): HistoryListItem[] => {
|
const historyListItems = useMemo((): HistoryListItem[] => {
|
||||||
return history.map(item => {
|
return buildHistoryDisplayRows(history).map(row => {
|
||||||
const formatted = formatHistoryListItem(item, false);
|
|
||||||
return {
|
return {
|
||||||
key: item.id,
|
key: row.id,
|
||||||
label: formatted.label,
|
label: row.label,
|
||||||
description: formatted.description,
|
description: row.description,
|
||||||
value: item,
|
value: row,
|
||||||
color: formatted.color,
|
color: getHistoryItemColorName(row, false),
|
||||||
hidden: !formatted.isValid,
|
hidden: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [history]);
|
}, [history]);
|
||||||
|
|
||||||
// Handle keyboard navigation between panels
|
/**
|
||||||
useInput((input, key) => {
|
* Fiat values are memoized so we only recompute when balance or rate changes.
|
||||||
|
*/
|
||||||
|
const formattedUsdPerBchRate = useMemo(() => {
|
||||||
|
return formattedFiatPerBchRate;
|
||||||
|
}, [formattedFiatPerBchRate]);
|
||||||
|
|
||||||
|
const formattedUsdBalance = useMemo(() => {
|
||||||
|
if (!balance || fiatPerBchRate === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatSatoshisToFiat(balance.totalSatoshis);
|
||||||
|
}, [balance, fiatPerBchRate, formatSatoshisToFiat]);
|
||||||
|
|
||||||
|
const getFiatSuffix = useCallback((satoshis: bigint): string => {
|
||||||
|
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||||
|
return fiatValue ? ` (~${fiatValue})` : '';
|
||||||
|
}, [formatSatoshisToFiat]);
|
||||||
|
|
||||||
|
// Screen input — automatically blocked when any dialog/overlay is capturing.
|
||||||
|
const isCaptured = useIsInputCaptured();
|
||||||
|
|
||||||
|
useBlockableInput((_input, key) => {
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu');
|
setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu');
|
||||||
}
|
}
|
||||||
@@ -222,49 +444,60 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
isSelected: boolean,
|
isSelected: boolean,
|
||||||
isFocused: boolean
|
isFocused: boolean
|
||||||
): React.ReactNode => {
|
): React.ReactNode => {
|
||||||
const historyItem = item.value;
|
const row = item.value;
|
||||||
if (!historyItem) return null;
|
if (!row) return null;
|
||||||
|
|
||||||
const colorName = getHistoryItemColorName(historyItem.type, isFocused);
|
const colorName = getHistoryItemColorName(row, isFocused);
|
||||||
const itemColor = isFocused ? colors.focus : getHistoryColor(colorName);
|
const itemColor = isFocused ? colors.focus : getHistoryColor(colorName);
|
||||||
const dateStr = formatHistoryDate(historyItem.timestamp);
|
const dateStr = formatHistoryDate(row.timestamp);
|
||||||
const indicator = isFocused ? '▸ ' : ' ';
|
const indicator = isFocused ? '▸ ' : ' ';
|
||||||
|
const groupingPrefix = row.isNested ? ' -> ' : '';
|
||||||
|
|
||||||
// Format based on type
|
if (row.type === 'history_item') {
|
||||||
if (historyItem.type === 'invitation_created') {
|
const sats = row.valueSatoshis ?? 0n;
|
||||||
return (
|
const fiatSuffix = getFiatSuffix(sats);
|
||||||
<Box flexDirection="row" justifyContent="space-between">
|
|
||||||
<Text color={itemColor}>
|
|
||||||
{indicator}[Invitation] {historyItem.description}
|
|
||||||
</Text>
|
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
} else if (historyItem.type === 'utxo_reserved') {
|
|
||||||
const sats = historyItem.valueSatoshis ?? 0n;
|
|
||||||
return (
|
|
||||||
<Box flexDirection="row" justifyContent="space-between">
|
|
||||||
<Box>
|
|
||||||
<Text color={itemColor}>
|
|
||||||
{indicator}[Reserved] {formatSatoshis(sats)}
|
|
||||||
</Text>
|
|
||||||
<Text color={colors.textMuted}> {historyItem.description}</Text>
|
|
||||||
</Box>
|
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
} else if (historyItem.type === 'utxo_received') {
|
|
||||||
const sats = historyItem.valueSatoshis ?? 0n;
|
|
||||||
const reservedTag = historyItem.reserved ? ' [Reserved]' : '';
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" justifyContent="space-between">
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
<Text color={itemColor}>
|
<Text color={itemColor}>
|
||||||
{indicator}{formatSatoshis(sats)}
|
{indicator}{formatSatoshis(sats)}{fiatSuffix}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={colors.textMuted}>
|
<Text color={colors.textMuted}> {row.label}</Text>
|
||||||
{' '}{historyItem.description}{reservedTag}
|
</Box>
|
||||||
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.type === 'history_input') {
|
||||||
|
const sats = row.valueSatoshis ?? 0n;
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
|
<Box>
|
||||||
|
<Text color={itemColor}>
|
||||||
|
{indicator}{groupingPrefix}[Input] {formatSatoshis(sats)}
|
||||||
|
{getFiatSuffix(sats)}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text color={colors.textMuted}> {row.label}</Text>
|
||||||
|
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
||||||
|
</Box>
|
||||||
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.type === 'history_output') {
|
||||||
|
const sats = row.valueSatoshis ?? 0n;
|
||||||
|
const reservedTag = row.reserved ? ' [Reserved]' : '';
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Text color={itemColor}>
|
||||||
|
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
|
||||||
|
{getFiatSuffix(sats)}
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}> {row.label}{reservedTag}</Text>
|
||||||
|
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -275,12 +508,12 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<Box flexDirection="row" justifyContent="space-between">
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
<Text color={itemColor}>
|
<Text color={itemColor}>
|
||||||
{indicator}{historyItem.type}: {historyItem.description}
|
{indicator}{row.label}
|
||||||
</Text>
|
</Text>
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}, []);
|
}, [getFiatSuffix]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
@@ -312,6 +545,20 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
<Text color={colors.success} bold>
|
<Text color={colors.success} bold>
|
||||||
{formatSatoshis(balance.totalSatoshis)}
|
{formatSatoshis(balance.totalSatoshis)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{formattedUsdBalance ? (
|
||||||
|
<Text color={colors.info}>
|
||||||
|
Approx. Fiat ({currencyCode}): {formattedUsdBalance}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Approx. Fiat ({currencyCode}): Waiting for BCH/{currencyCode} rate...
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{formattedUsdPerBchRate && (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
1 BCH = {formattedUsdPerBchRate}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Text color={colors.textMuted}>
|
<Text color={colors.textMuted}>
|
||||||
UTXOs: {balance.utxoCount}
|
UTXOs: {balance.utxoCount}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -341,7 +588,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
selectedIndex={selectedMenuIndex}
|
selectedIndex={selectedMenuIndex}
|
||||||
onSelect={setSelectedMenuIndex}
|
onSelect={setSelectedMenuIndex}
|
||||||
onActivate={handleMenuItemActivate}
|
onActivate={handleMenuItemActivate}
|
||||||
focus={focusedPanel === 'menu'}
|
focus={focusedPanel === 'menu' && !isCaptured}
|
||||||
emptyMessage="No actions"
|
emptyMessage="No actions"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -359,7 +606,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
height={14}
|
height={14}
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''}</Text>
|
<Text color={colors.primary} bold> Wallet History {historyListItems.length > 0 ? `(${selectedHistoryIndex + 1}/${historyListItems.length})` : ''}</Text>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>Loading...</Text>
|
<Text color={colors.textMuted}>Loading...</Text>
|
||||||
@@ -369,7 +616,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
items={historyListItems}
|
items={historyListItems}
|
||||||
selectedIndex={selectedHistoryIndex}
|
selectedIndex={selectedHistoryIndex}
|
||||||
onSelect={setSelectedHistoryIndex}
|
onSelect={setSelectedHistoryIndex}
|
||||||
focus={focusedPanel === 'history'}
|
focus={focusedPanel === 'history' && !isCaptured}
|
||||||
maxVisible={10}
|
maxVisible={10}
|
||||||
emptyMessage="No history found"
|
emptyMessage="No history found"
|
||||||
renderItem={renderHistoryItem}
|
renderItem={renderHistoryItem}
|
||||||
@@ -384,6 +631,35 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
Tab: Switch focus • Enter: Select • ↑↓: Navigate • Esc: Back
|
Tab: Switch focus • Enter: Select • ↑↓: Navigate • Esc: Back
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* QR Code dialog overlay for generated addresses */}
|
||||||
|
{qrAddress && (
|
||||||
|
<QRDialogOverlay
|
||||||
|
address={qrAddress}
|
||||||
|
onClose={() => setQrAddress(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fiat currency selection dialog overlay */}
|
||||||
|
{isCurrencyDialogOpen && (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<CurrencySelectionDialog
|
||||||
|
currentCurrency={currencyCode}
|
||||||
|
currencies={availableCurrencies}
|
||||||
|
isLoading={isLoadingCurrencyPairs}
|
||||||
|
errorMessage={currencyPairsError}
|
||||||
|
onSelectCurrency={applyCurrencySelection}
|
||||||
|
onCancel={() => setCurrencyDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,149 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { StepIndicator, type Step } from '../../components/ProgressBar.js';
|
import { StepIndicator, type Step } from '../../components/ProgressBar.js';
|
||||||
import { Button } from '../../components/Button.js';
|
import { Button } from '../../components/Button.js';
|
||||||
import { colors, logoSmall } from '../../theme.js';
|
import { colors, logoSmall } from '../../theme.js';
|
||||||
import { useActionWizard } from './useActionWizard.js';
|
import { useActionWizard } from './hooks/useActionWizard.js';
|
||||||
|
import { useWizardKeyboard } from './hooks/useWizardKeyboard.js';
|
||||||
|
|
||||||
// Steps
|
// Steps
|
||||||
import { InfoStep } from './steps/InfoStep.js';
|
|
||||||
import { RoleSelectStep } from './steps/RoleSelectStep.js';
|
import { RoleSelectStep } from './steps/RoleSelectStep.js';
|
||||||
import { VariablesStep } from './steps/VariablesStep.js';
|
import { VariablesStep } from './steps/VariablesStep.js';
|
||||||
import { InputsStep } from './steps/InputsStep.js';
|
import { InputsStep } from './steps/InputsStep.js';
|
||||||
import { ReviewStep } from './steps/ReviewStep.js';
|
import { ReviewStep } from './steps/ReviewStep.js';
|
||||||
import { PublishStep } from './steps/PublishStep.js';
|
import { PublishStep } from './steps/PublishStep.js';
|
||||||
|
import { DataResultStep } from './steps/DataResultStep.js';
|
||||||
|
|
||||||
export function ActionWizardScreen(): React.ReactElement {
|
export function ActionWizardScreen(): React.ReactElement {
|
||||||
const wizard = useActionWizard();
|
const wizard = useActionWizard();
|
||||||
|
useWizardKeyboard(wizard);
|
||||||
// ── Keyboard handling ──────────────────────────────────────────
|
|
||||||
useInput(
|
|
||||||
(input, key) => {
|
|
||||||
// Tab to cycle between content area and button bar
|
|
||||||
if (key.tab) {
|
|
||||||
if (wizard.focusArea === 'content') {
|
|
||||||
// Within the role-select step, tab through roles first
|
|
||||||
if (
|
|
||||||
wizard.currentStepData?.type === 'role-select' &&
|
|
||||||
wizard.availableRoles.length > 0
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
wizard.selectedRoleIndex <
|
|
||||||
wizard.availableRoles.length - 1
|
|
||||||
) {
|
|
||||||
wizard.setSelectedRoleIndex((prev) => prev + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Within the inputs step, tab through UTXOs first
|
|
||||||
if (
|
|
||||||
wizard.currentStepData?.type === 'inputs' &&
|
|
||||||
wizard.availableUtxos.length > 0
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
wizard.selectedUtxoIndex <
|
|
||||||
wizard.availableUtxos.length - 1
|
|
||||||
) {
|
|
||||||
wizard.setSelectedUtxoIndex((prev) => prev + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Move focus down to the button bar
|
|
||||||
wizard.setFocusArea('buttons');
|
|
||||||
wizard.setFocusedButton('next');
|
|
||||||
} else {
|
|
||||||
// Cycle through buttons, then wrap back to content
|
|
||||||
if (wizard.focusedButton === 'back') {
|
|
||||||
wizard.setFocusedButton('cancel');
|
|
||||||
} else if (wizard.focusedButton === 'cancel') {
|
|
||||||
wizard.setFocusedButton('next');
|
|
||||||
} else {
|
|
||||||
wizard.setFocusArea('content');
|
|
||||||
wizard.setFocusedInput(0);
|
|
||||||
wizard.setSelectedUtxoIndex(0);
|
|
||||||
wizard.setSelectedRoleIndex(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrow keys for role selection in the content area
|
|
||||||
if (
|
|
||||||
wizard.focusArea === 'content' &&
|
|
||||||
wizard.currentStepData?.type === 'role-select'
|
|
||||||
) {
|
|
||||||
if (key.upArrow) {
|
|
||||||
wizard.setSelectedRoleIndex((p) => Math.max(0, p - 1));
|
|
||||||
} else if (key.downArrow) {
|
|
||||||
wizard.setSelectedRoleIndex((p) =>
|
|
||||||
Math.min(wizard.availableRoles.length - 1, p + 1)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrow keys for UTXO selection in the content area
|
|
||||||
if (
|
|
||||||
wizard.focusArea === 'content' &&
|
|
||||||
wizard.currentStepData?.type === 'inputs'
|
|
||||||
) {
|
|
||||||
if (key.upArrow) {
|
|
||||||
wizard.setSelectedUtxoIndex((p) => Math.max(0, p - 1));
|
|
||||||
} else if (key.downArrow) {
|
|
||||||
wizard.setSelectedUtxoIndex((p) =>
|
|
||||||
Math.min(wizard.availableUtxos.length - 1, p + 1)
|
|
||||||
);
|
|
||||||
} else if (key.return || input === ' ') {
|
|
||||||
wizard.toggleUtxoSelection(wizard.selectedUtxoIndex);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrow keys in button bar
|
|
||||||
if (wizard.focusArea === 'buttons') {
|
|
||||||
if (key.leftArrow) {
|
|
||||||
wizard.setFocusedButton((p) =>
|
|
||||||
p === 'next' ? 'cancel' : p === 'cancel' ? 'back' : 'back'
|
|
||||||
);
|
|
||||||
} else if (key.rightArrow) {
|
|
||||||
wizard.setFocusedButton((p) =>
|
|
||||||
p === 'back' ? 'cancel' : p === 'cancel' ? 'next' : 'next'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter on a button
|
|
||||||
if (key.return) {
|
|
||||||
if (wizard.focusedButton === 'back') wizard.previousStep();
|
|
||||||
else if (wizard.focusedButton === 'cancel') wizard.cancel();
|
|
||||||
else if (wizard.focusedButton === 'next') wizard.nextStep();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 'c' to copy invitation ID on the publish step
|
|
||||||
if (
|
|
||||||
input === 'c' &&
|
|
||||||
wizard.currentStepData?.type === 'publish' &&
|
|
||||||
wizard.invitationId
|
|
||||||
) {
|
|
||||||
wizard.copyId();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 'a' to select all UTXOs
|
|
||||||
if (input === 'a' && wizard.currentStepData?.type === 'inputs') {
|
|
||||||
wizard.setAvailableUtxos((p) =>
|
|
||||||
p.map((u) => ({ ...u, selected: true }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 'n' to deselect all UTXOs
|
|
||||||
if (input === 'n' && wizard.currentStepData?.type === 'inputs') {
|
|
||||||
wizard.setAvailableUtxos((p) =>
|
|
||||||
p.map((u) => ({ ...u, selected: false }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ isActive: !wizard.textInputHasFocus }
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Step router ────────────────────────────────────────────────
|
// ── Step router ────────────────────────────────────────────────
|
||||||
const renderStep = () => {
|
const renderStep = () => {
|
||||||
@@ -152,15 +25,6 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (wizard.currentStepData?.type) {
|
switch (wizard.currentStepData?.type) {
|
||||||
case 'info':
|
|
||||||
return (
|
|
||||||
<InfoStep
|
|
||||||
template={wizard.template!}
|
|
||||||
actionIdentifier={wizard.actionIdentifier!}
|
|
||||||
roleIdentifier={wizard.roleIdentifier!}
|
|
||||||
actionName={wizard.actionName}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'role-select':
|
case 'role-select':
|
||||||
return (
|
return (
|
||||||
<RoleSelectStep
|
<RoleSelectStep
|
||||||
@@ -205,7 +69,21 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'publish':
|
case 'publish':
|
||||||
return <PublishStep invitationId={wizard.invitationId} />;
|
return (
|
||||||
|
<PublishStep
|
||||||
|
invitationId={wizard.invitationId}
|
||||||
|
requirementsComplete={wizard.requirementsComplete}
|
||||||
|
hasSignedAndBroadcasted={wizard.hasSignedAndBroadcasted}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'result':
|
||||||
|
return (
|
||||||
|
<DataResultStep
|
||||||
|
actionName={wizard.actionName}
|
||||||
|
variables={wizard.variables}
|
||||||
|
dataResults={wizard.dataResults}
|
||||||
|
/>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -272,7 +150,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
wizard.focusArea === "buttons" &&
|
wizard.focusArea === "buttons" &&
|
||||||
wizard.focusedButton === "back"
|
wizard.focusedButton === "back"
|
||||||
}
|
}
|
||||||
disabled={wizard.currentStepData?.type === "publish"}
|
disabled={wizard.isLastStep}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Cancel"
|
label="Cancel"
|
||||||
@@ -283,9 +161,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
label={
|
label={wizard.nextButtonLabel}
|
||||||
wizard.currentStepData?.type === "publish" ? "Done" : "Next"
|
|
||||||
}
|
|
||||||
focused={
|
focused={
|
||||||
wizard.focusArea === "buttons" &&
|
wizard.focusArea === "buttons" &&
|
||||||
wizard.focusedButton === "next"
|
wizard.focusedButton === "next"
|
||||||
|
|||||||
40
src/tui/screens/action-wizard/flows/DataWizardFlow.ts
Normal file
40
src/tui/screens/action-wizard/flows/DataWizardFlow.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { FlowContext, StepType } from "../types.js";
|
||||||
|
import { WizardFlow } from "./WizardFlow.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flow strategy for data-only actions (e.g. sign, verify).
|
||||||
|
*
|
||||||
|
* These actions produce computed data rather than a transaction.
|
||||||
|
* No invitation, UTXOs, or fees are involved — just variables in,
|
||||||
|
* data result out.
|
||||||
|
*
|
||||||
|
* NOTE: Engine-level data action execution is not yet implemented.
|
||||||
|
* The result step is currently stubbed.
|
||||||
|
*/
|
||||||
|
export class DataWizardFlow extends WizardFlow {
|
||||||
|
readonly type = "data" as const;
|
||||||
|
|
||||||
|
/** The data field identifiers this action produces (from action.data). */
|
||||||
|
readonly dataOutputs: string[];
|
||||||
|
|
||||||
|
constructor(dataOutputs: string[]) {
|
||||||
|
super();
|
||||||
|
this.dataOutputs = dataOutputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStepTypes(context: FlowContext): StepType[] {
|
||||||
|
const steps: StepType[] = [];
|
||||||
|
if (context.availableRoles.length > 1) steps.push("role-select");
|
||||||
|
if (context.hasVariables) steps.push("variables");
|
||||||
|
steps.push("result");
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
canFinalize(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFinalActionLabel(): string {
|
||||||
|
return "Done";
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/tui/screens/action-wizard/flows/TransactionWizardFlow.ts
Normal file
36
src/tui/screens/action-wizard/flows/TransactionWizardFlow.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { FlowContext, StepType } from "../types.js";
|
||||||
|
import { WizardFlow } from "./WizardFlow.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flow strategy for transaction-based actions.
|
||||||
|
*
|
||||||
|
* Handles both single-role actions (sendSatoshis, burn) where the
|
||||||
|
* creator provides inputs and signs locally, and multi-role actions
|
||||||
|
* (receive, request) where the creator publishes an invitation for
|
||||||
|
* another party to complete.
|
||||||
|
*/
|
||||||
|
export class TransactionWizardFlow extends WizardFlow {
|
||||||
|
readonly type = "transaction" as const;
|
||||||
|
|
||||||
|
getStepTypes(context: FlowContext): StepType[] {
|
||||||
|
const steps: StepType[] = [];
|
||||||
|
if (context.availableRoles.length > 1) steps.push("role-select");
|
||||||
|
if (context.hasVariables) steps.push("variables");
|
||||||
|
if (context.shouldCollectInputs) steps.push("inputs");
|
||||||
|
steps.push("review");
|
||||||
|
steps.push("publish");
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
canFinalize(context: FlowContext): boolean {
|
||||||
|
return (
|
||||||
|
context.requirementsComplete &&
|
||||||
|
context.wizardCollectedInputs &&
|
||||||
|
!context.hasSignedAndBroadcasted
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFinalActionLabel(context: FlowContext): string {
|
||||||
|
return this.canFinalize(context) ? "Sign & Broadcast" : "Done";
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/tui/screens/action-wizard/flows/WizardFlow.ts
Normal file
22
src/tui/screens/action-wizard/flows/WizardFlow.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { FlowContext, StepType } from "../types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract strategy that defines the shape of a wizard flow.
|
||||||
|
*
|
||||||
|
* Subclasses declare which steps are needed, whether the action can be
|
||||||
|
* finalized, and what the final button should say. They hold no React
|
||||||
|
* state — the orchestrator hook wires domain hooks to the step configs
|
||||||
|
* produced from these methods.
|
||||||
|
*/
|
||||||
|
export abstract class WizardFlow {
|
||||||
|
abstract readonly type: "transaction" | "data";
|
||||||
|
|
||||||
|
/** Determine which step types this flow needs given the current context. */
|
||||||
|
abstract getStepTypes(context: FlowContext): StepType[];
|
||||||
|
|
||||||
|
/** Whether the action can be finalized (e.g. signed & broadcast). */
|
||||||
|
abstract canFinalize(context: FlowContext): boolean;
|
||||||
|
|
||||||
|
/** Label for the primary action button on the final step. */
|
||||||
|
abstract getFinalActionLabel(context: FlowContext): string;
|
||||||
|
}
|
||||||
21
src/tui/screens/action-wizard/flows/index.ts
Normal file
21
src/tui/screens/action-wizard/flows/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { XOTemplateAction } from "@xo-cash/types";
|
||||||
|
import { TransactionWizardFlow } from "./TransactionWizardFlow.js";
|
||||||
|
import { DataWizardFlow } from "./DataWizardFlow.js";
|
||||||
|
import type { WizardFlow } from "./WizardFlow.js";
|
||||||
|
|
||||||
|
export { WizardFlow } from "./WizardFlow.js";
|
||||||
|
export { TransactionWizardFlow } from "./TransactionWizardFlow.js";
|
||||||
|
export { DataWizardFlow } from "./DataWizardFlow.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect a template action and return the appropriate wizard flow strategy.
|
||||||
|
*
|
||||||
|
* Actions with `data` fields and no `transaction` are data-only flows.
|
||||||
|
* Everything else uses the transaction flow.
|
||||||
|
*/
|
||||||
|
export function createWizardFlow(action: XOTemplateAction): WizardFlow {
|
||||||
|
if (action.data?.length && !action.transaction) {
|
||||||
|
return new DataWizardFlow([action.data]);
|
||||||
|
}
|
||||||
|
return new TransactionWizardFlow();
|
||||||
|
}
|
||||||
534
src/tui/screens/action-wizard/hooks/useActionWizard.ts
Normal file
534
src/tui/screens/action-wizard/hooks/useActionWizard.ts
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||||
|
import { useNavigation } from "../../../hooks/useNavigation.js";
|
||||||
|
import { useAppContext, useStatus } from "../../../hooks/useAppContext.js";
|
||||||
|
import { copyToClipboard } from "../../../utils/clipboard.js";
|
||||||
|
import { roleRequiresInputs } from "../../../../utils/invitation-flow.js";
|
||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
import type { StepConfig, FlowContext, DataResult } from "../types.js";
|
||||||
|
import {
|
||||||
|
createWizardFlow,
|
||||||
|
type WizardFlow,
|
||||||
|
DataWizardFlow,
|
||||||
|
} from "../flows/index.js";
|
||||||
|
import { useRoleSelection } from "./useRoleSelection.js";
|
||||||
|
import { useVariableInputs } from "./useVariableInputs.js";
|
||||||
|
import { useUtxoSelection } from "./useUtxoSelection.js";
|
||||||
|
import { useInvitationManager } from "./useInvitationManager.js";
|
||||||
|
import { useWizardFocus } from "./useWizardFocus.js";
|
||||||
|
import { useWizardSteps } from "./useWizardSteps.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin orchestrator that composes domain hooks and wires them
|
||||||
|
* to step configs produced by the WizardFlow strategy.
|
||||||
|
*
|
||||||
|
* This replaces the original 861-line god-hook.
|
||||||
|
*/
|
||||||
|
export function useActionWizard() {
|
||||||
|
const { goBack, data: navData } = useNavigation();
|
||||||
|
const { appService, showError, showInfo } = useAppContext();
|
||||||
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
|
if (!appService) {
|
||||||
|
throw new Error("AppService not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigation data ───────────────────────────────────────────
|
||||||
|
const templateIdentifier = navData.templateIdentifier as string | undefined;
|
||||||
|
const actionIdentifier = navData.actionIdentifier as string | undefined;
|
||||||
|
const template = navData.template as XOTemplate | undefined;
|
||||||
|
const actionRolesFromNavigation = navData.actionRoles as string[] | undefined;
|
||||||
|
|
||||||
|
// ── Derived template data ─────────────────────────────────────
|
||||||
|
const action = template?.actions?.[actionIdentifier ?? ""];
|
||||||
|
const actionName = action?.name || actionIdentifier || "Unknown";
|
||||||
|
|
||||||
|
// ── Flow strategy ─────────────────────────────────────────────
|
||||||
|
const flow = useMemo<WizardFlow>(() => {
|
||||||
|
// Create a default action if no action is found
|
||||||
|
if (!action) {
|
||||||
|
return createWizardFlow({ name: "", description: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the flow from the action
|
||||||
|
return createWizardFlow(action);
|
||||||
|
}, [action]);
|
||||||
|
|
||||||
|
// ── Domain hooks ──────────────────────────────────────────────
|
||||||
|
const roleSelection = useRoleSelection(
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
actionRolesFromNavigation,
|
||||||
|
);
|
||||||
|
const variableInputs = useVariableInputs();
|
||||||
|
const utxoSelection = useUtxoSelection();
|
||||||
|
const invitationManager = useInvitationManager({
|
||||||
|
appService,
|
||||||
|
showError,
|
||||||
|
showInfo,
|
||||||
|
setStatus,
|
||||||
|
});
|
||||||
|
const focus = useWizardFocus();
|
||||||
|
|
||||||
|
// ── Data results (data-only flows) ────────────────────────────
|
||||||
|
const [dataResults, setDataResults] = useState<DataResult[]>([]);
|
||||||
|
|
||||||
|
// ── Initialize variables when role becomes available ──────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (template && actionIdentifier && roleSelection.effectiveRole) {
|
||||||
|
const act = template.actions?.[actionIdentifier];
|
||||||
|
const role = act?.roles?.[roleSelection.effectiveRole];
|
||||||
|
const varIds = role?.requirements?.variables;
|
||||||
|
if (varIds && varIds.length > 0) {
|
||||||
|
variableInputs.initFromTemplate(
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
roleSelection.effectiveRole,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
roleSelection.effectiveRole,
|
||||||
|
variableInputs.initFromTemplate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Determine whether creator should provide inputs ───────────
|
||||||
|
const shouldCollectInputs = useMemo(() => {
|
||||||
|
if (flow.type !== "transaction") return false;
|
||||||
|
if (!template || !actionIdentifier || !roleSelection.effectiveRole)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const act = template.actions?.[actionIdentifier];
|
||||||
|
const totalActionRoles = Object.keys(act?.roles ?? {}).length;
|
||||||
|
const isSingleRoleAction = totalActionRoles <= 1;
|
||||||
|
return (
|
||||||
|
isSingleRoleAction &&
|
||||||
|
roleRequiresInputs(
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
roleSelection.effectiveRole,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [flow.type, template, actionIdentifier, roleSelection.effectiveRole]);
|
||||||
|
|
||||||
|
// ── Build flow context for strategy methods ───────────────────
|
||||||
|
const flowContext = useMemo<FlowContext>(
|
||||||
|
() => ({
|
||||||
|
availableRoles: roleSelection.availableRoles,
|
||||||
|
hasVariables: variableInputs.variables.length > 0,
|
||||||
|
shouldCollectInputs,
|
||||||
|
requirementsComplete: invitationManager.requirementsComplete,
|
||||||
|
wizardCollectedInputs: shouldCollectInputs,
|
||||||
|
hasSignedAndBroadcasted: invitationManager.hasSignedAndBroadcasted,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
roleSelection.availableRoles,
|
||||||
|
variableInputs.variables.length,
|
||||||
|
shouldCollectInputs,
|
||||||
|
invitationManager.requirementsComplete,
|
||||||
|
invitationManager.hasSignedAndBroadcasted,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Handle Enter inside a TextInput ───────────────────────────
|
||||||
|
const handleTextInputSubmit = useCallback(() => {
|
||||||
|
if (focus.focusedInput < variableInputs.variables.length - 1) {
|
||||||
|
focus.setFocusedInput((prev) => prev + 1);
|
||||||
|
} else {
|
||||||
|
focus.moveToButtons();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
focus.focusedInput,
|
||||||
|
variableInputs.variables.length,
|
||||||
|
focus.setFocusedInput,
|
||||||
|
focus.moveToButtons,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Copy invitation ID to clipboard ───────────────────────────
|
||||||
|
const copyId = useCallback(async () => {
|
||||||
|
if (!invitationManager.invitationId) return;
|
||||||
|
try {
|
||||||
|
await copyToClipboard(invitationManager.invitationId);
|
||||||
|
showInfo(`Copied to clipboard!\n\n${invitationManager.invitationId}`);
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to copy: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [invitationManager.invitationId, showInfo, showError]);
|
||||||
|
|
||||||
|
// ── Helper: create invitation if it doesn't exist yet ─────────
|
||||||
|
const ensureInvitation = useCallback(
|
||||||
|
async (roleId?: string): Promise<string | null> => {
|
||||||
|
if (invitationManager.invitationId) return invitationManager.invitationId;
|
||||||
|
const role = roleId ?? roleSelection.effectiveRole;
|
||||||
|
if (!templateIdentifier || !actionIdentifier || !role || !template)
|
||||||
|
return null;
|
||||||
|
return invitationManager.createWithVariables(
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
role,
|
||||||
|
template,
|
||||||
|
variableInputs.variables,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
invitationManager.invitationId,
|
||||||
|
invitationManager.createWithVariables,
|
||||||
|
roleSelection.effectiveRole,
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
template,
|
||||||
|
variableInputs.variables,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Helper: load UTXOs after invitation is created ────────────
|
||||||
|
const loadUtxosForInvitation = useCallback(
|
||||||
|
async (invId: string) => {
|
||||||
|
if (!appService || !templateIdentifier) return;
|
||||||
|
const instance = appService.invitations.find(
|
||||||
|
(inv) => inv.data.invitationIdentifier === invId,
|
||||||
|
);
|
||||||
|
if (instance) {
|
||||||
|
invitationManager.setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
await utxoSelection.loadUtxos(
|
||||||
|
instance,
|
||||||
|
templateIdentifier,
|
||||||
|
variableInputs.variables,
|
||||||
|
setStatus,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
invitationManager.setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
appService,
|
||||||
|
templateIdentifier,
|
||||||
|
variableInputs.variables,
|
||||||
|
utxoSelection.loadUtxos,
|
||||||
|
invitationManager.setIsProcessing,
|
||||||
|
setStatus,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Build step configs from flow strategy ─────────────────────
|
||||||
|
const stepConfigs = useMemo<StepConfig[]>(() => {
|
||||||
|
const stepTypes = flow.getStepTypes(flowContext);
|
||||||
|
|
||||||
|
return stepTypes.map((type): StepConfig => {
|
||||||
|
switch (type) {
|
||||||
|
case "role-select":
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
name: "Select Role",
|
||||||
|
validate: () => {
|
||||||
|
const selectedRole =
|
||||||
|
roleSelection.availableRoles[roleSelection.selectedRoleIndex];
|
||||||
|
return selectedRole ? null : "Please select a role";
|
||||||
|
},
|
||||||
|
onNext: async () => {
|
||||||
|
const selectedRole =
|
||||||
|
roleSelection.availableRoles[roleSelection.selectedRoleIndex];
|
||||||
|
if (!selectedRole) return false;
|
||||||
|
|
||||||
|
// Initialize variables for this role immediately
|
||||||
|
if (template && actionIdentifier) {
|
||||||
|
const act = template.actions?.[actionIdentifier];
|
||||||
|
const role = act?.roles?.[selectedRole];
|
||||||
|
const hasVars =
|
||||||
|
(role?.requirements?.variables?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
if (hasVars) {
|
||||||
|
variableInputs.initFromTemplate(
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
selectedRole,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no variables step follows, create the invitation now (transaction flows only)
|
||||||
|
if (!hasVars && flow.type === "transaction") {
|
||||||
|
if (templateIdentifier && template) {
|
||||||
|
const invId = await invitationManager.createWithVariables(
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
selectedRole,
|
||||||
|
template,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
if (!invId) return false;
|
||||||
|
|
||||||
|
// Pre-load UTXOs if the inputs step follows
|
||||||
|
const totalRoles = Object.keys(act?.roles ?? {}).length;
|
||||||
|
const needsInputs =
|
||||||
|
totalRoles <= 1 &&
|
||||||
|
roleRequiresInputs(
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
selectedRole,
|
||||||
|
);
|
||||||
|
if (needsInputs) {
|
||||||
|
await loadUtxosForInvitation(invId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
roleSelection.setRoleIdentifier(selectedRole);
|
||||||
|
focus.resetToContent();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case "variables":
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
name: "Variables",
|
||||||
|
validate: () => variableInputs.validate(),
|
||||||
|
onNext: async () => {
|
||||||
|
if (flow.type === "transaction") {
|
||||||
|
if (
|
||||||
|
!templateIdentifier ||
|
||||||
|
!actionIdentifier ||
|
||||||
|
!template ||
|
||||||
|
!roleSelection.effectiveRole
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const invId = await invitationManager.createWithVariables(
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
roleSelection.effectiveRole,
|
||||||
|
template,
|
||||||
|
variableInputs.variables,
|
||||||
|
);
|
||||||
|
if (!invId) return false;
|
||||||
|
|
||||||
|
// Pre-load UTXOs if the inputs step follows
|
||||||
|
if (shouldCollectInputs) {
|
||||||
|
await loadUtxosForInvitation(invId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For data flows, just advance — variables are used in the result step
|
||||||
|
focus.resetToContent();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case "inputs":
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
name: "Select UTXOs",
|
||||||
|
validate: () => utxoSelection.validate(),
|
||||||
|
onNext: async () => {
|
||||||
|
const selectedUtxos = utxoSelection.availableUtxos.filter(
|
||||||
|
(u) => u.selected,
|
||||||
|
);
|
||||||
|
const success = await invitationManager.addInputsAndOutputs(
|
||||||
|
selectedUtxos,
|
||||||
|
utxoSelection.changeAmount,
|
||||||
|
);
|
||||||
|
if (success) focus.resetToContent();
|
||||||
|
return success;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case "review":
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
name: "Review",
|
||||||
|
validate: () => null,
|
||||||
|
onNext: async () => {
|
||||||
|
// Ensure invitation exists (covers the case where no prior step created it)
|
||||||
|
const invId = await ensureInvitation();
|
||||||
|
if (!invId) return false;
|
||||||
|
await invitationManager.refreshRequirements(invId);
|
||||||
|
focus.resetToContent();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case "publish":
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
name: "Publish",
|
||||||
|
validate: () => null,
|
||||||
|
onNext: async () => {
|
||||||
|
if (flow.canFinalize(flowContext)) {
|
||||||
|
await invitationManager.signAndBroadcast();
|
||||||
|
// Stay on publish step (it's the last step, stepper won't advance)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
goBack();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case "result":
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
name: "Result",
|
||||||
|
validate: () => null,
|
||||||
|
onNext: async () => {
|
||||||
|
// Data-only flows: populate stubbed results, then exit
|
||||||
|
if (flow instanceof DataWizardFlow) {
|
||||||
|
const results: DataResult[] = flow.dataOutputs.map((dataId) => {
|
||||||
|
const dataDef = template?.data?.[dataId];
|
||||||
|
return {
|
||||||
|
id: dataId,
|
||||||
|
name: dataDef?.hint ?? dataId,
|
||||||
|
type: dataDef?.type ?? "unknown",
|
||||||
|
hint: dataDef?.hint,
|
||||||
|
value: null, // Engine-level data execution not yet implemented
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setDataResults(results);
|
||||||
|
}
|
||||||
|
goBack();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
name: type,
|
||||||
|
validate: () => null,
|
||||||
|
onNext: async () => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
flow,
|
||||||
|
flowContext,
|
||||||
|
roleSelection,
|
||||||
|
variableInputs,
|
||||||
|
utxoSelection,
|
||||||
|
invitationManager,
|
||||||
|
focus,
|
||||||
|
template,
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
shouldCollectInputs,
|
||||||
|
ensureInvitation,
|
||||||
|
loadUtxosForInvitation,
|
||||||
|
goBack,
|
||||||
|
setStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Step navigation ───────────────────────────────────────────
|
||||||
|
const stepper = useWizardSteps(stepConfigs, goBack, showError);
|
||||||
|
|
||||||
|
// ── Set initial status ────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!template || !actionIdentifier) {
|
||||||
|
showError("Missing wizard data");
|
||||||
|
goBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(
|
||||||
|
roleSelection.effectiveRole
|
||||||
|
? `${actionIdentifier}/${roleSelection.effectiveRole}`
|
||||||
|
: actionIdentifier,
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
roleSelection.effectiveRole,
|
||||||
|
showError,
|
||||||
|
goBack,
|
||||||
|
setStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Convenience derived values ────────────────────────────────
|
||||||
|
const textInputHasFocus =
|
||||||
|
stepper.currentStepData?.type === "variables" &&
|
||||||
|
focus.focusArea === "content";
|
||||||
|
|
||||||
|
const canSignAndBroadcast = flow.canFinalize(flowContext);
|
||||||
|
|
||||||
|
const isLastStep = stepper.currentStep >= stepper.steps.length - 1;
|
||||||
|
const lastStepType = stepper.currentStepData?.type;
|
||||||
|
const nextButtonLabel =
|
||||||
|
lastStepType === "publish"
|
||||||
|
? flow.getFinalActionLabel(flowContext)
|
||||||
|
: lastStepType === "result"
|
||||||
|
? "Done"
|
||||||
|
: "Next";
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────
|
||||||
|
return {
|
||||||
|
// Meta
|
||||||
|
template,
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
roleIdentifier: roleSelection.effectiveRole,
|
||||||
|
action,
|
||||||
|
actionName,
|
||||||
|
flow,
|
||||||
|
flowContext,
|
||||||
|
|
||||||
|
// Role selection
|
||||||
|
availableRoles: roleSelection.availableRoles,
|
||||||
|
selectedRoleIndex: roleSelection.selectedRoleIndex,
|
||||||
|
setSelectedRoleIndex: roleSelection.setSelectedRoleIndex,
|
||||||
|
|
||||||
|
// Steps
|
||||||
|
steps: stepper.steps,
|
||||||
|
currentStep: stepper.currentStep,
|
||||||
|
currentStepData: stepper.currentStepData,
|
||||||
|
|
||||||
|
// Variables
|
||||||
|
variables: variableInputs.variables,
|
||||||
|
updateVariable: variableInputs.updateVariable,
|
||||||
|
handleTextInputSubmit,
|
||||||
|
|
||||||
|
// UTXOs
|
||||||
|
availableUtxos: utxoSelection.availableUtxos,
|
||||||
|
selectedUtxoIndex: utxoSelection.selectedUtxoIndex,
|
||||||
|
setSelectedUtxoIndex: utxoSelection.setSelectedUtxoIndex,
|
||||||
|
requiredAmount: utxoSelection.requiredAmount,
|
||||||
|
fee: utxoSelection.fee,
|
||||||
|
selectedAmount: utxoSelection.selectedAmount,
|
||||||
|
changeAmount: utxoSelection.changeAmount,
|
||||||
|
toggleUtxoSelection: utxoSelection.toggleSelection,
|
||||||
|
selectAll: utxoSelection.selectAll,
|
||||||
|
deselectAll: utxoSelection.deselectAll,
|
||||||
|
|
||||||
|
// Invitation
|
||||||
|
invitation: invitationManager.invitation,
|
||||||
|
invitationId: invitationManager.invitationId,
|
||||||
|
requirementsComplete: invitationManager.requirementsComplete,
|
||||||
|
hasSignedAndBroadcasted: invitationManager.hasSignedAndBroadcasted,
|
||||||
|
canSignAndBroadcast,
|
||||||
|
|
||||||
|
// Data results
|
||||||
|
dataResults,
|
||||||
|
|
||||||
|
// UI focus
|
||||||
|
focusedInput: focus.focusedInput,
|
||||||
|
setFocusedInput: focus.setFocusedInput,
|
||||||
|
focusedButton: focus.focusedButton,
|
||||||
|
setFocusedButton: focus.setFocusedButton,
|
||||||
|
focusArea: focus.focusArea,
|
||||||
|
setFocusArea: focus.setFocusArea,
|
||||||
|
isProcessing: invitationManager.isProcessing,
|
||||||
|
textInputHasFocus,
|
||||||
|
nextButtonLabel,
|
||||||
|
isLastStep,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
nextStep: stepper.nextStep,
|
||||||
|
previousStep: stepper.previousStep,
|
||||||
|
cancel: stepper.cancel,
|
||||||
|
copyId,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience type so other files can type the return value. */
|
||||||
|
export type ActionWizardState = ReturnType<typeof useActionWizard>;
|
||||||
295
src/tui/screens/action-wizard/hooks/useInvitationManager.ts
Normal file
295
src/tui/screens/action-wizard/hooks/useInvitationManager.ts
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import type {
|
||||||
|
XOTemplate,
|
||||||
|
XOInvitation,
|
||||||
|
XOTemplateTransactionOutput,
|
||||||
|
} from "@xo-cash/types";
|
||||||
|
import type { VariableInput, SelectableUTXO } from "../types.js";
|
||||||
|
import {
|
||||||
|
getTransactionOutputIdentifier,
|
||||||
|
isInvitationRequirementsComplete,
|
||||||
|
resolveProvidedLockingBytecodeHex,
|
||||||
|
} from "../../../../utils/invitation-flow.js";
|
||||||
|
import type { AppService } from "../../../../services/app.js";
|
||||||
|
|
||||||
|
interface InvitationManagerDeps {
|
||||||
|
appService: AppService;
|
||||||
|
showError: (msg: string) => void;
|
||||||
|
showInfo: (msg: string) => void;
|
||||||
|
setStatus: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the full invitation lifecycle for transaction-based actions:
|
||||||
|
* creation, variable persistence, output generation, input addition,
|
||||||
|
* signing, and broadcasting.
|
||||||
|
*
|
||||||
|
* Only relevant for TransactionWizardFlow — data flows bypass this entirely.
|
||||||
|
*/
|
||||||
|
export function useInvitationManager(deps: InvitationManagerDeps) {
|
||||||
|
const { appService, showError, showInfo, setStatus } = deps;
|
||||||
|
|
||||||
|
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
|
||||||
|
const [invitationId, setInvitationId] = useState<string | null>(null);
|
||||||
|
const [requirementsComplete, setRequirementsComplete] = useState(false);
|
||||||
|
const [hasSignedAndBroadcasted, setHasSignedAndBroadcasted] = useState(false);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
/** Re-check whether all invitation requirements are satisfied. */
|
||||||
|
const refreshRequirements = useCallback(
|
||||||
|
async (identifier: string | null = invitationId): Promise<boolean> => {
|
||||||
|
if (!identifier || !appService) {
|
||||||
|
setRequirementsComplete(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = appService.invitations.find(
|
||||||
|
(inv: any) => inv.data.invitationIdentifier === identifier,
|
||||||
|
);
|
||||||
|
if (!instance) {
|
||||||
|
setRequirementsComplete(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const complete = await isInvitationRequirementsComplete(instance);
|
||||||
|
setRequirementsComplete(complete);
|
||||||
|
return complete;
|
||||||
|
},
|
||||||
|
[appService, invitationId],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an invitation, persist variable values, and add
|
||||||
|
* template-required transaction outputs.
|
||||||
|
*
|
||||||
|
* @returns The invitation identifier on success, or null on failure.
|
||||||
|
*/
|
||||||
|
const createWithVariables = useCallback(
|
||||||
|
async (
|
||||||
|
templateIdentifier: string,
|
||||||
|
actionIdentifier: string,
|
||||||
|
roleIdentifier: string,
|
||||||
|
template: XOTemplate,
|
||||||
|
variables: VariableInput[],
|
||||||
|
): Promise<string | null> => {
|
||||||
|
if (!appService) return null;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus("Creating invitation...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create via the engine
|
||||||
|
const xoInvitation = await appService.engine.createInvitation({
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap and track
|
||||||
|
const invitationInstance =
|
||||||
|
await appService.createInvitation(xoInvitation);
|
||||||
|
let inv = invitationInstance.data;
|
||||||
|
const invId = inv.invitationIdentifier;
|
||||||
|
setInvitationId(invId);
|
||||||
|
|
||||||
|
// Persist variable values
|
||||||
|
if (variables.length > 0) {
|
||||||
|
setStatus("Adding variables...");
|
||||||
|
const variableData = variables.map((v) => {
|
||||||
|
const isNumeric =
|
||||||
|
["integer", "number", "satoshis"].includes(v.type) ||
|
||||||
|
(v.hint && ["satoshis", "amount"].includes(v.hint));
|
||||||
|
|
||||||
|
return {
|
||||||
|
variableIdentifier: v.id,
|
||||||
|
roleIdentifier,
|
||||||
|
value: isNumeric ? BigInt(v.value || "0") : v.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await invitationInstance.addVariables(variableData);
|
||||||
|
inv = invitationInstance.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build variable values lookup for output resolution
|
||||||
|
const variableValuesByIdentifier = variables.reduce(
|
||||||
|
(acc, variable) => {
|
||||||
|
if (
|
||||||
|
typeof variable.value === "string" &&
|
||||||
|
variable.value.trim().length > 0
|
||||||
|
) {
|
||||||
|
acc[variable.id] = variable.value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add template-required transaction outputs
|
||||||
|
const act = template.actions?.[actionIdentifier];
|
||||||
|
const transaction = act?.transaction
|
||||||
|
? template.transactions?.[act.transaction]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (transaction?.outputs && transaction.outputs.length > 0) {
|
||||||
|
setStatus("Adding required outputs...");
|
||||||
|
const outputsToAdd = await Promise.all(
|
||||||
|
transaction.outputs.map(
|
||||||
|
async (output: XOTemplateTransactionOutput) => {
|
||||||
|
const outputIdentifier = getTransactionOutputIdentifier(output);
|
||||||
|
if (!outputIdentifier) {
|
||||||
|
throw new Error("Invalid transaction output definition");
|
||||||
|
}
|
||||||
|
|
||||||
|
const providedHex = resolveProvidedLockingBytecodeHex(
|
||||||
|
template,
|
||||||
|
outputIdentifier,
|
||||||
|
variableValuesByIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lockingBytecodeHex =
|
||||||
|
providedHex ??
|
||||||
|
(await invitationInstance.generateLockingBytecode(
|
||||||
|
outputIdentifier,
|
||||||
|
roleIdentifier,
|
||||||
|
));
|
||||||
|
|
||||||
|
return {
|
||||||
|
outputIdentifier,
|
||||||
|
lockingBytecode: lockingBytecodeHex,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOutputs accept a hex string. 3. Have addOutputs handle lockscript generation.
|
||||||
|
await invitationInstance.addOutputs(
|
||||||
|
outputsToAdd.map((output) => ({
|
||||||
|
outputIdentifier: output.outputIdentifier,
|
||||||
|
lockingBytecode: new Uint8Array(
|
||||||
|
Buffer.from(output.lockingBytecode, "hex"),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
inv = invitationInstance.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInvitation(inv);
|
||||||
|
await refreshRequirements(invId);
|
||||||
|
setStatus("Invitation created");
|
||||||
|
return invId;
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appService, showError, setStatus, refreshRequirements],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the selected UTXOs as inputs and a change output to the invitation.
|
||||||
|
*
|
||||||
|
* @returns true on success, false on failure.
|
||||||
|
*/
|
||||||
|
const addInputsAndOutputs = useCallback(
|
||||||
|
async (
|
||||||
|
selectedUtxos: SelectableUTXO[],
|
||||||
|
changeAmount: bigint,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!invitationId || !appService) return false;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus("Adding inputs and outputs...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const instance = appService.invitations.find(
|
||||||
|
(inv: any) => inv.data.invitationIdentifier === invitationId,
|
||||||
|
);
|
||||||
|
if (!instance) throw new Error("Invitation not found");
|
||||||
|
|
||||||
|
const inputs = selectedUtxos.map((utxo) => ({
|
||||||
|
outpointTransactionHash: new Uint8Array(
|
||||||
|
Buffer.from(utxo.outpointTransactionHash, "hex"),
|
||||||
|
),
|
||||||
|
outpointIndex: utxo.outpointIndex,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await instance.addInputs(inputs);
|
||||||
|
await instance.addOutputs([{ valueSatoshis: changeAmount }]);
|
||||||
|
await refreshRequirements(invitationId);
|
||||||
|
setStatus("Inputs and outputs added");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[invitationId, appService, showError, setStatus, refreshRequirements],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Sign the invitation and broadcast the transaction. */
|
||||||
|
const signAndBroadcast = useCallback(async (): Promise<boolean> => {
|
||||||
|
if (!invitationId || !appService) return false;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus("Signing invitation...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const instance = appService.invitations.find(
|
||||||
|
(inv: any) => inv.data.invitationIdentifier === invitationId,
|
||||||
|
);
|
||||||
|
if (!instance) throw new Error("Invitation not found");
|
||||||
|
|
||||||
|
const complete = await refreshRequirements(invitationId);
|
||||||
|
if (!complete) {
|
||||||
|
showError("Invitation requirements are not complete yet.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await instance.sign();
|
||||||
|
setStatus("Broadcasting transaction...");
|
||||||
|
await instance.broadcast();
|
||||||
|
setHasSignedAndBroadcasted(true);
|
||||||
|
setStatus("Transaction signed and broadcasted");
|
||||||
|
showInfo("Transaction signed and broadcasted.");
|
||||||
|
await refreshRequirements(invitationId);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to sign and broadcast: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
invitationId,
|
||||||
|
appService,
|
||||||
|
setStatus,
|
||||||
|
showError,
|
||||||
|
showInfo,
|
||||||
|
refreshRequirements,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
invitation,
|
||||||
|
invitationId,
|
||||||
|
requirementsComplete,
|
||||||
|
hasSignedAndBroadcasted,
|
||||||
|
isProcessing,
|
||||||
|
setIsProcessing,
|
||||||
|
refreshRequirements,
|
||||||
|
createWithVariables,
|
||||||
|
addInputsAndOutputs,
|
||||||
|
signAndBroadcast,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvitationManagerState = ReturnType<typeof useInvitationManager>;
|
||||||
50
src/tui/screens/action-wizard/hooks/useRoleSelection.ts
Normal file
50
src/tui/screens/action-wizard/hooks/useRoleSelection.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
import { resolveActionRoles } from "../../../../utils/invitation-flow.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages role selection state for the wizard.
|
||||||
|
*
|
||||||
|
* Derives the list of available roles from the template and auto-selects
|
||||||
|
* when only one role exists for the action.
|
||||||
|
*/
|
||||||
|
export function useRoleSelection(
|
||||||
|
template: XOTemplate | undefined,
|
||||||
|
actionIdentifier: string | undefined,
|
||||||
|
actionRolesFromNavigation: string[] | undefined,
|
||||||
|
) {
|
||||||
|
const [roleIdentifier, setRoleIdentifier] = useState<string | undefined>();
|
||||||
|
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
||||||
|
|
||||||
|
/** Roles that can start this action, derived from template start entries. */
|
||||||
|
const availableRoles = useMemo(() => {
|
||||||
|
return resolveActionRoles(
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
actionRolesFromNavigation,
|
||||||
|
);
|
||||||
|
}, [template, actionIdentifier, actionRolesFromNavigation]);
|
||||||
|
|
||||||
|
/** The role to use for the flow — either explicitly selected or auto-selected when only one exists. */
|
||||||
|
const effectiveRole =
|
||||||
|
roleIdentifier ??
|
||||||
|
(availableRoles.length === 1 ? availableRoles[0] : undefined);
|
||||||
|
|
||||||
|
// Auto-select when only one role exists.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!roleIdentifier && availableRoles.length === 1) {
|
||||||
|
setRoleIdentifier(availableRoles[0]);
|
||||||
|
}
|
||||||
|
}, [roleIdentifier, availableRoles]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
roleIdentifier,
|
||||||
|
setRoleIdentifier,
|
||||||
|
selectedRoleIndex,
|
||||||
|
setSelectedRoleIndex,
|
||||||
|
availableRoles,
|
||||||
|
effectiveRole,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoleSelectionState = ReturnType<typeof useRoleSelection>;
|
||||||
123
src/tui/screens/action-wizard/hooks/useUtxoSelection.ts
Normal file
123
src/tui/screens/action-wizard/hooks/useUtxoSelection.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { useState, useCallback, useMemo } from "react";
|
||||||
|
import type { SelectableUTXO, VariableInput } from "../types.js";
|
||||||
|
import type { Invitation } from "../../../../services/invitation.js";
|
||||||
|
import { formatSatoshis } from "../../../theme.js";
|
||||||
|
import {
|
||||||
|
autoSelectGreedyUtxos,
|
||||||
|
mapUnspentOutputsToSelectable,
|
||||||
|
} from "../../../../utils/invitation-flow.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages UTXO selection state for the wizard's inputs step.
|
||||||
|
*
|
||||||
|
* Only active for transaction flows that require the creator
|
||||||
|
* to provide funding inputs.
|
||||||
|
*/
|
||||||
|
export function useUtxoSelection() {
|
||||||
|
const [availableUtxos, setAvailableUtxos] = useState<SelectableUTXO[]>([]);
|
||||||
|
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
|
||||||
|
const [requiredAmount, setRequiredAmount] = useState<bigint>(0n);
|
||||||
|
const [fee, setFee] = useState<bigint>(500n);
|
||||||
|
|
||||||
|
const selectedAmount = useMemo(
|
||||||
|
() =>
|
||||||
|
availableUtxos
|
||||||
|
.filter((u) => u.selected)
|
||||||
|
.reduce((sum, u) => sum + u.valueSatoshis, 0n),
|
||||||
|
[availableUtxos],
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeAmount = useMemo(
|
||||||
|
() => selectedAmount - requiredAmount - fee,
|
||||||
|
[selectedAmount, requiredAmount, fee],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Toggle the selected state of a single UTXO. */
|
||||||
|
const toggleSelection = useCallback((index: number) => {
|
||||||
|
setAvailableUtxos((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const utxo = updated[index];
|
||||||
|
if (utxo) {
|
||||||
|
updated[index] = { ...utxo, selected: !utxo.selected };
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Select all available UTXOs. */
|
||||||
|
const selectAll = useCallback(() => {
|
||||||
|
setAvailableUtxos((prev) => prev.map((u) => ({ ...u, selected: true })));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Deselect all UTXOs. */
|
||||||
|
const deselectAll = useCallback(() => {
|
||||||
|
setAvailableUtxos((prev) => prev.map((u) => ({ ...u, selected: false })));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query the invitation instance for suitable UTXOs and auto-select
|
||||||
|
* greedily to meet the required amount.
|
||||||
|
*/
|
||||||
|
const loadUtxos = useCallback(
|
||||||
|
async (
|
||||||
|
invitationInstance: Invitation,
|
||||||
|
templateIdentifier: string,
|
||||||
|
variables: VariableInput[],
|
||||||
|
setStatus: (msg: string) => void,
|
||||||
|
): Promise<void> => {
|
||||||
|
setStatus("Finding suitable UTXOs...");
|
||||||
|
|
||||||
|
// Derive required amount from variables that look like satoshi/amount fields.
|
||||||
|
const requestedVar = variables.find(
|
||||||
|
(v) =>
|
||||||
|
v.id.toLowerCase().includes("satoshi") ||
|
||||||
|
v.id.toLowerCase().includes("amount"),
|
||||||
|
);
|
||||||
|
const requested = requestedVar ? BigInt(requestedVar.value || "0") : 0n;
|
||||||
|
setRequiredAmount(requested);
|
||||||
|
|
||||||
|
const unspentOutputs = await invitationInstance.findSuitableResources({
|
||||||
|
templateIdentifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapped = mapUnspentOutputsToSelectable(unspentOutputs);
|
||||||
|
const autoSelected = autoSelectGreedyUtxos(mapped, requested + fee);
|
||||||
|
setAvailableUtxos(autoSelected as SelectableUTXO[]);
|
||||||
|
setStatus("Ready");
|
||||||
|
},
|
||||||
|
[fee],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Validate that the selection meets the required amounts. */
|
||||||
|
const validate = useCallback((): string | null => {
|
||||||
|
const selected = availableUtxos.filter((u) => u.selected);
|
||||||
|
if (selected.length === 0) {
|
||||||
|
return "Please select at least one UTXO";
|
||||||
|
}
|
||||||
|
if (selectedAmount < requiredAmount + fee) {
|
||||||
|
return `Insufficient funds. Need ${formatSatoshis(requiredAmount + fee)}, selected ${formatSatoshis(selectedAmount)}`;
|
||||||
|
}
|
||||||
|
if (changeAmount < 546n) {
|
||||||
|
return `Change amount (${changeAmount}) is below dust threshold (546 sats)`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [availableUtxos, selectedAmount, requiredAmount, fee, changeAmount]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
availableUtxos,
|
||||||
|
setAvailableUtxos,
|
||||||
|
selectedUtxoIndex,
|
||||||
|
setSelectedUtxoIndex,
|
||||||
|
requiredAmount,
|
||||||
|
fee,
|
||||||
|
selectedAmount,
|
||||||
|
changeAmount,
|
||||||
|
toggleSelection,
|
||||||
|
selectAll,
|
||||||
|
deselectAll,
|
||||||
|
loadUtxos,
|
||||||
|
validate,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UtxoSelectionState = ReturnType<typeof useUtxoSelection>;
|
||||||
75
src/tui/screens/action-wizard/hooks/useVariableInputs.ts
Normal file
75
src/tui/screens/action-wizard/hooks/useVariableInputs.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
import type { VariableInput } from "../types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the variable input state for the wizard's variables step.
|
||||||
|
*
|
||||||
|
* Populates variables from the template's action/role requirements
|
||||||
|
* and provides validation + update helpers.
|
||||||
|
*/
|
||||||
|
export function useVariableInputs() {
|
||||||
|
const [variables, setVariables] = useState<VariableInput[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate the variable list from the template's role requirements.
|
||||||
|
* Calling this again replaces the current variables entirely.
|
||||||
|
*/
|
||||||
|
const initFromTemplate = useCallback(
|
||||||
|
(
|
||||||
|
template: XOTemplate,
|
||||||
|
actionIdentifier: string,
|
||||||
|
roleIdentifier: string,
|
||||||
|
) => {
|
||||||
|
const action = template.actions?.[actionIdentifier];
|
||||||
|
const role = action?.roles?.[roleIdentifier];
|
||||||
|
const varIds = role?.requirements?.variables ?? [];
|
||||||
|
|
||||||
|
const varInputs: VariableInput[] = varIds.map((varId) => {
|
||||||
|
const varDef = template.variables?.[varId];
|
||||||
|
return {
|
||||||
|
id: varId,
|
||||||
|
name: varDef?.name || varId,
|
||||||
|
type: varDef?.type || "string",
|
||||||
|
hint: varDef?.hint,
|
||||||
|
value: "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setVariables(varInputs);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Update a single variable's value by index. */
|
||||||
|
const updateVariable = useCallback((index: number, value: string) => {
|
||||||
|
setVariables((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const variable = updated[index];
|
||||||
|
if (variable) {
|
||||||
|
updated[index] = { ...variable, value };
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Returns an error message if any required variable is empty, or null if valid. */
|
||||||
|
const validate = useCallback((): string | null => {
|
||||||
|
const emptyVars = variables.filter(
|
||||||
|
(v) => !v.value || v.value.trim() === "",
|
||||||
|
);
|
||||||
|
if (emptyVars.length > 0) {
|
||||||
|
return `Please enter values for: ${emptyVars.map((v) => v.name).join(", ")}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [variables]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
variables,
|
||||||
|
setVariables,
|
||||||
|
initFromTemplate,
|
||||||
|
updateVariable,
|
||||||
|
validate,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VariableInputsState = ReturnType<typeof useVariableInputs>;
|
||||||
37
src/tui/screens/action-wizard/hooks/useWizardFocus.ts
Normal file
37
src/tui/screens/action-wizard/hooks/useWizardFocus.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import type { FocusArea, ButtonFocus } from "../types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages which area of the wizard UI has keyboard focus and
|
||||||
|
* which specific element within that area is highlighted.
|
||||||
|
*/
|
||||||
|
export function useWizardFocus() {
|
||||||
|
const [focusArea, setFocusArea] = useState<FocusArea>("content");
|
||||||
|
const [focusedButton, setFocusedButton] = useState<ButtonFocus>("next");
|
||||||
|
const [focusedInput, setFocusedInput] = useState(0);
|
||||||
|
|
||||||
|
/** Reset focus to the content area at the first element. */
|
||||||
|
const resetToContent = useCallback(() => {
|
||||||
|
setFocusArea("content");
|
||||||
|
setFocusedInput(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Move focus to the button bar. */
|
||||||
|
const moveToButtons = useCallback((button: ButtonFocus = "next") => {
|
||||||
|
setFocusArea("buttons");
|
||||||
|
setFocusedButton(button);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
focusArea,
|
||||||
|
setFocusArea,
|
||||||
|
focusedButton,
|
||||||
|
setFocusedButton,
|
||||||
|
focusedInput,
|
||||||
|
setFocusedInput,
|
||||||
|
resetToContent,
|
||||||
|
moveToButtons,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WizardFocusState = ReturnType<typeof useWizardFocus>;
|
||||||
153
src/tui/screens/action-wizard/hooks/useWizardKeyboard.ts
Normal file
153
src/tui/screens/action-wizard/hooks/useWizardKeyboard.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useBlockableInput } from "../../../hooks/useInputLayer.js";
|
||||||
|
import type { ActionWizardState } from "./useActionWizard.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard input handler for the action wizard.
|
||||||
|
*
|
||||||
|
* Dispatches key presses to step-specific handlers based on the
|
||||||
|
* current step type and focus area. Extracted from the screen
|
||||||
|
* component to keep it purely presentational.
|
||||||
|
*/
|
||||||
|
export function useWizardKeyboard(wizard: ActionWizardState): void {
|
||||||
|
useBlockableInput(
|
||||||
|
(input, key) => {
|
||||||
|
// ── Tab: cycle through content and button bar ─────────
|
||||||
|
if (key.tab) {
|
||||||
|
handleTab(wizard);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Content-area: step-specific input handling ────────
|
||||||
|
if (wizard.focusArea === "content") {
|
||||||
|
if (wizard.currentStepData?.type === "role-select") {
|
||||||
|
handleRoleSelectInput(wizard, input, key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (wizard.currentStepData?.type === "inputs") {
|
||||||
|
handleInputsStepInput(wizard, input, key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Button bar navigation + activation ────────────────
|
||||||
|
if (wizard.focusArea === "buttons") {
|
||||||
|
handleButtonBarInput(wizard, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Global shortcuts ──────────────────────────────────
|
||||||
|
if (
|
||||||
|
input === "c" &&
|
||||||
|
wizard.currentStepData?.type === "publish" &&
|
||||||
|
wizard.invitationId
|
||||||
|
) {
|
||||||
|
wizard.copyId();
|
||||||
|
}
|
||||||
|
if (input === "a" && wizard.currentStepData?.type === "inputs") {
|
||||||
|
wizard.selectAll();
|
||||||
|
}
|
||||||
|
if (input === "n" && wizard.currentStepData?.type === "inputs") {
|
||||||
|
wizard.deselectAll();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: !wizard.textInputHasFocus },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab cycling ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleTab(wizard: ActionWizardState): void {
|
||||||
|
if (wizard.focusArea === "content") {
|
||||||
|
// Within role-select, tab through roles before moving to buttons
|
||||||
|
if (
|
||||||
|
wizard.currentStepData?.type === "role-select" &&
|
||||||
|
wizard.availableRoles.length > 0 &&
|
||||||
|
wizard.selectedRoleIndex < wizard.availableRoles.length - 1
|
||||||
|
) {
|
||||||
|
wizard.setSelectedRoleIndex((prev) => prev + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Within inputs, tab through UTXOs before moving to buttons
|
||||||
|
if (
|
||||||
|
wizard.currentStepData?.type === "inputs" &&
|
||||||
|
wizard.availableUtxos.length > 0 &&
|
||||||
|
wizard.selectedUtxoIndex < wizard.availableUtxos.length - 1
|
||||||
|
) {
|
||||||
|
wizard.setSelectedUtxoIndex((prev) => prev + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to button bar
|
||||||
|
wizard.setFocusArea("buttons");
|
||||||
|
wizard.setFocusedButton("next");
|
||||||
|
} else {
|
||||||
|
// Cycle through buttons, then wrap back to content
|
||||||
|
if (wizard.focusedButton === "back") {
|
||||||
|
wizard.setFocusedButton("cancel");
|
||||||
|
} else if (wizard.focusedButton === "cancel") {
|
||||||
|
wizard.setFocusedButton("next");
|
||||||
|
} else {
|
||||||
|
wizard.setFocusArea("content");
|
||||||
|
wizard.setFocusedInput(0);
|
||||||
|
wizard.setSelectedUtxoIndex(0);
|
||||||
|
wizard.setSelectedRoleIndex(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Role-select step ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleRoleSelectInput(
|
||||||
|
wizard: ActionWizardState,
|
||||||
|
_input: string,
|
||||||
|
key: { upArrow: boolean; downArrow: boolean },
|
||||||
|
): void {
|
||||||
|
if (key.upArrow) {
|
||||||
|
wizard.setSelectedRoleIndex((p) => Math.max(0, p - 1));
|
||||||
|
} else if (key.downArrow) {
|
||||||
|
wizard.setSelectedRoleIndex((p) =>
|
||||||
|
Math.min(wizard.availableRoles.length - 1, p + 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inputs step (UTXO selection) ────────────────────────────────
|
||||||
|
|
||||||
|
function handleInputsStepInput(
|
||||||
|
wizard: ActionWizardState,
|
||||||
|
input: string,
|
||||||
|
key: { upArrow: boolean; downArrow: boolean; return: boolean },
|
||||||
|
): void {
|
||||||
|
if (key.upArrow) {
|
||||||
|
wizard.setSelectedUtxoIndex((p) => Math.max(0, p - 1));
|
||||||
|
} else if (key.downArrow) {
|
||||||
|
wizard.setSelectedUtxoIndex((p) =>
|
||||||
|
Math.min(wizard.availableUtxos.length - 1, p + 1),
|
||||||
|
);
|
||||||
|
} else if (key.return || input === " ") {
|
||||||
|
wizard.toggleUtxoSelection(wizard.selectedUtxoIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Button bar ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleButtonBarInput(
|
||||||
|
wizard: ActionWizardState,
|
||||||
|
key: { leftArrow: boolean; rightArrow: boolean; return: boolean },
|
||||||
|
): void {
|
||||||
|
if (key.leftArrow) {
|
||||||
|
wizard.setFocusedButton((p) =>
|
||||||
|
p === "next" ? "cancel" : p === "cancel" ? "back" : "back",
|
||||||
|
);
|
||||||
|
} else if (key.rightArrow) {
|
||||||
|
wizard.setFocusedButton((p) =>
|
||||||
|
p === "back" ? "cancel" : p === "cancel" ? "next" : "next",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.return) {
|
||||||
|
if (wizard.focusedButton === "back") wizard.previousStep();
|
||||||
|
else if (wizard.focusedButton === "cancel") wizard.cancel();
|
||||||
|
else if (wizard.focusedButton === "next") wizard.nextStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/tui/screens/action-wizard/hooks/useWizardSteps.ts
Normal file
73
src/tui/screens/action-wizard/hooks/useWizardSteps.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState, useCallback, useMemo } from "react";
|
||||||
|
import type { StepConfig, WizardStep } from "../types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic step navigation driven by an array of StepConfig objects.
|
||||||
|
*
|
||||||
|
* The orchestrator builds the StepConfig[] from the flow strategy
|
||||||
|
* and domain hooks; this hook just manages the step index and
|
||||||
|
* delegates validate/onNext to the current config.
|
||||||
|
*/
|
||||||
|
export function useWizardSteps(
|
||||||
|
stepConfigs: StepConfig[],
|
||||||
|
onCancel: () => void,
|
||||||
|
showError: (msg: string) => void,
|
||||||
|
) {
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
|
||||||
|
/** Flat step descriptors for the progress indicator. */
|
||||||
|
const steps: WizardStep[] = useMemo(
|
||||||
|
() => stepConfigs.map((c) => ({ name: c.name, type: c.type })),
|
||||||
|
[stepConfigs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentStepData = steps[currentStep];
|
||||||
|
const currentConfig = stepConfigs[currentStep];
|
||||||
|
|
||||||
|
/** Validate the current step, run its onNext, then advance if not the last step. */
|
||||||
|
const nextStep = useCallback(async () => {
|
||||||
|
const config = stepConfigs[currentStep];
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
const error = config.validate();
|
||||||
|
if (error) {
|
||||||
|
showError(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await config.onNext();
|
||||||
|
if (!success) return;
|
||||||
|
|
||||||
|
// Don't advance past the last step — the final step's onNext handles exit.
|
||||||
|
if (currentStep < stepConfigs.length - 1) {
|
||||||
|
setCurrentStep((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
}, [currentStep, stepConfigs, showError]);
|
||||||
|
|
||||||
|
/** Go back one step, or cancel the wizard if already at the first step. */
|
||||||
|
const previousStep = useCallback(() => {
|
||||||
|
if (currentStep <= 0) {
|
||||||
|
onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentStep((prev) => prev - 1);
|
||||||
|
}, [currentStep, onCancel]);
|
||||||
|
|
||||||
|
/** Cancel the wizard entirely. */
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
onCancel();
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
currentStep,
|
||||||
|
setCurrentStep,
|
||||||
|
currentStepData,
|
||||||
|
currentConfig,
|
||||||
|
nextStep,
|
||||||
|
previousStep,
|
||||||
|
cancel,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WizardStepsState = ReturnType<typeof useWizardSteps>;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './ActionWizardScreen.js';
|
export * from "./ActionWizardScreen.js";
|
||||||
export * from './useActionWizard.js';
|
export * from "./hooks/useActionWizard.js";
|
||||||
export * from './types.js';
|
export * from "./types.js";
|
||||||
export * from './steps/index.js';
|
export * from "./steps/index.js";
|
||||||
|
export * from "./flows/index.js";
|
||||||
|
|||||||
81
src/tui/screens/action-wizard/steps/DataResultStep.tsx
Normal file
81
src/tui/screens/action-wizard/steps/DataResultStep.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors } from '../../../theme.js';
|
||||||
|
import type { VariableInput, DataResult } from '../types.js';
|
||||||
|
|
||||||
|
interface DataResultStepProps {
|
||||||
|
actionName: string;
|
||||||
|
variables: VariableInput[];
|
||||||
|
dataResults: DataResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the result of a data-only action (e.g. sign, verify).
|
||||||
|
*
|
||||||
|
* NOTE: Engine-level data action execution is not yet implemented.
|
||||||
|
* The computed values are stubbed until the engine supports evaluating
|
||||||
|
* CashASM data expressions outside of a transaction context.
|
||||||
|
*/
|
||||||
|
export function DataResultStep({
|
||||||
|
actionName,
|
||||||
|
variables,
|
||||||
|
dataResults,
|
||||||
|
}: DataResultStepProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>
|
||||||
|
{actionName} — Result
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Variables that were provided */}
|
||||||
|
{variables.length > 0 && (
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.text}>Provided values:</Text>
|
||||||
|
{variables.map((v) => (
|
||||||
|
<Text key={v.id} color={colors.textMuted}>
|
||||||
|
{' '}{v.name}: {v.value || '(empty)'}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Computed data results */}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.text}>Output:</Text>
|
||||||
|
{dataResults.length === 0 ? (
|
||||||
|
<Text color={colors.warning}>
|
||||||
|
{' '}Engine support for data actions is not yet implemented.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
dataResults.map((result) => (
|
||||||
|
<Box key={result.id} flexDirection="column" marginTop={0}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}{result.name} ({result.type}):
|
||||||
|
</Text>
|
||||||
|
{result.value !== null ? (
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={colors.primary}
|
||||||
|
paddingX={1}
|
||||||
|
marginLeft={2}
|
||||||
|
>
|
||||||
|
<Text color={colors.accent}>{result.value}</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Text color={colors.warning} dimColor>
|
||||||
|
{' '}Pending — engine data execution not yet available
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
Press Done to exit.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Box, Text } from 'ink';
|
|
||||||
import { colors } from '../../../theme.js';
|
|
||||||
import type { WizardStepProps } from '../types.js';
|
|
||||||
|
|
||||||
type Props = Pick<
|
|
||||||
WizardStepProps,
|
|
||||||
'template' | 'actionIdentifier' | 'roleIdentifier' | 'actionName'
|
|
||||||
>;
|
|
||||||
|
|
||||||
export function InfoStep({
|
|
||||||
template,
|
|
||||||
actionIdentifier,
|
|
||||||
roleIdentifier,
|
|
||||||
actionName,
|
|
||||||
}: Props): React.ReactElement {
|
|
||||||
const action = template?.actions?.[actionIdentifier];
|
|
||||||
const role = action?.roles?.[roleIdentifier];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flexDirection='column'>
|
|
||||||
<Text color={colors.primary} bold>
|
|
||||||
Action: {actionName}
|
|
||||||
</Text>
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
{action?.description || 'No description'}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={colors.text}>Your Role: </Text>
|
|
||||||
<Text color={colors.accent}>{roleIdentifier}</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{role?.requirements && (
|
|
||||||
<Box marginTop={1} flexDirection='column'>
|
|
||||||
<Text color={colors.text}>Requirements:</Text>
|
|
||||||
{role.requirements.variables?.map((v) => (
|
|
||||||
<Text key={v} color={colors.textMuted}>
|
|
||||||
{' '}• Variable: {v}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
{role.requirements.slots && (
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
{' '}• Slots: {role.requirements.slots.min} min (UTXO selection
|
|
||||||
required)
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { colors, formatSatoshis, formatHex } from '../../../theme.js';
|
import { colors, formatSatoshis, formatHex } from '../../../theme.js';
|
||||||
import type { WizardStepProps } from '../types.js';
|
import { useSatoshisConversion } from '../../../hooks/useSatoshisConversion.js';
|
||||||
|
import type { SelectableUTXO, FocusArea } from '../types.js';
|
||||||
|
|
||||||
type Props = Pick<
|
interface Props {
|
||||||
WizardStepProps,
|
availableUtxos: SelectableUTXO[];
|
||||||
| 'availableUtxos'
|
selectedUtxoIndex: number;
|
||||||
| 'selectedUtxoIndex'
|
requiredAmount: bigint;
|
||||||
| 'requiredAmount'
|
fee: bigint;
|
||||||
| 'fee'
|
selectedAmount: bigint;
|
||||||
| 'selectedAmount'
|
changeAmount: bigint;
|
||||||
| 'changeAmount'
|
focusArea: FocusArea;
|
||||||
| 'focusArea'
|
}
|
||||||
>;
|
|
||||||
|
|
||||||
export function InputsStep({
|
export function InputsStep({
|
||||||
availableUtxos,
|
availableUtxos,
|
||||||
@@ -23,6 +23,13 @@ export function InputsStep({
|
|||||||
changeAmount,
|
changeAmount,
|
||||||
focusArea,
|
focusArea,
|
||||||
}: Props): React.ReactElement {
|
}: Props): React.ReactElement {
|
||||||
|
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||||
|
|
||||||
|
const getFiatSuffix = (satoshis: bigint): string => {
|
||||||
|
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||||
|
return fiatValue ? ` (~${fiatValue})` : '';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection='column'>
|
<Box flexDirection='column'>
|
||||||
<Text color={colors.text} bold>
|
<Text color={colors.text} bold>
|
||||||
@@ -33,6 +40,7 @@ export function InputsStep({
|
|||||||
<Text color={colors.textMuted}>
|
<Text color={colors.textMuted}>
|
||||||
Required: {formatSatoshis(requiredAmount)} +{' '}
|
Required: {formatSatoshis(requiredAmount)} +{' '}
|
||||||
{formatSatoshis(fee)} fee
|
{formatSatoshis(fee)} fee
|
||||||
|
{getFiatSuffix(requiredAmount + fee)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
color={
|
color={
|
||||||
@@ -42,10 +50,12 @@ export function InputsStep({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
Selected: {formatSatoshis(selectedAmount)}
|
Selected: {formatSatoshis(selectedAmount)}
|
||||||
|
{getFiatSuffix(selectedAmount)}
|
||||||
</Text>
|
</Text>
|
||||||
{selectedAmount > requiredAmount + fee && (
|
{selectedAmount > requiredAmount + fee && (
|
||||||
<Text color={colors.info}>
|
<Text color={colors.info}>
|
||||||
Change: {formatSatoshis(changeAmount)}
|
Change: {formatSatoshis(changeAmount)}
|
||||||
|
{getFiatSuffix(changeAmount)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -66,6 +76,7 @@ export function InputsStep({
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
||||||
|
flexDirection='column'
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
color={isCursor ? colors.focus : colors.text}
|
color={isCursor ? colors.focus : colors.text}
|
||||||
@@ -76,6 +87,15 @@ export function InputsStep({
|
|||||||
{formatHex(utxo.outpointTransactionHash, 12)}:
|
{formatHex(utxo.outpointTransactionHash, 12)}:
|
||||||
{utxo.outpointIndex}
|
{utxo.outpointIndex}
|
||||||
</Text>
|
</Text>
|
||||||
|
{(() => {
|
||||||
|
const fiatValue = formatSatoshisToFiat(utxo.valueSatoshis);
|
||||||
|
if (!fiatValue) return null;
|
||||||
|
return (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}≈ {fiatValue}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,15 +4,19 @@ import { colors } from '../../../theme.js';
|
|||||||
|
|
||||||
interface PublishStepProps {
|
interface PublishStepProps {
|
||||||
invitationId: string | null;
|
invitationId: string | null;
|
||||||
|
requirementsComplete: boolean;
|
||||||
|
hasSignedAndBroadcasted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PublishStep({
|
export function PublishStep({
|
||||||
invitationId,
|
invitationId,
|
||||||
|
requirementsComplete,
|
||||||
|
hasSignedAndBroadcasted,
|
||||||
}: PublishStepProps): React.ReactElement {
|
}: PublishStepProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection='column'>
|
<Box flexDirection='column'>
|
||||||
<Text color={colors.success} bold>
|
<Text color={colors.success} bold>
|
||||||
✓ Invitation Created & Published!
|
✓ Invitation Ready
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Box marginTop={1} flexDirection='column'>
|
<Box marginTop={1} flexDirection='column'>
|
||||||
@@ -30,9 +34,19 @@ export function PublishStep({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>
|
{hasSignedAndBroadcasted ? (
|
||||||
Share this ID with the other party to complete the transaction.
|
<Text color={colors.success}>
|
||||||
|
Transaction signed and broadcasted.
|
||||||
</Text>
|
</Text>
|
||||||
|
) : requirementsComplete ? (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Requirements are complete. Use the Sign & Broadcast button to finalize.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={colors.warning}>
|
||||||
|
Requirements are incomplete. Complete missing requirements before signing.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { colors, formatSatoshis } from '../../../theme.js';
|
import { colors, formatSatoshis } from '../../../theme.js';
|
||||||
|
import { useSatoshisConversion } from '../../../hooks/useSatoshisConversion.js';
|
||||||
import type { VariableInput, SelectableUTXO } from '../types.js';
|
import type { VariableInput, SelectableUTXO } from '../types.js';
|
||||||
import type { XOTemplate } from '@xo-cash/types';
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
@@ -22,6 +23,32 @@ export function ReviewStep({
|
|||||||
changeAmount,
|
changeAmount,
|
||||||
}: ReviewStepProps): React.ReactElement {
|
}: ReviewStepProps): React.ReactElement {
|
||||||
const selectedUtxos = availableUtxos.filter((u) => u.selected);
|
const selectedUtxos = availableUtxos.filter((u) => u.selected);
|
||||||
|
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||||
|
|
||||||
|
const getFiatSuffix = (satoshis: bigint): string => {
|
||||||
|
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||||
|
return fiatValue ? ` (~${fiatValue})` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVariableFiatSuffix = (variable: VariableInput): string => {
|
||||||
|
if (variable.type !== 'integer') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variable.hint?.toLowerCase().includes('satoshi') !== true) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[-]?\d+$/.test(variable.value.trim())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return getFiatSuffix(BigInt(variable.value));
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection='column'>
|
<Box flexDirection='column'>
|
||||||
@@ -44,6 +71,7 @@ export function ReviewStep({
|
|||||||
<Text key={v.id} color={colors.textMuted}>
|
<Text key={v.id} color={colors.textMuted}>
|
||||||
{' '}
|
{' '}
|
||||||
{v.name}: {v.value || '(empty)'}
|
{v.name}: {v.value || '(empty)'}
|
||||||
|
{v.value ? getVariableFiatSuffix(v) : ''}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -62,6 +90,7 @@ export function ReviewStep({
|
|||||||
>
|
>
|
||||||
{' '}
|
{' '}
|
||||||
{formatSatoshis(u.valueSatoshis)}
|
{formatSatoshis(u.valueSatoshis)}
|
||||||
|
{getFiatSuffix(u.valueSatoshis)}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
{selectedUtxos.length > 3 && (
|
{selectedUtxos.length > 3 && (
|
||||||
@@ -78,6 +107,7 @@ export function ReviewStep({
|
|||||||
<Text color={colors.text}>Outputs:</Text>
|
<Text color={colors.text}>Outputs:</Text>
|
||||||
<Text color={colors.textMuted}>
|
<Text color={colors.textMuted}>
|
||||||
{' '}Change: {formatSatoshis(changeAmount)}
|
{' '}Change: {formatSatoshis(changeAmount)}
|
||||||
|
{getFiatSuffix(changeAmount)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -61,13 +61,16 @@ export function RoleSelectStep({
|
|||||||
{availableRoles.length === 0 ? (
|
{availableRoles.length === 0 ? (
|
||||||
<Text color={colors.textMuted}>No roles available</Text>
|
<Text color={colors.textMuted}>No roles available</Text>
|
||||||
) : (
|
) : (
|
||||||
availableRoles.map((roleId, index) => {
|
availableRoles.map((roleId: string, index: number) => {
|
||||||
const isCursor =
|
const isCursor =
|
||||||
selectedRoleIndex === index && focusArea === 'content';
|
selectedRoleIndex === index && focusArea === 'content';
|
||||||
const roleDef = template.roles?.[roleId];
|
const roleDef = template.roles?.[roleId];
|
||||||
const actionRole = action?.roles?.[roleId];
|
const actionRole = action?.roles?.[roleId];
|
||||||
const requirements = actionRole?.requirements;
|
const requirements = actionRole?.requirements;
|
||||||
|
|
||||||
|
const actionRequirements = action?.requirements;
|
||||||
|
const actionRoleRequirements = actionRole && actionRequirements && actionRequirements.participants?.find((participant) => participant.role === roleId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={roleId} flexDirection="column" marginY={0}>
|
<Box key={roleId} flexDirection="column" marginY={0}>
|
||||||
<Text
|
<Text
|
||||||
@@ -96,10 +99,10 @@ export function RoleSelectStep({
|
|||||||
{' '}
|
{' '}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{requirements.slots && requirements.slots.min > 0 && (
|
{actionRoleRequirements && actionRoleRequirements.slots && actionRoleRequirements.slots.min > 0 && (
|
||||||
<Text color={colors.textMuted} dimColor>
|
<Text color={colors.textMuted} dimColor>
|
||||||
{requirements.slots.min} input slot
|
{actionRoleRequirements.slots.min} input slot
|
||||||
{requirements.slots.min !== 1 ? 's' : ''}
|
{actionRoleRequirements.slots.min !== 1 ? 's' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ import React from 'react';
|
|||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { colors } from '../../../theme.js';
|
import { colors } from '../../../theme.js';
|
||||||
import { VariableInputField } from '../../../components/VariableInputField.js';
|
import { VariableInputField } from '../../../components/VariableInputField.js';
|
||||||
import type { WizardStepProps } from '../types.js';
|
import type { VariableInput, FocusArea } from '../types.js';
|
||||||
|
|
||||||
type Props = Pick<
|
interface Props {
|
||||||
WizardStepProps,
|
variables: VariableInput[];
|
||||||
| 'variables'
|
updateVariable: (index: number, value: string) => void;
|
||||||
| 'updateVariable'
|
handleTextInputSubmit: () => void;
|
||||||
| 'handleTextInputSubmit'
|
focusArea: FocusArea;
|
||||||
| 'focusArea'
|
focusedInput: number;
|
||||||
| 'focusedInput'
|
}
|
||||||
>;
|
|
||||||
|
|
||||||
export function VariablesStep({
|
export function VariablesStep({
|
||||||
variables,
|
variables,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export * from './InfoStep.js';
|
export * from "./RoleSelectStep.js";
|
||||||
export * from './RoleSelectStep.js';
|
export * from "./VariablesStep.js";
|
||||||
export * from './VariablesStep.js';
|
export * from "./InputsStep.js";
|
||||||
export * from './InputsStep.js';
|
export * from "./ReviewStep.js";
|
||||||
export * from './ReviewStep.js';
|
export * from "./PublishStep.js";
|
||||||
export * from './PublishStep.js';
|
export * from "./DataResultStep.js";
|
||||||
|
|||||||
@@ -1,12 +1,50 @@
|
|||||||
import type { XOTemplate } from '@xo-cash/types';
|
/**
|
||||||
|
* Shared types for the action wizard.
|
||||||
|
*/
|
||||||
|
|
||||||
export type StepType = 'info' | 'role-select' | 'variables' | 'inputs' | 'review' | 'publish';
|
/** Supported step types in the wizard. */
|
||||||
|
export type StepType =
|
||||||
|
| "role-select"
|
||||||
|
| "variables"
|
||||||
|
| "inputs"
|
||||||
|
| "review"
|
||||||
|
| "publish"
|
||||||
|
| "result";
|
||||||
|
|
||||||
|
/** A step displayed in the wizard's progress indicator. */
|
||||||
export interface WizardStep {
|
export interface WizardStep {
|
||||||
name: string;
|
name: string;
|
||||||
type: StepType;
|
type: StepType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for a single wizard step.
|
||||||
|
* The flow strategy determines which steps exist; the orchestrator
|
||||||
|
* wires validate/onNext to the appropriate domain hooks.
|
||||||
|
*/
|
||||||
|
export interface StepConfig {
|
||||||
|
type: StepType;
|
||||||
|
name: string;
|
||||||
|
/** Return an error message if the step is invalid, or null if OK to proceed. */
|
||||||
|
validate: () => string | null;
|
||||||
|
/** Execute transition logic. Return true on success, false to stay on this step. */
|
||||||
|
onNext: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context passed to WizardFlow strategy methods so they can
|
||||||
|
* determine steps and finalization state without holding React state.
|
||||||
|
*/
|
||||||
|
export interface FlowContext {
|
||||||
|
availableRoles: string[];
|
||||||
|
hasVariables: boolean;
|
||||||
|
shouldCollectInputs: boolean;
|
||||||
|
requirementsComplete: boolean;
|
||||||
|
wizardCollectedInputs: boolean;
|
||||||
|
hasSignedAndBroadcasted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Variable input state for the variables step. */
|
||||||
export interface VariableInput {
|
export interface VariableInput {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -15,6 +53,7 @@ export interface VariableInput {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A UTXO that can be toggled for transaction funding. */
|
||||||
export interface SelectableUTXO {
|
export interface SelectableUTXO {
|
||||||
outpointTransactionHash: string;
|
outpointTransactionHash: string;
|
||||||
outpointIndex: number;
|
outpointIndex: number;
|
||||||
@@ -23,40 +62,18 @@ export interface SelectableUTXO {
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FocusArea = 'content' | 'buttons';
|
/** Which area of the wizard UI currently has keyboard focus. */
|
||||||
export type ButtonFocus = 'back' | 'cancel' | 'next';
|
export type FocusArea = "content" | "buttons";
|
||||||
|
|
||||||
/**
|
/** Which button in the bottom bar is focused. */
|
||||||
* The 'downward' contract — what every step component receives.
|
export type ButtonFocus = "back" | "cancel" | "next";
|
||||||
*/
|
|
||||||
export interface WizardStepProps {
|
|
||||||
// Data
|
|
||||||
template: XOTemplate;
|
|
||||||
actionIdentifier: string;
|
|
||||||
roleIdentifier: string;
|
|
||||||
actionName: string;
|
|
||||||
|
|
||||||
// Variable state
|
/** A computed data result from a data-only action. */
|
||||||
variables: VariableInput[];
|
export interface DataResult {
|
||||||
updateVariable: (index: number, value: string) => void;
|
id: string;
|
||||||
|
name: string;
|
||||||
// UTXO state
|
type: string;
|
||||||
availableUtxos: SelectableUTXO[];
|
hint?: string;
|
||||||
selectedUtxoIndex: number;
|
/** null when the engine hasn't computed the value yet. */
|
||||||
requiredAmount: bigint;
|
value: string | null;
|
||||||
fee: bigint;
|
|
||||||
selectedAmount: bigint;
|
|
||||||
changeAmount: bigint;
|
|
||||||
toggleUtxoSelection: (index: number) => void;
|
|
||||||
|
|
||||||
// Invitation
|
|
||||||
invitationId: string | null;
|
|
||||||
|
|
||||||
// Focus
|
|
||||||
focusArea: FocusArea;
|
|
||||||
focusedInput: number;
|
|
||||||
|
|
||||||
// Callbacks
|
|
||||||
handleTextInputSubmit: () => void;
|
|
||||||
copyId: () => Promise<void>;
|
|
||||||
}
|
}
|
||||||
@@ -1,680 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
||||||
import { useNavigation } from '../../hooks/useNavigation.js';
|
|
||||||
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
|
||||||
import { formatSatoshis } from '../../theme.js';
|
|
||||||
import { copyToClipboard } from '../../utils/clipboard.js';
|
|
||||||
import type { XOTemplate, XOInvitation, XOTemplateTransactionOutput } from '@xo-cash/types';
|
|
||||||
import type {
|
|
||||||
WizardStep,
|
|
||||||
VariableInput,
|
|
||||||
SelectableUTXO,
|
|
||||||
FocusArea,
|
|
||||||
ButtonFocus,
|
|
||||||
} from './types.js';
|
|
||||||
|
|
||||||
export function useActionWizard() {
|
|
||||||
const { navigate, goBack, data: navData } = useNavigation();
|
|
||||||
const { appService, showError, showInfo } = useAppContext();
|
|
||||||
const { setStatus } = useStatus();
|
|
||||||
|
|
||||||
// ── Navigation data ──────────────────────────────────────────────
|
|
||||||
// Role is no longer passed via navigation — it is selected in the wizard.
|
|
||||||
const templateIdentifier = navData.templateIdentifier as string | undefined;
|
|
||||||
const actionIdentifier = navData.actionIdentifier as string | undefined;
|
|
||||||
const template = navData.template as XOTemplate | undefined;
|
|
||||||
|
|
||||||
// ── Role selection state ────────────────────────────────────────
|
|
||||||
const [roleIdentifier, setRoleIdentifier] = useState<string | undefined>();
|
|
||||||
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Roles that can start this action, derived from the template's
|
|
||||||
* `start` entries filtered to the current action.
|
|
||||||
*/
|
|
||||||
const availableRoles = useMemo(() => {
|
|
||||||
if (!template || !actionIdentifier) return [];
|
|
||||||
const starts = template.start ?? [];
|
|
||||||
const roleIds = starts
|
|
||||||
.filter((s) => s.action === actionIdentifier)
|
|
||||||
.map((s) => s.role);
|
|
||||||
// Deduplicate while preserving order
|
|
||||||
return [...new Set(roleIds)];
|
|
||||||
}, [template, actionIdentifier]);
|
|
||||||
|
|
||||||
// ── Wizard state ─────────────────────────────────────────────────
|
|
||||||
const [steps, setSteps] = useState<WizardStep[]>([]);
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
|
||||||
|
|
||||||
// ── Variable inputs ──────────────────────────────────────────────
|
|
||||||
const [variables, setVariables] = useState<VariableInput[]>([]);
|
|
||||||
|
|
||||||
// ── UTXO selection ───────────────────────────────────────────────
|
|
||||||
const [availableUtxos, setAvailableUtxos] = useState<SelectableUTXO[]>([]);
|
|
||||||
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
|
|
||||||
const [requiredAmount, setRequiredAmount] = useState<bigint>(0n);
|
|
||||||
const [fee, setFee] = useState<bigint>(500n);
|
|
||||||
|
|
||||||
// ── Invitation ───────────────────────────────────────────────────
|
|
||||||
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
|
|
||||||
const [invitationId, setInvitationId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// ── UI state ─────────────────────────────────────────────────────
|
|
||||||
const [focusedInput, setFocusedInput] = useState(0);
|
|
||||||
const [focusedButton, setFocusedButton] = useState<ButtonFocus>('next');
|
|
||||||
const [focusArea, setFocusArea] = useState<FocusArea>('content');
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
|
|
||||||
// ── Derived values ───────────────────────────────────────────────
|
|
||||||
const currentStepData = steps[currentStep];
|
|
||||||
const action = template?.actions?.[actionIdentifier ?? ''];
|
|
||||||
const actionName = action?.name || actionIdentifier || 'Unknown';
|
|
||||||
|
|
||||||
const selectedAmount = availableUtxos
|
|
||||||
.filter((u) => u.selected)
|
|
||||||
.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
|
||||||
|
|
||||||
const changeAmount = selectedAmount - requiredAmount - fee;
|
|
||||||
|
|
||||||
const textInputHasFocus =
|
|
||||||
currentStepData?.type === 'variables' && focusArea === 'content';
|
|
||||||
|
|
||||||
// ── Initialization ───────────────────────────────────────────────
|
|
||||||
// Builds the wizard steps dynamically based on the selected role.
|
|
||||||
// Re-runs when roleIdentifier changes to add role-specific steps.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!template || !actionIdentifier) {
|
|
||||||
showError('Missing wizard data');
|
|
||||||
goBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wizardSteps: WizardStep[] = [];
|
|
||||||
|
|
||||||
// Always start with role selection
|
|
||||||
wizardSteps.push({ name: 'Select Role', type: 'role-select' });
|
|
||||||
|
|
||||||
// Add role-specific steps only after role is selected
|
|
||||||
if (roleIdentifier) {
|
|
||||||
const act = template.actions?.[actionIdentifier];
|
|
||||||
const role = act?.roles?.[roleIdentifier];
|
|
||||||
const requirements = role?.requirements;
|
|
||||||
|
|
||||||
// Add variables step if needed
|
|
||||||
if (requirements?.variables && requirements.variables.length > 0) {
|
|
||||||
wizardSteps.push({ name: 'Variables', type: 'variables' });
|
|
||||||
|
|
||||||
const varInputs = requirements.variables.map((varId) => {
|
|
||||||
const varDef = template.variables?.[varId];
|
|
||||||
return {
|
|
||||||
id: varId,
|
|
||||||
name: varDef?.name || varId,
|
|
||||||
type: varDef?.type || 'string',
|
|
||||||
hint: varDef?.hint,
|
|
||||||
value: '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setVariables(varInputs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add inputs step if role requires slots (funding inputs)
|
|
||||||
if (requirements?.slots && requirements.slots.min > 0) {
|
|
||||||
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always add review and publish at the end
|
|
||||||
wizardSteps.push({ name: 'Review', type: 'review' });
|
|
||||||
wizardSteps.push({ name: 'Publish', type: 'publish' });
|
|
||||||
|
|
||||||
setSteps(wizardSteps);
|
|
||||||
setStatus(roleIdentifier ? `${actionIdentifier}/${roleIdentifier}` : actionIdentifier);
|
|
||||||
}, [
|
|
||||||
template,
|
|
||||||
actionIdentifier,
|
|
||||||
roleIdentifier,
|
|
||||||
showError,
|
|
||||||
goBack,
|
|
||||||
setStatus,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Auto-advance from role-select after role is chosen ──────────
|
|
||||||
// This runs after the main useEffect has rebuilt steps, ensuring
|
|
||||||
// we advance to the correct step (variables, inputs, or review).
|
|
||||||
useEffect(() => {
|
|
||||||
if (roleIdentifier && currentStep === 0 && steps[0]?.type === 'role-select') {
|
|
||||||
setCurrentStep(1);
|
|
||||||
setFocusArea('content');
|
|
||||||
setFocusedInput(0);
|
|
||||||
}
|
|
||||||
}, [roleIdentifier, currentStep, steps]);
|
|
||||||
|
|
||||||
// ── Update a single variable value ───────────────────────────────
|
|
||||||
const updateVariable = useCallback((index: number, value: string) => {
|
|
||||||
setVariables((prev) => {
|
|
||||||
const updated = [...prev];
|
|
||||||
const variable = updated[index];
|
|
||||||
if (variable) {
|
|
||||||
updated[index] = { ...variable, value };
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── Toggle a UTXO's selected state ──────────────────────────────
|
|
||||||
const toggleUtxoSelection = useCallback((index: number) => {
|
|
||||||
setAvailableUtxos((prev) => {
|
|
||||||
const updated = [...prev];
|
|
||||||
const utxo = updated[index];
|
|
||||||
if (utxo) {
|
|
||||||
updated[index] = { ...utxo, selected: !utxo.selected };
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── Handle Enter inside a TextInput ─────────────────────────────
|
|
||||||
const handleTextInputSubmit = useCallback(() => {
|
|
||||||
if (focusedInput < variables.length - 1) {
|
|
||||||
setFocusedInput((prev) => prev + 1);
|
|
||||||
} else {
|
|
||||||
setFocusArea('buttons');
|
|
||||||
setFocusedButton('next');
|
|
||||||
}
|
|
||||||
}, [focusedInput, variables.length]);
|
|
||||||
|
|
||||||
// ── Copy invitation ID to clipboard ─────────────────────────────
|
|
||||||
const copyId = useCallback(async () => {
|
|
||||||
if (!invitationId) return;
|
|
||||||
try {
|
|
||||||
await copyToClipboard(invitationId);
|
|
||||||
showInfo(`Copied to clipboard!\n\n${invitationId}`);
|
|
||||||
} catch (error) {
|
|
||||||
showError(
|
|
||||||
`Failed to copy: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [invitationId, showInfo, showError]);
|
|
||||||
|
|
||||||
// ── Load available UTXOs for the inputs step ────────────────────
|
|
||||||
const loadAvailableUtxos = useCallback(async () => {
|
|
||||||
if (!invitation || !templateIdentifier || !appService || !invitationId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsProcessing(true);
|
|
||||||
setStatus('Finding suitable UTXOs...');
|
|
||||||
|
|
||||||
// Determine required amount from variables
|
|
||||||
const requestedVar = variables.find(
|
|
||||||
(v) =>
|
|
||||||
v.id.toLowerCase().includes('satoshi') ||
|
|
||||||
v.id.toLowerCase().includes('amount')
|
|
||||||
);
|
|
||||||
const requested = requestedVar
|
|
||||||
? BigInt(requestedVar.value || '0')
|
|
||||||
: 0n;
|
|
||||||
setRequiredAmount(requested);
|
|
||||||
|
|
||||||
// Find the tracked invitation instance
|
|
||||||
const invitationInstance = appService.invitations.find(
|
|
||||||
(inv) => inv.data.invitationIdentifier === invitationId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!invitationInstance) {
|
|
||||||
throw new Error('Invitation not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query for suitable resources
|
|
||||||
const unspentOutputs = await invitationInstance.findSuitableResources({
|
|
||||||
templateIdentifier,
|
|
||||||
outputIdentifier: 'receiveOutput',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Map to selectable UTXOs
|
|
||||||
const utxos: SelectableUTXO[] = unspentOutputs.map((utxo: any) => ({
|
|
||||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
|
||||||
outpointIndex: utxo.outpointIndex,
|
|
||||||
valueSatoshis: BigInt(utxo.valueSatoshis),
|
|
||||||
lockingBytecode: utxo.lockingBytecode
|
|
||||||
? typeof utxo.lockingBytecode === 'string'
|
|
||||||
? utxo.lockingBytecode
|
|
||||||
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
|
||||||
: undefined,
|
|
||||||
selected: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Auto-select UTXOs greedily until the requirement is met
|
|
||||||
let accumulated = 0n;
|
|
||||||
const seenLockingBytecodes = new Set<string>();
|
|
||||||
|
|
||||||
for (const utxo of utxos) {
|
|
||||||
if (
|
|
||||||
utxo.lockingBytecode &&
|
|
||||||
seenLockingBytecodes.has(utxo.lockingBytecode)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (utxo.lockingBytecode) {
|
|
||||||
seenLockingBytecodes.add(utxo.lockingBytecode);
|
|
||||||
}
|
|
||||||
|
|
||||||
utxo.selected = true;
|
|
||||||
accumulated += utxo.valueSatoshis;
|
|
||||||
|
|
||||||
if (accumulated >= requested + fee) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setAvailableUtxos(utxos);
|
|
||||||
setStatus('Ready');
|
|
||||||
} catch (error) {
|
|
||||||
showError(
|
|
||||||
`Failed to load UTXOs: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
invitation,
|
|
||||||
templateIdentifier,
|
|
||||||
variables,
|
|
||||||
appService,
|
|
||||||
invitationId,
|
|
||||||
fee,
|
|
||||||
showError,
|
|
||||||
setStatus,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Create invitation and persist variables ─────────────────────
|
|
||||||
/**
|
|
||||||
* Creates an invitation, optionally persists variable values,
|
|
||||||
* and adds template-required outputs.
|
|
||||||
*
|
|
||||||
* Accepts an explicit `roleId` to avoid stale-closure issues
|
|
||||||
* when called immediately after setting role state.
|
|
||||||
*
|
|
||||||
* Does NOT advance the wizard step — the caller is responsible.
|
|
||||||
*
|
|
||||||
* @returns `true` on success, `false` on failure.
|
|
||||||
*/
|
|
||||||
const createInvitationWithVariables = useCallback(
|
|
||||||
async (roleId?: string): Promise<boolean> => {
|
|
||||||
const effectiveRole = roleId ?? roleIdentifier;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!templateIdentifier ||
|
|
||||||
!actionIdentifier ||
|
|
||||||
!effectiveRole ||
|
|
||||||
!template ||
|
|
||||||
!appService
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
setStatus('Creating invitation...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create via the engine
|
|
||||||
const xoInvitation = await appService.engine.createInvitation({
|
|
||||||
templateIdentifier,
|
|
||||||
actionIdentifier,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(xoInvitation)
|
|
||||||
|
|
||||||
// Wrap and track
|
|
||||||
const invitationInstance =
|
|
||||||
await appService.createInvitation(xoInvitation);
|
|
||||||
|
|
||||||
let inv = invitationInstance.data;
|
|
||||||
const invId = inv.invitationIdentifier;
|
|
||||||
setInvitationId(invId);
|
|
||||||
|
|
||||||
// Persist variable values
|
|
||||||
if (variables.length > 0) {
|
|
||||||
const variableData = variables.map((v) => {
|
|
||||||
const isNumeric =
|
|
||||||
['integer', 'number', 'satoshis'].includes(v.type) ||
|
|
||||||
(v.hint && ['satoshis', 'amount'].includes(v.hint));
|
|
||||||
|
|
||||||
return {
|
|
||||||
variableIdentifier: v.id,
|
|
||||||
roleIdentifier: effectiveRole,
|
|
||||||
value: isNumeric ? BigInt(v.value || '0') : v.value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
await invitationInstance.addVariables(variableData);
|
|
||||||
inv = invitationInstance.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add template-required outputs for the current role
|
|
||||||
const act = template.actions?.[actionIdentifier];
|
|
||||||
const transaction = act?.transaction
|
|
||||||
? template.transactions?.[act.transaction]
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (transaction?.outputs && transaction.outputs.length > 0) {
|
|
||||||
setStatus('Adding required outputs...');
|
|
||||||
|
|
||||||
const outputsToAdd = transaction.outputs.map(
|
|
||||||
(output: XOTemplateTransactionOutput) => ({
|
|
||||||
outputIdentifier: output.output,
|
|
||||||
roleIdentifier: roleIdentifier,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await invitationInstance.addOutputs(outputsToAdd);
|
|
||||||
inv = invitationInstance.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInvitation(inv);
|
|
||||||
setStatus('Invitation created');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
showError(
|
|
||||||
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
templateIdentifier,
|
|
||||||
actionIdentifier,
|
|
||||||
roleIdentifier,
|
|
||||||
template,
|
|
||||||
variables,
|
|
||||||
appService,
|
|
||||||
showError,
|
|
||||||
setStatus,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Add selected inputs + change output to the invitation ───────
|
|
||||||
const addInputsAndOutputs = useCallback(async () => {
|
|
||||||
if (!invitationId || !invitation || !appService) return;
|
|
||||||
|
|
||||||
const selectedUtxos = availableUtxos.filter((u) => u.selected);
|
|
||||||
|
|
||||||
if (selectedUtxos.length === 0) {
|
|
||||||
showError('Please select at least one UTXO');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedAmount < requiredAmount + fee) {
|
|
||||||
showError(
|
|
||||||
`Insufficient funds. Need ${formatSatoshis(requiredAmount + fee)}, selected ${formatSatoshis(selectedAmount)}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changeAmount < 546n) {
|
|
||||||
showError(
|
|
||||||
`Change amount (${changeAmount}) is below dust threshold (546 sats)`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
setStatus('Adding inputs and outputs...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const invitationInstance = appService.invitations.find(
|
|
||||||
(inv) => inv.data.invitationIdentifier === invitationId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!invitationInstance) {
|
|
||||||
throw new Error('Invitation not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add selected inputs
|
|
||||||
const inputs = selectedUtxos.map((utxo) => ({
|
|
||||||
outpointTransactionHash: new Uint8Array(
|
|
||||||
Buffer.from(utxo.outpointTransactionHash, 'hex')
|
|
||||||
),
|
|
||||||
outpointIndex: utxo.outpointIndex,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await invitationInstance.addInputs(inputs);
|
|
||||||
|
|
||||||
// Add change output
|
|
||||||
const outputs = [
|
|
||||||
{
|
|
||||||
valueSatoshis: changeAmount,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
await invitationInstance.addOutputs(outputs);
|
|
||||||
|
|
||||||
setCurrentStep((prev) => prev + 1);
|
|
||||||
setStatus('Inputs and outputs added');
|
|
||||||
} catch (error) {
|
|
||||||
showError(
|
|
||||||
`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
invitationId,
|
|
||||||
invitation,
|
|
||||||
availableUtxos,
|
|
||||||
selectedAmount,
|
|
||||||
requiredAmount,
|
|
||||||
fee,
|
|
||||||
changeAmount,
|
|
||||||
appService,
|
|
||||||
showError,
|
|
||||||
setStatus,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Publish the invitation ──────────────────────────────────────
|
|
||||||
const publishInvitation = useCallback(async () => {
|
|
||||||
if (!invitationId || !appService) return;
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
setStatus('Publishing invitation...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const invitationInstance = appService.invitations.find(
|
|
||||||
(inv) => inv.data.invitationIdentifier === invitationId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!invitationInstance) {
|
|
||||||
throw new Error('Invitation not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Already tracked and synced via SSE from createInvitation
|
|
||||||
setCurrentStep((prev) => prev + 1);
|
|
||||||
setStatus('Invitation published');
|
|
||||||
} catch (error) {
|
|
||||||
showError(
|
|
||||||
`Failed to publish: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [invitationId, appService, showError, setStatus]);
|
|
||||||
|
|
||||||
// ── Navigate to the next step ───────────────────────────────────
|
|
||||||
const nextStep = useCallback(async () => {
|
|
||||||
if (currentStep >= steps.length - 1) return;
|
|
||||||
|
|
||||||
const stepType = currentStepData?.type;
|
|
||||||
|
|
||||||
// ── Role selection ──────────────────────────────────────────
|
|
||||||
if (stepType === 'role-select') {
|
|
||||||
const selectedRole = availableRoles[selectedRoleIndex];
|
|
||||||
if (!selectedRole) {
|
|
||||||
showError('Please select a role');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check what the selected role requires
|
|
||||||
const act = template?.actions?.[actionIdentifier ?? ''];
|
|
||||||
const role = act?.roles?.[selectedRole];
|
|
||||||
const requirements = role?.requirements;
|
|
||||||
|
|
||||||
const hasVariables =
|
|
||||||
requirements?.variables && requirements.variables.length > 0;
|
|
||||||
const hasSlots = requirements?.slots && requirements.slots.min > 0;
|
|
||||||
|
|
||||||
// If there is no variables step, the invitation must be created now
|
|
||||||
// because the variables step would normally handle it.
|
|
||||||
if (!hasVariables) {
|
|
||||||
const success = await createInvitationWithVariables(selectedRole);
|
|
||||||
if (!success) return;
|
|
||||||
|
|
||||||
// If we're going to the inputs step, load UTXOs
|
|
||||||
if (hasSlots) {
|
|
||||||
setTimeout(() => loadAvailableUtxos(), 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set role — this triggers the useEffect to rebuild steps and advance
|
|
||||||
setRoleIdentifier(selectedRole);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Variables ───────────────────────────────────────────────
|
|
||||||
if (stepType === 'variables') {
|
|
||||||
const emptyVars = variables.filter(
|
|
||||||
(v) => !v.value || v.value.trim() === ''
|
|
||||||
);
|
|
||||||
if (emptyVars.length > 0) {
|
|
||||||
showError(
|
|
||||||
`Please enter values for: ${emptyVars.map((v) => v.name).join(', ')}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the invitation and persist the variable values
|
|
||||||
const success = await createInvitationWithVariables();
|
|
||||||
if (!success) return;
|
|
||||||
|
|
||||||
// Advance, optionally kicking off UTXO loading
|
|
||||||
const nextStepType = steps[currentStep + 1]?.type;
|
|
||||||
if (nextStepType === 'inputs') {
|
|
||||||
setCurrentStep((prev) => prev + 1);
|
|
||||||
setTimeout(() => loadAvailableUtxos(), 100);
|
|
||||||
} else {
|
|
||||||
setCurrentStep((prev) => prev + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFocusArea('content');
|
|
||||||
setFocusedInput(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Inputs ──────────────────────────────────────────────────
|
|
||||||
if (stepType === 'inputs') {
|
|
||||||
await addInputsAndOutputs();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Review ──────────────────────────────────────────────────
|
|
||||||
if (stepType === 'review') {
|
|
||||||
await publishInvitation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Generic advance (e.g. publish → done) ───────────────────
|
|
||||||
setCurrentStep((prev) => prev + 1);
|
|
||||||
setFocusArea('content');
|
|
||||||
setFocusedInput(0);
|
|
||||||
}, [
|
|
||||||
currentStep,
|
|
||||||
steps,
|
|
||||||
currentStepData,
|
|
||||||
availableRoles,
|
|
||||||
selectedRoleIndex,
|
|
||||||
template,
|
|
||||||
actionIdentifier,
|
|
||||||
variables,
|
|
||||||
showError,
|
|
||||||
createInvitationWithVariables,
|
|
||||||
loadAvailableUtxos,
|
|
||||||
addInputsAndOutputs,
|
|
||||||
publishInvitation,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Navigate to the previous step ──────────────────────────────
|
|
||||||
const previousStep = useCallback(() => {
|
|
||||||
if (currentStep <= 0) {
|
|
||||||
goBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCurrentStep((prev) => prev - 1);
|
|
||||||
setFocusArea('content');
|
|
||||||
setFocusedInput(0);
|
|
||||||
}, [currentStep, goBack]);
|
|
||||||
|
|
||||||
// ── Cancel the wizard entirely ──────────────────────────────────
|
|
||||||
const cancel = useCallback(() => {
|
|
||||||
goBack();
|
|
||||||
}, [goBack]);
|
|
||||||
|
|
||||||
// ── Public API ──────────────────────────────────────────────────
|
|
||||||
return {
|
|
||||||
// Navigation / meta
|
|
||||||
template,
|
|
||||||
templateIdentifier,
|
|
||||||
actionIdentifier,
|
|
||||||
roleIdentifier,
|
|
||||||
action,
|
|
||||||
actionName,
|
|
||||||
|
|
||||||
// Role selection
|
|
||||||
availableRoles,
|
|
||||||
selectedRoleIndex,
|
|
||||||
setSelectedRoleIndex,
|
|
||||||
|
|
||||||
// Steps
|
|
||||||
steps,
|
|
||||||
currentStep,
|
|
||||||
currentStepData,
|
|
||||||
|
|
||||||
// Variables
|
|
||||||
variables,
|
|
||||||
updateVariable,
|
|
||||||
handleTextInputSubmit,
|
|
||||||
|
|
||||||
// UTXOs
|
|
||||||
availableUtxos,
|
|
||||||
setAvailableUtxos,
|
|
||||||
selectedUtxoIndex,
|
|
||||||
setSelectedUtxoIndex,
|
|
||||||
requiredAmount,
|
|
||||||
fee,
|
|
||||||
selectedAmount,
|
|
||||||
changeAmount,
|
|
||||||
toggleUtxoSelection,
|
|
||||||
|
|
||||||
// Invitation
|
|
||||||
invitation,
|
|
||||||
invitationId,
|
|
||||||
|
|
||||||
// UI focus
|
|
||||||
focusedInput,
|
|
||||||
setFocusedInput,
|
|
||||||
focusedButton,
|
|
||||||
setFocusedButton,
|
|
||||||
focusArea,
|
|
||||||
setFocusArea,
|
|
||||||
isProcessing,
|
|
||||||
textInputHasFocus,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
nextStep,
|
|
||||||
previousStep,
|
|
||||||
cancel,
|
|
||||||
copyId,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convenience type so other files can type the return value. */
|
|
||||||
export type ActionWizardState = ReturnType<typeof useActionWizard>;
|
|
||||||
@@ -6,5 +6,4 @@ export * from './action-wizard/index.js';
|
|||||||
export { SeedInputScreen } from './SeedInput.js';
|
export { SeedInputScreen } from './SeedInput.js';
|
||||||
export { WalletStateScreen } from './WalletState.js';
|
export { WalletStateScreen } from './WalletState.js';
|
||||||
export { TemplateListScreen } from './TemplateList.js';
|
export { TemplateListScreen } from './TemplateList.js';
|
||||||
export { InvitationScreen } from './Invitation.js';
|
export { InvitationScreen } from './invitations/InvitationScreen.js';
|
||||||
export { TransactionScreen } from './Transaction.js';
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,421 @@
|
|||||||
|
/**
|
||||||
|
* InvitationImportFlow — orchestrates the multi-step invitation import.
|
||||||
|
*
|
||||||
|
* Manages the step state machine, accumulates data from each step, and
|
||||||
|
* injects it into the next step via props (dependency injection).
|
||||||
|
*
|
||||||
|
* Supports two display modes:
|
||||||
|
* - `'dialog'`: renders as an absolute-positioned overlay (used when called from InvitationScreen)
|
||||||
|
* - `'screen'`: renders as a full-screen component with header, step indicator, and button bar
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors, logoSmall } from '../../../theme.js';
|
||||||
|
import { StepIndicator, type Step } from '../../../components/ProgressBar.js';
|
||||||
|
|
||||||
|
import { FetchInvitationStep } from './steps/FetchInvitationStep.js';
|
||||||
|
import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js';
|
||||||
|
import { RoleSelectStep } from './steps/RoleSelectStep.js';
|
||||||
|
import { VariablesStep } from './steps/VariablesStep.js';
|
||||||
|
import { InputsSelectStep } from './steps/InputsSelectStep.js';
|
||||||
|
import { ReviewStep } from './steps/ReviewStep.js';
|
||||||
|
|
||||||
|
import { IMPORT_STEPS, type ImportFlowProps, type ImportStepType, type ImportVariableInput, type SelectableUTXO } from './types.js';
|
||||||
|
import type { Invitation } from '../../../../services/invitation.js';
|
||||||
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
import { DialogWrapper } from '../../../components/Dialog.js';
|
||||||
|
import { useInputLayer, useLayeredInput } from '../../../hooks/useInputLayer.js';
|
||||||
|
import { hexToBin } from '@bitauth/libauth';
|
||||||
|
|
||||||
|
/** Default fee estimate in satoshis. */
|
||||||
|
const DEFAULT_FEE = 500n;
|
||||||
|
|
||||||
|
/** Dust threshold — outputs below this are unspendable. */
|
||||||
|
const DUST_THRESHOLD = 546n;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the fixed index of a flow step from `IMPORT_STEPS`.
|
||||||
|
* We centralize this so step transitions do not rely on magic numbers.
|
||||||
|
*/
|
||||||
|
function getStepIndex(type: ImportStepType): number {
|
||||||
|
const index = IMPORT_STEPS.findIndex((step) => step.type === type);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error(`Import step not found: ${type}`);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREVIEW_STEP_INDEX = getStepIndex('preview');
|
||||||
|
const ROLE_SELECT_STEP_INDEX = getStepIndex('role-select');
|
||||||
|
const VARIABLES_STEP_INDEX = getStepIndex('variables');
|
||||||
|
const INPUTS_SELECT_STEP_INDEX = getStepIndex('inputs-select');
|
||||||
|
const REVIEW_STEP_INDEX = getStepIndex('review');
|
||||||
|
|
||||||
|
export function InvitationImportFlow({
|
||||||
|
invitationId,
|
||||||
|
mode,
|
||||||
|
appService,
|
||||||
|
onClose,
|
||||||
|
showError,
|
||||||
|
showInfo,
|
||||||
|
setStatus,
|
||||||
|
}: ImportFlowProps): React.ReactElement {
|
||||||
|
// ── Accumulated state ────────────────────────────────────────────────────
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [invitation, setInvitation] = useState<Invitation | null>(null);
|
||||||
|
const [template, setTemplate] = useState<XOTemplate | null>(null);
|
||||||
|
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
||||||
|
const [selectedRole, setSelectedRole] = useState<string | null>(null);
|
||||||
|
const [variableInputs, setVariableInputs] = useState<ImportVariableInput[]>([]);
|
||||||
|
const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]);
|
||||||
|
const [changeAmount, setChangeAmount] = useState(0n);
|
||||||
|
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||||
|
|
||||||
|
// ── Cancel handler ───────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Cleans up (removes the invitation if it was fetched) and signals the parent.
|
||||||
|
*/
|
||||||
|
const handleCancel = useCallback(async () => {
|
||||||
|
if (invitation && appService) {
|
||||||
|
try {
|
||||||
|
await appService.removeInvitation(invitation);
|
||||||
|
} catch {
|
||||||
|
// Best-effort removal — don't block close on failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}, [invitation, appService, onClose]);
|
||||||
|
|
||||||
|
// ── Step completion callbacks ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FetchStep completed — invitation and template are now available.
|
||||||
|
* Also pre-fetches available roles for the next steps.
|
||||||
|
*/
|
||||||
|
const handleFetchComplete = useCallback(async (inv: Invitation, tmpl: XOTemplate | null) => {
|
||||||
|
setInvitation(inv);
|
||||||
|
setTemplate(tmpl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const roles = await inv.getAvailableRoles();
|
||||||
|
setAvailableRoles(roles);
|
||||||
|
} catch (err) {
|
||||||
|
showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentStep(PREVIEW_STEP_INDEX); // → Preview
|
||||||
|
}, [showError]);
|
||||||
|
|
||||||
|
/** PreviewStep completed — user reviewed the invitation state and wants to proceed. */
|
||||||
|
const handlePreviewComplete = useCallback(() => {
|
||||||
|
setCurrentStep(ROLE_SELECT_STEP_INDEX); // → Role Select
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** RoleSelectStep completed — user picked a role. */
|
||||||
|
const handleRoleComplete = useCallback((role: string) => {
|
||||||
|
setSelectedRole(role);
|
||||||
|
|
||||||
|
const action = template?.actions?.[invitation?.data.actionIdentifier ?? ""];
|
||||||
|
const roleRequirements = action?.roles?.[role]?.requirements?.variables ?? [];
|
||||||
|
const hasRequiredVariables = roleRequirements.length > 0;
|
||||||
|
|
||||||
|
if (!hasRequiredVariables) {
|
||||||
|
setVariableInputs([]);
|
||||||
|
setCurrentStep(INPUTS_SELECT_STEP_INDEX); // → Inputs Select
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initializedVariables: ImportVariableInput[] = roleRequirements.map((variableId) => {
|
||||||
|
const variableDefinition = template?.variables?.[variableId];
|
||||||
|
return {
|
||||||
|
id: variableId,
|
||||||
|
name: variableDefinition?.name ?? variableId,
|
||||||
|
type: variableDefinition?.type ?? 'string',
|
||||||
|
hint: variableDefinition?.hint,
|
||||||
|
value: '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setVariableInputs(initializedVariables);
|
||||||
|
setCurrentStep(VARIABLES_STEP_INDEX); // → Variables
|
||||||
|
}, [template, invitation]);
|
||||||
|
|
||||||
|
/** VariablesStep edited a field value. */
|
||||||
|
const handleVariableUpdate = useCallback((index: number, value: string) => {
|
||||||
|
setVariableInputs((previous) => {
|
||||||
|
const updated = [...previous];
|
||||||
|
const current = updated[index];
|
||||||
|
if (current) {
|
||||||
|
updated[index] = { ...current, value };
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert variable input value to its invitation payload representation.
|
||||||
|
* Numeric variables are persisted as bigint so they match action wizard behavior.
|
||||||
|
*/
|
||||||
|
const parseVariableValue = useCallback((variable: ImportVariableInput) => {
|
||||||
|
const variableHint = variable.hint?.toLowerCase();
|
||||||
|
const isNumeric =
|
||||||
|
['integer', 'number', 'satoshis'].includes(variable.type) ||
|
||||||
|
(variableHint !== undefined && ['satoshis', 'amount'].includes(variableHint));
|
||||||
|
|
||||||
|
if (!isNumeric) {
|
||||||
|
return variable.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BigInt(variable.value || '0');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** VariablesStep completed — persist variables then continue to input selection. */
|
||||||
|
const handleVariablesComplete = useCallback(async () => {
|
||||||
|
if (!invitation || !selectedRole) return;
|
||||||
|
|
||||||
|
const emptyVariables = variableInputs.filter((variable) => variable.value.trim() === '');
|
||||||
|
if (emptyVariables.length > 0) {
|
||||||
|
showError(`Please enter values for: ${emptyVariables.map((variable) => variable.name).join(', ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invitation.addVariables(
|
||||||
|
variableInputs.map((variable) => ({
|
||||||
|
variableIdentifier: variable.id,
|
||||||
|
roleIdentifier: selectedRole,
|
||||||
|
value: parseVariableValue(variable),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setCurrentStep(INPUTS_SELECT_STEP_INDEX); // → Inputs Select
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to add variables: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [invitation, selectedRole, variableInputs, parseVariableValue, showError]);
|
||||||
|
|
||||||
|
/** InputsSelectStep completed — user selected UTXOs. */
|
||||||
|
const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => {
|
||||||
|
setSelectedInputs(inputs);
|
||||||
|
|
||||||
|
await invitation?.addInputs(inputs.map(input => ({
|
||||||
|
outpointTransactionHash: hexToBin(input.outpointTransactionHash),
|
||||||
|
outpointIndex: input.outpointIndex,
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Compute totals from selected inputs
|
||||||
|
const totalSelected = inputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
||||||
|
|
||||||
|
// Determine required amount from invitation variables
|
||||||
|
const requiredSats = await invitation?.getSatsOut() ?? 0n;
|
||||||
|
setRequiredAmount(requiredSats);
|
||||||
|
|
||||||
|
// Set the change amount for the review step
|
||||||
|
const changeAmountSats = totalSelected - requiredSats - DEFAULT_FEE;
|
||||||
|
setChangeAmount(changeAmountSats);
|
||||||
|
|
||||||
|
// Add the change output if it exceeds the dust threshold
|
||||||
|
if (changeAmountSats >= DUST_THRESHOLD) {
|
||||||
|
await invitation?.addOutputs([{
|
||||||
|
valueSatoshis: changeAmountSats,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentStep(REVIEW_STEP_INDEX); // → Review
|
||||||
|
}, [invitation]);
|
||||||
|
|
||||||
|
/** ReviewStep completed — invitation import is done. */
|
||||||
|
const handleReviewComplete = useCallback(() => {
|
||||||
|
const roleName = (() => {
|
||||||
|
if (!selectedRole || !template) return selectedRole ?? '';
|
||||||
|
const raw = template.roles?.[selectedRole];
|
||||||
|
return (raw && typeof raw === 'object' && 'name' in raw) ? String(raw.name) : selectedRole;
|
||||||
|
})();
|
||||||
|
|
||||||
|
showInfo(
|
||||||
|
`Invitation imported and accepted!\n\n` +
|
||||||
|
`Role: ${roleName}\n` +
|
||||||
|
`Template: ${template?.name ?? invitation?.data.templateIdentifier ?? 'Unknown'}\n` +
|
||||||
|
`Action: ${invitation?.data.actionIdentifier ?? 'Unknown'}`
|
||||||
|
);
|
||||||
|
setStatus('Ready');
|
||||||
|
onClose(invitation?.data.invitationIdentifier);
|
||||||
|
}, [selectedRole, template, invitation, showInfo, setStatus, onClose]);
|
||||||
|
|
||||||
|
// ── Keyboard handling ────────────────────────────────────────────────────
|
||||||
|
// The import flow registers its own layer so it captures input above the
|
||||||
|
// parent screen. Individual steps also register sub-layers when needed.
|
||||||
|
useInputLayer('import-flow');
|
||||||
|
|
||||||
|
useLayeredInput('import-flow', (_input, key) => {
|
||||||
|
if (currentStep !== 0) return;
|
||||||
|
// Enter retries, Esc cancels — handled within FetchStep rendering,
|
||||||
|
// but we also catch Esc here for safety.
|
||||||
|
if (key.escape) handleCancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Step router ──────────────────────────────────────────────────────────
|
||||||
|
const renderStep = (): React.ReactNode => {
|
||||||
|
const stepDef = IMPORT_STEPS[currentStep];
|
||||||
|
if (!stepDef) return null;
|
||||||
|
|
||||||
|
switch (stepDef.type) {
|
||||||
|
case 'fetch':
|
||||||
|
return (
|
||||||
|
<FetchInvitationStep
|
||||||
|
invitationId={invitationId}
|
||||||
|
appService={appService}
|
||||||
|
onComplete={handleFetchComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'preview':
|
||||||
|
if (!invitation) return null;
|
||||||
|
return (
|
||||||
|
<PreviewInvitationStep
|
||||||
|
invitation={invitation}
|
||||||
|
template={template}
|
||||||
|
onComplete={handlePreviewComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'role-select':
|
||||||
|
if (!invitation) return null;
|
||||||
|
return (
|
||||||
|
<RoleSelectStep
|
||||||
|
invitation={invitation}
|
||||||
|
template={template}
|
||||||
|
availableRoles={availableRoles}
|
||||||
|
onComplete={handleRoleComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'variables':
|
||||||
|
return (
|
||||||
|
<VariablesStep
|
||||||
|
variables={variableInputs}
|
||||||
|
onUpdateVariable={handleVariableUpdate}
|
||||||
|
onComplete={handleVariablesComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'inputs-select':
|
||||||
|
if (!invitation || !selectedRole) return null;
|
||||||
|
return (
|
||||||
|
<InputsSelectStep
|
||||||
|
invitation={invitation}
|
||||||
|
template={template}
|
||||||
|
selectedRole={selectedRole}
|
||||||
|
appService={appService}
|
||||||
|
onComplete={handleInputsComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'review':
|
||||||
|
if (!invitation || !selectedRole) return null;
|
||||||
|
return (
|
||||||
|
<ReviewStep
|
||||||
|
invitation={invitation}
|
||||||
|
template={template}
|
||||||
|
selectedRole={selectedRole}
|
||||||
|
selectedInputs={selectedInputs}
|
||||||
|
changeAmount={changeAmount}
|
||||||
|
requiredAmount={requiredAmount}
|
||||||
|
appService={appService}
|
||||||
|
onComplete={handleReviewComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Step indicator data ──────────────────────────────────────────────────
|
||||||
|
const indicatorSteps: Step[] = IMPORT_STEPS.map(s => ({ label: s.name }));
|
||||||
|
|
||||||
|
// ── Layout: dialog mode ──────────────────────────────────────────────────
|
||||||
|
if (mode === 'dialog') {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<DialogWrapper title="Import Invitation" borderColor={colors.primary}>
|
||||||
|
{/* Step indicator (compact) */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<StepIndicator steps={indicatorSteps} currentStep={currentStep} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Step content */}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{renderStep()}
|
||||||
|
</Box>
|
||||||
|
</DialogWrapper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout: screen mode ──────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1} flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>{logoSmall} - Import Invitation</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{template?.name ?? 'Loading...'}
|
||||||
|
{selectedRole ? ` (as ${selectedRole})` : ''}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Step indicator */}
|
||||||
|
<Box marginTop={1} paddingX={1}>
|
||||||
|
<StepIndicator steps={indicatorSteps} currentStep={currentStep} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Step content */}
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={colors.primary}
|
||||||
|
flexDirection="column"
|
||||||
|
paddingX={1}
|
||||||
|
paddingY={1}
|
||||||
|
marginTop={1}
|
||||||
|
marginX={1}
|
||||||
|
flexGrow={1}
|
||||||
|
>
|
||||||
|
<Text color={colors.primary} bold>
|
||||||
|
{IMPORT_STEPS[currentStep]?.name ?? 'Unknown'} ({currentStep + 1}/{IMPORT_STEPS.length})
|
||||||
|
</Text>
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{renderStep()}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Help text */}
|
||||||
|
<Box marginTop={1} marginX={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
Esc: Cancel import
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* FetchInvitationStep — first step in the import flow.
|
||||||
|
*
|
||||||
|
* Receives an invitation ID, fetches the invitation from the sync server,
|
||||||
|
* resolves its template, and auto-advances once loaded.
|
||||||
|
* Shows a loading spinner while fetching and an error state with retry/cancel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors } from '../../../../theme.js';
|
||||||
|
import type { FetchStepProps } from '../types.js';
|
||||||
|
|
||||||
|
export function FetchInvitationStep({
|
||||||
|
invitationId,
|
||||||
|
appService,
|
||||||
|
onComplete,
|
||||||
|
isActive,
|
||||||
|
}: FetchStepProps): React.ReactElement {
|
||||||
|
const [status, setStatus] = useState<'loading' | 'error'>('loading');
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the invitation and its template, then auto-advance.
|
||||||
|
*/
|
||||||
|
const fetchInvitation = useCallback(async () => {
|
||||||
|
setStatus('loading');
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create/fetch the invitation instance (fetches from sync server if needed)
|
||||||
|
const invitation = await appService.createInvitation(invitationId);
|
||||||
|
|
||||||
|
// Resolve the template for display in later steps
|
||||||
|
const template = await appService.engine.getTemplate(invitation.data.templateIdentifier);
|
||||||
|
|
||||||
|
// Auto-advance — hand the loaded data to the flow controller
|
||||||
|
onComplete(invitation, template ?? null);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setErrorMessage(message);
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
}, [invitationId, appService, onComplete]);
|
||||||
|
|
||||||
|
// Kick off the fetch on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive) {
|
||||||
|
fetchInvitation();
|
||||||
|
}
|
||||||
|
}, [isActive, fetchInvitation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{status === 'loading' && (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.info}>Fetching invitation...</Text>
|
||||||
|
<Text color={colors.textMuted} dimColor>ID: {invitationId}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.error} bold>Failed to fetch invitation</Text>
|
||||||
|
<Text color={colors.textMuted} wrap="wrap">{errorMessage}</Text>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Press Enter to retry or Esc to cancel</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* InputsSelectStep — lets the user select UTXOs to fund the invitation.
|
||||||
|
*
|
||||||
|
* On mount, queries for suitable resources via the invitation's `findSuitableResources`.
|
||||||
|
* Auto-selects greedily, then lets the user toggle individual UTXOs.
|
||||||
|
* Shows required, selected, and change amounts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||||
|
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
|
||||||
|
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
||||||
|
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
|
||||||
|
import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js';
|
||||||
|
import type { UnspentOutputData } from '@xo-cash/state';
|
||||||
|
|
||||||
|
/** Default fee estimate in satoshis. */
|
||||||
|
const DEFAULT_FEE = 500n;
|
||||||
|
|
||||||
|
/** Dust threshold — outputs below this are unspendable. */
|
||||||
|
const DUST_THRESHOLD = 546n;
|
||||||
|
|
||||||
|
export function InputsSelectStep({
|
||||||
|
invitation,
|
||||||
|
appService,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
isActive,
|
||||||
|
}: InputsSelectStepProps): React.ReactElement {
|
||||||
|
const [utxos, setUtxos] = useState<SelectableUTXO[]>([]);
|
||||||
|
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||||
|
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||||
|
|
||||||
|
const fee = DEFAULT_FEE;
|
||||||
|
|
||||||
|
// Derived totals
|
||||||
|
const selectedAmount = utxos
|
||||||
|
.filter(u => u.selected)
|
||||||
|
.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
||||||
|
const changeAmount = selectedAmount - requiredAmount - fee;
|
||||||
|
const hasEnough = selectedAmount >= requiredAmount + fee;
|
||||||
|
|
||||||
|
const getFiatSuffix = (satoshis: bigint): string => {
|
||||||
|
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||||
|
return fiatValue ? ` (~${fiatValue})` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the required satoshi amount from the invitation's variables.
|
||||||
|
*/
|
||||||
|
const computeRequiredAmount = useCallback(async (): Promise<bigint> => {
|
||||||
|
return await invitation.getSatsOut() ?? 0n;
|
||||||
|
}, [invitation]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch suitable UTXOs from the engine and auto-select greedily.
|
||||||
|
*/
|
||||||
|
const loadUtxos = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const required = await computeRequiredAmount();
|
||||||
|
setRequiredAmount(required);
|
||||||
|
|
||||||
|
const template = await appService.engine.getTemplate(invitation.data.templateIdentifier);
|
||||||
|
if (!template) {
|
||||||
|
throw new Error('Template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the action that we are calling from the template
|
||||||
|
const action = template.actions[invitation.data.actionIdentifier];
|
||||||
|
if (!action) {
|
||||||
|
throw new Error('Action not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!action.transaction) {
|
||||||
|
throw new Error('Action does not have a transaction');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the transaction that the action is creating
|
||||||
|
const transaction = template.transactions?.[action.transaction];
|
||||||
|
if (!transaction) {
|
||||||
|
throw new Error(`Transaction not found for action: ${action.transaction}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transaction.outputs) {
|
||||||
|
throw new Error(`Transaction does not have outputs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a set to store all the output identifiers
|
||||||
|
const outputIdentifiers = new Set<string>();
|
||||||
|
for (const output of transaction.outputs) {
|
||||||
|
outputIdentifiers.add(output.output);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of the utxoID to suitable resource
|
||||||
|
const utxoIdToSuitableResource = new Map<string, UnspentOutputData>();
|
||||||
|
for (const outputIdentifier of outputIdentifiers) {
|
||||||
|
const suitableResources = await invitation.findSuitableResources();
|
||||||
|
for (const suitableResource of suitableResources) {
|
||||||
|
utxoIdToSuitableResource.set(suitableResource.outpointTransactionHash + ':' + suitableResource.outpointIndex, suitableResource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectable = mapUnspentOutputsToSelectable(Array.from(utxoIdToSuitableResource.values()));
|
||||||
|
const autoSelected = autoSelectGreedyUtxos(selectable, required + fee);
|
||||||
|
setUtxos(autoSelected as SelectableUTXO[]);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [invitation, computeRequiredAmount, fee]);
|
||||||
|
|
||||||
|
// Load UTXOs once on mount. We use a ref guard to prevent re-firing when
|
||||||
|
// `loadUtxos` identity changes due to parent re-renders — each re-fire
|
||||||
|
// flashes the loading state, causing the visible flicker bug.
|
||||||
|
const hasLoadedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive && !hasLoadedRef.current) {
|
||||||
|
hasLoadedRef.current = true;
|
||||||
|
loadUtxos();
|
||||||
|
}
|
||||||
|
}, [isActive, loadUtxos]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the selection of a UTXO at the given index.
|
||||||
|
*/
|
||||||
|
const toggleSelection = useCallback((index: number) => {
|
||||||
|
setUtxos(prev => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const utxo = updated[index];
|
||||||
|
if (utxo) updated[index] = { ...utxo, selected: !utxo.selected };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keyboard handling — gated by the import-flow layer so dialogs on top block input.
|
||||||
|
useLayeredInput('import-flow', (input, key) => {
|
||||||
|
if (key.upArrow || input === 'k') {
|
||||||
|
setFocusedIndex(prev => Math.max(0, prev - 1));
|
||||||
|
} else if (key.downArrow || input === 'j') {
|
||||||
|
setFocusedIndex(prev => Math.min(utxos.length - 1, prev + 1));
|
||||||
|
} else if (input === ' ') {
|
||||||
|
if (utxos.length > 0) toggleSelection(focusedIndex);
|
||||||
|
} else if (input === 'a') {
|
||||||
|
setUtxos(prev => prev.map(u => ({ ...u, selected: true })));
|
||||||
|
} else if (input === 'n') {
|
||||||
|
setUtxos(prev => prev.map(u => ({ ...u, selected: false })));
|
||||||
|
} else if (key.return) {
|
||||||
|
if (hasEnough) {
|
||||||
|
onComplete(utxos.filter(u => u.selected));
|
||||||
|
}
|
||||||
|
} else if (key.escape) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}, { isActive });
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.info}>Finding suitable UTXOs...</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.error} bold>Failed to load UTXOs</Text>
|
||||||
|
<Text color={colors.textMuted}>{error}</Text>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Esc: Cancel</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No UTXOs found
|
||||||
|
if (utxos.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.warning}>No suitable UTXOs found. Make sure your wallet has funds.</Text>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Esc: Cancel</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Summary bar */}
|
||||||
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
|
<Text color={colors.primary} bold>Required: </Text>
|
||||||
|
<Text color={colors.text}>
|
||||||
|
{formatSatoshis(requiredAmount + fee)}
|
||||||
|
{getFiatSuffix(requiredAmount + fee)}
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}> (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)})</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
|
<Text color={colors.primary} bold>Selected: </Text>
|
||||||
|
<Text color={hasEnough ? colors.success : colors.error}>
|
||||||
|
{formatSatoshis(selectedAmount)}
|
||||||
|
{getFiatSuffix(selectedAmount)}
|
||||||
|
</Text>
|
||||||
|
{hasEnough && changeAmount >= DUST_THRESHOLD && (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}
|
||||||
|
(change: {formatSatoshis(changeAmount)}
|
||||||
|
{getFiatSuffix(changeAmount)})
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!hasEnough && (
|
||||||
|
<Text color={colors.error}>
|
||||||
|
{' '}
|
||||||
|
— need {formatSatoshis(requiredAmount + fee - selectedAmount)}
|
||||||
|
{getFiatSuffix(requiredAmount + fee - selectedAmount)} more
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* UTXO list */}
|
||||||
|
<Text color={colors.primary} bold>UTXOs ({utxos.length}):</Text>
|
||||||
|
{utxos.map((utxo, index) => {
|
||||||
|
const isFocused = index === focusedIndex;
|
||||||
|
const checkMark = utxo.selected ? '☑' : '☐';
|
||||||
|
const txShort = utxo.outpointTransactionHash.slice(0, 8);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
||||||
|
flexDirection="column"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text}
|
||||||
|
bold={isFocused}
|
||||||
|
>
|
||||||
|
{isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}…:{utxo.outpointIndex})
|
||||||
|
</Text>
|
||||||
|
{formatSatoshisToFiat(utxo.valueSatoshis) && (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}≈ {formatSatoshisToFiat(utxo.valueSatoshis)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Navigation hint */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
↑↓: Navigate • Space: Toggle • a: All • n: None • return: Confirm • Esc: Cancel
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* PreviewInvitationStep — displays the current state of a fetched invitation.
|
||||||
|
*
|
||||||
|
* Shows which roles, inputs, outputs, and variables have already been filled
|
||||||
|
* so the user can understand what they're joining before proceeding.
|
||||||
|
* Press Enter to continue, Esc to cancel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||||
|
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
|
||||||
|
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
||||||
|
import {
|
||||||
|
getInvitationState,
|
||||||
|
getStateColorName,
|
||||||
|
} from '../../../../../utils/invitation-utils.js';
|
||||||
|
import type { PreviewStepProps } from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a semantic color name to an actual theme color value.
|
||||||
|
*/
|
||||||
|
function 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a semantic color name to an actual theme color value.
|
||||||
|
*/
|
||||||
|
function stateColor(state: string): string {
|
||||||
|
const name = getStateColorName(state);
|
||||||
|
switch (name) {
|
||||||
|
case 'info': return colors.info as string;
|
||||||
|
case 'warning': return colors.warning as string;
|
||||||
|
case 'success': return colors.success as string;
|
||||||
|
case 'error': return colors.error as string;
|
||||||
|
case 'muted':
|
||||||
|
default: return colors.textMuted as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PreviewInvitationStep({
|
||||||
|
invitation,
|
||||||
|
template,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
isActive,
|
||||||
|
}: PreviewStepProps): React.ReactElement {
|
||||||
|
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||||
|
|
||||||
|
useLayeredInput('import-flow', (_input, key) => {
|
||||||
|
if (key.return) onComplete();
|
||||||
|
if (key.escape) onCancel();
|
||||||
|
}, { isActive });
|
||||||
|
|
||||||
|
const state = getInvitationState(invitation);
|
||||||
|
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||||
|
const { inputs, outputs, variables } = invitation.resolvedData;
|
||||||
|
|
||||||
|
// Collect role identifiers that appear across resolved invitation data
|
||||||
|
const filledRoles = new Set<string>();
|
||||||
|
for (const input of inputs) {
|
||||||
|
if (input.roleIdentifier) filledRoles.add(input.roleIdentifier);
|
||||||
|
}
|
||||||
|
for (const output of outputs) {
|
||||||
|
if (output.roleIdentifier) filledRoles.add(output.roleIdentifier);
|
||||||
|
}
|
||||||
|
for (const variable of variables) {
|
||||||
|
if (variable.roleIdentifier) filledRoles.add(variable.roleIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Template info */}
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.primary} bold>Template:</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.text}>{template?.name ?? invitation.data.templateIdentifier}</Text>
|
||||||
|
</Box>
|
||||||
|
{template?.description && (
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.textMuted} dimColor>{template.description}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Action info */}
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.primary} bold>Action:</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.text}>{action?.name ?? invitation.data.actionIdentifier}</Text>
|
||||||
|
</Box>
|
||||||
|
{action?.description && (
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.textMuted} dimColor>{action.description}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.primary} bold>Status:</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text color={stateColor(state)}>{state}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Roles already filled */}
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.primary} bold>Roles Filled ({filledRoles.size}):</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
{filledRoles.size === 0 ? (
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.textMuted}> None yet</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
Array.from(filledRoles).map(role => {
|
||||||
|
const roleInfoRaw = template?.roles?.[role];
|
||||||
|
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||||
|
return (
|
||||||
|
<Box key={role}>
|
||||||
|
<Text color={colors.text}> • {roleInfo?.name ?? role}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Inputs */}
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
{inputs.length === 0 ? (
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.textMuted}> None yet</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
inputs.map((input, idx) => {
|
||||||
|
return (
|
||||||
|
<Box key={`input-${idx}`}>
|
||||||
|
<Text color={colors.text}>
|
||||||
|
{' '}• {input.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||||
|
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Outputs */}
|
||||||
|
<Box flexDirection="column" marginBottom={1} marginLeft={1}>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
{outputs.length === 0 ? (
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.textMuted}>None yet</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
outputs.map((output, idx) => {
|
||||||
|
const fiatValue = output.valueSatoshis !== undefined
|
||||||
|
? formatSatoshisToFiat(output.valueSatoshis)
|
||||||
|
: null;
|
||||||
|
const outputSatoshis = output.valueSatoshis !== undefined
|
||||||
|
? parseNumberishToBigInt(output.valueSatoshis)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<Box key={`output-${idx}`}>
|
||||||
|
<Text color={colors.text}>
|
||||||
|
{' '}• {output.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||||
|
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)})`}
|
||||||
|
{fiatValue && ` (~${fiatValue})`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Variables */}
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
{variables.length === 0 ? (
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.textMuted}> None set</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
variables.map((variable, idx) => {
|
||||||
|
const displayValue = typeof variable.value === 'bigint'
|
||||||
|
? variable.value.toString()
|
||||||
|
: String(variable.value);
|
||||||
|
return (
|
||||||
|
<Box key={`var-${idx}`}>
|
||||||
|
<Text color={colors.text}>
|
||||||
|
{' '}• {variable.name ?? variable.variableIdentifier}: {displayValue}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Navigation hint */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Enter: Continue • Esc: Cancel</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* ReviewStep — final step that summarizes the import and executes it.
|
||||||
|
*
|
||||||
|
* Displays the accumulated selections (role, inputs, amounts) and on confirmation:
|
||||||
|
* 1. Adds inputs (with the selected role identifier) to the invitation.
|
||||||
|
* 2. Optionally adds a change output if the change exceeds the dust threshold.
|
||||||
|
* 3. Calls `onComplete()` to signal the flow is finished.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||||
|
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
|
||||||
|
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
||||||
|
import type { ReviewStepProps, SelectableUTXO } from '../types.js';
|
||||||
|
|
||||||
|
/** Default fee estimate in satoshis. */
|
||||||
|
const DEFAULT_FEE = 500n;
|
||||||
|
|
||||||
|
/** Dust threshold — outputs below this are unspendable. */
|
||||||
|
const DUST_THRESHOLD = 546n;
|
||||||
|
|
||||||
|
export function ReviewStep({
|
||||||
|
invitation,
|
||||||
|
template,
|
||||||
|
selectedRole,
|
||||||
|
selectedInputs,
|
||||||
|
requiredAmount,
|
||||||
|
changeAmount,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
isActive,
|
||||||
|
}: ReviewStepProps): React.ReactElement {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||||
|
|
||||||
|
const fee = DEFAULT_FEE;
|
||||||
|
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||||
|
|
||||||
|
// Compute totals from selected inputs
|
||||||
|
const totalSelected = selectedInputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
||||||
|
|
||||||
|
const getFiatSuffix = (satoshis: bigint): string => {
|
||||||
|
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||||
|
return fiatValue ? ` (~${fiatValue})` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the import: add inputs (with role) and optional change output.
|
||||||
|
*/
|
||||||
|
const submit = useCallback(async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
onComplete();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [invitation, selectedRole, selectedInputs, onComplete]);
|
||||||
|
|
||||||
|
// Keyboard handling — gated by the import-flow layer.
|
||||||
|
useLayeredInput('import-flow', (_input, key) => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
|
||||||
|
if (key.return) {
|
||||||
|
submit();
|
||||||
|
} else if (key.escape) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}, { isActive });
|
||||||
|
|
||||||
|
// Resolve role display name
|
||||||
|
const roleInfoRaw = template?.roles?.[selectedRole];
|
||||||
|
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Review Import</Text>
|
||||||
|
|
||||||
|
{/* Template & action */}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.text}>Template: {template?.name ?? invitation.data.templateIdentifier}</Text>
|
||||||
|
<Text color={colors.text}>Action: {action?.name ?? invitation.data.actionIdentifier}</Text>
|
||||||
|
<Text color={colors.text}>Role: {roleInfo?.name ?? selectedRole}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Funding summary */}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Funding:</Text>
|
||||||
|
<Text color={colors.text}> • UTXOs: {selectedInputs.length}</Text>
|
||||||
|
<Text color={colors.text}> • Total: {formatSatoshis(totalSelected)}{getFiatSuffix(totalSelected)}</Text>
|
||||||
|
<Text color={colors.text}> • Required: {formatSatoshis(requiredAmount)}{getFiatSuffix(requiredAmount)}</Text>
|
||||||
|
<Text color={colors.text}> • Fee: {formatSatoshis(fee)}{getFiatSuffix(fee)}</Text>
|
||||||
|
{changeAmount >= DUST_THRESHOLD && (
|
||||||
|
<Text color={colors.text}> • Change: {formatSatoshis(changeAmount)}{getFiatSuffix(changeAmount)}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{selectedInputs.length > 0 && (
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Selected UTXOs:</Text>
|
||||||
|
{selectedInputs.slice(0, 3).map((utxo) => (
|
||||||
|
<Text
|
||||||
|
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
||||||
|
color={colors.textMuted}
|
||||||
|
>
|
||||||
|
{' '}• {formatSatoshis(utxo.valueSatoshis)}
|
||||||
|
{getFiatSuffix(utxo.valueSatoshis)}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{selectedInputs.length > 3 && (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}...and {selectedInputs.length - 3} more
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.error} bold>Error: {error}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status / hint */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<Text color={colors.info}>Submitting...</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={colors.textMuted}>Enter: Confirm & Import • Esc: Cancel</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* RoleSelectStep — lets the user choose which role to take in the invitation.
|
||||||
|
*
|
||||||
|
* Displays available roles with their template-level and action-level descriptions.
|
||||||
|
* Arrow keys to navigate, Enter to select, Esc to cancel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors } from '../../../../theme.js';
|
||||||
|
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
||||||
|
import type { RoleSelectStepProps } from '../types.js';
|
||||||
|
|
||||||
|
export function RoleSelectStep({
|
||||||
|
invitation,
|
||||||
|
template,
|
||||||
|
availableRoles,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
isActive,
|
||||||
|
}: RoleSelectStepProps): React.ReactElement {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
useLayeredInput('import-flow', (input, key) => {
|
||||||
|
if (key.upArrow || input === 'k') {
|
||||||
|
setSelectedIndex(prev => Math.max(0, prev - 1));
|
||||||
|
} else if (key.downArrow || input === 'j') {
|
||||||
|
setSelectedIndex(prev => Math.min(availableRoles.length - 1, prev + 1));
|
||||||
|
} else if (key.return) {
|
||||||
|
const role = availableRoles[selectedIndex];
|
||||||
|
if (role) onComplete(role);
|
||||||
|
} else if (key.escape) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}, { isActive });
|
||||||
|
|
||||||
|
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Context header */}
|
||||||
|
<Box marginBottom={1} flexDirection="column">
|
||||||
|
<Text color={colors.text}>Template: {template?.name ?? 'Unknown'}</Text>
|
||||||
|
<Text color={colors.text}>Action: {action?.name ?? invitation.data.actionIdentifier}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Role list */}
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Available Roles:</Text>
|
||||||
|
|
||||||
|
{availableRoles.length === 0 ? (
|
||||||
|
<Text color={colors.warning}>No roles available (you may have already joined)</Text>
|
||||||
|
) : (
|
||||||
|
availableRoles.map((role, index) => {
|
||||||
|
const roleInfoRaw = template?.roles?.[role];
|
||||||
|
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||||
|
const actionRoleRaw = action?.roles?.[role];
|
||||||
|
const actionRole = actionRoleRaw && typeof actionRoleRaw === 'object' ? actionRoleRaw : null;
|
||||||
|
const isFocused = index === selectedIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={role} flexDirection="column">
|
||||||
|
<Text
|
||||||
|
color={isFocused ? colors.focus : colors.text}
|
||||||
|
bold={isFocused}
|
||||||
|
>
|
||||||
|
{isFocused ? '▸ ' : ' '}
|
||||||
|
{roleInfo?.name ?? role}
|
||||||
|
</Text>
|
||||||
|
{(roleInfo?.description || actionRole?.description) && (
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
{' '}{actionRole?.description ?? roleInfo?.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Navigation hint */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>↑↓: Select role • Enter: Accept • Esc: Cancel</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* VariablesStep — collects all required variable values for invitation import.
|
||||||
|
*
|
||||||
|
* This runs after role selection and before input selection so cashasm
|
||||||
|
* expressions can resolve required variables during `getSatsOut()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState, useCallback } from "react";
|
||||||
|
import { Box, Text } from "ink";
|
||||||
|
import { colors } from "../../../../theme.js";
|
||||||
|
import { useLayeredInput } from "../../../../hooks/useInputLayer.js";
|
||||||
|
import { VariableInputField } from "../../../../components/VariableInputField.js";
|
||||||
|
import type { VariablesStepProps } from "../types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a user-facing validation error for empty required fields.
|
||||||
|
*/
|
||||||
|
function validateVariables(
|
||||||
|
variables: VariablesStepProps["variables"],
|
||||||
|
): string | null {
|
||||||
|
const empty = variables.filter((v) => v.value.trim() === "");
|
||||||
|
if (empty.length === 0) return null;
|
||||||
|
return `Please enter values for: ${empty.map((v) => v.name).join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VariablesStep({
|
||||||
|
variables,
|
||||||
|
onUpdateVariable,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
isActive,
|
||||||
|
}: VariablesStepProps): React.ReactElement {
|
||||||
|
const [focusedInput, setFocusedInput] = useState(0);
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const helpText = useMemo(() => {
|
||||||
|
if (variables.length === 0) {
|
||||||
|
return "No variables required for this role.";
|
||||||
|
}
|
||||||
|
return "Enter a value for each variable, then press Enter on the last field to continue.";
|
||||||
|
}, [variables.length]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move focus to next input, or finish the step if this is the last one.
|
||||||
|
*/
|
||||||
|
const handleInputSubmit = useCallback(() => {
|
||||||
|
if (variables.length === 0) {
|
||||||
|
onComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusedInput < variables.length - 1) {
|
||||||
|
setFocusedInput((prev) => prev + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validateVariables(variables);
|
||||||
|
setValidationError(validation);
|
||||||
|
if (!validation) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
}, [variables, focusedInput, onComplete]);
|
||||||
|
|
||||||
|
// Keyboard navigation for non-text actions.
|
||||||
|
useLayeredInput(
|
||||||
|
"import-flow",
|
||||||
|
(input, key) => {
|
||||||
|
if (key.upArrow || input === "k") {
|
||||||
|
setFocusedInput((prev) => Math.max(0, prev - 1));
|
||||||
|
} else if (key.downArrow || input === "j") {
|
||||||
|
setFocusedInput((prev) => Math.min(variables.length - 1, prev + 1));
|
||||||
|
} else if (key.escape) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>
|
||||||
|
Required Variables
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{variables.map((variable, index) => (
|
||||||
|
<VariableInputField
|
||||||
|
key={variable.id}
|
||||||
|
variable={variable}
|
||||||
|
index={index}
|
||||||
|
isFocused={focusedInput === index}
|
||||||
|
onChange={onUpdateVariable}
|
||||||
|
onSubmit={handleInputSubmit}
|
||||||
|
borderColor={colors.border as string}
|
||||||
|
focusColor={colors.primary as string}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{validationError && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.error}>{validationError}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{helpText} ↑↓: Change field • Esc: Cancel
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
src/tui/screens/invitations/invitation-import/types.ts
Normal file
151
src/tui/screens/invitations/invitation-import/types.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Shared types for the invitation import flow.
|
||||||
|
*
|
||||||
|
* Each step in the flow receives only what it needs via props (dependency injection).
|
||||||
|
* The flow controller (`InvitationImportFlow`) accumulates data and passes it forward.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Invitation } from "../../../../services/invitation.js";
|
||||||
|
import type { AppService } from "../../../../services/app.js";
|
||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
|
||||||
|
// ── Step definitions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Identifies each step in the import flow. */
|
||||||
|
export type ImportStepType =
|
||||||
|
| "fetch"
|
||||||
|
| "preview"
|
||||||
|
| "role-select"
|
||||||
|
| "variables"
|
||||||
|
| "inputs-select"
|
||||||
|
| "review";
|
||||||
|
|
||||||
|
/** A single step descriptor used by the flow controller and step indicator. */
|
||||||
|
export interface ImportStep {
|
||||||
|
name: string;
|
||||||
|
type: ImportStepType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ordered list of steps in the import flow. */
|
||||||
|
export const IMPORT_STEPS: ImportStep[] = [
|
||||||
|
{ name: "Fetch", type: "fetch" },
|
||||||
|
{ name: "Preview", type: "preview" },
|
||||||
|
{ name: "Select Role", type: "role-select" },
|
||||||
|
{ name: "Variables", type: "variables" },
|
||||||
|
{ name: "Select Inputs", type: "inputs-select" },
|
||||||
|
{ name: "Review", type: "review" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Display mode ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Controls whether the import flow renders as a dialog overlay or a full screen. */
|
||||||
|
export type ImportFlowMode = "dialog" | "screen";
|
||||||
|
|
||||||
|
// ── UTXO selection ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A UTXO that the user can toggle on/off during the inputs step. */
|
||||||
|
export interface SelectableUTXO {
|
||||||
|
outpointTransactionHash: string;
|
||||||
|
outpointIndex: number;
|
||||||
|
valueSatoshis: bigint;
|
||||||
|
lockingBytecode?: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step props ───────────────────────────────────────────────────────────────
|
||||||
|
// Each step receives exactly the data and callbacks it needs.
|
||||||
|
|
||||||
|
/** Props for FetchInvitationStep — loads the invitation from an ID. */
|
||||||
|
export interface FetchStepProps {
|
||||||
|
invitationId: string;
|
||||||
|
appService: AppService;
|
||||||
|
onComplete: (invitation: Invitation, template: XOTemplate | null) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for PreviewInvitationStep — displays invitation state. */
|
||||||
|
export interface PreviewStepProps {
|
||||||
|
invitation: Invitation;
|
||||||
|
template: XOTemplate | null;
|
||||||
|
onComplete: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for RoleSelectStep — lets user pick a role. */
|
||||||
|
export interface RoleSelectStepProps {
|
||||||
|
invitation: Invitation;
|
||||||
|
template: XOTemplate | null;
|
||||||
|
availableRoles: string[];
|
||||||
|
onComplete: (selectedRole: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single variable input required by the selected action role. */
|
||||||
|
export interface ImportVariableInput {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
hint?: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for VariablesStep — collects required role/action variable values. */
|
||||||
|
export interface VariablesStepProps {
|
||||||
|
variables: ImportVariableInput[];
|
||||||
|
onUpdateVariable: (index: number, value: string) => void;
|
||||||
|
onComplete: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for InputsSelectStep — lets user pick UTXOs to fund the invitation. */
|
||||||
|
export interface InputsSelectStepProps {
|
||||||
|
invitation: Invitation;
|
||||||
|
template: XOTemplate | null;
|
||||||
|
selectedRole: string;
|
||||||
|
appService: AppService;
|
||||||
|
onComplete: (inputs: SelectableUTXO[]) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for ReviewStep — summarizes and executes the import. */
|
||||||
|
export interface ReviewStepProps {
|
||||||
|
invitation: Invitation;
|
||||||
|
template: XOTemplate | null;
|
||||||
|
selectedRole: string;
|
||||||
|
selectedInputs: SelectableUTXO[];
|
||||||
|
changeAmount: bigint;
|
||||||
|
requiredAmount: bigint;
|
||||||
|
appService: AppService;
|
||||||
|
onComplete: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flow controller props ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Props for the top-level InvitationImportFlow component. */
|
||||||
|
export interface ImportFlowProps {
|
||||||
|
/** The invitation ID to import (already entered by the user in InvitationScreen). */
|
||||||
|
invitationId: string;
|
||||||
|
/** Whether to render as a dialog overlay or a full screen. */
|
||||||
|
mode: ImportFlowMode;
|
||||||
|
/** The application service — injected, not pulled from context. */
|
||||||
|
appService: AppService;
|
||||||
|
/**
|
||||||
|
* Called when the flow completes or is cancelled.
|
||||||
|
* When import succeeds, the invitation identifier is provided so callers can
|
||||||
|
* select/focus the imported invitation in their UI.
|
||||||
|
*/
|
||||||
|
onClose: (importedInvitationId?: string) => void;
|
||||||
|
/** Display an error message to the user. */
|
||||||
|
showError: (message: string) => void;
|
||||||
|
/** Display an info message to the user. */
|
||||||
|
showInfo: (message: string) => void;
|
||||||
|
/** Update the global status bar. */
|
||||||
|
setStatus: (message: string) => void;
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@
|
|||||||
* Defines colors, styles, and visual constants used throughout the application.
|
* Defines colors, styles, and visual constants used throughout the application.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TextProps } from 'ink';
|
import type { TextProps } from "ink";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Color type - supports Ink color names.
|
* Color type - supports Ink color names.
|
||||||
*/
|
*/
|
||||||
export type Color = TextProps['color'];
|
export type Color = TextProps["color"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Color palette for the application.
|
* Color palette for the application.
|
||||||
@@ -16,33 +16,33 @@ export type Color = TextProps['color'];
|
|||||||
*/
|
*/
|
||||||
export const colors = {
|
export const colors = {
|
||||||
// Primary colors
|
// Primary colors
|
||||||
primary: 'cyan' as Color,
|
primary: "cyan" as Color,
|
||||||
secondary: 'blue' as Color,
|
secondary: "blue" as Color,
|
||||||
accent: 'magenta' as Color,
|
accent: "magenta" as Color,
|
||||||
|
|
||||||
// Status colors
|
// Status colors
|
||||||
success: 'green' as Color,
|
success: "green" as Color,
|
||||||
warning: 'yellow' as Color,
|
warning: "yellow" as Color,
|
||||||
error: 'red' as Color,
|
error: "red" as Color,
|
||||||
info: 'cyan' as Color,
|
info: "cyan" as Color,
|
||||||
|
|
||||||
// Text colors
|
// Text colors
|
||||||
text: 'white' as Color,
|
text: "white" as Color,
|
||||||
textMuted: 'gray' as Color,
|
textMuted: "gray" as Color,
|
||||||
textHighlight: 'whiteBright' as Color,
|
textHighlight: "whiteBright" as Color,
|
||||||
|
|
||||||
// Background colors
|
// Background colors
|
||||||
bg: 'black' as Color,
|
bg: "black" as Color,
|
||||||
bgSelected: 'blue' as Color,
|
bgSelected: "blue" as Color,
|
||||||
bgHover: 'gray' as Color,
|
bgHover: "gray" as Color,
|
||||||
|
|
||||||
// Border colors
|
// Border colors
|
||||||
border: 'cyan' as Color,
|
border: "cyan" as Color,
|
||||||
borderFocused: 'yellowBright' as Color,
|
borderFocused: "yellowBright" as Color,
|
||||||
borderMuted: 'gray' as Color,
|
borderMuted: "gray" as Color,
|
||||||
|
|
||||||
// Focus highlight color (very visible)
|
// Focus highlight color (very visible)
|
||||||
focus: 'yellowBright' as Color,
|
focus: "yellowBright" as Color,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,7 +76,7 @@ export const logo = `
|
|||||||
/**
|
/**
|
||||||
* Small logo for status bar.
|
* Small logo for status bar.
|
||||||
*/
|
*/
|
||||||
export const logoSmall = 'XO Wallet';
|
export const logoSmall = "XO Wallet";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to format satoshis for display.
|
* Helper to format satoshis for display.
|
||||||
@@ -84,7 +84,7 @@ export const logoSmall = 'XO Wallet';
|
|||||||
* @returns Formatted string with BCH amount
|
* @returns Formatted string with BCH amount
|
||||||
*/
|
*/
|
||||||
export function formatSatoshis(satoshis: bigint | number): string {
|
export function formatSatoshis(satoshis: bigint | number): string {
|
||||||
const value = typeof satoshis === 'bigint' ? satoshis : BigInt(satoshis);
|
const value = typeof satoshis === "bigint" ? satoshis : BigInt(satoshis);
|
||||||
const bch = Number(value) / 100_000_000;
|
const bch = Number(value) / 100_000_000;
|
||||||
return `${bch.toFixed(8)} BCH (${value.toLocaleString()} sats)`;
|
return `${bch.toFixed(8)} BCH (${value.toLocaleString()} sats)`;
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ export function formatSatoshis(satoshis: bigint | number): string {
|
|||||||
*/
|
*/
|
||||||
export function truncate(str: string, maxLength: number): string {
|
export function truncate(str: string, maxLength: number): string {
|
||||||
if (str.length <= maxLength) return str;
|
if (str.length <= maxLength) return str;
|
||||||
return str.slice(0, maxLength - 3) + '...';
|
return str.slice(0, maxLength - 3) + "...";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
* Shared types for the CLI TUI.
|
* Shared types for the CLI TUI.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AppService } from '../services/app.js';
|
import type { AppService } from "../services/app.js";
|
||||||
import type { AppConfig } from '../app.js';
|
import type { AppConfig } from "../app.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Screen names for navigation.
|
* Screen names for navigation.
|
||||||
*/
|
*/
|
||||||
export type ScreenName =
|
export type ScreenName =
|
||||||
| 'seed-input'
|
| "seed-input"
|
||||||
| 'wallet'
|
| "wallet"
|
||||||
| 'templates'
|
| "templates"
|
||||||
| 'wizard'
|
| "wizard"
|
||||||
| 'invitations'
|
| "invitations"
|
||||||
| 'transaction';
|
| "transaction";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation context data that can be passed between screens.
|
* Navigation context data that can be passed between screens.
|
||||||
@@ -81,7 +81,7 @@ export interface DialogState {
|
|||||||
/** Whether dialog is visible */
|
/** Whether dialog is visible */
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
/** Dialog type */
|
/** Dialog type */
|
||||||
type: 'error' | 'info' | 'confirm';
|
type: "error" | "info" | "confirm";
|
||||||
/** Dialog message */
|
/** Dialog message */
|
||||||
message: string;
|
message: string;
|
||||||
/** Callback for confirm dialog */
|
/** Callback for confirm dialog */
|
||||||
|
|||||||
@@ -2,11 +2,48 @@
|
|||||||
* Cross-platform clipboard utility with multiple fallback methods.
|
* Cross-platform clipboard utility with multiple fallback methods.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { exec } from 'child_process';
|
import clipboardy from "clipboardy";
|
||||||
import { promisify } from 'util';
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
// Define a list of clipboard methods with their platform and command.
|
||||||
|
// The platform is a function that returns true if the method is available on the current platform.
|
||||||
|
// The command is a function that returns a promise that resolves to the result of the command.
|
||||||
|
const clipboardMethods = {
|
||||||
|
pbCopy: {
|
||||||
|
platform: (platform: string) => platform === "darwin",
|
||||||
|
command: async (text: string) =>
|
||||||
|
execAsync(`printf '%s' '${text}' | pbcopy`),
|
||||||
|
},
|
||||||
|
xclip: {
|
||||||
|
platform: (platform: string) => platform === "linux",
|
||||||
|
command: async (text: string) =>
|
||||||
|
execAsync(`printf '%s' '${text}' | xclip -selection clipboard`),
|
||||||
|
},
|
||||||
|
xsel: {
|
||||||
|
platform: (platform: string) => platform === "linux",
|
||||||
|
command: async (text: string) =>
|
||||||
|
execAsync(`printf '%s' '${text}' | xsel --clipboard --input`),
|
||||||
|
},
|
||||||
|
ssh: {
|
||||||
|
platform: (platform: string) => platform === "linux",
|
||||||
|
command: async (text: string) =>
|
||||||
|
process.stdout.write(
|
||||||
|
`\x1b]52;c;${Buffer.from(text, "utf-8").toString("base64")}\x07`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
clip: {
|
||||||
|
platform: (platform: string) => platform === "windows",
|
||||||
|
command: async (text: string) => execAsync(`echo|set /p="${text}" | clip`),
|
||||||
|
},
|
||||||
|
clipboardy: {
|
||||||
|
platform: (platform: string) => platform === "windows",
|
||||||
|
command: async (text: string) => clipboardy.writeSync(text),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to copy text to clipboard using multiple methods.
|
* Attempts to copy text to clipboard using multiple methods.
|
||||||
* Tries native commands first (most reliable), then clipboardy as fallback.
|
* Tries native commands first (most reliable), then clipboardy as fallback.
|
||||||
@@ -20,43 +57,29 @@ export async function copyToClipboard(text: string): Promise<void> {
|
|||||||
// Escape the text for shell commands
|
// Escape the text for shell commands
|
||||||
const escapedText = text.replace(/'/g, "'\\''");
|
const escapedText = text.replace(/'/g, "'\\''");
|
||||||
|
|
||||||
// Try native commands first - they're more reliable
|
const availableMethods = Object.values(clipboardMethods).filter((method) =>
|
||||||
try {
|
method.platform(platform),
|
||||||
if (platform === 'darwin') {
|
);
|
||||||
// macOS - use pbcopy directly
|
|
||||||
await execAsync(`printf '%s' '${escapedText}' | pbcopy`);
|
|
||||||
return;
|
|
||||||
} else if (platform === 'linux') {
|
|
||||||
// Linux - try xclip, then xsel
|
|
||||||
try {
|
|
||||||
await execAsync(`printf '%s' '${escapedText}' | xclip -selection clipboard`);
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
await execAsync(`printf '%s' '${escapedText}' | xsel --clipboard --input`);
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
// Fall through to clipboardy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (platform === 'win32') {
|
|
||||||
// Windows - use clip.exe
|
|
||||||
await execAsync(`echo|set /p="${text}" | clip`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Native command failed, try clipboardy
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to clipboardy
|
const errors: Error[] = [];
|
||||||
|
|
||||||
|
for (const method of availableMethods) {
|
||||||
try {
|
try {
|
||||||
const clipboard = await import('clipboardy');
|
if (method.platform(platform)) {
|
||||||
await clipboard.default.write(text);
|
await method.command(escapedText);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch (error) {
|
||||||
// clipboardy also failed
|
if (error instanceof Error) {
|
||||||
|
errors.push(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All methods failed
|
// All methods failed
|
||||||
throw new Error(`Clipboard not available. Install xclip or xsel on Linux.`);
|
throw new Error(
|
||||||
|
`Clipboard not available. ${errors.map((error) => error.message).join("\n")}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
121
src/tui/utils/format-dialog-message.ts
Normal file
121
src/tui/utils/format-dialog-message.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Formats multi-line dialog messages for readable terminal display.
|
||||||
|
*
|
||||||
|
* Ink's `wrap="wrap"` breaks long lines mid-word, which looks broken for
|
||||||
|
* dot-separated template validation paths. We pre-split on newlines and break
|
||||||
|
* long lines at `.` segment boundaries instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard-wraps text when a single segment still exceeds the maximum width.
|
||||||
|
*/
|
||||||
|
function hardWrapLine(line: string, maxWidth: number): string[] {
|
||||||
|
if (line.length <= maxWidth) {
|
||||||
|
return [line];
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapped: string[] = [];
|
||||||
|
let remaining = line;
|
||||||
|
|
||||||
|
while (remaining.length > maxWidth) {
|
||||||
|
wrapped.push(remaining.slice(0, maxWidth));
|
||||||
|
remaining = ` ${remaining.slice(maxWidth)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
wrapped.push(remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breaks a long line at dot-separated segments, indenting continuations.
|
||||||
|
*/
|
||||||
|
function breakLongLineAtDots(line: string, maxWidth: number): string[] {
|
||||||
|
const segments: string[] = [];
|
||||||
|
let segmentStart = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < line.length; index += 1) {
|
||||||
|
if (line[index] === "." && index > 0) {
|
||||||
|
segments.push(line.slice(segmentStart, index + 1));
|
||||||
|
segmentStart = index + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segmentStart < line.length) {
|
||||||
|
segments.push(line.slice(segmentStart));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return hardWrapLine(line, maxWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
const candidate = current + segment;
|
||||||
|
|
||||||
|
if (candidate.length > maxWidth && current.length > 0) {
|
||||||
|
lines.push(current);
|
||||||
|
current = ` ${segment}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.length > maxWidth) {
|
||||||
|
lines.push(...hardWrapLine(segment, maxWidth));
|
||||||
|
current = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length > 0) {
|
||||||
|
lines.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a dialog message into display lines that fit the available width.
|
||||||
|
*/
|
||||||
|
export function formatDialogMessageLines(
|
||||||
|
message: string,
|
||||||
|
contentWidth: number,
|
||||||
|
): string[] {
|
||||||
|
const output: string[] = [];
|
||||||
|
|
||||||
|
for (const rawLine of message.split("\n")) {
|
||||||
|
const line = rawLine.trimEnd();
|
||||||
|
if (line.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.length <= contentWidth) {
|
||||||
|
output.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push(...breakLongLineAtDots(line, contentWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes dialog width from the terminal size.
|
||||||
|
*/
|
||||||
|
export function getMessageDialogWidth(terminalColumns: number): number {
|
||||||
|
return Math.min(Math.max(terminalColumns - 4, 60), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inner text width after dialog border and horizontal padding. */
|
||||||
|
export function getMessageContentWidth(dialogWidth: number): number {
|
||||||
|
return Math.max(dialogWidth - 6, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maximum number of body lines shown before truncating with a summary. */
|
||||||
|
export const MAX_MESSAGE_DIALOG_LINES = 24;
|
||||||
169
src/tui/utils/list-directory-entries.ts
Normal file
169
src/tui/utils/list-directory-entries.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Directory listing helpers for terminal file pickers.
|
||||||
|
*
|
||||||
|
* Uses synchronous filesystem APIs to match other TUI screens (e.g. SeedInput).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kind of entry shown in a file picker list.
|
||||||
|
*/
|
||||||
|
export type DirectoryEntryKind = "parent" | "directory" | "file";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single row in a directory listing.
|
||||||
|
*/
|
||||||
|
export interface DirectoryEntry {
|
||||||
|
/** Display name (e.g. ".." or "foo.json"). */
|
||||||
|
name: string;
|
||||||
|
/** Absolute path on disk. */
|
||||||
|
absolutePath: string;
|
||||||
|
/** Whether this row navigates up, into a folder, or selects a file. */
|
||||||
|
kind: DirectoryEntryKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for {@link listDirectoryEntries}.
|
||||||
|
*/
|
||||||
|
export interface ListDirectoryEntriesOptions {
|
||||||
|
/**
|
||||||
|
* Allowed file extensions without a leading dot (e.g. `['json']`).
|
||||||
|
* When omitted or empty, all non-hidden files are included.
|
||||||
|
* Directories are always included regardless of this filter.
|
||||||
|
*/
|
||||||
|
extensions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of listing a directory for the file picker.
|
||||||
|
*/
|
||||||
|
export interface ListDirectoryEntriesResult {
|
||||||
|
entries: DirectoryEntry[];
|
||||||
|
/** Set when the directory could not be read. */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the file extension matches one of the allowed extensions.
|
||||||
|
* Comparison is case-insensitive; extensions may be passed with or without a dot.
|
||||||
|
*/
|
||||||
|
function matchesExtension(
|
||||||
|
filename: string,
|
||||||
|
extensions: string[] | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (extensions === undefined || extensions.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileExtension = path.extname(filename).slice(1).toLowerCase();
|
||||||
|
if (fileExtension.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return extensions.some((extension) => {
|
||||||
|
const normalized = extension.startsWith(".")
|
||||||
|
? extension.slice(1)
|
||||||
|
: extension;
|
||||||
|
return normalized.toLowerCase() === fileExtension;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists files and folders in `directory` for display in a terminal file picker.
|
||||||
|
*
|
||||||
|
* - Prepends `..` when not at the filesystem root.
|
||||||
|
* - Always shows subdirectories (except `.` and `..` from readdir).
|
||||||
|
* - Filters files by optional `extensions`.
|
||||||
|
* - Sort order: parent link first, then directories A→Z, then files A→Z.
|
||||||
|
* - Returns an empty list and `error` instead of throwing on permission or missing paths.
|
||||||
|
*/
|
||||||
|
export function listDirectoryEntries(
|
||||||
|
directory: string,
|
||||||
|
options: ListDirectoryEntriesOptions = {},
|
||||||
|
): ListDirectoryEntriesResult {
|
||||||
|
const resolvedDirectory = path.resolve(directory);
|
||||||
|
const { extensions } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(resolvedDirectory)) {
|
||||||
|
return {
|
||||||
|
entries: [],
|
||||||
|
error: `Directory does not exist: ${resolvedDirectory}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const directoryStat = fs.statSync(resolvedDirectory);
|
||||||
|
if (!directoryStat.isDirectory()) {
|
||||||
|
return {
|
||||||
|
entries: [],
|
||||||
|
error: `Not a directory: ${resolvedDirectory}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: DirectoryEntry[] = [];
|
||||||
|
const parentDirectory = path.dirname(resolvedDirectory);
|
||||||
|
|
||||||
|
if (parentDirectory !== resolvedDirectory) {
|
||||||
|
entries.push({
|
||||||
|
name: "..",
|
||||||
|
absolutePath: parentDirectory,
|
||||||
|
kind: "parent",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const childNames = fs.readdirSync(resolvedDirectory);
|
||||||
|
const directories: DirectoryEntry[] = [];
|
||||||
|
const files: DirectoryEntry[] = [];
|
||||||
|
|
||||||
|
for (const name of childNames) {
|
||||||
|
if (name === "." || name === "..") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = path.join(resolvedDirectory, name);
|
||||||
|
|
||||||
|
let childStat: fs.Stats;
|
||||||
|
try {
|
||||||
|
childStat = fs.statSync(absolutePath);
|
||||||
|
} catch {
|
||||||
|
// Skip broken symlinks or entries we cannot stat.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childStat.isDirectory()) {
|
||||||
|
directories.push({
|
||||||
|
name,
|
||||||
|
absolutePath,
|
||||||
|
kind: "directory",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childStat.isFile() && matchesExtension(name, extensions)) {
|
||||||
|
files.push({
|
||||||
|
name,
|
||||||
|
absolutePath,
|
||||||
|
kind: "file",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortByName = (a: DirectoryEntry, b: DirectoryEntry): number =>
|
||||||
|
a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
||||||
|
|
||||||
|
directories.sort(sortByName);
|
||||||
|
files.sort(sortByName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: [...entries, ...directories, ...files],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
entries: [],
|
||||||
|
error: `Unable to read directory: ${message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/utils/bch-mnemonic-url.ts
Normal file
183
src/utils/bch-mnemonic-url.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/*
|
||||||
|
* Handles BCH Mnemonic parsing to/from URL form.
|
||||||
|
* Pulled directly from the old stack package.
|
||||||
|
*/
|
||||||
|
import { z } from "zod";
|
||||||
|
import { decodeBip39Mnemonic } from "@bitauth/libauth";
|
||||||
|
|
||||||
|
export type BCHMnemonicURLRaw = {
|
||||||
|
entropy: Uint8Array;
|
||||||
|
passphrase?: string;
|
||||||
|
language?: (typeof BCHMnemonicURL.SUPPORTED_LANGUAGES)[number];
|
||||||
|
comment?: string;
|
||||||
|
path?: string;
|
||||||
|
startHeight?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles BCHMnemonic URLs
|
||||||
|
*/
|
||||||
|
export class BCHMnemonicURL {
|
||||||
|
static PROTOCOL = "bch-mnemonic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL is a valid wallet backup URL
|
||||||
|
*
|
||||||
|
* @param url The URL to check
|
||||||
|
* @returns True if the URL is a valid wallet backup URL, false otherwise
|
||||||
|
*/
|
||||||
|
public static canHandle(urlStr: string): boolean {
|
||||||
|
try {
|
||||||
|
BCHMnemonicURL.fromURL(urlStr);
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a BCHMnemonic from a URL-encoded string
|
||||||
|
* @param urlStr - The URL-encoded mnemonic string
|
||||||
|
* @returns A new BCHMnemonic instance
|
||||||
|
* @throws Error if the URL format is invalid or entropy is invalid
|
||||||
|
*/
|
||||||
|
static fromURL(urlStr: string): BCHMnemonicURL {
|
||||||
|
const url = new URL(urlStr);
|
||||||
|
|
||||||
|
if (url.protocol !== `${BCHMnemonicURL.PROTOCOL}:`) {
|
||||||
|
throw new Error(`Invalid URL protocol: ${url.protocol}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the entropy.
|
||||||
|
const entropy = new Uint8Array(Buffer.from(url.pathname, "base64"));
|
||||||
|
|
||||||
|
// Pick out our encoding keys from the URL
|
||||||
|
const params = BCHMnemonicURL.schema.parse(
|
||||||
|
Object.fromEntries(url.searchParams.entries()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create and return the backup with validated parameters
|
||||||
|
return BCHMnemonicURL.fromRaw({
|
||||||
|
entropy,
|
||||||
|
language: params[BCHMnemonicURL.ENCODING_KEYS.language],
|
||||||
|
comment: params[BCHMnemonicURL.ENCODING_KEYS.comment],
|
||||||
|
path: params[BCHMnemonicURL.ENCODING_KEYS.path],
|
||||||
|
startHeight: params[BCHMnemonicURL.ENCODING_KEYS.startHeight],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new WalletBackup from a raw object
|
||||||
|
*
|
||||||
|
* @param raw - The raw object to create the WalletBackup from
|
||||||
|
* @returns The created WalletBackup
|
||||||
|
*/
|
||||||
|
static fromRaw(raw: BCHMnemonicURLRaw): BCHMnemonicURL {
|
||||||
|
// Add entropy validation
|
||||||
|
if (!raw.entropy || raw.entropy.length === 0) {
|
||||||
|
throw new Error("Invalid entropy: must be non-empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate entropy length (typically 16, 20, 24, 28, or 32 bytes for BIP39)
|
||||||
|
const validLengths = [16, 20, 24, 28, 32];
|
||||||
|
if (!validLengths.includes(raw.entropy.length)) {
|
||||||
|
throw new Error(`Invalid entropy length: ${raw.entropy.length} bytes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BCHMnemonicURL(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromSeed(seed: string): BCHMnemonicURL {
|
||||||
|
// Encode the seed to a Uint8Array
|
||||||
|
const entropy = decodeBip39Mnemonic(seed);
|
||||||
|
|
||||||
|
// If the decode failed, throw an error
|
||||||
|
if (typeof entropy === "string") {
|
||||||
|
throw new Error(`Invalid seed: ${entropy}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BCHMnemonicURL.fromRaw({ entropy });
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(protected raw: BCHMnemonicURLRaw) {}
|
||||||
|
|
||||||
|
toObject() {
|
||||||
|
return this.raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode the backup into a URL encoding
|
||||||
|
*
|
||||||
|
* @param prefix - The prefix to use for the URL encoding
|
||||||
|
* @returns The URL encoding of the backup
|
||||||
|
*/
|
||||||
|
toURL(): string {
|
||||||
|
// Conver the mnemonic words into the entropy used to derive the mnemonic words
|
||||||
|
const entropyBase64 = Buffer.from(this.raw.entropy).toString("base64");
|
||||||
|
|
||||||
|
// Create a new URL object with the prefix and the base64 encoded mnemonic
|
||||||
|
const url = new URL(`${BCHMnemonicURL.PROTOCOL}:${entropyBase64}`);
|
||||||
|
|
||||||
|
// Add the raw values to the url encoded string. Only add the values that are defined.
|
||||||
|
if (this.raw.language !== undefined) {
|
||||||
|
url.searchParams.set(
|
||||||
|
BCHMnemonicURL.ENCODING_KEYS.language,
|
||||||
|
this.raw.language,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.raw.comment !== undefined) {
|
||||||
|
url.searchParams.set(
|
||||||
|
BCHMnemonicURL.ENCODING_KEYS.comment,
|
||||||
|
this.raw.comment,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.raw.path !== undefined) {
|
||||||
|
url.searchParams.set(BCHMnemonicURL.ENCODING_KEYS.path, this.raw.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.raw.startHeight !== undefined) {
|
||||||
|
url.searchParams.set(
|
||||||
|
BCHMnemonicURL.ENCODING_KEYS.startHeight,
|
||||||
|
this.raw.startHeight.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static ENCODING_KEYS = {
|
||||||
|
language: "l",
|
||||||
|
passphrase: "p",
|
||||||
|
comment: "c",
|
||||||
|
path: "d",
|
||||||
|
startHeight: "h",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
static SUPPORTED_LANGUAGES = [
|
||||||
|
"en",
|
||||||
|
"zh-CN",
|
||||||
|
"zh-TW",
|
||||||
|
"ja",
|
||||||
|
"es",
|
||||||
|
"pt",
|
||||||
|
"ko",
|
||||||
|
"fr",
|
||||||
|
"it",
|
||||||
|
"cs",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod schema for validating URL parameters
|
||||||
|
*/
|
||||||
|
static schema = z.object({
|
||||||
|
[BCHMnemonicURL.ENCODING_KEYS.language]: z
|
||||||
|
.enum(BCHMnemonicURL.SUPPORTED_LANGUAGES)
|
||||||
|
.optional(),
|
||||||
|
[BCHMnemonicURL.ENCODING_KEYS.passphrase]: z.string().optional(),
|
||||||
|
[BCHMnemonicURL.ENCODING_KEYS.comment]: z.string().optional(),
|
||||||
|
[BCHMnemonicURL.ENCODING_KEYS.path]: z.string().optional(),
|
||||||
|
[BCHMnemonicURL.ENCODING_KEYS.startHeight]: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ export class ExponentialBackoff {
|
|||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
onError = (_error: Error) => {},
|
onError = (_error: Error) => {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
let lastError: Error = new Error('Exponential backoff: Max retries hit');
|
let lastError: Error = new Error("Exponential backoff: Max retries hit");
|
||||||
|
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* See: https://github.com/bitauth/libauth/pull/108
|
* See: https://github.com/bitauth/libauth/pull/108
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { binToHex, hexToBin } from '@bitauth/libauth';
|
import { binToHex, hexToBin } from "@bitauth/libauth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces BigInt and Uint8Array values with their ExtJSON string representations.
|
* Replaces BigInt and Uint8Array values with their ExtJSON string representations.
|
||||||
@@ -15,7 +15,7 @@ import { binToHex, hexToBin } from '@bitauth/libauth';
|
|||||||
* @returns The replaced value as an ExtJSON string, or the original value
|
* @returns The replaced value as an ExtJSON string, or the original value
|
||||||
*/
|
*/
|
||||||
export const extendedJsonReplacer = function (value: unknown): unknown {
|
export const extendedJsonReplacer = function (value: unknown): unknown {
|
||||||
if (typeof value === 'bigint') {
|
if (typeof value === "bigint") {
|
||||||
return `<bigint: ${value.toString()}n>`;
|
return `<bigint: ${value.toString()}n>`;
|
||||||
} else if (value instanceof Uint8Array) {
|
} else if (value instanceof Uint8Array) {
|
||||||
return `<Uint8Array: ${binToHex(value)}>`;
|
return `<Uint8Array: ${binToHex(value)}>`;
|
||||||
@@ -36,7 +36,7 @@ export const extendedJsonReviver = function (value: unknown): unknown {
|
|||||||
|
|
||||||
// Only perform a check if the value is a string.
|
// Only perform a check if the value is a string.
|
||||||
// NOTE: We can skip all other values as all Extended JSON encoded fields WILL be a string.
|
// NOTE: We can skip all other values as all Extended JSON encoded fields WILL be a string.
|
||||||
if (typeof value === 'string') {
|
if (typeof value === "string") {
|
||||||
// Check if this value matches an Extended JSON encoded bigint.
|
// Check if this value matches an Extended JSON encoded bigint.
|
||||||
const bigintMatch = value.match(bigIntRegex);
|
const bigintMatch = value.match(bigIntRegex);
|
||||||
if (bigintMatch) {
|
if (bigintMatch) {
|
||||||
@@ -70,7 +70,7 @@ export const encodeExtendedJsonObject = function (value: unknown): unknown {
|
|||||||
// If this is an object type (and it is not null - which is technically an "object")...
|
// If this is an object type (and it is not null - which is technically an "object")...
|
||||||
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
|
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
|
||||||
if (
|
if (
|
||||||
typeof value === 'object' &&
|
typeof value === "object" &&
|
||||||
value !== null &&
|
value !== null &&
|
||||||
!ArrayBuffer.isView(value)
|
!ArrayBuffer.isView(value)
|
||||||
) {
|
) {
|
||||||
@@ -83,7 +83,9 @@ export const encodeExtendedJsonObject = function (value: unknown): unknown {
|
|||||||
const encodedObject: Record<string, unknown> = {};
|
const encodedObject: Record<string, unknown> = {};
|
||||||
|
|
||||||
// Iterate through each entry and encode it to extended JSON.
|
// Iterate through each entry and encode it to extended JSON.
|
||||||
for (const [key, valueToEncode] of Object.entries(value as Record<string, unknown>)) {
|
for (const [key, valueToEncode] of Object.entries(
|
||||||
|
value as Record<string, unknown>,
|
||||||
|
)) {
|
||||||
encodedObject[key] = encodeExtendedJsonObject(valueToEncode);
|
encodedObject[key] = encodeExtendedJsonObject(valueToEncode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +106,7 @@ export const decodeExtendedJsonObject = function (value: unknown): unknown {
|
|||||||
// If this is an object type (and it is not null - which is technically an "object")...
|
// If this is an object type (and it is not null - which is technically an "object")...
|
||||||
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
|
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
|
||||||
if (
|
if (
|
||||||
typeof value === 'object' &&
|
typeof value === "object" &&
|
||||||
value !== null &&
|
value !== null &&
|
||||||
!ArrayBuffer.isView(value)
|
!ArrayBuffer.isView(value)
|
||||||
) {
|
) {
|
||||||
@@ -117,7 +119,9 @@ export const decodeExtendedJsonObject = function (value: unknown): unknown {
|
|||||||
const decodedObject: Record<string, unknown> = {};
|
const decodedObject: Record<string, unknown> = {};
|
||||||
|
|
||||||
// Iterate through each entry and decode it from extended JSON.
|
// Iterate through each entry and decode it from extended JSON.
|
||||||
for (const [key, valueToEncode] of Object.entries(value as Record<string, unknown>)) {
|
for (const [key, valueToEncode] of Object.entries(
|
||||||
|
value as Record<string, unknown>,
|
||||||
|
)) {
|
||||||
decodedObject[key] = decodeExtendedJsonObject(valueToEncode);
|
decodedObject[key] = decodeExtendedJsonObject(valueToEncode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,259 +1,130 @@
|
|||||||
/**
|
import type {
|
||||||
* History utility functions.
|
HistoryItem,
|
||||||
*
|
WalletHistoryInput,
|
||||||
* Pure functions for parsing and formatting wallet history data.
|
WalletHistoryItem,
|
||||||
* These functions have no React dependencies and can be used
|
WalletHistoryOutput,
|
||||||
* in both TUI and CLI contexts.
|
} from "../services/history.js";
|
||||||
*/
|
|
||||||
|
|
||||||
import type { HistoryItem, HistoryItemType } from '../services/history.js';
|
export type HistoryColorName =
|
||||||
|
| "info"
|
||||||
|
| "warning"
|
||||||
|
| "success"
|
||||||
|
| "error"
|
||||||
|
| "muted"
|
||||||
|
| "text";
|
||||||
|
|
||||||
/**
|
export type HistoryRowType =
|
||||||
* Color names for history item types.
|
| "history_item"
|
||||||
* These are semantic color names that can be mapped to actual colors
|
| "history_input"
|
||||||
* by the consuming application (TUI or CLI).
|
| "history_output";
|
||||||
*/
|
|
||||||
export type HistoryColorName = 'info' | 'warning' | 'success' | 'error' | 'muted' | 'text';
|
|
||||||
|
|
||||||
/**
|
export interface HistoryDisplayRow {
|
||||||
* Formatted history list item data.
|
id: string;
|
||||||
*/
|
type: HistoryRowType;
|
||||||
export interface FormattedHistoryItem {
|
|
||||||
/** The display label for the history item */
|
|
||||||
label: string;
|
label: string;
|
||||||
/** Optional secondary description */
|
|
||||||
description?: string;
|
description?: string;
|
||||||
/** The formatted date string */
|
timestamp?: number;
|
||||||
dateStr?: string;
|
isNested: boolean;
|
||||||
/** The semantic color name for this item type */
|
valueSatoshis?: bigint;
|
||||||
color: HistoryColorName;
|
reserved?: boolean;
|
||||||
/** The history item type */
|
input?: WalletHistoryInput;
|
||||||
type: HistoryItemType;
|
output?: WalletHistoryOutput;
|
||||||
/** Whether the item data is valid */
|
item?: WalletHistoryItem;
|
||||||
isValid: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the semantic color name for a history item type.
|
|
||||||
*
|
|
||||||
* @param type - The history item type
|
|
||||||
* @param isSelected - Whether the item is currently selected
|
|
||||||
* @returns A semantic color name
|
|
||||||
*/
|
|
||||||
export function getHistoryItemColorName(type: HistoryItemType, isSelected: boolean = false): HistoryColorName {
|
|
||||||
if (isSelected) return 'info'; // Use focus color when selected
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'invitation_created':
|
|
||||||
return 'text';
|
|
||||||
case 'utxo_reserved':
|
|
||||||
return 'warning';
|
|
||||||
case 'utxo_received':
|
|
||||||
return 'success';
|
|
||||||
default:
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a satoshi value for display.
|
|
||||||
*
|
|
||||||
* @param satoshis - The value in satoshis
|
|
||||||
* @returns Formatted string with BCH amount
|
|
||||||
*/
|
|
||||||
export function formatSatoshisValue(satoshis: bigint | number): string {
|
|
||||||
const value = typeof satoshis === 'bigint' ? satoshis : BigInt(satoshis);
|
|
||||||
const bch = Number(value) / 100_000_000;
|
|
||||||
return `${bch.toFixed(8)} BCH (${value.toLocaleString()} sats)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a timestamp for display.
|
|
||||||
*
|
|
||||||
* @param timestamp - Unix timestamp in milliseconds
|
|
||||||
* @returns Formatted date string or undefined
|
|
||||||
*/
|
|
||||||
export function formatHistoryDate(timestamp?: number): string | undefined {
|
export function formatHistoryDate(timestamp?: number): string | undefined {
|
||||||
if (!timestamp) return undefined;
|
if (!timestamp) return undefined;
|
||||||
return new Date(timestamp).toLocaleDateString();
|
return new Date(timestamp).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function buildHistoryDisplayRows(
|
||||||
* Format a history item for display in a list.
|
|
||||||
*
|
|
||||||
* @param item - The history item to format
|
|
||||||
* @param isSelected - Whether the item is currently selected
|
|
||||||
* @returns Formatted item data for display
|
|
||||||
*/
|
|
||||||
export function formatHistoryListItem(
|
|
||||||
item: HistoryItem | null | undefined,
|
|
||||||
isSelected: boolean = false
|
|
||||||
): FormattedHistoryItem {
|
|
||||||
if (!item) {
|
|
||||||
return {
|
|
||||||
label: '',
|
|
||||||
description: undefined,
|
|
||||||
dateStr: undefined,
|
|
||||||
color: 'muted',
|
|
||||||
type: 'utxo_received',
|
|
||||||
isValid: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateStr = formatHistoryDate(item.timestamp);
|
|
||||||
const color = getHistoryItemColorName(item.type, isSelected);
|
|
||||||
|
|
||||||
switch (item.type) {
|
|
||||||
case 'invitation_created':
|
|
||||||
return {
|
|
||||||
label: `[Invitation] ${item.description}`,
|
|
||||||
description: undefined,
|
|
||||||
dateStr,
|
|
||||||
color,
|
|
||||||
type: item.type,
|
|
||||||
isValid: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'utxo_reserved': {
|
|
||||||
const satsStr = item.valueSatoshis !== undefined
|
|
||||||
? formatSatoshisValue(item.valueSatoshis)
|
|
||||||
: 'Unknown amount';
|
|
||||||
return {
|
|
||||||
label: `[Reserved] ${satsStr}`,
|
|
||||||
description: item.description,
|
|
||||||
dateStr,
|
|
||||||
color,
|
|
||||||
type: item.type,
|
|
||||||
isValid: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'utxo_received': {
|
|
||||||
const satsStr = item.valueSatoshis !== undefined
|
|
||||||
? formatSatoshisValue(item.valueSatoshis)
|
|
||||||
: 'Unknown amount';
|
|
||||||
const reservedTag = item.reserved ? ' [Reserved]' : '';
|
|
||||||
return {
|
|
||||||
label: satsStr,
|
|
||||||
description: `${item.description}${reservedTag}`,
|
|
||||||
dateStr,
|
|
||||||
color,
|
|
||||||
type: item.type,
|
|
||||||
isValid: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
label: `${item.type}: ${item.description}`,
|
|
||||||
description: undefined,
|
|
||||||
dateStr,
|
|
||||||
color: 'text',
|
|
||||||
type: item.type,
|
|
||||||
isValid: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a type label for display.
|
|
||||||
*
|
|
||||||
* @param type - The history item type
|
|
||||||
* @returns Human-readable type label
|
|
||||||
*/
|
|
||||||
export function getHistoryTypeLabel(type: HistoryItemType): string {
|
|
||||||
switch (type) {
|
|
||||||
case 'invitation_created':
|
|
||||||
return 'Invitation';
|
|
||||||
case 'utxo_reserved':
|
|
||||||
return 'Reserved';
|
|
||||||
case 'utxo_received':
|
|
||||||
return 'Received';
|
|
||||||
default:
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate scrolling window indices for a list.
|
|
||||||
*
|
|
||||||
* @param selectedIndex - Currently selected index
|
|
||||||
* @param totalItems - Total number of items
|
|
||||||
* @param maxVisible - Maximum visible items
|
|
||||||
* @returns Start and end indices for the visible window
|
|
||||||
*/
|
|
||||||
export function calculateScrollWindow(
|
|
||||||
selectedIndex: number,
|
|
||||||
totalItems: number,
|
|
||||||
maxVisible: number
|
|
||||||
): { startIndex: number; endIndex: number } {
|
|
||||||
const halfWindow = Math.floor(maxVisible / 2);
|
|
||||||
let startIndex = Math.max(0, selectedIndex - halfWindow);
|
|
||||||
const endIndex = Math.min(totalItems, startIndex + maxVisible);
|
|
||||||
|
|
||||||
// Adjust start if we're near the end
|
|
||||||
if (endIndex - startIndex < maxVisible) {
|
|
||||||
startIndex = Math.max(0, endIndex - maxVisible);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { startIndex, endIndex };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a history item is a UTXO-related event.
|
|
||||||
*
|
|
||||||
* @param item - The history item to check
|
|
||||||
* @returns True if the item is UTXO-related
|
|
||||||
*/
|
|
||||||
export function isUtxoEvent(item: HistoryItem): boolean {
|
|
||||||
return item.type === 'utxo_received' || item.type === 'utxo_reserved';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter history items by type.
|
|
||||||
*
|
|
||||||
* @param items - Array of history items
|
|
||||||
* @param types - Types to include
|
|
||||||
* @returns Filtered array
|
|
||||||
*/
|
|
||||||
export function filterHistoryByType(
|
|
||||||
items: HistoryItem[],
|
items: HistoryItem[],
|
||||||
types: HistoryItemType[]
|
): HistoryDisplayRow[] {
|
||||||
): HistoryItem[] {
|
const rows: HistoryDisplayRow[] = [];
|
||||||
return items.filter(item => types.includes(item.type));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get summary statistics for history items.
|
|
||||||
*
|
|
||||||
* @param items - Array of history items
|
|
||||||
* @returns Summary statistics
|
|
||||||
*/
|
|
||||||
export function getHistorySummary(items: HistoryItem[]): {
|
|
||||||
totalReceived: bigint;
|
|
||||||
totalReserved: bigint;
|
|
||||||
invitationCount: number;
|
|
||||||
utxoCount: number;
|
|
||||||
} {
|
|
||||||
let totalReceived = 0n;
|
|
||||||
let totalReserved = 0n;
|
|
||||||
let invitationCount = 0;
|
|
||||||
let utxoCount = 0;
|
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
switch (item.type) {
|
const roles = item.roles.length > 0 ? item.roles.join(", ") : "unknown";
|
||||||
case 'invitation_created':
|
if (item.source === "utxo") {
|
||||||
invitationCount++;
|
for (const output of item.outputs) {
|
||||||
break;
|
rows.push({
|
||||||
case 'utxo_reserved':
|
id: `${item.id}-output-${output.id}`,
|
||||||
totalReserved += item.valueSatoshis ?? 0n;
|
type: "history_output",
|
||||||
break;
|
label: output.outpoint
|
||||||
case 'utxo_received':
|
? `${output.outpoint.txid}:${output.outpoint.index}`
|
||||||
totalReceived += item.valueSatoshis ?? 0n;
|
: (output.outputIdentifier ?? "Output"),
|
||||||
utxoCount++;
|
description: `${item.template} | ${roles} | ${output.description}`,
|
||||||
break;
|
timestamp: item.createdAtTimestamp,
|
||||||
|
isNested: false,
|
||||||
|
valueSatoshis: output.valueSatoshis,
|
||||||
|
reserved: output.reserved,
|
||||||
|
output,
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
id: item.id,
|
||||||
|
type: "history_item",
|
||||||
|
label: `${item.template} | ${roles} | ${item.description}`,
|
||||||
|
description: item.action,
|
||||||
|
timestamp: item.createdAtTimestamp,
|
||||||
|
isNested: false,
|
||||||
|
valueSatoshis: item.valueSatoshis,
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (item.source !== "invitation") continue;
|
||||||
|
|
||||||
|
for (const input of item.inputs) {
|
||||||
|
rows.push({
|
||||||
|
id: `${item.id}-input-${input.id}`,
|
||||||
|
type: "history_input",
|
||||||
|
label: `${input.outpoint.txid}:${input.outpoint.index}`,
|
||||||
|
description: input.description,
|
||||||
|
isNested: true,
|
||||||
|
valueSatoshis: input.valueSatoshis,
|
||||||
|
input,
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const output of item.outputs) {
|
||||||
|
rows.push({
|
||||||
|
id: `${item.id}-output-${output.id}`,
|
||||||
|
type: "history_output",
|
||||||
|
label: output.outpoint
|
||||||
|
? `${output.outpoint.txid}:${output.outpoint.index}`
|
||||||
|
: (output.outputIdentifier ?? "Output"),
|
||||||
|
description: output.description,
|
||||||
|
isNested: true,
|
||||||
|
valueSatoshis: output.valueSatoshis,
|
||||||
|
reserved: output.reserved,
|
||||||
|
output,
|
||||||
|
item,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { totalReceived, totalReserved, invitationCount, utxoCount };
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHistoryItemColorName(
|
||||||
|
row: HistoryDisplayRow,
|
||||||
|
isSelected: boolean = false,
|
||||||
|
): HistoryColorName {
|
||||||
|
if (isSelected) return "info";
|
||||||
|
switch (row.type) {
|
||||||
|
case "history_input":
|
||||||
|
return "error";
|
||||||
|
case "history_output":
|
||||||
|
return row.reserved ? "warning" : "success";
|
||||||
|
case "history_item":
|
||||||
|
if ((row.valueSatoshis ?? 0n) < 0n) return "error";
|
||||||
|
if ((row.valueSatoshis ?? 0n) > 0n) return "success";
|
||||||
|
return "text";
|
||||||
|
default:
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
219
src/utils/invitation-flow.ts
Normal file
219
src/utils/invitation-flow.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import type { XOTemplate, XOTemplateTransactionOutput } from "@xo-cash/types";
|
||||||
|
import type { Invitation } from "../services/invitation.js";
|
||||||
|
import { cashAddressToLockingBytecode, binToHex } from "@bitauth/libauth";
|
||||||
|
|
||||||
|
export interface SelectableUtxoLike {
|
||||||
|
outpointTransactionHash: string;
|
||||||
|
outpointIndex: number;
|
||||||
|
valueSatoshis: bigint;
|
||||||
|
lockingBytecode?: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Move to engine
|
||||||
|
export const hasMissingRequirements = (missingRequirements: {
|
||||||
|
variables?: string[];
|
||||||
|
inputs?: string[];
|
||||||
|
outputs?: string[];
|
||||||
|
roles?: Record<string, unknown>;
|
||||||
|
}): boolean => {
|
||||||
|
return (
|
||||||
|
(missingRequirements.variables?.length ?? 0) > 0 ||
|
||||||
|
(missingRequirements.inputs?.length ?? 0) > 0 ||
|
||||||
|
(missingRequirements.outputs?.length ?? 0) > 0 ||
|
||||||
|
(missingRequirements.roles !== undefined &&
|
||||||
|
Object.keys(missingRequirements.roles).length > 0)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isInvitationRequirementsComplete = async (
|
||||||
|
invitation: Invitation,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const missingRequirements = await invitation.getMissingRequirements();
|
||||||
|
return !hasMissingRequirements(missingRequirements.templateRequirements);
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Move to engine in templates.ts
|
||||||
|
export const resolveActionRoles = (
|
||||||
|
template: XOTemplate | undefined,
|
||||||
|
actionIdentifier: string | undefined,
|
||||||
|
rolesFromNavigation?: string[],
|
||||||
|
): string[] => {
|
||||||
|
if (rolesFromNavigation && rolesFromNavigation.length > 0) {
|
||||||
|
return [...new Set(rolesFromNavigation)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!template || !actionIdentifier) return [];
|
||||||
|
const starts = template.start ?? [];
|
||||||
|
const roleIds = starts
|
||||||
|
.filter((entry) => entry.action === actionIdentifier)
|
||||||
|
.map((entry) => entry.role)
|
||||||
|
.filter((roleId) => roleId !== undefined);
|
||||||
|
|
||||||
|
return [...new Set(roleIds)];
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Move to engine
|
||||||
|
export const roleRequiresInputs = (
|
||||||
|
template: XOTemplate | undefined,
|
||||||
|
actionIdentifier: string | undefined,
|
||||||
|
roleIdentifier: string | undefined,
|
||||||
|
): boolean => {
|
||||||
|
if (!template || !actionIdentifier || !roleIdentifier) return false;
|
||||||
|
const action = template.actions?.[actionIdentifier];
|
||||||
|
if (!action) return false;
|
||||||
|
|
||||||
|
const actionRole = action.roles?.[roleIdentifier];
|
||||||
|
const actionRequirements = action.requirements;
|
||||||
|
const actionRoleRequirements =
|
||||||
|
actionRole &&
|
||||||
|
actionRequirements &&
|
||||||
|
actionRequirements.participants?.find(
|
||||||
|
(participant) => participant.role === roleIdentifier,
|
||||||
|
);
|
||||||
|
const roleSlotsMin =
|
||||||
|
actionRoleRequirements &&
|
||||||
|
actionRoleRequirements.slots &&
|
||||||
|
actionRoleRequirements.slots.min > 0
|
||||||
|
? actionRoleRequirements.slots.min
|
||||||
|
: 0;
|
||||||
|
if (roleSlotsMin > 0) return true;
|
||||||
|
|
||||||
|
const transactionIdentifier = action.transaction;
|
||||||
|
const transaction = transactionIdentifier
|
||||||
|
? template.transactions?.[transactionIdentifier]
|
||||||
|
: undefined;
|
||||||
|
const roleInputs = transaction?.roles?.[roleIdentifier]?.inputs;
|
||||||
|
|
||||||
|
return (roleInputs?.length ?? 0) > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTransactionOutputIdentifier = (
|
||||||
|
output: XOTemplateTransactionOutput,
|
||||||
|
): string | undefined => {
|
||||||
|
if (typeof output === "string") return output;
|
||||||
|
if (
|
||||||
|
output &&
|
||||||
|
typeof output === "object" &&
|
||||||
|
"output" in output &&
|
||||||
|
typeof output.output === "string"
|
||||||
|
) {
|
||||||
|
return output.output;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeLockingBytecodeHex = (value: string): string =>
|
||||||
|
value.trim().replace(/^0x/i, "");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a string looks like a CashAddress and, if so, converts it
|
||||||
|
* to locking bytecode hex. Returns undefined when the value is not a
|
||||||
|
* recognizable CashAddress (callers should fall through to treat it as raw hex).
|
||||||
|
*/
|
||||||
|
export const tryCashAddressToLockingBytecodeHex = (
|
||||||
|
value: string,
|
||||||
|
): string | undefined => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
// Quick prefix check so we don't run the decoder on obvious hex strings.
|
||||||
|
const looksLikeCashAddress =
|
||||||
|
trimmed.startsWith("bitcoincash:") ||
|
||||||
|
trimmed.startsWith("bchtest:") ||
|
||||||
|
trimmed.startsWith("bchreg:") ||
|
||||||
|
// Handle prefix-less addresses (e.g. "qp..." or "pp...")
|
||||||
|
/^[qpQP][a-zA-Z0-9]{41,}$/.test(trimmed);
|
||||||
|
|
||||||
|
if (!looksLikeCashAddress) return undefined;
|
||||||
|
|
||||||
|
const result = cashAddressToLockingBytecode(trimmed);
|
||||||
|
|
||||||
|
// cashAddressToLockingBytecode returns a string on failure.
|
||||||
|
if (typeof result === "string") return undefined;
|
||||||
|
|
||||||
|
return binToHex(result.bytecode);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace with libauth compiler in the engine
|
||||||
|
export const resolveProvidedLockingBytecodeHex = (
|
||||||
|
template: XOTemplate,
|
||||||
|
outputIdentifier: string,
|
||||||
|
variableValues: Record<string, string>,
|
||||||
|
): string | undefined => {
|
||||||
|
const outputDefinition = template.outputs?.[outputIdentifier];
|
||||||
|
if (!outputDefinition || typeof outputDefinition.lockingScript !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockingScriptDefinition =
|
||||||
|
template.lockingScripts?.[outputDefinition.lockingScript];
|
||||||
|
const scriptIdentifier = lockingScriptDefinition?.lockingBytecode;
|
||||||
|
if (!scriptIdentifier) return undefined;
|
||||||
|
|
||||||
|
const scriptExpression = (
|
||||||
|
template.scripts as Record<string, unknown> | undefined
|
||||||
|
)?.[scriptIdentifier];
|
||||||
|
if (typeof scriptExpression !== "string") return undefined;
|
||||||
|
|
||||||
|
const directVariableMatch = scriptExpression.match(
|
||||||
|
/^<\s*([A-Za-z0-9_]+)\s*>$/,
|
||||||
|
);
|
||||||
|
if (!directVariableMatch) return undefined;
|
||||||
|
|
||||||
|
const variableIdentifier = directVariableMatch[1];
|
||||||
|
if (!variableIdentifier) return undefined;
|
||||||
|
|
||||||
|
const providedValue = variableValues[variableIdentifier];
|
||||||
|
if (!providedValue) return undefined;
|
||||||
|
|
||||||
|
// If the user pasted a CashAddress, convert it to locking bytecode hex.
|
||||||
|
const fromAddress = tryCashAddressToLockingBytecodeHex(providedValue);
|
||||||
|
if (fromAddress) return fromAddress;
|
||||||
|
|
||||||
|
return normalizeLockingBytecodeHex(providedValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapUnspentOutputsToSelectable = (
|
||||||
|
unspentOutputs: any[],
|
||||||
|
): SelectableUtxoLike[] => {
|
||||||
|
return unspentOutputs.map((utxo: any) => ({
|
||||||
|
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||||
|
outpointIndex: utxo.outpointIndex,
|
||||||
|
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||||
|
lockingBytecode: utxo.lockingBytecode
|
||||||
|
? typeof utxo.lockingBytecode === "string"
|
||||||
|
? utxo.lockingBytecode
|
||||||
|
: Buffer.from(utxo.lockingBytecode).toString("hex")
|
||||||
|
: undefined,
|
||||||
|
selected: false,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const autoSelectGreedyUtxos = (
|
||||||
|
utxos: SelectableUtxoLike[],
|
||||||
|
requiredWithFee: bigint,
|
||||||
|
): SelectableUtxoLike[] => {
|
||||||
|
let accumulated = 0n;
|
||||||
|
const seenLockingBytecodes = new Set<string>();
|
||||||
|
|
||||||
|
for (const utxo of utxos) {
|
||||||
|
if (
|
||||||
|
utxo.lockingBytecode &&
|
||||||
|
seenLockingBytecodes.has(utxo.lockingBytecode)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (utxo.lockingBytecode) {
|
||||||
|
seenLockingBytecodes.add(utxo.lockingBytecode);
|
||||||
|
}
|
||||||
|
|
||||||
|
utxo.selected = true;
|
||||||
|
accumulated += utxo.valueSatoshis;
|
||||||
|
|
||||||
|
if (accumulated >= requiredWithFee) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return utxos;
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user