Compare commits
13 Commits
ebe1d8acda
...
simplified
| Author | SHA1 | Date | |
|---|---|---|---|
|
27022a9390
|
|||
|
33a465eca0
|
|||
|
2442b17a4c
|
|||
|
a79213436c
|
|||
|
d45f1ddeb3
|
|||
| bcc3277cb9 | |||
|
b2ccff5b19
|
|||
| 12b7bde74f | |||
| 42d23fa35e | |||
| b6ee25d1dd | |||
| b4d82b8b1f | |||
| a0d9775015 | |||
| 6c01ac1c1b |
@@ -1,904 +0,0 @@
|
|||||||
{
|
|
||||||
"$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": ""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
4298
package-lock.json
generated
4298
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,8 @@
|
|||||||
},
|
},
|
||||||
"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 && npm run build:copy-scripts",
|
"build": "tsc --nocheck --noEmitOnError false || true && npm run build:copy-scripts",
|
||||||
|
"build:safe": "tsc && npm run build:copy-scripts",
|
||||||
"build:copy-scripts": "cp -r src/cli/autocomplete/scripts dist/cli/autocomplete/",
|
"build:copy-scripts": "cp -r src/cli/autocomplete/scripts dist/cli/autocomplete/",
|
||||||
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
||||||
"test": "vitest --run --passWithNoTests",
|
"test": "vitest --run --passWithNoTests",
|
||||||
@@ -38,10 +39,9 @@
|
|||||||
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
|
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
|
||||||
"@xo-cash/crypto": "^0.0.1",
|
"@xo-cash/crypto": "^0.0.1",
|
||||||
"@xo-cash/engine": "file:../engine",
|
"@xo-cash/engine": "file:../engine",
|
||||||
"@xo-cash/state": "file:../state",
|
"@xo-cash/state": "0.0.1",
|
||||||
"@xo-cash/templates": "^0.0.1",
|
"@xo-cash/templates": "^0.0.3",
|
||||||
"@xo-cash/types": "^0.0.1",
|
"@xo-cash/types": "^0.0.1",
|
||||||
"better-sqlite3": "^12.6.2",
|
|
||||||
"clipboardy": "^5.1.0",
|
"clipboardy": "^5.1.0",
|
||||||
"ink": "^6.6.0",
|
"ink": "^6.6.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
@@ -50,7 +50,6 @@
|
|||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
|
|||||||
23
readme.md
23
readme.md
@@ -9,7 +9,7 @@ mkdir xo-terminal && cd xo-terminal
|
|||||||
|
|
||||||
# ----- Start Engine Setup -----
|
# ----- Start Engine Setup -----
|
||||||
# Clone the Engine Repo (Note, this uses harvey's fork of the engine repo to access the cli-test branch)
|
# Clone the Engine Repo (Note, this uses harvey's fork of the engine repo to access the cli-test branch)
|
||||||
git clone git@gitlab.com:Harvmaster/engine.git
|
git clone https://gitlab.com/Harvmaster/engine.git
|
||||||
|
|
||||||
# Move into teh engine directory
|
# Move into teh engine directory
|
||||||
cd engine
|
cd engine
|
||||||
@@ -27,28 +27,9 @@ npm run build
|
|||||||
# Move back to the top level directory
|
# Move back to the top level directory
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
# ----- Start State Setup -----
|
|
||||||
# Clone the State Repo
|
|
||||||
git clone git@gitlab.com:Harvmaster/state.git
|
|
||||||
|
|
||||||
# Move into the state directory
|
|
||||||
cd state
|
|
||||||
|
|
||||||
git checkout in-memory-adapter
|
|
||||||
|
|
||||||
# Install the dependencies
|
|
||||||
npm ci
|
|
||||||
|
|
||||||
# Build the state
|
|
||||||
npm run build
|
|
||||||
# ----- End State Setup -----
|
|
||||||
|
|
||||||
# Move back to the top level directory
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
# ----- Start CLI Setup -----
|
# ----- Start CLI Setup -----
|
||||||
# Clone the CLI Repo
|
# Clone the CLI Repo
|
||||||
git clone git@git.harvmaster.com:Harvmaster/xo-cli.git
|
git clone https://git.harvmaster.com/Harvmaster/xo-cli.git
|
||||||
|
|
||||||
# Move into the cli directory
|
# Move into the cli directory
|
||||||
cd xo-cli
|
cd xo-cli
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Wallet state lives under **`~/.config/xo-cli/`** (XDG-style), so you can run com
|
|||||||
| ----------------------------- | ----------------------------------------------------------------------- |
|
| ----------------------------- | ----------------------------------------------------------------------- |
|
||||||
| `~/.config/xo-cli/mnemonics/` | Mnemonic files (`mnemonic-*`) |
|
| `~/.config/xo-cli/mnemonics/` | Mnemonic files (`mnemonic-*`) |
|
||||||
| `~/.config/xo-cli/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) |
|
| `~/.config/xo-cli/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) |
|
||||||
| `~/.config/xo-cli/.wallet` | Last-used mnemonic reference (so `-m` can be omitted) |
|
| `~/.config/xo-cli/.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`).
|
**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`).
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ npx tsx src/index.ts # TUI
|
|||||||
xo-cli mnemonic create
|
xo-cli mnemonic create
|
||||||
|
|
||||||
# Import an existing mnemonic seed phrase
|
# Import an existing mnemonic seed phrase
|
||||||
xo-cli mnemonic import page pencil stock planet limb cluster assault speak off joke private pioneer
|
xo-cli mnemonic import oven crop same above under tower promote decrease vocal pretty require slow
|
||||||
|
|
||||||
# List mnemonic basenames (use with -m)
|
# List mnemonic basenames (use with -m)
|
||||||
xo-cli mnemonic list
|
xo-cli mnemonic list
|
||||||
@@ -67,7 +67,10 @@ xo-cli mnemonic list
|
|||||||
|
|
||||||
### Wallet Persistence
|
### Wallet Persistence
|
||||||
|
|
||||||
The first time you pass `-m <name>`, that reference is saved to `~/.config/xo-cli/.wallet`. Later runs can omit `-m`.
|
The first time you pass `-m <name>`, that reference is saved as
|
||||||
|
`default-mnemonic` in `~/.config/xo-cli/.wallet`. Later runs can omit `-m`.
|
||||||
|
|
||||||
|
`currency` controls the fiat unit used when showing BCH/sats conversions in the TUI.
|
||||||
|
|
||||||
Mnemonic resolution order:
|
Mnemonic resolution order:
|
||||||
|
|
||||||
@@ -85,6 +88,7 @@ xo-cli resource list
|
|||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
| ------------------------------ | --------------------------------------------------- |
|
| ------------------------------ | --------------------------------------------------- |
|
||||||
| `-m`, `--mnemonic-file <file>` | Mnemonic file (basename, cwd-relative, or absolute) |
|
| `-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`) |
|
| `-o`, `--output <filename>` | Output filename (used by `mnemonic create`/`import`) |
|
||||||
| `-v`, `--verbose` | Verbose output |
|
| `-v`, `--verbose` | Verbose output |
|
||||||
| `-h`, `--help` | Help |
|
| `-h`, `--help` | Help |
|
||||||
@@ -126,6 +130,16 @@ xo-cli resource unreserve <txhash:vout>
|
|||||||
xo-cli resource unreserve-all
|
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
|
### `receive` — Generate a Receiving Address
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ 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.\
|
* Converts the CLI args to a key-value object and return the options object along with the other arguments still in the array.\
|
||||||
* eg: `xo-cli mnemonic create page pencil stock planet limb cluster assault speak off joke private pioneer -v -o mnemonic.txt` will return:
|
* 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", "page", "pencil", "stock", "planet", "limb", "cluster", "assault", "speak", "off", "joke", "private", "pioneer"],
|
* args: ["mnemonic", "create", "oven", "crop", "same", "above", "under", "tower", "promote", "decrease", "vocal", "pretty", "require", "slow"],
|
||||||
* options: {
|
* options: {
|
||||||
* output: "mnemonic.txt",
|
* output: "mnemonic.txt",
|
||||||
* verbose: "true",
|
* verbose: "true",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
* 1 - Error (no output, fails silently for shell integration)
|
* 1 - Error (no output, fails silently for shell integration)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
import { existsSync, readdirSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
} from "../../utils/paths.js";
|
} from "../../utils/paths.js";
|
||||||
import { loadMnemonic } from "../mnemonic.js";
|
import { loadMnemonic } from "../mnemonic.js";
|
||||||
import { Storage } from "../../services/storage.js";
|
import { Storage } from "../../services/storage.js";
|
||||||
|
import { SettingsService } from "../../services/settings.js";
|
||||||
import { COMMAND_TREE } from "./completions.js";
|
import { COMMAND_TREE } from "./completions.js";
|
||||||
|
|
||||||
// Lazy-loaded modules (only loaded when needed for dynamic completions)
|
// Lazy-loaded modules (only loaded when needed for dynamic completions)
|
||||||
@@ -103,12 +104,8 @@ function listSubcommands(command: string, prefix?: string): void {
|
|||||||
*/
|
*/
|
||||||
function getCurrentMnemonic(): string | null {
|
function getCurrentMnemonic(): string | null {
|
||||||
try {
|
try {
|
||||||
const walletConfigPath = getWalletConfigPath();
|
const settings = new SettingsService(getWalletConfigPath());
|
||||||
if (!existsSync(walletConfigPath)) {
|
const mnemonicFile = settings.getDefaultMnemonic();
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mnemonicFile = readFileSync(walletConfigPath, "utf8").trim();
|
|
||||||
if (!mnemonicFile) {
|
if (!mnemonicFile) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { homedir } from "node:os";
|
|||||||
* - template.ts: import, list, inspect, set-default
|
* - template.ts: import, list, inspect, set-default
|
||||||
* - invitation.ts: create, append, sign, broadcast, requirements, import, inspect, list
|
* - invitation.ts: create, append, sign, broadcast, requirements, import, inspect, list
|
||||||
* - resource.ts: list, unreserve, unreserve-all
|
* - resource.ts: list, unreserve, unreserve-all
|
||||||
|
* - settings.ts: show, get, set
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Subcommands for the mnemonic command */
|
/** Subcommands for the mnemonic command */
|
||||||
@@ -56,6 +57,8 @@ const INVITATION_SUBS = [
|
|||||||
];
|
];
|
||||||
/** Subcommands for the resource command */
|
/** Subcommands for the resource command */
|
||||||
const RESOURCE_SUBS = ["list", "unreserve", "unreserve-all"];
|
const RESOURCE_SUBS = ["list", "unreserve", "unreserve-all"];
|
||||||
|
/** Subcommands for the settings command */
|
||||||
|
const SETTINGS_SUBS = ["show", "get", "set"];
|
||||||
/** Subcommands for the completions command */
|
/** Subcommands for the completions command */
|
||||||
const COMPLETIONS_SUBS = ["bash", "zsh", "fish"];
|
const COMPLETIONS_SUBS = ["bash", "zsh", "fish"];
|
||||||
|
|
||||||
@@ -65,6 +68,7 @@ export const COMMAND_TREE = {
|
|||||||
invitation: INVITATION_SUBS,
|
invitation: INVITATION_SUBS,
|
||||||
receive: [],
|
receive: [],
|
||||||
resource: RESOURCE_SUBS,
|
resource: RESOURCE_SUBS,
|
||||||
|
settings: SETTINGS_SUBS,
|
||||||
help: [],
|
help: [],
|
||||||
completions: COMPLETIONS_SUBS,
|
completions: COMPLETIONS_SUBS,
|
||||||
} as const;
|
} as const;
|
||||||
@@ -77,6 +81,7 @@ const GLOBAL_OPTIONS = [
|
|||||||
"--verbose",
|
"--verbose",
|
||||||
"-m",
|
"-m",
|
||||||
"--mnemonic-file",
|
"--mnemonic-file",
|
||||||
|
"--currency",
|
||||||
"-o",
|
"-o",
|
||||||
"--output",
|
"--output",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -201,6 +201,17 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
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)
|
||||||
# receive <template> [output]
|
# receive <template> [output]
|
||||||
# Template is the first positional argument after `receive`.
|
# Template is the first positional argument after `receive`.
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ complete -c {{BIN_NAME}} -s v -d "Verbose output"
|
|||||||
complete -c {{BIN_NAME}} -l verbose -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}} -s o -d "Output file"
|
||||||
complete -c {{BIN_NAME}} -l output -d "Output file"
|
complete -c {{BIN_NAME}} -l output -d "Output file"
|
||||||
|
complete -c {{BIN_NAME}} -l currency -d "Set fiat display currency"
|
||||||
|
|
||||||
# Dynamic completion for `-m/--mnemonic-file`.
|
# Dynamic completion for `-m/--mnemonic-file`.
|
||||||
complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_dynamic mnemonics)'
|
complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_dynamic mnemonics)'
|
||||||
|
|||||||
@@ -180,6 +180,17 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
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)
|
||||||
# receive <template> [output]
|
# receive <template> [output]
|
||||||
local pos=$((CURRENT - cmd_idx))
|
local pos=$((CURRENT - cmd_idx))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { handleMnemonicCommand, printMnemonicHelp } from "./mnemonic.js";
|
export { handleMnemonicCommand, printMnemonicHelp } from "./mnemonic.js";
|
||||||
|
export { handleSettingsCommand, printSettingsHelp } from "./settings.js";
|
||||||
export { handleTemplateCommand, printTemplateHelp } from "./template.js";
|
export { handleTemplateCommand, printTemplateHelp } from "./template.js";
|
||||||
export { handleInvitationCommand, printInvitationHelp } from "./invitation.js";
|
export { handleInvitationCommand, printInvitationHelp } from "./invitation.js";
|
||||||
export { handleReceiveCommand, printReceiveHelp } from "./receive.js";
|
export { handleReceiveCommand, printReceiveHelp } from "./receive.js";
|
||||||
|
|||||||
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -35,10 +35,10 @@
|
|||||||
* -m --mnemonic-file <mnemonic-file>
|
* -m --mnemonic-file <mnemonic-file>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
||||||
import { AppService } from "../services/app.js";
|
import { AppService } from "../services/app.js";
|
||||||
|
import { SettingsService } from "../services/settings.js";
|
||||||
import { convertArgsToObject } from "./arguments.js";
|
import { convertArgsToObject } from "./arguments.js";
|
||||||
import { bold, dim, formatObject } from "./utils.js";
|
import { bold, dim, formatObject } from "./utils.js";
|
||||||
import { listGlobalMnemonicFiles, loadMnemonic } from "./mnemonic.js";
|
import { listGlobalMnemonicFiles, loadMnemonic } from "./mnemonic.js";
|
||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
type CommandPaths,
|
type CommandPaths,
|
||||||
CommandError,
|
CommandError,
|
||||||
handleMnemonicCommand,
|
handleMnemonicCommand,
|
||||||
|
handleSettingsCommand,
|
||||||
handleTemplateCommand,
|
handleTemplateCommand,
|
||||||
handleInvitationCommand,
|
handleInvitationCommand,
|
||||||
handleReceiveCommand,
|
handleReceiveCommand,
|
||||||
@@ -115,6 +116,7 @@ async function main(): Promise<void> {
|
|||||||
walletConfigPath: getWalletConfigPath(),
|
walletConfigPath: getWalletConfigPath(),
|
||||||
workingDir: process.cwd(),
|
workingDir: process.cwd(),
|
||||||
};
|
};
|
||||||
|
const settings = new SettingsService(paths.walletConfigPath);
|
||||||
|
|
||||||
// Early handling for completions command
|
// Early handling for completions command
|
||||||
if (command === "completions") {
|
if (command === "completions") {
|
||||||
@@ -134,10 +136,26 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve mnemonic file: explicit flag > persisted config > 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 mnemonicFile = options["mnemonicFile"];
|
||||||
if (!mnemonicFile && existsSync(paths.walletConfigPath)) {
|
let didUsePersistedMnemonic = false;
|
||||||
mnemonicFile = readFileSync(paths.walletConfigPath, "utf8").trim();
|
if (!mnemonicFile) {
|
||||||
|
mnemonicFile = settings.getDefaultMnemonic();
|
||||||
|
didUsePersistedMnemonic = Boolean(mnemonicFile);
|
||||||
|
}
|
||||||
|
if (didUsePersistedMnemonic && mnemonicFile) {
|
||||||
io.verbose(`Using persisted wallet: ${mnemonicFile}`);
|
io.verbose(`Using persisted wallet: ${mnemonicFile}`);
|
||||||
}
|
}
|
||||||
if (!mnemonicFile) {
|
if (!mnemonicFile) {
|
||||||
@@ -152,7 +170,11 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist the choice so subsequent commands can omit -m.
|
// Persist the choice so subsequent commands can omit -m.
|
||||||
writeFileSync(paths.walletConfigPath, mnemonicFile);
|
settings.setDefaultMnemonic(mnemonicFile);
|
||||||
|
if (options["currency"]) {
|
||||||
|
settings.setCurrency(options["currency"]);
|
||||||
|
io.verbose(`Using configured currency: ${settings.getCurrency()}`);
|
||||||
|
}
|
||||||
|
|
||||||
const mnemonic = loadMnemonic(paths.mnemonicsDir, mnemonicFile);
|
const mnemonic = loadMnemonic(paths.mnemonicsDir, mnemonicFile);
|
||||||
io.verbose(`Loaded mnemonic from file: ${mnemonicFile} ${mnemonic}`);
|
io.verbose(`Loaded mnemonic from file: ${mnemonicFile} ${mnemonic}`);
|
||||||
@@ -168,7 +190,7 @@ async function main(): Promise<void> {
|
|||||||
invitationStoragePath:
|
invitationStoragePath:
|
||||||
options["invitationStoragePath"] ??
|
options["invitationStoragePath"] ??
|
||||||
join(paths.dataDir, "xo-invitations.db"),
|
join(paths.dataDir, "xo-invitations.db"),
|
||||||
});
|
}, settings);
|
||||||
io.verbose("App instance created");
|
io.verbose("App instance created");
|
||||||
|
|
||||||
// Start the app
|
// Start the app
|
||||||
@@ -255,12 +277,14 @@ Commands:
|
|||||||
invitation ${dim("Manage invitations")}
|
invitation ${dim("Manage invitations")}
|
||||||
receive ${dim("Generate a single-use receiving address")}
|
receive ${dim("Generate a single-use receiving address")}
|
||||||
resource ${dim("Manage resources")}
|
resource ${dim("Manage resources")}
|
||||||
|
settings ${dim("Manage persisted wallet settings")}
|
||||||
completions ${dim("Generate shell completion scripts (bash, zsh, fish)")}
|
completions ${dim("Generate shell completion scripts (bash, zsh, fish)")}
|
||||||
help ${dim("Show this help message")}
|
help ${dim("Show this help message")}
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-h, --help ${dim("Show this help message")}
|
-h, --help ${dim("Show this help message")}
|
||||||
-m, --mnemonic-file <mnemonic-file> ${dim("Use a specific mnemonic file")}
|
-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")}`,
|
-v, --verbose ${dim("Show verbose output")}`,
|
||||||
);
|
);
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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 { type BlockchainService, ElectrumService } from "./electrum.js";
|
||||||
import { RatesService } from "./rates.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";
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
public history: HistoryService;
|
public history: HistoryService;
|
||||||
public electrum: BlockchainService;
|
public electrum: BlockchainService;
|
||||||
public rates: RatesService;
|
public rates: RatesService;
|
||||||
|
public settings: SettingsService;
|
||||||
|
|
||||||
public invitations: Invitation[] = [];
|
public invitations: Invitation[] = [];
|
||||||
private invitationEventCleanup = new Map<
|
private invitationEventCleanup = new Map<
|
||||||
@@ -59,7 +61,11 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
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");
|
||||||
@@ -77,20 +83,33 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
// Import the default P2PKH template
|
// Import the default P2PKH template
|
||||||
const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate);
|
const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate);
|
||||||
|
|
||||||
engine
|
// engine
|
||||||
.subscribeToLockingBytecodesForTemplate(templateIdentifier)
|
// .subscribeToLockingBytecodesForTemplate(templateIdentifier)
|
||||||
.catch((err) =>
|
// .catch((err) =>
|
||||||
console.error(
|
// console.error(
|
||||||
`Error subscribing to locking bytecodes for template ${templateIdentifier}: ${err}`,
|
// `Error subscribing to locking bytecodes for template ${templateIdentifier}: ${err}`,
|
||||||
),
|
// ),
|
||||||
);
|
// );
|
||||||
engine
|
// engine
|
||||||
.updateUnspentOutputsForTemplate(templateIdentifier)
|
// .updateUnspentOutputsForTemplate(templateIdentifier)
|
||||||
.catch((err) =>
|
// .catch((err) =>
|
||||||
console.error(
|
// console.error(
|
||||||
`Error updating unspent outputs for template ${templateIdentifier}: ${err}`,
|
// `Error updating unspent outputs for template ${templateIdentifier}: ${err}`,
|
||||||
),
|
// ),
|
||||||
);
|
// );
|
||||||
|
|
||||||
|
// Update all the unspents for every template, and subscribe to the locking bytecodes for changes
|
||||||
|
// TODO: Remove the above lines that do the same thing. Minimising changes for BLISS.
|
||||||
|
const updateTemplates = async () => {
|
||||||
|
const templates = await engine.listImportedTemplates();
|
||||||
|
|
||||||
|
templates.forEach(async (template) => {
|
||||||
|
// engine.updateUnspentOutputsForTemplate(generateTemplateIdentifier(template));
|
||||||
|
engine.subscribeToLockingBytecodesForTemplate(generateTemplateIdentifier(template));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateTemplates();
|
||||||
|
|
||||||
// Set default locking parameters for P2PKH
|
// Set default locking parameters for P2PKH
|
||||||
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
|
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
|
||||||
@@ -110,9 +129,9 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
host: config.electrumHost,
|
host: config.electrumHost,
|
||||||
applicationIdentifier: config.electrumApplicationIdentifier,
|
applicationIdentifier: config.electrumApplicationIdentifier,
|
||||||
});
|
});
|
||||||
const rates = await RatesService.create();
|
const rates = await RatesService.create(settings);
|
||||||
|
|
||||||
return new AppService(engine, walletStorage, config, electrum, rates);
|
return new AppService(engine, walletStorage, config, electrum, rates, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -121,6 +140,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
electrum: BlockchainService,
|
electrum: BlockchainService,
|
||||||
rates: RatesService,
|
rates: RatesService,
|
||||||
|
settings: SettingsService,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
@@ -129,6 +149,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
this.electrum = electrum;
|
this.electrum = electrum;
|
||||||
this.rates = rates;
|
this.rates = rates;
|
||||||
|
this.settings = settings;
|
||||||
this.history = new HistoryService(engine, this.invitations);
|
this.history = new HistoryService(engine, this.invitations);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,12 @@ export class HistoryService {
|
|||||||
private invitations: Invitation[],
|
private invitations: Invitation[],
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I Might swap this over to invitation based history before the event to make it a bit more evident... Really not happy with the UTXO for demo purposes
|
||||||
|
* But for the actual usage, UTXO is easier to follow - just not good for demo
|
||||||
|
* Long term, this is intended to be in the Engine, so we will just be a consumer of history state.
|
||||||
|
*/
|
||||||
async getHistory(): Promise<WalletHistoryItem[]> {
|
async getHistory(): Promise<WalletHistoryItem[]> {
|
||||||
const allUtxos = await this.engine.listUnspentOutputsData();
|
const allUtxos = await this.engine.listUnspentOutputsData();
|
||||||
const metadataIndex = await this.buildWalletMetadataIndex(allUtxos);
|
const metadataIndex = await this.buildWalletMetadataIndex(allUtxos);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
Engine,
|
Engine,
|
||||||
GetSpendableResourcesParameters,
|
GetSpendableResourcesParameters,
|
||||||
} from "@xo-cash/engine";
|
} from "@xo-cash/engine";
|
||||||
import { hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
|
import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
|
||||||
import type {
|
import type {
|
||||||
XOInvitation,
|
XOInvitation,
|
||||||
XOInvitationCommit,
|
XOInvitationCommit,
|
||||||
@@ -498,16 +498,38 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedOptions: GetSpendableResourcesParameters = {
|
// const resolvedOptions: GetSpendableResourcesParameters = {
|
||||||
templateIdentifier,
|
// templateIdentifier,
|
||||||
outputIdentifier: options.outputIdentifier ?? fallbackOutputIdentifier ?? "",
|
// outputIdentifier: options.outputIdentifier ?? fallbackOutputIdentifier ?? "",
|
||||||
};
|
// };
|
||||||
|
|
||||||
// Find the suitable resources
|
// There are disagreements around whether all spendables should be returned from getSpendableResources.
|
||||||
const { unspentOutputs } = await this.engine.getSpendableResources(
|
// I had a fix merged in, but it got overwritten. So, im just going to get all of them manually and go around
|
||||||
this.data,
|
// The engine's expectations.
|
||||||
resolvedOptions,
|
// 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();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type RatesEventMap,
|
type RatesEventMap,
|
||||||
} from '../utils/rates/base-rates.js';
|
} from '../utils/rates/base-rates.js';
|
||||||
import { RatesOracle } from '../utils/rates/rates-oracles.js';
|
import { RatesOracle } from '../utils/rates/rates-oracles.js';
|
||||||
|
import { SettingsService } from './settings.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event map emitted by {@link RatesService}.
|
* Event map emitted by {@link RatesService}.
|
||||||
@@ -52,13 +53,15 @@ export interface RatesAdapter {
|
|||||||
*/
|
*/
|
||||||
export class RatesService extends EventEmitter<RatesServiceEventMap> {
|
export class RatesService extends EventEmitter<RatesServiceEventMap> {
|
||||||
private readonly adapter: RatesAdapter;
|
private readonly adapter: RatesAdapter;
|
||||||
|
private readonly settings: SettingsService;
|
||||||
private readonly ratesByPair = new Map<string, CachedRate>();
|
private readonly ratesByPair = new Map<string, CachedRate>();
|
||||||
private unsubscribeFromAdapter: (() => void) | null = null;
|
private unsubscribeFromAdapter: (() => void) | null = null;
|
||||||
private started = false;
|
private started = false;
|
||||||
|
|
||||||
constructor(adapter: RatesAdapter) {
|
constructor(adapter: RatesAdapter, settings: SettingsService) {
|
||||||
super();
|
super();
|
||||||
this.adapter = adapter;
|
this.adapter = adapter;
|
||||||
|
this.settings = settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,9 +69,12 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
|
|||||||
*
|
*
|
||||||
* If no adapter is passed, this defaults to the Oracle-backed adapter.
|
* If no adapter is passed, this defaults to the Oracle-backed adapter.
|
||||||
*/
|
*/
|
||||||
public static async create(adapter?: RatesAdapter): Promise<RatesService> {
|
public static async create(
|
||||||
const resolvedAdapter = adapter ?? (await RatesOracle.from());
|
settings: SettingsService,
|
||||||
return new RatesService(resolvedAdapter);
|
adapter?: RatesAdapter,
|
||||||
|
): Promise<RatesService> {
|
||||||
|
const resolvedAdapter = adapter ?? (await RatesOracle.from(undefined, settings));
|
||||||
|
return new RatesService(resolvedAdapter, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -162,6 +168,20 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
|
|||||||
return this.adapter.formatCurrency(amount, currencyCode.toUpperCase());
|
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.
|
* Handles normalized updates from the underlying adapter.
|
||||||
*/
|
*/
|
||||||
|
|||||||
194
src/services/settings.ts
Normal file
194
src/services/settings.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { EventEmitter } from "../utils/event-emitter.js";
|
||||||
|
import { getSettingsPath } from "../utils/paths.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported persisted settings keys.
|
||||||
|
*/
|
||||||
|
export type SettingsData = {
|
||||||
|
"default-mnemonic"?: string;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event payloads emitted by {@link SettingsService}.
|
||||||
|
*/
|
||||||
|
export type SettingsServiceEventMap = {
|
||||||
|
"settings-updated": {
|
||||||
|
key: keyof SettingsData;
|
||||||
|
value: string | undefined;
|
||||||
|
settings: SettingsData;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime defaults for settings that should always exist in memory.
|
||||||
|
*/
|
||||||
|
const DEFAULT_SETTINGS: SettingsData = {
|
||||||
|
currency: "USD",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles loading, migrating, and persisting wallet settings.
|
||||||
|
*
|
||||||
|
* The backing file is `~/.config/xo-cli/.wallet`. Historically it stored a raw
|
||||||
|
* mnemonic reference string. This service migrates that legacy format to JSON:
|
||||||
|
* `{ "default-mnemonic": "<value>", "currency": "USD" }`.
|
||||||
|
*/
|
||||||
|
export class SettingsService extends EventEmitter<SettingsServiceEventMap> {
|
||||||
|
private readonly settingsPath: string;
|
||||||
|
private settings: SettingsData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new settings service instance.
|
||||||
|
*
|
||||||
|
* @param settingsPath - Optional custom settings file path (useful for tests)
|
||||||
|
*/
|
||||||
|
constructor(settingsPath: string = getSettingsPath()) {
|
||||||
|
super();
|
||||||
|
this.settingsPath = settingsPath;
|
||||||
|
this.settings = this.loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current settings snapshot.
|
||||||
|
*/
|
||||||
|
public getSettings(): SettingsData {
|
||||||
|
return { ...this.settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the currently selected default mnemonic reference.
|
||||||
|
*/
|
||||||
|
public getDefaultMnemonic(): string | undefined {
|
||||||
|
return this.settings["default-mnemonic"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the default mnemonic reference and persists it to disk.
|
||||||
|
*/
|
||||||
|
public setDefaultMnemonic(mnemonicRef: string): void {
|
||||||
|
const normalizedMnemonicRef = mnemonicRef.trim();
|
||||||
|
if (normalizedMnemonicRef.length === 0) {
|
||||||
|
throw new Error("default-mnemonic cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.settings["default-mnemonic"] = normalizedMnemonicRef;
|
||||||
|
this.persistSettings();
|
||||||
|
|
||||||
|
this.emit("settings-updated", {
|
||||||
|
key: "default-mnemonic",
|
||||||
|
value: normalizedMnemonicRef,
|
||||||
|
settings: this.getSettings(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the selected fiat currency code (ISO-like uppercase).
|
||||||
|
*/
|
||||||
|
public getCurrency(): string {
|
||||||
|
return this.settings.currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the selected fiat currency and persists it to disk.
|
||||||
|
*/
|
||||||
|
public setCurrency(currencyCode: string): void {
|
||||||
|
const normalizedCurrency = this.normalizeCurrency(currencyCode);
|
||||||
|
if (this.settings.currency === normalizedCurrency) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.settings.currency = normalizedCurrency;
|
||||||
|
this.persistSettings();
|
||||||
|
|
||||||
|
this.emit("settings-updated", {
|
||||||
|
key: "currency",
|
||||||
|
value: normalizedCurrency,
|
||||||
|
settings: this.getSettings(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads and normalizes the settings file from disk.
|
||||||
|
*
|
||||||
|
* If the file contains the old legacy format (raw mnemonic string), the
|
||||||
|
* migrated JSON shape is written back immediately.
|
||||||
|
*/
|
||||||
|
private loadSettings(): SettingsData {
|
||||||
|
if (!existsSync(this.settingsPath)) {
|
||||||
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawContents = readFileSync(this.settingsPath, "utf8").trim();
|
||||||
|
if (rawContents.length === 0) {
|
||||||
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawContents);
|
||||||
|
const normalized = this.normalizeSettings(parsed);
|
||||||
|
return normalized;
|
||||||
|
} catch {
|
||||||
|
const migrated = this.normalizeSettings({
|
||||||
|
"default-mnemonic": rawContents,
|
||||||
|
});
|
||||||
|
this.persistSettings(migrated);
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the given settings object to disk as pretty JSON.
|
||||||
|
*
|
||||||
|
* @param nextSettings - Optional explicit value, defaults to in-memory state
|
||||||
|
*/
|
||||||
|
private persistSettings(nextSettings?: SettingsData): void {
|
||||||
|
if (nextSettings) {
|
||||||
|
this.settings = nextSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
this.settingsPath,
|
||||||
|
`${JSON.stringify(this.settings, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerces unknown input into a safe settings object.
|
||||||
|
*/
|
||||||
|
private normalizeSettings(input: unknown): SettingsData {
|
||||||
|
const normalized: SettingsData = {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!input || typeof input !== "object") {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeMnemonic = (input as Record<string, unknown>)["default-mnemonic"];
|
||||||
|
if (typeof maybeMnemonic === "string" && maybeMnemonic.trim().length > 0) {
|
||||||
|
normalized["default-mnemonic"] = maybeMnemonic.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeCurrency = (input as Record<string, unknown>).currency;
|
||||||
|
if (typeof maybeCurrency === "string" && maybeCurrency.trim().length > 0) {
|
||||||
|
normalized.currency = this.normalizeCurrency(maybeCurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures currency values stay uppercase and non-empty.
|
||||||
|
*/
|
||||||
|
private normalizeCurrency(currencyCode: string): string {
|
||||||
|
const normalizedCurrency = currencyCode.trim().toUpperCase();
|
||||||
|
if (normalizedCurrency.length === 0) {
|
||||||
|
throw new Error("currency cannot be empty");
|
||||||
|
}
|
||||||
|
return normalizedCurrency;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import Database from "better-sqlite3";
|
// import Database from "better-sqlite3";
|
||||||
import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js";
|
import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js";
|
||||||
|
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
* 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.
|
* 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.
|
||||||
@@ -21,7 +23,7 @@ export abstract class BaseStorage {
|
|||||||
export class Storage extends BaseStorage {
|
export class Storage extends BaseStorage {
|
||||||
static async create(dbPath: string): Promise<Storage> {
|
static async create(dbPath: string): Promise<Storage> {
|
||||||
// Create the database
|
// Create the database
|
||||||
const database = new Database(dbPath);
|
const database = new DatabaseSync(dbPath);
|
||||||
|
|
||||||
// Create the storage table if it doesn't exist
|
// Create the storage table if it doesn't exist
|
||||||
database
|
database
|
||||||
@@ -34,7 +36,7 @@ export class Storage extends BaseStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly database: Database.Database,
|
private readonly database: DatabaseSync,
|
||||||
private readonly basePath: string,
|
private readonly basePath: string,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|||||||
1435
src/templates/p2pkh.ts
Normal file
1435
src/templates/p2pkh.ts
Normal file
File diff suppressed because it is too large
Load Diff
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,7 +68,7 @@ export function VariableInputField({
|
|||||||
focusColor,
|
focusColor,
|
||||||
}: VariableInputFieldProps): React.ReactElement {
|
}: VariableInputFieldProps): React.ReactElement {
|
||||||
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
|
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
|
||||||
useSatoshisConversion("USD");
|
useSatoshisConversion();
|
||||||
const satoshisValue = useMemo(
|
const satoshisValue = useMemo(
|
||||||
() => parseSatoshis(variable.value),
|
() => parseSatoshis(variable.value),
|
||||||
[variable.value],
|
[variable.value],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
||||||
import { useAppContext } from './useAppContext.js';
|
import { useAppContext } from './useAppContext.js';
|
||||||
import { useBchToFiatRate } from './useRates.js';
|
import { useBchToFiatRate } from './useRates.js';
|
||||||
|
|
||||||
@@ -9,9 +9,40 @@ import { useBchToFiatRate } from './useRates.js';
|
|||||||
* component using it will re-render automatically when the selected pair
|
* component using it will re-render automatically when the selected pair
|
||||||
* receives a new quote.
|
* receives a new quote.
|
||||||
*/
|
*/
|
||||||
export function useSatoshisConversion(targetCurrency: string = 'USD') {
|
export function useSatoshisConversion(targetCurrency?: string) {
|
||||||
const { appService } = useAppContext();
|
const { appService } = useAppContext();
|
||||||
const currencyCode = useMemo(() => targetCurrency.toUpperCase(), [targetCurrency]);
|
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 fiatPerBchRate = useBchToFiatRate(currencyCode);
|
||||||
|
|
||||||
const formattedFiatPerBchRate = useMemo(() => {
|
const formattedFiatPerBchRate = useMemo(() => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import path from 'path';
|
|||||||
import { createMnemonicFile } from '../../cli/mnemonic.js';
|
import { createMnemonicFile } from '../../cli/mnemonic.js';
|
||||||
import { getMnemonicsDir } from '../../utils/paths.js';
|
import { getMnemonicsDir } from '../../utils/paths.js';
|
||||||
import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js';
|
import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js';
|
||||||
import { encodeBip39Mnemonic } from '@bitauth/libauth';
|
import { encodeBip39Mnemonic, generateBip39Mnemonic } from '@bitauth/libauth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Status message type.
|
* Status message type.
|
||||||
@@ -41,7 +41,7 @@ interface MnemonicFileEntry {
|
|||||||
* Focus sections the user can tab between.
|
* Focus sections the user can tab between.
|
||||||
* When saved wallets exist the file list is shown first.
|
* When saved wallets exist the file list is shown first.
|
||||||
*/
|
*/
|
||||||
type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'button';
|
type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'generateRandomSeed' | 'button';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads mnemonic-* files from ~/.config/xo-cli/mnemonics/ (same as xo-cli),
|
* Reads mnemonic-* files from ~/.config/xo-cli/mnemonics/ (same as xo-cli),
|
||||||
@@ -117,8 +117,8 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
* The ordered list of focusable sections (files section only when entries exist).
|
* The ordered list of focusable sections (files section only when entries exist).
|
||||||
*/
|
*/
|
||||||
const focusSections: FocusSection[] = mnemonicFiles.length > 0
|
const focusSections: FocusSection[] = mnemonicFiles.length > 0
|
||||||
? ['files', 'input', 'saveCheckbox', 'button']
|
? ['files', 'input', 'generateRandomSeed', 'saveCheckbox', 'button']
|
||||||
: ['input', 'saveCheckbox', 'button'];
|
: ['input', 'generateRandomSeed', 'saveCheckbox', 'button'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a status message with the given type.
|
* Shows a status message with the given type.
|
||||||
@@ -202,7 +202,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
}, [mnemonicFiles, doInitialize]);
|
}, [mnemonicFiles, doInitialize]);
|
||||||
|
|
||||||
// Keyboard navigation
|
// Keyboard navigation
|
||||||
useBlockableInput((_input, key) => {
|
useBlockableInput((input, key) => {
|
||||||
if (isSubmitting) return;
|
if (isSubmitting) return;
|
||||||
|
|
||||||
// Tab / Shift-Tab to cycle focus sections
|
// Tab / Shift-Tab to cycle focus sections
|
||||||
@@ -219,7 +219,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
|
|
||||||
// Space or Enter toggles "save mnemonic" when that row is focused
|
// Space or Enter toggles "save mnemonic" when that row is focused
|
||||||
if (focusedSection === 'saveCheckbox') {
|
if (focusedSection === 'saveCheckbox') {
|
||||||
if (_input === ' ' || key.return) {
|
if (input === ' ' || key.return) {
|
||||||
setSaveMnemonicChecked((v) => !v);
|
setSaveMnemonicChecked((v) => !v);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -241,6 +241,18 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Enter on button submits manual seed
|
||||||
if (key.return && focusedSection === 'button') {
|
if (key.return && focusedSection === 'button') {
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
@@ -358,6 +370,19 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
</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) */}
|
{/* Save mnemonic checkbox (manual entry only; applies on Continue) */}
|
||||||
<Box
|
<Box
|
||||||
marginTop={1}
|
marginTop={1}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|||||||
import { Box, Text } 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 { 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 { useSatoshisConversion } from '../hooks/useSatoshisConversion.js';
|
||||||
@@ -60,6 +61,7 @@ const menuItems: ListItemData<string>[] = [
|
|||||||
{ key: 'import', label: 'Import Invitation', value: 'import' },
|
{ key: 'import', label: 'Import Invitation', value: 'import' },
|
||||||
{ key: 'invitations', label: 'View Invitations', value: 'invitations' },
|
{ key: 'invitations', label: 'View Invitations', value: 'invitations' },
|
||||||
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' },
|
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' },
|
||||||
|
{ key: 'set-currency', label: 'Set Fiat Currency', value: 'set-currency' },
|
||||||
{ key: 'unreserve-all', label: 'Unreserve All Resources', value: 'unreserve-all' },
|
{ key: 'unreserve-all', label: 'Unreserve All Resources', value: 'unreserve-all' },
|
||||||
{ key: 'refresh', label: 'Refresh', value: 'refresh' },
|
{ key: 'refresh', label: 'Refresh', value: 'refresh' },
|
||||||
];
|
];
|
||||||
@@ -121,7 +123,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
fiatPerBchRate,
|
fiatPerBchRate,
|
||||||
formattedFiatPerBchRate,
|
formattedFiatPerBchRate,
|
||||||
formatSatoshisToFiat,
|
formatSatoshisToFiat,
|
||||||
} = useSatoshisConversion('USD');
|
} = useSatoshisConversion();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
||||||
@@ -133,6 +135,14 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
|
|
||||||
/** Cash address to display in the QR code dialog (null when dialog is hidden). */
|
/** Cash address to display in the QR code dialog (null when dialog is hidden). */
|
||||||
const [qrAddress, setQrAddress] = useState<string | null>(null);
|
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.
|
||||||
@@ -260,6 +270,89 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [appService, setStatus, showError, showInfo, refresh]);
|
}, [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.
|
||||||
*/
|
*/
|
||||||
@@ -277,6 +370,9 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
case 'new-address':
|
case 'new-address':
|
||||||
generateNewAddress();
|
generateNewAddress();
|
||||||
break;
|
break;
|
||||||
|
case 'set-currency':
|
||||||
|
openCurrencyDialog();
|
||||||
|
break;
|
||||||
case 'unreserve-all':
|
case 'unreserve-all':
|
||||||
unreserveAll();
|
unreserveAll();
|
||||||
break;
|
break;
|
||||||
@@ -284,7 +380,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
refresh();
|
refresh();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [navigate, generateNewAddress, unreserveAll, refresh]);
|
}, [navigate, generateNewAddress, openCurrencyDialog, unreserveAll, refresh]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle menu item activation.
|
* Handle menu item activation.
|
||||||
@@ -543,6 +639,27 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
onClose={() => setQrAddress(null)}
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function InputsStep({
|
|||||||
changeAmount,
|
changeAmount,
|
||||||
focusArea,
|
focusArea,
|
||||||
}: Props): React.ReactElement {
|
}: Props): React.ReactElement {
|
||||||
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||||
|
|
||||||
const getFiatSuffix = (satoshis: bigint): string => {
|
const getFiatSuffix = (satoshis: bigint): string => {
|
||||||
const fiatValue = formatSatoshisToFiat(satoshis);
|
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function ReviewStep({
|
|||||||
changeAmount,
|
changeAmount,
|
||||||
}: ReviewStepProps): React.ReactElement {
|
}: ReviewStepProps): React.ReactElement {
|
||||||
const selectedUtxos = availableUtxos.filter((u) => u.selected);
|
const selectedUtxos = availableUtxos.filter((u) => u.selected);
|
||||||
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||||
|
|
||||||
const getFiatSuffix = (satoshis: bigint): string => {
|
const getFiatSuffix = (satoshis: bigint): string => {
|
||||||
const fiatValue = formatSatoshisToFiat(satoshis);
|
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
|
|
||||||
const invitations = useInvitations();
|
const invitations = useInvitations();
|
||||||
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
|
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
|
||||||
useSatoshisConversion('USD');
|
useSatoshisConversion();
|
||||||
|
|
||||||
// ── UI state ─────────────────────────────────────────────────────────────
|
// ── UI state ─────────────────────────────────────────────────────────────
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ import { StepIndicator, type Step } from '../../../components/ProgressBar.js';
|
|||||||
import { FetchInvitationStep } from './steps/FetchInvitationStep.js';
|
import { FetchInvitationStep } from './steps/FetchInvitationStep.js';
|
||||||
import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js';
|
import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js';
|
||||||
import { RoleSelectStep } from './steps/RoleSelectStep.js';
|
import { RoleSelectStep } from './steps/RoleSelectStep.js';
|
||||||
|
import { VariablesStep } from './steps/VariablesStep.js';
|
||||||
import { InputsSelectStep } from './steps/InputsSelectStep.js';
|
import { InputsSelectStep } from './steps/InputsSelectStep.js';
|
||||||
import { ReviewStep } from './steps/ReviewStep.js';
|
import { ReviewStep } from './steps/ReviewStep.js';
|
||||||
|
|
||||||
import { IMPORT_STEPS, type ImportFlowProps, type SelectableUTXO } from './types.js';
|
import { IMPORT_STEPS, type ImportFlowProps, type ImportStepType, type ImportVariableInput, type SelectableUTXO } from './types.js';
|
||||||
import type { Invitation } from '../../../../services/invitation.js';
|
import type { Invitation } from '../../../../services/invitation.js';
|
||||||
import type { XOTemplate } from '@xo-cash/types';
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
import { DialogWrapper } from '../../../components/Dialog.js';
|
import { DialogWrapper } from '../../../components/Dialog.js';
|
||||||
import { useInputLayer, useLayeredInput } from '../../../hooks/useInputLayer.js';
|
import { useInputLayer, useLayeredInput } from '../../../hooks/useInputLayer.js';
|
||||||
import { InvitationBuilder } from '@xo-cash/engine';
|
|
||||||
import { hexToBin } from '@bitauth/libauth';
|
import { hexToBin } from '@bitauth/libauth';
|
||||||
|
|
||||||
/** Default fee estimate in satoshis. */
|
/** Default fee estimate in satoshis. */
|
||||||
@@ -34,6 +34,24 @@ const DEFAULT_FEE = 500n;
|
|||||||
/** Dust threshold — outputs below this are unspendable. */
|
/** Dust threshold — outputs below this are unspendable. */
|
||||||
const DUST_THRESHOLD = 546n;
|
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({
|
export function InvitationImportFlow({
|
||||||
invitationId,
|
invitationId,
|
||||||
mode,
|
mode,
|
||||||
@@ -46,10 +64,10 @@ export function InvitationImportFlow({
|
|||||||
// ── Accumulated state ────────────────────────────────────────────────────
|
// ── Accumulated state ────────────────────────────────────────────────────
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [invitation, setInvitation] = useState<Invitation | null>(null);
|
const [invitation, setInvitation] = useState<Invitation | null>(null);
|
||||||
const [buildableInvitation, setBuildableInvitation] = useState<InvitationBuilder | null>(null);
|
|
||||||
const [template, setTemplate] = useState<XOTemplate | null>(null);
|
const [template, setTemplate] = useState<XOTemplate | null>(null);
|
||||||
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
||||||
const [selectedRole, setSelectedRole] = useState<string | null>(null);
|
const [selectedRole, setSelectedRole] = useState<string | null>(null);
|
||||||
|
const [variableInputs, setVariableInputs] = useState<ImportVariableInput[]>([]);
|
||||||
const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]);
|
const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]);
|
||||||
const [changeAmount, setChangeAmount] = useState(0n);
|
const [changeAmount, setChangeAmount] = useState(0n);
|
||||||
const [requiredAmount, setRequiredAmount] = useState(0n);
|
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||||
@@ -79,9 +97,6 @@ export function InvitationImportFlow({
|
|||||||
setInvitation(inv);
|
setInvitation(inv);
|
||||||
setTemplate(tmpl);
|
setTemplate(tmpl);
|
||||||
|
|
||||||
const builder = InvitationBuilder.fromInvitation(inv.data);
|
|
||||||
setBuildableInvitation(builder);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const roles = await inv.getAvailableRoles();
|
const roles = await inv.getAvailableRoles();
|
||||||
setAvailableRoles(roles);
|
setAvailableRoles(roles);
|
||||||
@@ -89,20 +104,98 @@ export function InvitationImportFlow({
|
|||||||
showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`);
|
showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentStep(1); // → Preview
|
setCurrentStep(PREVIEW_STEP_INDEX); // → Preview
|
||||||
}, [showError]);
|
}, [showError]);
|
||||||
|
|
||||||
/** PreviewStep completed — user reviewed the invitation state and wants to proceed. */
|
/** PreviewStep completed — user reviewed the invitation state and wants to proceed. */
|
||||||
const handlePreviewComplete = useCallback(() => {
|
const handlePreviewComplete = useCallback(() => {
|
||||||
setCurrentStep(2); // → Role Select
|
setCurrentStep(ROLE_SELECT_STEP_INDEX); // → Role Select
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** RoleSelectStep completed — user picked a role. */
|
/** RoleSelectStep completed — user picked a role. */
|
||||||
const handleRoleComplete = useCallback((role: string) => {
|
const handleRoleComplete = useCallback((role: string) => {
|
||||||
setSelectedRole(role);
|
setSelectedRole(role);
|
||||||
setCurrentStep(3); // → Inputs Select
|
|
||||||
|
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. */
|
/** InputsSelectStep completed — user selected UTXOs. */
|
||||||
const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => {
|
const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => {
|
||||||
setSelectedInputs(inputs);
|
setSelectedInputs(inputs);
|
||||||
@@ -130,8 +223,8 @@ export function InvitationImportFlow({
|
|||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentStep(4); // → Review
|
setCurrentStep(REVIEW_STEP_INDEX); // → Review
|
||||||
}, [invitation, buildableInvitation, selectedInputs]);
|
}, [invitation]);
|
||||||
|
|
||||||
/** ReviewStep completed — invitation import is done. */
|
/** ReviewStep completed — invitation import is done. */
|
||||||
const handleReviewComplete = useCallback(() => {
|
const handleReviewComplete = useCallback(() => {
|
||||||
@@ -205,6 +298,17 @@ export function InvitationImportFlow({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'variables':
|
||||||
|
return (
|
||||||
|
<VariablesStep
|
||||||
|
variables={variableInputs}
|
||||||
|
onUpdateVariable={handleVariableUpdate}
|
||||||
|
onComplete={handleVariablesComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case 'inputs-select':
|
case 'inputs-select':
|
||||||
if (!invitation || !selectedRole) return null;
|
if (!invitation || !selectedRole) return null;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function InputsSelectStep({
|
|||||||
const [requiredAmount, setRequiredAmount] = useState(0n);
|
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||||
|
|
||||||
const fee = DEFAULT_FEE;
|
const fee = DEFAULT_FEE;
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function PreviewInvitationStep({
|
|||||||
onCancel,
|
onCancel,
|
||||||
isActive,
|
isActive,
|
||||||
}: PreviewStepProps): React.ReactElement {
|
}: PreviewStepProps): React.ReactElement {
|
||||||
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||||
|
|
||||||
useLayeredInput('import-flow', (_input, key) => {
|
useLayeredInput('import-flow', (_input, key) => {
|
||||||
if (key.return) onComplete();
|
if (key.return) onComplete();
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function ReviewStep({
|
|||||||
}: ReviewStepProps): React.ReactElement {
|
}: ReviewStepProps): React.ReactElement {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
|
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||||
|
|
||||||
const fee = DEFAULT_FEE;
|
const fee = DEFAULT_FEE;
|
||||||
const action = template?.actions?.[invitation.data.actionIdentifier];
|
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export type ImportStepType =
|
|||||||
| "fetch"
|
| "fetch"
|
||||||
| "preview"
|
| "preview"
|
||||||
| "role-select"
|
| "role-select"
|
||||||
|
| "variables"
|
||||||
| "inputs-select"
|
| "inputs-select"
|
||||||
| "review";
|
| "review";
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export const IMPORT_STEPS: ImportStep[] = [
|
|||||||
{ name: "Fetch", type: "fetch" },
|
{ name: "Fetch", type: "fetch" },
|
||||||
{ name: "Preview", type: "preview" },
|
{ name: "Preview", type: "preview" },
|
||||||
{ name: "Select Role", type: "role-select" },
|
{ name: "Select Role", type: "role-select" },
|
||||||
|
{ name: "Variables", type: "variables" },
|
||||||
{ name: "Select Inputs", type: "inputs-select" },
|
{ name: "Select Inputs", type: "inputs-select" },
|
||||||
{ name: "Review", type: "review" },
|
{ name: "Review", type: "review" },
|
||||||
];
|
];
|
||||||
@@ -81,6 +83,24 @@ export interface RoleSelectStepProps {
|
|||||||
isActive: boolean;
|
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. */
|
/** Props for InputsSelectStep — lets user pick UTXOs to fund the invitation. */
|
||||||
export interface InputsSelectStepProps {
|
export interface InputsSelectStepProps {
|
||||||
invitation: Invitation;
|
invitation: Invitation;
|
||||||
|
|||||||
@@ -35,10 +35,17 @@ export function getDataDir(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* File storing the last-used mnemonic reference for `-m` omission.
|
* File storing CLI settings (JSON), including the last-used mnemonic reference.
|
||||||
|
*/
|
||||||
|
export function getSettingsPath(): string {
|
||||||
|
return join(getConfigDir(), ".wallet");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Prefer {@link getSettingsPath}.
|
||||||
*/
|
*/
|
||||||
export function getWalletConfigPath(): string {
|
export function getWalletConfigPath(): string {
|
||||||
return join(getConfigDir(), ".wallet");
|
return getSettingsPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -38,19 +38,34 @@ export abstract class BaseRates<
|
|||||||
* @returns The formatted amount.
|
* @returns The formatted amount.
|
||||||
*/
|
*/
|
||||||
public formatCurrency(amount: number, targetCurrency: string): string {
|
public formatCurrency(amount: number, targetCurrency: string): string {
|
||||||
|
const normalizedCurrency = targetCurrency.toUpperCase();
|
||||||
const minimumFractionDigitsMap: { [currency: string]: number } = {
|
const minimumFractionDigitsMap: { [currency: string]: number } = {
|
||||||
AUD: 2,
|
AUD: 2,
|
||||||
BCH: 8,
|
BCH: 8,
|
||||||
USD: 2,
|
USD: 2,
|
||||||
};
|
};
|
||||||
|
const minimumFractionDigits = minimumFractionDigitsMap[normalizedCurrency] ?? 2;
|
||||||
|
const maximumFractionDigits = Math.max(minimumFractionDigits, 8);
|
||||||
|
|
||||||
const formatter = new Intl.NumberFormat('en-US', {
|
try {
|
||||||
style: 'currency',
|
const formatter = new Intl.NumberFormat('en-US', {
|
||||||
currency: targetCurrency,
|
style: 'currency',
|
||||||
currencyDisplay: 'narrowSymbol',
|
currency: normalizedCurrency,
|
||||||
minimumFractionDigits: minimumFractionDigitsMap[targetCurrency] || 0,
|
currencyDisplay: 'narrowSymbol',
|
||||||
});
|
minimumFractionDigits,
|
||||||
|
maximumFractionDigits,
|
||||||
|
});
|
||||||
|
|
||||||
return formatter.format(amount);
|
return formatter.format(amount);
|
||||||
|
} catch {
|
||||||
|
// Some numerator symbols from oracle pairs (e.g. DOGE/BCH) are not ISO-4217
|
||||||
|
// fiat currency codes, so Intl currency formatting will throw a RangeError.
|
||||||
|
// In that case we still return a human-readable formatted value.
|
||||||
|
const numericFormatter = new Intl.NumberFormat('en-US', {
|
||||||
|
minimumFractionDigits,
|
||||||
|
maximumFractionDigits,
|
||||||
|
});
|
||||||
|
return `${numericFormatter.format(amount)} ${normalizedCurrency}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
} from '@generalprotocols/oracle-client';
|
} from '@generalprotocols/oracle-client';
|
||||||
|
|
||||||
import { type RatesEventMap, BaseRates } from './base-rates.js';
|
import { type RatesEventMap, BaseRates } from './base-rates.js';
|
||||||
|
import { type OffCallback } from '../event-emitter.js';
|
||||||
|
import { SettingsService } from '../../services/settings.js';
|
||||||
|
|
||||||
// Add the Oracle Price Message to our Events for this Adapter.
|
// Add the Oracle Price Message to our Events for this Adapter.
|
||||||
export type RatesOracleEventMap = RatesEventMap & {
|
export type RatesOracleEventMap = RatesEventMap & {
|
||||||
@@ -22,22 +24,34 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
|||||||
* @param client The underlying oracle client. If not provided, a new client will be created.
|
* @param client The underlying oracle client. If not provided, a new client will be created.
|
||||||
* @returns The rates oracle.
|
* @returns The rates oracle.
|
||||||
*/
|
*/
|
||||||
static async from(client?: OracleClient) {
|
static async from(
|
||||||
const ratesOracle = new RatesOracle(client ?? (await OracleClient.from()));
|
client?: OracleClient,
|
||||||
|
settings: SettingsService = new SettingsService(),
|
||||||
|
) {
|
||||||
|
const ratesOracle = new RatesOracle(
|
||||||
|
client ?? (await OracleClient.from()),
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
|
||||||
return ratesOracle;
|
return ratesOracle;
|
||||||
}
|
}
|
||||||
|
|
||||||
private client: OracleClient;
|
private client: OracleClient;
|
||||||
|
private settings: SettingsService;
|
||||||
private oracles: OracleMetadataMap;
|
private oracles: OracleMetadataMap;
|
||||||
|
|
||||||
private started: boolean = false;
|
private started: boolean = false;
|
||||||
|
private targetNumeratorUnitCode: string;
|
||||||
|
private targetDenominatorUnitCode: string = 'BCH';
|
||||||
|
private unsubscribeFromSettings: OffCallback | null = null;
|
||||||
|
|
||||||
private constructor(client: OracleClient) {
|
private constructor(client: OracleClient, settings: SettingsService) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.client = client;
|
this.client = client;
|
||||||
|
this.settings = settings;
|
||||||
this.oracles = {};
|
this.oracles = {};
|
||||||
|
this.targetNumeratorUnitCode = settings.getCurrency().toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +62,10 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.started = true;
|
this.started = true;
|
||||||
|
this.unsubscribeFromSettings = this.settings.on(
|
||||||
|
'settings-updated',
|
||||||
|
this.handleSettingsUpdated.bind(this),
|
||||||
|
);
|
||||||
|
|
||||||
// Create event listeners for the client.
|
// Create event listeners for the client.
|
||||||
this.client.setOnMetadataMessage(this.handleMetadataMessage.bind(this));
|
this.client.setOnMetadataMessage(this.handleMetadataMessage.bind(this));
|
||||||
@@ -71,6 +89,8 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.started = false;
|
this.started = false;
|
||||||
|
this.unsubscribeFromSettings?.();
|
||||||
|
this.unsubscribeFromSettings = null;
|
||||||
|
|
||||||
// Remove event listeners by setting them to empty functions.
|
// Remove event listeners by setting them to empty functions.
|
||||||
this.client.setOnMetadataMessage(() => {});
|
this.client.setOnMetadataMessage(() => {});
|
||||||
@@ -85,6 +105,12 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
|||||||
* @returns A set of pairs.
|
* @returns A set of pairs.
|
||||||
*/
|
*/
|
||||||
async listPairs() {
|
async listPairs() {
|
||||||
|
// If metadata has not arrived yet but the client is running, query once so
|
||||||
|
// callers (like the currency picker) can still discover available pairs.
|
||||||
|
if (Object.keys(this.oracles).length === 0 && this.started) {
|
||||||
|
this.oracles = await this.client.getMetadataMap();
|
||||||
|
}
|
||||||
|
|
||||||
return new Set(
|
return new Set(
|
||||||
Object.values(this.oracles).map((oracle) => {
|
Object.values(this.oracles).map((oracle) => {
|
||||||
return `${oracle.SOURCE_NUMERATOR_UNIT_CODE}/${oracle.SOURCE_DENOMINATOR_UNIT_CODE}`;
|
return `${oracle.SOURCE_NUMERATOR_UNIT_CODE}/${oracle.SOURCE_DENOMINATOR_UNIT_CODE}`;
|
||||||
@@ -157,14 +183,48 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourceNumeratorUnitCode = oracle.SOURCE_NUMERATOR_UNIT_CODE.toUpperCase();
|
||||||
|
const sourceDenominatorUnitCode = oracle.SOURCE_DENOMINATOR_UNIT_CODE.toUpperCase();
|
||||||
|
|
||||||
|
// Only emit the pair currently selected in settings.
|
||||||
|
if (
|
||||||
|
sourceNumeratorUnitCode !== this.targetNumeratorUnitCode ||
|
||||||
|
sourceDenominatorUnitCode !== this.targetDenominatorUnitCode
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Scale the price
|
// Scale the price
|
||||||
const priceValue = message.priceValue / oracle.ATTESTATION_SCALING;
|
const priceValue = message.priceValue / oracle.ATTESTATION_SCALING;
|
||||||
|
|
||||||
this.emit('rateUpdated', {
|
this.emit('rateUpdated', {
|
||||||
numeratorUnitCode: oracle.SOURCE_NUMERATOR_UNIT_CODE,
|
numeratorUnitCode: sourceNumeratorUnitCode,
|
||||||
denominatorUnitCode: oracle.SOURCE_DENOMINATOR_UNIT_CODE,
|
denominatorUnitCode: sourceDenominatorUnitCode,
|
||||||
price: priceValue,
|
price: priceValue,
|
||||||
oraclePriceMessage: message,
|
oraclePriceMessage: message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks updates to settings and switches the actively emitted fiat pair.
|
||||||
|
*/
|
||||||
|
private handleSettingsUpdated(
|
||||||
|
event: {
|
||||||
|
key: 'currency' | 'default-mnemonic';
|
||||||
|
value: string | undefined;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (event.key !== 'currency' || !event.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.targetNumeratorUnitCode = event.value.toUpperCase();
|
||||||
|
|
||||||
|
// Refresh so listeners get the latest value for the new currency quickly.
|
||||||
|
if (this.started) {
|
||||||
|
this.refreshPrices().catch((error) => {
|
||||||
|
console.error('Error refreshing prices after currency update:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
83
tests/cli/commands/settings.test.ts
Normal file
83
tests/cli/commands/settings.test.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { handleSettingsCommand } from "../../../src/cli/commands/settings";
|
||||||
|
import { CommandError } from "../../../src/cli/commands/types";
|
||||||
|
import { createMockIO, createMockPaths } from "../mocks/command";
|
||||||
|
|
||||||
|
describe("settings command", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-settings-command-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows default settings when .wallet does not exist", async () => {
|
||||||
|
const { io, capture } = createMockIO();
|
||||||
|
const paths = createMockPaths(tempDir);
|
||||||
|
|
||||||
|
const result = await handleSettingsCommand({ io, paths }, ["show"], {});
|
||||||
|
|
||||||
|
expect(result).toEqual({ currency: "USD" });
|
||||||
|
expect(capture.out.join("\n")).toContain("currency");
|
||||||
|
expect(capture.out.join("\n")).toContain("USD");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets and gets currency", async () => {
|
||||||
|
const { io, capture } = createMockIO();
|
||||||
|
const paths = createMockPaths(tempDir);
|
||||||
|
|
||||||
|
await handleSettingsCommand({ io, paths }, ["set", "currency", "aud"], {});
|
||||||
|
const getResult = await handleSettingsCommand(
|
||||||
|
{ io, paths },
|
||||||
|
["get", "currency"],
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResult).toEqual({ key: "currency", value: "AUD" });
|
||||||
|
expect(capture.out).toContain("Updated currency: AUD");
|
||||||
|
expect(capture.out).toContain("AUD");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets default-mnemonic and persists JSON .wallet", async () => {
|
||||||
|
const { io } = createMockIO();
|
||||||
|
const paths = createMockPaths(tempDir);
|
||||||
|
|
||||||
|
await handleSettingsCommand(
|
||||||
|
{ io, paths },
|
||||||
|
["set", "default-mnemonic", "mnemonic-primary"],
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const persisted = JSON.parse(readFileSync(paths.walletConfigPath, "utf8")) as {
|
||||||
|
currency: string;
|
||||||
|
"default-mnemonic"?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(persisted).toEqual({
|
||||||
|
currency: "USD",
|
||||||
|
"default-mnemonic": "mnemonic-primary",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws command error for unknown subcommand", async () => {
|
||||||
|
const { io } = createMockIO();
|
||||||
|
const paths = createMockPaths(tempDir);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleSettingsCommand({ io, paths }, ["unknown"], {});
|
||||||
|
expect.fail("Expected settings command to throw");
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
|
expect((error as CommandError).event).toBe("settings.subcommand.unknown");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,8 +19,7 @@ import {
|
|||||||
import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url";
|
import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url";
|
||||||
|
|
||||||
const TEST_SEED =
|
const TEST_SEED =
|
||||||
"page pencil stock planet limb cluster assault speak off joke private pioneer";
|
"oven crop same above under tower promote decrease vocal pretty require slow";
|
||||||
|
|
||||||
describe("mnemonic utilities", () => {
|
describe("mnemonic utilities", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ describe("mnemonic utilities", () => {
|
|||||||
test("creates a mnemonic file with auto-generated name", () => {
|
test("creates a mnemonic file with auto-generated name", () => {
|
||||||
const filename = createMnemonicFile(tempDir, TEST_SEED);
|
const filename = createMnemonicFile(tempDir, TEST_SEED);
|
||||||
|
|
||||||
expect(filename).toMatch(/^mnemonic-page$/);
|
expect(filename).toMatch(/^mnemonic-oven$/);
|
||||||
expect(existsSync(path.join(tempDir, filename))).toBe(true);
|
expect(existsSync(path.join(tempDir, filename))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
// Node js tool for temp dir
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
import { BlockchainMonitor, Engine } from "@xo-cash/engine";
|
import { BlockchainMonitor, Engine } from "@xo-cash/engine";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createStorageAdapter,
|
|
||||||
State,
|
State,
|
||||||
StorageType,
|
|
||||||
UnspentOutputStatus,
|
UnspentOutputStatus,
|
||||||
type UnspentOutputData,
|
type UnspentOutputData,
|
||||||
} from "@xo-cash/state";
|
} from "@xo-cash/state";
|
||||||
@@ -12,13 +13,15 @@ import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
|
|||||||
|
|
||||||
import { binToHex, sha256 } from "@bitauth/libauth";
|
import { binToHex, sha256 } from "@bitauth/libauth";
|
||||||
import { AppService } from "../../../src/services/app";
|
import { AppService } from "../../../src/services/app";
|
||||||
|
import { InMemoryStorage as XOStorageInMemory } from './xo-storage-in-memory'
|
||||||
import { InMemoryStorage } from "../../../src/services/storage";
|
import { InMemoryStorage } from "../../../src/services/storage";
|
||||||
import { MockElectrumService } from "./electrum-service";
|
import { MockElectrumService } from "./electrum-service";
|
||||||
import { MockRatesService } from "./rates-service";
|
import { MockRatesService } from "./rates-service";
|
||||||
import { RatesService } from "../../../src/services/rates";
|
import { RatesService } from "../../../src/services/rates";
|
||||||
|
import { SettingsService } from "../../../src/services/settings";
|
||||||
|
|
||||||
export const DEFAULT_SEED =
|
export const DEFAULT_SEED =
|
||||||
"page pencil stock planet limb cluster assault speak off joke private pioneer";
|
"oven crop same above under tower promote decrease vocal pretty require slow";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for creating a fake resource (UTXO) in tests.
|
* Options for creating a fake resource (UTXO) in tests.
|
||||||
@@ -67,8 +70,6 @@ export const addFakeResource = async (
|
|||||||
status: UnspentOutputStatus.CONFIRMED,
|
status: UnspentOutputStatus.CONFIRMED,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
privacy: false,
|
privacy: false,
|
||||||
templateIdentifier: options.templateIdentifier ?? "test-template",
|
|
||||||
outputIdentifier: options.outputIdentifier ?? "receiveOutput",
|
|
||||||
outpointIndex: options.outpointIndex ?? 0,
|
outpointIndex: options.outpointIndex ?? 0,
|
||||||
outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(),
|
outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(),
|
||||||
minedAtHeight: options.minedAtHeight ?? 800000,
|
minedAtHeight: options.minedAtHeight ?? 800000,
|
||||||
@@ -130,10 +131,7 @@ export const unreserveResource = async (
|
|||||||
*/
|
*/
|
||||||
export const createMockEngine = async (seed: string) => {
|
export const createMockEngine = async (seed: string) => {
|
||||||
// Create the in-memory storage adapter.
|
// Create the in-memory storage adapter.
|
||||||
const storage = await createStorageAdapter({
|
const storage = new XOStorageInMemory();
|
||||||
storageType: "inmemory",
|
|
||||||
accountHash: binToHex(sha256.hash(convertMnemonicToSeedBytes(seed))),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize the storage adapter.
|
// Initialize the storage adapter.
|
||||||
await storage.initialize();
|
await storage.initialize();
|
||||||
@@ -143,10 +141,7 @@ export const createMockEngine = async (seed: string) => {
|
|||||||
|
|
||||||
// Create the in-memory blockchain provider.
|
// Create the in-memory blockchain provider.
|
||||||
const blockchainProvider = new InMemoryBlockchainProvider();
|
const blockchainProvider = new InMemoryBlockchainProvider();
|
||||||
await blockchainProvider.initialize({
|
await blockchainProvider.initialize();
|
||||||
applicationIdentifier: "xo-cli-tests",
|
|
||||||
electrumOptions: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the blockchain monitor instance.
|
// Create the blockchain monitor instance.
|
||||||
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
|
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
|
||||||
@@ -160,10 +155,15 @@ export const createMockEngine = async (seed: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createMockAppService = async (engine: Engine) => {
|
export const createMockAppService = async (engine: Engine) => {
|
||||||
|
const settings = new SettingsService(
|
||||||
|
`${tmpdir()}/xo-cli-tests-settings.json`,
|
||||||
|
);
|
||||||
|
settings.setCurrency("USD");
|
||||||
|
|
||||||
const storage = await InMemoryStorage.create();
|
const storage = await InMemoryStorage.create();
|
||||||
|
|
||||||
const mockRates = new MockRatesService();
|
const mockRates = new MockRatesService();
|
||||||
const rates = new RatesService(mockRates);
|
const rates = new RatesService(mockRates, settings);
|
||||||
|
|
||||||
const mockElectrum = new MockElectrumService();
|
const mockElectrum = new MockElectrumService();
|
||||||
|
|
||||||
@@ -176,5 +176,5 @@ export const createMockAppService = async (engine: Engine) => {
|
|||||||
invitationStoragePath: "test-invitations.db",
|
invitationStoragePath: "test-invitations.db",
|
||||||
};
|
};
|
||||||
|
|
||||||
return new AppService(engine, storage, config, mockElectrum, rates);
|
return new AppService(engine, storage, config, mockElectrum, rates, settings);
|
||||||
};
|
};
|
||||||
|
|||||||
536
tests/cli/mocks/xo-storage-in-memory.ts
Normal file
536
tests/cli/mocks/xo-storage-in-memory.ts
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
import type { XOInvitation, XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DatabaseClosedError,
|
||||||
|
DatabaseNotInitializedError,
|
||||||
|
QueryFieldsLimitExceededError,
|
||||||
|
QueryInvalidError,
|
||||||
|
UnspentOutputNotFoundInStoreError,
|
||||||
|
UnspentOutputReservationConflictError,
|
||||||
|
DatabaseState,
|
||||||
|
EventData,
|
||||||
|
EventDataQuery,
|
||||||
|
ScriptHashData,
|
||||||
|
ScriptHashDataQuery,
|
||||||
|
UnspentOutputData,
|
||||||
|
UnspentOutputDataFilter,
|
||||||
|
UnspentOutputDataQuery,
|
||||||
|
UnspentOutputStatus,
|
||||||
|
XOStorage,
|
||||||
|
} from '@xo-cash/state';
|
||||||
|
|
||||||
|
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the names of all fields in a query object that have a defined value.
|
||||||
|
*
|
||||||
|
* @param query - The query object to inspect
|
||||||
|
* @returns Array of field names whose values are not undefined
|
||||||
|
*/
|
||||||
|
export const getDefinedQueryFields = (query: Record<string, unknown>): string[] => {
|
||||||
|
const definedFields: string[] = [];
|
||||||
|
|
||||||
|
for (const [ field, value ] of Object.entries(query)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
definedFields.push(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return definedFields;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory storage adapter implementation.
|
||||||
|
*
|
||||||
|
* This adapter mirrors the behavior of the IndexedDB implementation while keeping
|
||||||
|
* all data in process memory. It is useful for tests and ephemeral sessions where
|
||||||
|
* persistence across process restarts is not required.
|
||||||
|
*/
|
||||||
|
export class InMemoryStorage implements XOStorage {
|
||||||
|
/**
|
||||||
|
* Lifecycle state for this in-memory adapter instance.
|
||||||
|
*/
|
||||||
|
private databaseState: DatabaseState = DatabaseState.Uninitialized;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending initialization promise used to deduplicate concurrent initialize calls.
|
||||||
|
*/
|
||||||
|
private databaseInitializationPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template records keyed by template identifier.
|
||||||
|
*/
|
||||||
|
private templates = new Map<string, XOTemplate>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invitation records keyed by invitation identifier.
|
||||||
|
*/
|
||||||
|
private invitations = new Map<string, XOInvitation>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script hash records keyed by script hash.
|
||||||
|
*/
|
||||||
|
private scriptHashes = new Map<string, ScriptHashData>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unspent output records keyed by outpoint hash:index.
|
||||||
|
*/
|
||||||
|
private unspentOutputs = new Map<string, UnspentOutputData>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event records keyed by event identifier.
|
||||||
|
*/
|
||||||
|
private events = new Map<string, EventData>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the storage adapter.
|
||||||
|
*
|
||||||
|
* @returns Promise that resolves when initialization is complete
|
||||||
|
* @throws {DatabaseClosedError} if the adapter has already been closed
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (this.databaseState === DatabaseState.Ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.databaseState === DatabaseState.Closed) {
|
||||||
|
throw new DatabaseClosedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.databaseInitializationPromise !== null) {
|
||||||
|
return this.databaseInitializationPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.databaseInitializationPromise = Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
this.databaseState = DatabaseState.Ready;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.databaseInitializationPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.databaseInitializationPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a template from storage by its identifier.
|
||||||
|
*
|
||||||
|
* @param templateIdentifier - Identifier for the template
|
||||||
|
* @returns Promise resolving to the template data, or undefined if not found
|
||||||
|
*/
|
||||||
|
async getTemplate(templateIdentifier: string): Promise<XOTemplate | undefined> {
|
||||||
|
this.ensureReady();
|
||||||
|
const template = this.templates.get(templateIdentifier);
|
||||||
|
|
||||||
|
return template === undefined ? undefined : this.clone(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a template in storage.
|
||||||
|
*
|
||||||
|
* @param template - The template data to store
|
||||||
|
* @returns Promise that resolves when the template is stored
|
||||||
|
*/
|
||||||
|
async storeTemplate(template: XOTemplate): Promise<void> {
|
||||||
|
this.ensureReady();
|
||||||
|
const templateIdentifier = generateTemplateIdentifier(template);
|
||||||
|
|
||||||
|
this.templates.set(templateIdentifier, this.clone(template));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all templates stored in storage.
|
||||||
|
*
|
||||||
|
* @returns Promise resolving to all stored templates
|
||||||
|
*/
|
||||||
|
async listTemplates(): Promise<XOTemplate[]> {
|
||||||
|
this.ensureReady();
|
||||||
|
|
||||||
|
return Array.from(this.templates.values(), (template) => this.clone(template));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves an invitation from storage by its identifier.
|
||||||
|
*
|
||||||
|
* @param invitationIdentifier - Identifier for the invitation
|
||||||
|
* @returns Promise resolving to the invitation data, or undefined if not found
|
||||||
|
*/
|
||||||
|
async getInvitation(invitationIdentifier: string): Promise<XOInvitation | undefined> {
|
||||||
|
this.ensureReady();
|
||||||
|
const invitation = this.invitations.get(invitationIdentifier);
|
||||||
|
|
||||||
|
return invitation === undefined ? undefined : this.clone(invitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores an invitation in storage.
|
||||||
|
*
|
||||||
|
* @param invitationIdentifier - Identifier for the invitation
|
||||||
|
* @param invitation - The invitation data to store
|
||||||
|
* @returns Promise that resolves when the invitation is stored
|
||||||
|
*/
|
||||||
|
async storeInvitation(invitationIdentifier: string, invitation: XOInvitation): Promise<void> {
|
||||||
|
this.ensureReady();
|
||||||
|
this.invitations.set(invitationIdentifier, this.clone(invitation));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves script hash data from storage by script hash.
|
||||||
|
*
|
||||||
|
* @param scriptHash - Script hash to retrieve
|
||||||
|
* @returns Promise resolving to script hash data, or undefined if not found
|
||||||
|
*/
|
||||||
|
async getScriptHashData(scriptHash: string): Promise<ScriptHashData | undefined> {
|
||||||
|
this.ensureReady();
|
||||||
|
const scriptHashData = this.scriptHashes.get(scriptHash);
|
||||||
|
|
||||||
|
return scriptHashData === undefined ? undefined : this.clone(scriptHashData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores script hash data in storage.
|
||||||
|
*
|
||||||
|
* @param data - Script hash data to store
|
||||||
|
* @returns Promise that resolves when data is stored
|
||||||
|
*/
|
||||||
|
async storeScriptHashData(data: ScriptHashData): Promise<void> {
|
||||||
|
this.ensureReady();
|
||||||
|
this.scriptHashes.set(data.scriptHash, this.clone(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the last derivative index used for standard locking bytecodes.
|
||||||
|
* Filters by templateIdentifier and outputIdentifier, and optionally by invitationIdentifier.
|
||||||
|
*
|
||||||
|
* @param templateIdentifier - The template identifier to filter by
|
||||||
|
* @param outputIdentifier - The output identifier to filter by
|
||||||
|
* @param invitationIdentifier - Optional invitation identifier to filter by
|
||||||
|
* @returns Promise resolving to the last derivative index as a string, or null if none found
|
||||||
|
*/
|
||||||
|
async getLastDerivativeIndexForP2PKH(
|
||||||
|
templateIdentifier: string,
|
||||||
|
outputIdentifier: string,
|
||||||
|
invitationIdentifier?: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
this.ensureReady();
|
||||||
|
const allLockingBytecodes = await this.listScriptHashData({ templateIdentifier });
|
||||||
|
|
||||||
|
const filtered = allLockingBytecodes.filter((lockingBytecodeData) =>
|
||||||
|
lockingBytecodeData.derivationSource?.lockingType === 'standard'
|
||||||
|
&& lockingBytecodeData.outputIdentifier === outputIdentifier
|
||||||
|
&& lockingBytecodeData.derivationSource?.derivativeIndex !== undefined
|
||||||
|
&& (invitationIdentifier === undefined || lockingBytecodeData.invitationIdentifier === invitationIdentifier));
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.reduce((maximumDerivativeIndex, lockingBytecodeData) => {
|
||||||
|
const currentDerivativeIndex = lockingBytecodeData.derivationSource?.derivativeIndex ?? '0';
|
||||||
|
const currentNum = parseInt(currentDerivativeIndex, 10);
|
||||||
|
const maxNum = parseInt(maximumDerivativeIndex, 10);
|
||||||
|
|
||||||
|
return currentNum > maxNum ? currentDerivativeIndex : maximumDerivativeIndex;
|
||||||
|
}, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves unspent output data from storage.
|
||||||
|
*
|
||||||
|
* @param outpointTransactionHash - Outpoint transaction hash
|
||||||
|
* @param outpointIndex - Outpoint index
|
||||||
|
* @returns Promise resolving to unspent output data, or undefined if not found
|
||||||
|
*/
|
||||||
|
async getUnspentOutputData(outpointTransactionHash: string, outpointIndex: number): Promise<UnspentOutputData | undefined> {
|
||||||
|
this.ensureReady();
|
||||||
|
const key = this.getUnspentOutputKey(outpointTransactionHash, outpointIndex);
|
||||||
|
const output = this.unspentOutputs.get(key);
|
||||||
|
|
||||||
|
return output === undefined ? undefined : this.clone(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores unspent output data in storage.
|
||||||
|
*
|
||||||
|
* @param data - Unspent output data to store
|
||||||
|
* @returns Promise that resolves when data is stored
|
||||||
|
*/
|
||||||
|
async storeUnspentOutputData(data: UnspentOutputData): Promise<void> {
|
||||||
|
this.ensureReady();
|
||||||
|
const key = this.getUnspentOutputKey(data.outpointTransactionHash, data.outpointIndex);
|
||||||
|
|
||||||
|
this.unspentOutputs.set(key, this.clone(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the status of an existing unspent output.
|
||||||
|
*
|
||||||
|
* @param outpointTransactionHash - Outpoint transaction hash
|
||||||
|
* @param outpointIndex - Outpoint index
|
||||||
|
* @param status - New status to set
|
||||||
|
* @returns Promise that resolves when the update is complete
|
||||||
|
* @throws {UnspentOutputNotFoundInStoreError} if the output doesn't exist
|
||||||
|
*/
|
||||||
|
async updateUnspentOutputDataStatus(outpointTransactionHash: string, outpointIndex: number, status: UnspentOutputStatus): Promise<void> {
|
||||||
|
this.ensureReady();
|
||||||
|
const key = this.getUnspentOutputKey(outpointTransactionHash, outpointIndex);
|
||||||
|
const existing = this.unspentOutputs.get(key);
|
||||||
|
|
||||||
|
if (existing === undefined) {
|
||||||
|
throw new UnspentOutputNotFoundInStoreError(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.unspentOutputs.set(key, {
|
||||||
|
...existing,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets reservation status of unspent outputs in bulk.
|
||||||
|
*
|
||||||
|
* @param outpoints - The outpoints to reserve or unreserve
|
||||||
|
* @param shouldBeReserved - Whether outputs should be reserved
|
||||||
|
* @param invitationIdentifier - Invitation identifier owning the reservation
|
||||||
|
* @returns Promise that resolves when reservation updates complete
|
||||||
|
* @throws {UnspentOutputNotFoundInStoreError} if any output doesn't exist
|
||||||
|
* @throws {UnspentOutputReservationConflictError} if any output is held by another invitation
|
||||||
|
*/
|
||||||
|
async executeBulkUnspentOutputReservation(
|
||||||
|
outpoints: Array<{ outpointTransactionHash: string; outpointIndex: number }>,
|
||||||
|
shouldBeReserved: boolean,
|
||||||
|
invitationIdentifier: string,
|
||||||
|
): Promise<void> {
|
||||||
|
this.ensureReady();
|
||||||
|
|
||||||
|
const outputsToUpdate: Array<{ storageKey: string; updatedOutput: UnspentOutputData }> = [];
|
||||||
|
|
||||||
|
for (const { outpointTransactionHash, outpointIndex } of outpoints) {
|
||||||
|
const storageKey = this.getUnspentOutputKey(outpointTransactionHash, outpointIndex);
|
||||||
|
const existingOutput = this.unspentOutputs.get(storageKey);
|
||||||
|
|
||||||
|
if (existingOutput === undefined) {
|
||||||
|
throw new UnspentOutputNotFoundInStoreError(storageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldBeReserved) {
|
||||||
|
if (existingOutput.reservedBy === invitationIdentifier) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingOutput.reservedBy !== undefined) {
|
||||||
|
throw new UnspentOutputReservationConflictError(storageKey, existingOutput.reservedBy);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (existingOutput.reservedBy === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingOutput.reservedBy !== invitationIdentifier) {
|
||||||
|
throw new UnspentOutputReservationConflictError(storageKey, existingOutput.reservedBy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outputsToUpdate.push({
|
||||||
|
storageKey,
|
||||||
|
updatedOutput: {
|
||||||
|
...existingOutput,
|
||||||
|
reservedBy: shouldBeReserved ? invitationIdentifier : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { storageKey, updatedOutput } of outputsToUpdate) {
|
||||||
|
this.unspentOutputs.set(storageKey, updatedOutput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes unspent output data from storage.
|
||||||
|
*
|
||||||
|
* @param outpointTransactionHash - Outpoint transaction hash
|
||||||
|
* @param outpointIndex - Outpoint index
|
||||||
|
* @returns Promise that resolves when deletion is complete
|
||||||
|
*/
|
||||||
|
async deleteUnspentOutputData(outpointTransactionHash: string, outpointIndex: number): Promise<void> {
|
||||||
|
this.ensureReady();
|
||||||
|
const key = this.getUnspentOutputKey(outpointTransactionHash, outpointIndex);
|
||||||
|
|
||||||
|
this.unspentOutputs.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists script hash data matching the provided query.
|
||||||
|
*
|
||||||
|
* @param query - Query to apply
|
||||||
|
* @returns Promise resolving to matching script hash records
|
||||||
|
* @throws {QueryFieldsLimitExceededError} if more than one query field is provided
|
||||||
|
* @throws {QueryInvalidError} if the query contains no recognized field
|
||||||
|
*/
|
||||||
|
async listScriptHashData(query: ScriptHashDataQuery): Promise<ScriptHashData[]> {
|
||||||
|
this.ensureReady();
|
||||||
|
const definedFields = getDefinedQueryFields(query);
|
||||||
|
|
||||||
|
if (definedFields.length > 1) {
|
||||||
|
throw new QueryFieldsLimitExceededError('scriptHashes', definedFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.templateIdentifier !== undefined) {
|
||||||
|
return this.filterScriptHashes((item) => item.templateIdentifier === query.templateIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.invitationIdentifier !== undefined) {
|
||||||
|
return this.filterScriptHashes((item) => item.invitationIdentifier === query.invitationIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.scriptHash !== undefined) {
|
||||||
|
return this.filterScriptHashes((item) => item.scriptHash === query.scriptHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new QueryInvalidError('scriptHashes');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists unspent output data matching query and optional in-memory filter.
|
||||||
|
*
|
||||||
|
* @param query - Query for selecting unspent outputs
|
||||||
|
* @param filter - Optional filter for reserved status
|
||||||
|
* @returns Promise resolving to matching unspent outputs
|
||||||
|
* @throws {QueryFieldsLimitExceededError} if more than one query field is provided
|
||||||
|
*/
|
||||||
|
async listUnspentOutputs(query: UnspentOutputDataQuery = {}, filter?: UnspentOutputDataFilter): Promise<UnspentOutputData[]> {
|
||||||
|
this.ensureReady();
|
||||||
|
const definedFields = getDefinedQueryFields(query);
|
||||||
|
|
||||||
|
if (definedFields.length > 1) {
|
||||||
|
throw new QueryFieldsLimitExceededError('unspentOutputs', definedFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
let unspentOutputs = Array.from(this.unspentOutputs.values(), (output) => this.clone(output));
|
||||||
|
|
||||||
|
if (query.templateIdentifier !== undefined) {
|
||||||
|
unspentOutputs = unspentOutputs.filter((output) => output.templateIdentifier === query.templateIdentifier);
|
||||||
|
} else if (query.reservedBy !== undefined) {
|
||||||
|
unspentOutputs = unspentOutputs.filter((output) => output.reservedBy === query.reservedBy);
|
||||||
|
} else if (query.scriptHash !== undefined) {
|
||||||
|
unspentOutputs = unspentOutputs.filter((output) => output.scriptHash === query.scriptHash);
|
||||||
|
} else if (query.status !== undefined) {
|
||||||
|
unspentOutputs = unspentOutputs.filter((output) => output.status === query.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.reserved !== undefined) {
|
||||||
|
unspentOutputs = unspentOutputs.filter((unspentOutput) => (unspentOutput.reservedBy !== undefined) === filter.reserved);
|
||||||
|
}
|
||||||
|
|
||||||
|
return unspentOutputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores an event in storage.
|
||||||
|
*
|
||||||
|
* @param eventId - Event identifier
|
||||||
|
* @param eventType - Event type
|
||||||
|
* @param data - Event payload
|
||||||
|
* @param timestamp - Optional timestamp, defaults to Date.now()
|
||||||
|
* @returns Promise that resolves when event is stored
|
||||||
|
*/
|
||||||
|
async storeEvent(eventId: string, eventType: string, data: unknown, timestamp?: number): Promise<void> {
|
||||||
|
this.ensureReady();
|
||||||
|
this.events.set(eventId, {
|
||||||
|
eventId,
|
||||||
|
timestamp: timestamp ?? Date.now(),
|
||||||
|
eventType,
|
||||||
|
data: this.clone(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists event data matching the provided query.
|
||||||
|
*
|
||||||
|
* @param query - Query to apply
|
||||||
|
* @returns Promise resolving to matching events
|
||||||
|
* @throws {QueryFieldsLimitExceededError} if more than one query field is provided
|
||||||
|
* @throws {QueryInvalidError} if the query contains no recognized field
|
||||||
|
*/
|
||||||
|
async listEvents(query: EventDataQuery): Promise<EventData[]> {
|
||||||
|
this.ensureReady();
|
||||||
|
const definedFields = getDefinedQueryFields(query);
|
||||||
|
|
||||||
|
if (definedFields.length > 1) {
|
||||||
|
throw new QueryFieldsLimitExceededError('events', definedFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.eventType !== undefined) {
|
||||||
|
return Array.from(this.events.values())
|
||||||
|
.filter((event) => event.eventType === query.eventType)
|
||||||
|
.map((event) => this.clone(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new QueryInvalidError('events');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the storage adapter and releases in-memory resources.
|
||||||
|
*
|
||||||
|
* @returns Promise that resolves when close operation finishes
|
||||||
|
*/
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.templates.clear();
|
||||||
|
this.invitations.clear();
|
||||||
|
this.scriptHashes.clear();
|
||||||
|
this.unspentOutputs.clear();
|
||||||
|
this.events.clear();
|
||||||
|
|
||||||
|
this.databaseState = DatabaseState.Closed;
|
||||||
|
this.databaseInitializationPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the adapter is in a ready state before handling any operation.
|
||||||
|
* Mirrors the behavior of the IndexedDB implementation where calls after its been closed throw an error.
|
||||||
|
*
|
||||||
|
* @throws {DatabaseClosedError} if the adapter has already been closed
|
||||||
|
* @throws {DatabaseNotInitializedError} if initialize has not been called
|
||||||
|
*/
|
||||||
|
private ensureReady(): void {
|
||||||
|
if (this.databaseState === DatabaseState.Closed) {
|
||||||
|
throw new DatabaseClosedError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the canonical storage key for unspent outputs.
|
||||||
|
*
|
||||||
|
* @param outpointTransactionHash - Outpoint transaction hash
|
||||||
|
* @param outpointIndex - Outpoint index
|
||||||
|
* @returns Canonical key in hash:index format
|
||||||
|
*/
|
||||||
|
private getUnspentOutputKey(outpointTransactionHash: string, outpointIndex: number): string {
|
||||||
|
return `${outpointTransactionHash}:${outpointIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns script hash records matching the given predicate.
|
||||||
|
*
|
||||||
|
* @param predicate - Predicate used to filter script hash records
|
||||||
|
* @returns Matching script hash records
|
||||||
|
*/
|
||||||
|
private filterScriptHashes(predicate: (item: ScriptHashData) => boolean): ScriptHashData[] {
|
||||||
|
return Array.from(this.scriptHashes.values())
|
||||||
|
.filter(predicate)
|
||||||
|
.map((item) => this.clone(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a detached clone to preserve storage value semantics.
|
||||||
|
*
|
||||||
|
* IndexedDB deserializes values when reading, so callers cannot mutate data that is
|
||||||
|
* still stored in the database. We clone in-memory values to keep that same behavior.
|
||||||
|
*
|
||||||
|
* @param value - Value to clone
|
||||||
|
* @returns Detached clone of the provided value
|
||||||
|
*/
|
||||||
|
private clone<T>(value: T): T {
|
||||||
|
return structuredClone(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
tests/cli/rates-format.test.ts
Normal file
40
tests/cli/rates-format.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { BaseRates } from "../../src/utils/rates/base-rates";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal concrete adapter used only for testing BaseRates helpers.
|
||||||
|
*/
|
||||||
|
class TestRatesAdapter extends BaseRates {
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listPairs(): Promise<Set<string>> {
|
||||||
|
return new Set(["USD/BCH", "DOGE/BCH"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BaseRates.formatCurrency", () => {
|
||||||
|
test("formats ISO currency codes with Intl currency style", () => {
|
||||||
|
const rates = new TestRatesAdapter();
|
||||||
|
const formatted = rates.formatCurrency(12.5, "USD");
|
||||||
|
|
||||||
|
expect(formatted).toContain("$");
|
||||||
|
expect(formatted).toContain("12.50");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats non-ISO symbols without throwing", () => {
|
||||||
|
const rates = new TestRatesAdapter();
|
||||||
|
const formatted = rates.formatCurrency(12.3456789, "DOGE");
|
||||||
|
|
||||||
|
expect(formatted).toContain("DOGE");
|
||||||
|
expect(formatted).toContain("12.3456789");
|
||||||
|
});
|
||||||
|
});
|
||||||
96
tests/cli/settings.test.ts
Normal file
96
tests/cli/settings.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
|
||||||
|
import { beforeEach, afterEach, describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
|
import { SettingsService } from "../../src/services/settings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for SettingsService persistence and migration behavior.
|
||||||
|
*/
|
||||||
|
describe("SettingsService", () => {
|
||||||
|
let testDir: string;
|
||||||
|
let settingsPath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testDir = mkdtempSync(join(tmpdir(), "xo-cli-settings-test-"));
|
||||||
|
settingsPath = join(testDir, ".wallet");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns defaults when settings file does not exist", () => {
|
||||||
|
const settings = new SettingsService(settingsPath);
|
||||||
|
|
||||||
|
expect(settings.getDefaultMnemonic()).toBeUndefined();
|
||||||
|
expect(settings.getCurrency()).toBe("USD");
|
||||||
|
expect(settings.getSettings()).toEqual({ currency: "USD" });
|
||||||
|
expect(existsSync(settingsPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("migrates legacy .wallet plaintext mnemonic to JSON", () => {
|
||||||
|
writeFileSync(settingsPath, "mnemonic-legacy", "utf8");
|
||||||
|
|
||||||
|
const settings = new SettingsService(settingsPath);
|
||||||
|
|
||||||
|
expect(settings.getDefaultMnemonic()).toBe("mnemonic-legacy");
|
||||||
|
expect(settings.getCurrency()).toBe("USD");
|
||||||
|
|
||||||
|
const persisted = JSON.parse(readFileSync(settingsPath, "utf8")) as {
|
||||||
|
"default-mnemonic"?: string;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
expect(persisted).toEqual({
|
||||||
|
"default-mnemonic": "mnemonic-legacy",
|
||||||
|
currency: "USD",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes and persists currency and default-mnemonic", () => {
|
||||||
|
const settings = new SettingsService(settingsPath);
|
||||||
|
|
||||||
|
settings.setDefaultMnemonic(" mnemonic-primary ");
|
||||||
|
settings.setCurrency("aud");
|
||||||
|
|
||||||
|
expect(settings.getSettings()).toEqual({
|
||||||
|
"default-mnemonic": "mnemonic-primary",
|
||||||
|
currency: "AUD",
|
||||||
|
});
|
||||||
|
|
||||||
|
const persisted = JSON.parse(readFileSync(settingsPath, "utf8")) as {
|
||||||
|
"default-mnemonic"?: string;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
expect(persisted).toEqual({
|
||||||
|
"default-mnemonic": "mnemonic-primary",
|
||||||
|
currency: "AUD",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits settings-updated events on setting changes", () => {
|
||||||
|
const settings = new SettingsService(settingsPath);
|
||||||
|
const events: Array<{ key: string; value: string | undefined }> = [];
|
||||||
|
|
||||||
|
settings.on("settings-updated", (event) => {
|
||||||
|
events.push({ key: event.key, value: event.value });
|
||||||
|
});
|
||||||
|
|
||||||
|
settings.setCurrency("cad");
|
||||||
|
settings.setDefaultMnemonic("mnemonic-blue");
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ key: "currency", value: "CAD" },
|
||||||
|
{ key: "default-mnemonic", value: "mnemonic-blue" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user