add the bitcoin wallet sidecar and ui
the owner's work, committed as one unit rather than split: the registration
files (App.tsx, Dock, AppRegistry, hono.ts, the schema and db barrels,
ecosystem.config.cjs) all reference modules under src/servers/{api,sidecar}/wallet
and src/workspaces/officerdev/src/apps/Wallet, so committing the shared plumbing
on its own would leave a commit that does not build.
officer-wallet is a new pm2 peer holding seed material sealed under an owner
passphrase on top of VAULT_STORE_KEY, with an unlock ttl after which the root key
is wiped from memory. five backends: on-chain via esplora, and lnd, clnrest,
lndhub and nwc for lightning. bolt11 encode/decode is implemented in-tree.
no secrets in the diff — the key-shaped literals under sidecar/wallet are the
bolt11 spec vectors and the bip39 "abandon … about" vector. .env.example gains
placeholders only. bun test src/servers/sidecar/wallet: 38 pass, 0 fail.
not reviewed line by line; assembled and verified to build, not audited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -63,3 +63,17 @@ VAULT_STORE_KEY="<generate with: openssl rand -base64 32>"
|
||||
# HEADSCALE_URL=https://headscale.example.com
|
||||
# HEADSCALE_API_KEY="<headscale admin api key>"
|
||||
# HEADSCALE_USER=officer
|
||||
|
||||
# ── Bitcoin wallet (officer-wallet) ─────────────────────────────────────────────────────────────
|
||||
# Chain data source for the self-custodial on-chain wallet. Any Esplora-compatible API works —
|
||||
# mempool.space by default, or point it at your own node's esplora/electrs when you run one.
|
||||
# WALLET_ESPLORA_URL=https://mempool.space/api
|
||||
# WALLET_NETWORK=bitcoin # bitcoin | testnet | signet | regtest
|
||||
#
|
||||
# How long an unlocked wallet stays unlocked, in seconds. Default 900 (15 min). The root key is held
|
||||
# in the sidecar's memory for exactly this long after an unlock, then wiped. Shorter is safer.
|
||||
# WALLET_UNLOCK_TTL_SEC=900
|
||||
#
|
||||
# NOTE: seed material is encrypted with VAULT_STORE_KEY (above) on top of the owner passphrase that
|
||||
# seals it. Both are required to spend. If you lose VAULT_STORE_KEY, every stored seed is
|
||||
# unrecoverable — back up the mnemonics separately, offline.
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
"name": "officer",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||
"@bitcoinerlab/secp256k1": "1.2.0",
|
||||
"@getalby/sdk": "^8.0.3",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@noble/secp256k1": "^3.1.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||
@@ -38,6 +41,8 @@
|
||||
"@react-oauth/google": "^0.13.4",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@scure/bip32": "^2.2.0",
|
||||
"@scure/bip39": "1.6.0",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tabler/icons-react": "^3.36.0",
|
||||
@@ -53,6 +58,8 @@
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"argon2": "^0.44.0",
|
||||
"bech32": "2.0.0",
|
||||
"bitcoinjs-lib": "6.1.5",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"check-password-strength": "^3.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -65,6 +72,7 @@
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"ecpair": "3.0.1",
|
||||
"emailer": "workspace:*",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"googleapis": "^169.0.0",
|
||||
@@ -117,6 +125,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.182.0",
|
||||
"types": "workspace:*",
|
||||
"varuint-bitcoin": "^2.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"whatsapp-web.js": "^1.34.6",
|
||||
"widgets": "workspace:*",
|
||||
@@ -321,6 +330,8 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@bitcoinerlab/secp256k1": ["@bitcoinerlab/secp256k1@1.2.0", "", { "dependencies": { "@noble/curves": "^1.7.0" } }, "sha512-jeujZSzb3JOZfmJYI0ph1PVpCRV5oaexCgy+RvCXV8XlY+XFB/2n3WOcvBsKLsOw78KYgnQrQWb2HrKE4be88Q=="],
|
||||
|
||||
"@cypress/request": ["@cypress/request@3.0.10", "", { "dependencies": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", "caseless": "~0.12.0", "combined-stream": "~1.0.6", "extend": "~3.0.2", "forever-agent": "~0.6.1", "form-data": "~4.0.4", "http-signature": "~1.4.0", "is-typedarray": "~1.0.0", "isstream": "~0.1.2", "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", "qs": "~6.14.1", "safe-buffer": "^5.1.2", "tough-cookie": "^5.0.0", "tunnel-agent": "^0.6.0", "uuid": "^8.3.2" } }, "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ=="],
|
||||
|
||||
"@cypress/request-promise": ["@cypress/request-promise@5.0.0", "", { "dependencies": { "bluebird": "^3.5.0", "request-promise-core": "1.1.3", "stealthy-require": "^1.1.1", "tough-cookie": "^4.1.3" }, "peerDependencies": { "@cypress/request": "^3.0.0" } }, "sha512-eKdYVpa9cBEw2kTBlHeu1PP16Blwtum6QHg/u9s/MoHkZfuo1pRGka1VlUHXF5kdew82BvOJVVGk0x8X0nbp+w=="],
|
||||
@@ -411,6 +422,10 @@
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@getalby/lightning-tools": ["@getalby/lightning-tools@8.2.0", "", {}, "sha512-eL+cnHyzeUARKVzNRuFBRBppAU5RBJOLjhFVzSWt8hDibRzL5+e4hq8I79+RGHfrWWMUDv382Jx8ewOazrBj7w=="],
|
||||
|
||||
"@getalby/sdk": ["@getalby/sdk@8.0.3", "", { "dependencies": { "@getalby/lightning-tools": "^8.1.1", "nostr-tools": "^2.23.3" } }, "sha512-vPEogAWwLHbL55COeXrRN7yRBXMDGDxX1M2A+o3Lqw60FFng6fa9FK9sw8ptdILMD1dQZq8FzPXuabQ7seoDBA=="],
|
||||
|
||||
"@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
|
||||
@@ -497,6 +512,14 @@
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.1.2", "", { "os": "win32", "cpu": "x64" }, "sha512-D3cNA8NoT3aWISWmo7HF5Eyko/0OdOO+VagkoJuiTk7pyX3P/b+n8XA/MYvyR+xSVcbKn68B1rY9fgqjNISqzQ=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
|
||||
|
||||
"@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
|
||||
|
||||
"@noble/secp256k1": ["@noble/secp256k1@3.1.0", "", {}, "sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
@@ -793,6 +816,12 @@
|
||||
|
||||
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
|
||||
|
||||
"@scure/base": ["@scure/base@2.2.0", "", {}, "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="],
|
||||
|
||||
"@scure/bip32": ["@scure/bip32@2.2.0", "", { "dependencies": { "@noble/curves": "2.2.0", "@noble/hashes": "2.2.0", "@scure/base": "2.2.0" } }, "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg=="],
|
||||
|
||||
"@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="],
|
||||
|
||||
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
|
||||
|
||||
"@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
|
||||
@@ -1067,6 +1096,8 @@
|
||||
|
||||
"bare-url": ["bare-url@2.3.2", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw=="],
|
||||
|
||||
"base-x": ["base-x@4.0.1", "", {}, "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw=="],
|
||||
|
||||
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
@@ -1079,6 +1110,8 @@
|
||||
|
||||
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
|
||||
|
||||
"bech32": ["bech32@2.0.0", "", {}, "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||
|
||||
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
|
||||
@@ -1089,6 +1122,10 @@
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"bip174": ["bip174@2.1.1", "", {}, "sha512-mdFV5+/v0XyNYXjBS6CQPLo9ekCx4gtKZFnJm5PMto7Fs9hTTDpkkzOB7/FtluRI6JbUUAu+snTYfJRgHLZbZQ=="],
|
||||
|
||||
"bitcoinjs-lib": ["bitcoinjs-lib@6.1.5", "", { "dependencies": { "@noble/hashes": "^1.2.0", "bech32": "^2.0.0", "bip174": "^2.1.1", "bs58check": "^3.0.1", "typeforce": "^1.11.3", "varuint-bitcoin": "^1.1.2" } }, "sha512-yuf6xs9QX/E8LWE2aMJPNd0IxGofwfuVOiYdNUESkc+2bHHVKjhJd8qewqapeoolh9fihzHGoDCB5Vkr57RZCQ=="],
|
||||
|
||||
"bl": ["bl@1.2.3", "", { "dependencies": { "readable-stream": "^2.3.5", "safe-buffer": "^5.1.1" } }, "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww=="],
|
||||
|
||||
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
|
||||
@@ -1101,6 +1138,10 @@
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"bs58": ["bs58@5.0.0", "", { "dependencies": { "base-x": "^4.0.0" } }, "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ=="],
|
||||
|
||||
"bs58check": ["bs58check@3.0.1", "", { "dependencies": { "@noble/hashes": "^1.2.0", "bs58": "^5.0.0" } }, "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ=="],
|
||||
|
||||
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||
|
||||
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
|
||||
@@ -1353,6 +1394,8 @@
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"ecpair": ["ecpair@3.0.1", "", { "dependencies": { "uint8array-tools": "^0.0.8", "valibot": "^1.2.0", "wif": "^5.0.0" } }, "sha512-uz8wMFvtdr58TLrXnAesBsoMEyY8UudLOfApcyg40XfZjP+gt1xO4cuZSIkZ8hTMTQ8+ETgt7xSIV4eM7M6VNw=="],
|
||||
|
||||
"editorconfig": ["editorconfig@1.0.4", "", { "dependencies": { "@one-ini/wasm": "0.1.1", "commander": "^10.0.0", "minimatch": "9.0.1", "semver": "^7.5.3" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
@@ -2005,6 +2048,10 @@
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"nostr-tools": ["nostr-tools@2.24.1", "", { "dependencies": { "@noble/ciphers": "2.1.1", "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0", "@scure/bip32": "2.0.1", "@scure/bip39": "2.0.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-KdrKjC74n/rr6J3eCSfZj8dcbZFvolHYe4S22SefNZ5YWbhHiB0KL/mmJjEZ0u6B9mZK0YcQtl+WQ46KzwapeQ=="],
|
||||
|
||||
"nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="],
|
||||
|
||||
"oauth-sign": ["oauth-sign@0.9.0", "", {}, "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
@@ -2585,12 +2632,16 @@
|
||||
|
||||
"typed-query-selector": ["typed-query-selector@2.12.1", "", {}, "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA=="],
|
||||
|
||||
"typeforce": ["typeforce@1.18.0", "", {}, "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g=="],
|
||||
|
||||
"types": ["types@workspace:src/workspaces/types"],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
|
||||
|
||||
"uint8array-tools": ["uint8array-tools@0.0.8", "", {}, "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g=="],
|
||||
|
||||
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
|
||||
|
||||
"undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="],
|
||||
@@ -2639,6 +2690,10 @@
|
||||
|
||||
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="],
|
||||
|
||||
"varuint-bitcoin": ["varuint-bitcoin@2.0.0", "", { "dependencies": { "uint8array-tools": "^0.0.8" } }, "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
|
||||
@@ -2693,6 +2748,8 @@
|
||||
|
||||
"widgets": ["widgets@workspace:src/workspaces/widgets"],
|
||||
|
||||
"wif": ["wif@5.0.0", "", { "dependencies": { "bs58check": "^4.0.0" } }, "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
@@ -2753,6 +2810,8 @@
|
||||
|
||||
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"@puppeteer/browsers/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
@@ -2799,6 +2858,12 @@
|
||||
|
||||
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
|
||||
|
||||
"@scure/bip32/@noble/curves": ["@noble/curves@2.2.0", "", { "dependencies": { "@noble/hashes": "2.2.0" } }, "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ=="],
|
||||
|
||||
"@scure/bip39/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"@scure/bip39/@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="],
|
||||
|
||||
"accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
@@ -2809,8 +2874,14 @@
|
||||
|
||||
"argon2/cross-env": ["cross-env@10.1.0", "", { "dependencies": { "@epic-web/invariant": "^1.0.0", "cross-spawn": "^7.0.6" }, "bin": { "cross-env": "dist/bin/cross-env.js", "cross-env-shell": "dist/bin/cross-env-shell.js" } }, "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw=="],
|
||||
|
||||
"bitcoinjs-lib/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"bitcoinjs-lib/varuint-bitcoin": ["varuint-bitcoin@1.1.2", "", { "dependencies": { "safe-buffer": "^5.1.1" } }, "sha512-4EVb+w4rx+YfVM32HQX42AbbT7/1f5zwAYhIujKXKk8NQK+JfRVl3pqT3hjNn/L+RstigmGGKVwHA/P0wgITZw=="],
|
||||
|
||||
"body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"bs58check/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
@@ -2897,6 +2968,16 @@
|
||||
|
||||
"node-telegram-bot-api/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"nostr-tools/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="],
|
||||
|
||||
"nostr-tools/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
|
||||
|
||||
"nostr-tools/@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="],
|
||||
|
||||
"nostr-tools/@scure/bip32": ["@scure/bip32@2.0.1", "", { "dependencies": { "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA=="],
|
||||
|
||||
"nostr-tools/@scure/bip39": ["@scure/bip39@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg=="],
|
||||
|
||||
"ora/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
@@ -3025,6 +3106,8 @@
|
||||
|
||||
"which-builtin-type/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"wif/bs58check": ["bs58check@4.0.0", "", { "dependencies": { "@noble/hashes": "^1.2.0", "bs58": "^6.0.0" } }, "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g=="],
|
||||
|
||||
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
@@ -3295,6 +3378,10 @@
|
||||
|
||||
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
||||
|
||||
"wif/bs58check/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"wif/bs58check/bs58": ["bs58@6.0.0", "", { "dependencies": { "base-x": "^5.0.0" } }, "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw=="],
|
||||
|
||||
"zip-stream/archiver-utils/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
"zip-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
@@ -3311,6 +3398,8 @@
|
||||
|
||||
"react-email/glob/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="],
|
||||
|
||||
"wif/bs58check/bs58/base-x": ["base-x@5.0.1", "", {}, "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="],
|
||||
|
||||
"zip-stream/archiver-utils/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
@@ -88,5 +88,13 @@ module.exports = {
|
||||
args: 'run src/servers/sidecar/invoiceshelf/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// The bitcoin wallet. Holds seed material (sealed under an owner passphrase) and node credentials, so
|
||||
// it is the one sidecar whose restart has a security-relevant side effect: every wallet relocks.
|
||||
{
|
||||
name: 'officer-wallet',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/wallet/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -37,9 +37,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||
"@bitcoinerlab/secp256k1": "1.2.0",
|
||||
"@getalby/sdk": "^8.0.3",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@noble/secp256k1": "^3.1.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||
@@ -69,6 +72,8 @@
|
||||
"@react-oauth/google": "^0.13.4",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@scure/bip32": "^2.2.0",
|
||||
"@scure/bip39": "1.6.0",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tabler/icons-react": "^3.36.0",
|
||||
@@ -84,6 +89,8 @@
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"argon2": "^0.44.0",
|
||||
"bech32": "2.0.0",
|
||||
"bitcoinjs-lib": "6.1.5",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"check-password-strength": "^3.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -96,6 +103,7 @@
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"ecpair": "3.0.1",
|
||||
"emailer": "workspace:*",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"googleapis": "^169.0.0",
|
||||
@@ -148,6 +156,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.182.0",
|
||||
"types": "workspace:*",
|
||||
"varuint-bitcoin": "^2.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"whatsapp-web.js": "^1.34.6",
|
||||
"widgets": "workspace:*",
|
||||
|
||||
@@ -48,6 +48,8 @@ export function App() {
|
||||
<Route path="/transmission/:section" element={<Dashboard.TransmissionScreen />} />
|
||||
<Route path="/invoices" element={<Dashboard.InvoicesScreen />} />
|
||||
<Route path="/invoices/:section" element={<Dashboard.InvoicesScreen />} />
|
||||
<Route path="/wallet" element={<Dashboard.WalletScreen />} />
|
||||
<Route path="/wallet/:section" element={<Dashboard.WalletScreen />} />
|
||||
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
|
||||
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ import {
|
||||
Radio,
|
||||
Network,
|
||||
ArrowDownUp,
|
||||
Bitcoin,
|
||||
Receipt,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -148,6 +149,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
|
||||
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
|
||||
{ label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' },
|
||||
{ label: 'Wallet', to: '/wallet', icon: Bitcoin, color: '#f7931a' },
|
||||
{ label: 'Invoices', to: '/invoices', icon: Receipt, color: '#0891b2' },
|
||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Navigate, useLocation, useParams } from 'react-router';
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView, DEFAULT_WALLET_SECTION, walletSectionPath, isWalletSection } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /wallet uses the Workspace/Panel system (like /transmission): the wallet list and section nav on the left,
|
||||
// the section itself on the right. Both talk to the officer-wallet sidecar through the /api/wallet auth
|
||||
// proxy, which holds no key material of its own — seeds live encrypted in the sidecar and are decrypted
|
||||
// there only for the length of an unlock window.
|
||||
//
|
||||
// The open section is :section in the URL, the open wallet is ?wallet= and any coin-control selection is
|
||||
// ?coins=, so both panels read the URL with useParams/useSearchParams rather than passing state between
|
||||
// themselves over a channel. This screen backs both /wallet and /wallet/:section and is the single place
|
||||
// that decides what an absent or bogus section means.
|
||||
|
||||
const ALLOWED_APP_TYPES = new Set<string | null>(['wallet-nav', 'wallet-view', null]);
|
||||
|
||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
||||
if (node.type === 'panel') {
|
||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'wallet-view' };
|
||||
}
|
||||
const children = node.children.map((c) => {
|
||||
const fixed = normalizeLayout(c.node);
|
||||
return fixed === c.node ? c : { ...c, node: fixed };
|
||||
});
|
||||
const changed = children.some((c, i) => c !== node.children[i]);
|
||||
return changed ? { ...node, children } : node;
|
||||
}
|
||||
|
||||
export const WalletScreen = () => {
|
||||
const { section } = useParams();
|
||||
const { search } = useLocation();
|
||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/wallet', defaultLayout);
|
||||
|
||||
const workspace = useMemo(() => {
|
||||
const fixed = normalizeLayout(rawWorkspace.value);
|
||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
||||
return { ...rawWorkspace, value: fixed };
|
||||
}, [rawWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
||||
rawWorkspace.setValue(workspace.value);
|
||||
}
|
||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
||||
|
||||
// Bare /wallet, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
|
||||
// default while the address bar says something else — the nav highlight is derived from the URL. The
|
||||
// ?wallet= param is deliberately carried through rather than dropped: /wallet?wallet=3 is a legitimate
|
||||
// deep link and canonicalising the section must not silently change which wallet is open.
|
||||
if (!isWalletSection(section)) {
|
||||
return <Navigate to={`${walletSectionPath(DEFAULT_WALLET_SECTION)}${search}`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView workspace={workspace} locked />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'wallet-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'wallet-nav', appType: 'wallet-nav' }, size: 24 },
|
||||
{ node: { type: 'panel', id: 'wallet-view', appType: 'wallet-view' }, size: 76 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './WalletScreen';
|
||||
@@ -15,6 +15,7 @@ export * from './Soulseek';
|
||||
export * from './Headscale';
|
||||
export * from './Transmission';
|
||||
export * from './Invoices';
|
||||
export * from './Wallet';
|
||||
export * from './SystemMonitor';
|
||||
export * from './Activity';
|
||||
export * from './CodeEditor';
|
||||
|
||||
@@ -20,6 +20,7 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
|
||||
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
|
||||
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
|
||||
{ match: (p) => p.startsWith('/wallet'), title: 'Wallet' },
|
||||
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
|
||||
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
|
||||
{ match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' },
|
||||
|
||||
@@ -151,6 +151,22 @@ export {
|
||||
clearVaultUnlockKey,
|
||||
} from './queries/vault';
|
||||
export type { VaultTokenSet } from './queries/vault';
|
||||
export {
|
||||
listWallets,
|
||||
getWallet,
|
||||
getActiveWallet,
|
||||
getWalletSecrets,
|
||||
getSealedSeed,
|
||||
createWallet,
|
||||
updateWallet,
|
||||
setActiveWallet,
|
||||
deleteWallet,
|
||||
getWalletLabels,
|
||||
setWalletLabel,
|
||||
getFrozenOutpoints,
|
||||
setUtxoFrozen,
|
||||
} from './queries/wallet';
|
||||
export type { WalletKind, WalletSummary, WalletSecrets, WalletLabel, CreateWalletParams } from './queries/wallet';
|
||||
|
||||
export { db } from './db';
|
||||
export * as schema from './schema';
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { walletWallets, walletLabels, walletFrozenUtxos } from '../schema';
|
||||
import { encryptSecret, decryptSecret } from '../crypto';
|
||||
|
||||
// Wallet access for the officer-wallet sidecar. Callers deal in PLAINTEXT — the VAULT_STORE_KEY layer is
|
||||
// applied and stripped here, so route handlers never touch crypto. See ../crypto.ts, ../schema/wallet.ts.
|
||||
//
|
||||
// Note what "plaintext" means for `seedEnvelope`: it is the passphrase-sealed envelope, which is itself
|
||||
// still ciphertext. This layer only removes the SECOND wrapping. Nothing in this file can read a mnemonic,
|
||||
// and that is intentional — only servers/sidecar/wallet/keys.ts can, and only with the owner passphrase.
|
||||
//
|
||||
// THREE return types, and the projection is what enforces the separation:
|
||||
// WalletSummary — safe to serialize to the browser. No config, no seed envelope, at all.
|
||||
// WalletSecrets — decrypted config for the sidecar's own upstream calls. Never returned by a handler.
|
||||
// SealedSeed — the sealed envelope, for keys.ts to open with the owner passphrase.
|
||||
// A bare `select()` would leak both ciphertext columns into every list response the moment someone
|
||||
// forgot to strip them, so no function here uses one.
|
||||
|
||||
export type WalletKind = 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc';
|
||||
|
||||
export type WalletSummary = {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: WalletKind;
|
||||
network: string;
|
||||
fingerprint: string | null;
|
||||
xpubs: Record<string, string> | null;
|
||||
defaultBip: number;
|
||||
isActive: boolean;
|
||||
/** Whether this wallet holds a seed at all — i.e. whether unlock/lock apply to it. */
|
||||
hasSeed: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type WalletSecrets = { id: number; kind: WalletKind; network: string; config: Record<string, unknown> | null };
|
||||
|
||||
const walletCols = {
|
||||
id: walletWallets.id,
|
||||
name: walletWallets.name,
|
||||
kind: walletWallets.kind,
|
||||
network: walletWallets.network,
|
||||
fingerprint: walletWallets.fingerprint,
|
||||
xpubs: walletWallets.xpubs,
|
||||
defaultBip: walletWallets.defaultBip,
|
||||
isActive: walletWallets.isActive,
|
||||
createdAt: walletWallets.createdAt,
|
||||
seedEnvelope: walletWallets.seedEnvelope,
|
||||
};
|
||||
|
||||
function toSummary(row: {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: string;
|
||||
network: string;
|
||||
fingerprint: string | null;
|
||||
xpubs: Record<string, string> | null;
|
||||
defaultBip: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
seedEnvelope: string | null;
|
||||
}): WalletSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as WalletKind,
|
||||
network: row.network,
|
||||
fingerprint: row.fingerprint,
|
||||
xpubs: row.xpubs,
|
||||
defaultBip: row.defaultBip,
|
||||
isActive: row.isActive,
|
||||
hasSeed: row.seedEnvelope !== null,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every wallet the owner has registered, active first then newest. Never includes secrets. */
|
||||
export async function listWallets(userId: number): Promise<WalletSummary[]> {
|
||||
const rows = await db
|
||||
.select(walletCols)
|
||||
.from(walletWallets)
|
||||
.where(eq(walletWallets.userId, userId))
|
||||
.orderBy(desc(walletWallets.isActive), desc(walletWallets.createdAt));
|
||||
return rows.map(toSummary);
|
||||
}
|
||||
|
||||
export async function getWallet(userId: number, id: number): Promise<WalletSummary | null> {
|
||||
const [row] = await db
|
||||
.select(walletCols)
|
||||
.from(walletWallets)
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
|
||||
return row ? toSummary(row) : null;
|
||||
}
|
||||
|
||||
export async function getActiveWallet(userId: number): Promise<WalletSummary | null> {
|
||||
const [row] = await db
|
||||
.select(walletCols)
|
||||
.from(walletWallets)
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.isActive, true)));
|
||||
return row ? toSummary(row) : null;
|
||||
}
|
||||
|
||||
/** Decrypted backend connection config, for the sidecar's upstream calls. Never leaves the sidecar. */
|
||||
export async function getWalletSecrets(userId: number, id: number): Promise<WalletSecrets | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: walletWallets.id,
|
||||
kind: walletWallets.kind,
|
||||
network: walletWallets.network,
|
||||
config: walletWallets.config,
|
||||
})
|
||||
.from(walletWallets)
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as WalletKind,
|
||||
network: row.network,
|
||||
config: row.config ? (JSON.parse(decryptSecret(row.config)) as Record<string, unknown>) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The passphrase-sealed seed envelope, still sealed. Returns null for wallets that hold no seed.
|
||||
* The ONLY caller should be the sidecar's unlock path.
|
||||
*/
|
||||
export async function getSealedSeed(userId: number, id: number): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ seedEnvelope: walletWallets.seedEnvelope })
|
||||
.from(walletWallets)
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
|
||||
if (!row?.seedEnvelope) return null;
|
||||
return decryptSecret(row.seedEnvelope);
|
||||
}
|
||||
|
||||
export type CreateWalletParams = {
|
||||
userId: number;
|
||||
name: string;
|
||||
kind: WalletKind;
|
||||
network: string;
|
||||
/** Plaintext; encrypted here. */
|
||||
config?: Record<string, unknown> | null;
|
||||
/** The already-sealed envelope as JSON; encrypted again here. */
|
||||
sealedSeed?: string | null;
|
||||
fingerprint?: string | null;
|
||||
xpubs?: Record<string, string> | null;
|
||||
defaultBip?: number;
|
||||
makeActive?: boolean;
|
||||
};
|
||||
|
||||
export async function createWallet(params: CreateWalletParams): Promise<WalletSummary> {
|
||||
return db.transaction(async (tx) => {
|
||||
if (params.makeActive) {
|
||||
await tx
|
||||
.update(walletWallets)
|
||||
.set({ isActive: false })
|
||||
.where(and(eq(walletWallets.userId, params.userId), eq(walletWallets.isActive, true)));
|
||||
}
|
||||
const [row] = await tx
|
||||
.insert(walletWallets)
|
||||
.values({
|
||||
userId: params.userId,
|
||||
name: params.name,
|
||||
kind: params.kind,
|
||||
network: params.network,
|
||||
config: params.config ? encryptSecret(JSON.stringify(params.config)) : null,
|
||||
seedEnvelope: params.sealedSeed ? encryptSecret(params.sealedSeed) : null,
|
||||
fingerprint: params.fingerprint ?? null,
|
||||
xpubs: params.xpubs ?? null,
|
||||
defaultBip: params.defaultBip ?? 84,
|
||||
isActive: params.makeActive ?? false,
|
||||
})
|
||||
.returning(walletCols);
|
||||
return toSummary(row!);
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWallet(
|
||||
userId: number,
|
||||
id: number,
|
||||
patch: { name?: string; config?: Record<string, unknown>; defaultBip?: number; sealedSeed?: string },
|
||||
): Promise<WalletSummary | null> {
|
||||
const set: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (patch.name !== undefined) set.name = patch.name;
|
||||
if (patch.defaultBip !== undefined) set.defaultBip = patch.defaultBip;
|
||||
if (patch.config !== undefined) set.config = encryptSecret(JSON.stringify(patch.config));
|
||||
if (patch.sealedSeed !== undefined) set.seedEnvelope = encryptSecret(patch.sealedSeed);
|
||||
|
||||
const [row] = await db
|
||||
.update(walletWallets)
|
||||
.set(set)
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)))
|
||||
.returning(walletCols);
|
||||
return row ? toSummary(row) : null;
|
||||
}
|
||||
|
||||
/** Exactly one active wallet per owner. Cleared and set in one transaction; the partial unique index is the backstop. */
|
||||
export async function setActiveWallet(userId: number, id: number): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(walletWallets)
|
||||
.set({ isActive: false })
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.isActive, true)));
|
||||
await tx
|
||||
.update(walletWallets)
|
||||
.set({ isActive: true, updatedAt: new Date() })
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteWallet(userId: number, id: number): Promise<boolean> {
|
||||
const rows = await db
|
||||
.delete(walletWallets)
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)))
|
||||
.returning({ id: walletWallets.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// ── labels ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type WalletLabel = { kind: 'address' | 'tx'; ref: string; label: string };
|
||||
|
||||
export async function getWalletLabels(walletId: number): Promise<WalletLabel[]> {
|
||||
const rows = await db
|
||||
.select({ kind: walletLabels.kind, ref: walletLabels.ref, label: walletLabels.label })
|
||||
.from(walletLabels)
|
||||
.where(eq(walletLabels.walletId, walletId));
|
||||
return rows.map((r) => ({ kind: r.kind as 'address' | 'tx', ref: r.ref, label: r.label }));
|
||||
}
|
||||
|
||||
export async function setWalletLabel(walletId: number, kind: 'address' | 'tx', ref: string, label: string): Promise<void> {
|
||||
// An empty label is a delete — the UI clears a field rather than pressing a separate button.
|
||||
if (!label.trim()) {
|
||||
await db
|
||||
.delete(walletLabels)
|
||||
.where(and(eq(walletLabels.walletId, walletId), eq(walletLabels.kind, kind), eq(walletLabels.ref, ref)));
|
||||
return;
|
||||
}
|
||||
await db
|
||||
.insert(walletLabels)
|
||||
.values({ walletId, kind, ref, label })
|
||||
.onConflictDoUpdate({ target: [walletLabels.walletId, walletLabels.kind, walletLabels.ref], set: { label } });
|
||||
}
|
||||
|
||||
// ── frozen UTXOs ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getFrozenOutpoints(walletId: number): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ outpoint: walletFrozenUtxos.outpoint })
|
||||
.from(walletFrozenUtxos)
|
||||
.where(eq(walletFrozenUtxos.walletId, walletId));
|
||||
return rows.map((r) => r.outpoint);
|
||||
}
|
||||
|
||||
export async function setUtxoFrozen(walletId: number, outpoint: string, frozen: boolean, reason?: string): Promise<void> {
|
||||
if (!frozen) {
|
||||
await db
|
||||
.delete(walletFrozenUtxos)
|
||||
.where(and(eq(walletFrozenUtxos.walletId, walletId), eq(walletFrozenUtxos.outpoint, outpoint)));
|
||||
return;
|
||||
}
|
||||
await db
|
||||
.insert(walletFrozenUtxos)
|
||||
.values({ walletId, outpoint, reason: reason ?? null })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
@@ -10,3 +10,4 @@ export * from './music';
|
||||
export * from './soulseek';
|
||||
export * from './headscale';
|
||||
export * from './vault';
|
||||
export * from './wallet';
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { pgTable, serial, integer, text, boolean, timestamp, jsonb, unique, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { users } from './auth';
|
||||
|
||||
// Bitcoin wallets for the officer-wallet sidecar. The owner registers one or more wallets — either a
|
||||
// self-custodial on-chain wallet whose seed lives here, or a connection to a node (LND / Core Lightning /
|
||||
// LNDHub / NWC) exactly as Zeus models them — and switches between them.
|
||||
//
|
||||
// TWO COLUMNS HOLD SPENDING AUTHORITY AND THEY ARE PROTECTED DIFFERENTLY. This asymmetry is deliberate:
|
||||
//
|
||||
// `config` — node credentials (macaroon, rune, LNDHub password, NWC URI). Encrypted at rest with
|
||||
// VAULT_STORE_KEY via ../crypto.ts, same as headscale_servers.api_key. It CANNOT be
|
||||
// passphrase-protected: background balance polling needs it without the owner present.
|
||||
//
|
||||
// `seed_envelope` — a BIP39 mnemonic that is ALREADY sealed under an owner passphrase by the sidecar
|
||||
// (see servers/sidecar/wallet/keys.ts) before it ever arrives here, and is then
|
||||
// encrypted AGAIN with VAULT_STORE_KEY on the way into this column. Two independent
|
||||
// secrets, neither sufficient alone. A database dump does not spend; a leaked .env
|
||||
// does not spend.
|
||||
//
|
||||
// `xpubs` and `fingerprint` are stored in the CLEAR, on purpose. They are what lets the wallet show
|
||||
// balances, history and fresh receive addresses while locked — the watch-only-when-locked property. An
|
||||
// xpub leak costs privacy (an observer can enumerate the wallet's addresses), never funds.
|
||||
//
|
||||
// Every table is `wallet_`-prefixed and this file holds nothing else, so it can move into
|
||||
// src/servers/sidecar/wallet/ wholesale when sidecars own their schema. Only officer-wallet reads these.
|
||||
|
||||
export const walletWallets = pgTable(
|
||||
'wallet_wallets',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
// BackendKind: 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc'. TS-only enum, no DB CHECK —
|
||||
// matches how pipeline_jobs.mode is handled elsewhere in this schema.
|
||||
kind: text('kind').notNull(),
|
||||
// BitcoinNetwork: 'bitcoin' | 'testnet' | 'signet' | 'regtest'.
|
||||
network: text('network').notNull().default('bitcoin'),
|
||||
// Backend connection config as JSON, encrypted. Null for a pure on-chain wallet, which connects to
|
||||
// nothing but the Esplora endpoint the sidecar is configured with.
|
||||
config: text('config'),
|
||||
// The passphrase-sealed SeedEnvelope, encrypted again. Null for every remote-node wallet — those
|
||||
// hold no seed, the node does.
|
||||
seedEnvelope: text('seed_envelope'),
|
||||
// BIP32 master fingerprint (8 hex chars), for PSBT construction and hardware-wallet pairing.
|
||||
fingerprint: text('fingerprint'),
|
||||
// { "44": "xpub…", "49": "ypub…", "84": "zpub…", "86": "xpub…" } — plaintext, see header.
|
||||
xpubs: jsonb('xpubs').$type<Record<string, string>>(),
|
||||
// Which derivation standard new receive addresses use. 84 (native segwit) is the default.
|
||||
defaultBip: integer('default_bip').notNull().default(84),
|
||||
isActive: boolean('is_active').notNull().default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
unique('uq_wallet_wallets_user_name').on(t.userId, t.name),
|
||||
// At most one active wallet per owner, enforced by the DB rather than convention — a partial unique
|
||||
// index over active rows only, mirroring uq_headscale_servers_one_active.
|
||||
uniqueIndex('uq_wallet_wallets_one_active')
|
||||
.on(t.userId)
|
||||
.where(sql`${t.isActive}`),
|
||||
],
|
||||
);
|
||||
|
||||
// Owner-assigned labels for addresses and transactions. Zeus keeps these client-side; here they belong to
|
||||
// the server so every client sees the same annotations.
|
||||
export const walletLabels = pgTable(
|
||||
'wallet_labels',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
walletId: integer('wallet_id')
|
||||
.notNull()
|
||||
.references(() => walletWallets.id, { onDelete: 'cascade' }),
|
||||
// 'address' | 'tx'
|
||||
kind: text('kind').notNull(),
|
||||
// The address string or txid being labelled.
|
||||
ref: text('ref').notNull(),
|
||||
label: text('label').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [unique('uq_wallet_labels_wallet_kind_ref').on(t.walletId, t.kind, t.ref)],
|
||||
);
|
||||
|
||||
// Frozen UTXOs, excluded from automatic coin selection. Separate from labels because this one affects
|
||||
// what the wallet will actually spend — losing a label is cosmetic, losing a freeze spends a coin the
|
||||
// owner meant to keep back.
|
||||
export const walletFrozenUtxos = pgTable(
|
||||
'wallet_frozen_utxos',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
walletId: integer('wallet_id')
|
||||
.notNull()
|
||||
.references(() => walletWallets.id, { onDelete: 'cascade' }),
|
||||
// `txid:vout`
|
||||
outpoint: text('outpoint').notNull(),
|
||||
reason: text('reason'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [unique('uq_wallet_frozen_utxos_wallet_outpoint').on(t.walletId, t.outpoint)],
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getWalletServerUrl } from './sidecar-server';
|
||||
|
||||
// Thin reverse-proxy for /api/wallet/*. The platform's ONLY job here is AUTH + FORWARDING: this router
|
||||
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards
|
||||
// the subpath + query + body to the officer-wallet sidecar, which owns every wallet contract and holds
|
||||
// the key material.
|
||||
//
|
||||
// A catch-all with no routes of its own. This file must never grow wallet logic — and for this sidecar
|
||||
// that rule carries more weight than usual. The platform process is long-lived, restarts on every
|
||||
// deploy, and is the largest attack surface in the system. Keeping it structurally incapable of seeing a
|
||||
// seed, a macaroon, or an unlock passphrase is the entire design.
|
||||
//
|
||||
// The unlock passphrase DOES transit this proxy on its way to the sidecar. That is unavoidable — the
|
||||
// browser has to send it somewhere — but it is forwarded as an opaque body and never logged, never
|
||||
// parsed, and never retained here. Note the deliberate absence of any body inspection below.
|
||||
|
||||
export const walletRouter = createRouter();
|
||||
|
||||
const PREFIX = '/api/wallet';
|
||||
|
||||
walletRouter.all('/*', async (ctx) => {
|
||||
const baseUrl = getWalletServerUrl();
|
||||
if (!baseUrl) return ctx.text('wallet sidecar not available', 503);
|
||||
|
||||
const url = new URL(ctx.req.url);
|
||||
const subpath = url.pathname.slice(PREFIX.length) || '/';
|
||||
const target = `${baseUrl}${subpath}${url.search}`;
|
||||
|
||||
const method = ctx.req.method;
|
||||
const headers: Record<string, string> = {};
|
||||
const contentType = ctx.req.header('content-type');
|
||||
if (contentType) headers['Content-Type'] = contentType;
|
||||
// Forward the authenticated user id so the sidecar can scope every wallet to its owner. The sidecar
|
||||
// binds loopback only, so this header is trusted.
|
||||
headers['X-Officer-User'] = String(ctx.get('user').id);
|
||||
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(target, {
|
||||
method,
|
||||
headers,
|
||||
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
// Deliberately logs the target path only — never the body, which may carry a passphrase.
|
||||
console.error('[wallet] proxy fetch failed', { target, error: String(err) });
|
||||
return ctx.text('wallet sidecar unreachable', 502);
|
||||
}
|
||||
|
||||
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
|
||||
// The officer-wallet sidecar starts its HTTP server on a random loopback port and reports it here on
|
||||
// connect. We remember it so `/api/wallet/*` always forwards to the current sidecar. The platform holds
|
||||
// NO wallet knowledge whatsoever — not a seed, not a node credential, not an xpub. It cannot spend, and
|
||||
// it cannot read a balance except by asking the sidecar.
|
||||
|
||||
let serverPort: number | null = null;
|
||||
|
||||
sidecar.on('wallet:server', (msg) => {
|
||||
const port = (msg as { port?: number }).port;
|
||||
if (typeof port !== 'number') return;
|
||||
serverPort = port;
|
||||
console.log(`[wallet] sidecar registered on port ${port}`);
|
||||
});
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if the sidecar hasn't reported in yet. */
|
||||
export function getWalletServerUrl(): string | null {
|
||||
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { slskdRouter } from './api/slskd/router';
|
||||
import { headscaleRouter } from './api/headscale/router';
|
||||
import { transmissionRouter } from './api/transmission/router';
|
||||
import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
||||
import { walletRouter } from './api/wallet/router';
|
||||
import { vpnRouter } from './api/vpn/router';
|
||||
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
|
||||
import { activityRouter } from './api/activity/router';
|
||||
@@ -34,6 +35,7 @@ import './api/slskd/sidecar-server'; // side-effect: capture the officer-slskd r
|
||||
import './api/headscale/sidecar-server'; // side-effect: capture the officer-headscale server port
|
||||
import './api/transmission/sidecar-server'; // side-effect: capture the officer-transmission server port
|
||||
import './api/invoiceshelf/sidecar-server'; // side-effect: capture the officer-invoiceshelf server port
|
||||
import './api/wallet/sidecar-server'; // side-effect: capture the officer-wallet server port
|
||||
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
|
||||
import { dockRouter } from './api/dock/dock';
|
||||
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
||||
@@ -119,6 +121,7 @@ protectedRouter.route('/slskd', slskdRouter);
|
||||
protectedRouter.route('/headscale', headscaleRouter);
|
||||
protectedRouter.route('/transmission', transmissionRouter);
|
||||
protectedRouter.route('/invoiceshelf', invoiceshelfRouter);
|
||||
protectedRouter.route('/wallet', walletRouter);
|
||||
protectedRouter.route('/vpn', vpnRouter);
|
||||
protectedRouter.route('/system-monitor', systemMonitorRouter);
|
||||
protectedRouter.route('/activity', activityRouter);
|
||||
|
||||
@@ -74,6 +74,8 @@ export type SidecarEvent =
|
||||
| { type: 'transmission:server'; port: number }
|
||||
// InvoiceShelf — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||
| { type: 'invoiceshelf:server'; port: number }
|
||||
// Wallet — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||
| { type: 'wallet:server'; port: number }
|
||||
// Generic
|
||||
| { type: 'error'; id?: string; error: string };
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
BackendError,
|
||||
type Balances,
|
||||
type BackendKind,
|
||||
type Capability,
|
||||
type Channel,
|
||||
type CreateInvoiceRequest,
|
||||
type DecodedInvoice,
|
||||
type FeeEstimates,
|
||||
type Invoice,
|
||||
type KeysendRequest,
|
||||
type NewAddressRequest,
|
||||
type NodeInfo,
|
||||
type OnchainTx,
|
||||
type PayInvoiceRequest,
|
||||
type Payment,
|
||||
type Peer,
|
||||
type SendCoinsRequest,
|
||||
type SendCoinsResult,
|
||||
type SignMessageResult,
|
||||
type Utxo,
|
||||
type VerifyMessageResult,
|
||||
type WalletBackend,
|
||||
type AddressType,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Shared backend base. Declares its capability set once and turns every unimplemented method into a
|
||||
* clean 501 — the counterpart to Zeus's `BackendUtils.call()` returning `false` for a missing method
|
||||
* (utils/BackendUtils.ts:56-58), but loud instead of silent.
|
||||
*/
|
||||
export abstract class BaseBackend implements WalletBackend {
|
||||
abstract readonly kind: BackendKind;
|
||||
protected abstract readonly capabilities: ReadonlySet<Capability>;
|
||||
|
||||
supports(cap: Capability): boolean {
|
||||
return this.capabilities.has(cap);
|
||||
}
|
||||
|
||||
protected notSupported(op: string): never {
|
||||
throw new BackendError(`${this.kind} does not support ${op}`, 501, 'NOT_SUPPORTED');
|
||||
}
|
||||
|
||||
abstract getInfo(): Promise<NodeInfo>;
|
||||
abstract getBalances(): Promise<Balances>;
|
||||
|
||||
getTransactions(_opts?: { limit?: number }): Promise<OnchainTx[]> {
|
||||
return this.notSupported('on-chain transaction history');
|
||||
}
|
||||
getNewAddress(_req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
|
||||
return this.notSupported('on-chain receive');
|
||||
}
|
||||
getUtxos(): Promise<Utxo[]> {
|
||||
return this.notSupported('coin control');
|
||||
}
|
||||
estimateFees(): Promise<FeeEstimates> {
|
||||
return this.notSupported('fee estimation');
|
||||
}
|
||||
sendCoins(_req: SendCoinsRequest): Promise<SendCoinsResult> {
|
||||
return this.notSupported('on-chain send');
|
||||
}
|
||||
|
||||
getInvoices(_opts?: { limit?: number }): Promise<Invoice[]> {
|
||||
return this.notSupported('invoice listing');
|
||||
}
|
||||
createInvoice(_req: CreateInvoiceRequest): Promise<Invoice> {
|
||||
return this.notSupported('lightning receive');
|
||||
}
|
||||
lookupInvoice(_paymentHash: string): Promise<Invoice | null> {
|
||||
return this.notSupported('invoice lookup');
|
||||
}
|
||||
decodeInvoice(_bolt11: string): Promise<DecodedInvoice> {
|
||||
return this.notSupported('invoice decoding');
|
||||
}
|
||||
getPayments(_opts?: { limit?: number }): Promise<Payment[]> {
|
||||
return this.notSupported('payment history');
|
||||
}
|
||||
payInvoice(_req: PayInvoiceRequest): Promise<Payment> {
|
||||
return this.notSupported('lightning send');
|
||||
}
|
||||
sendKeysend(_req: KeysendRequest): Promise<Payment> {
|
||||
return this.notSupported('keysend');
|
||||
}
|
||||
|
||||
getChannels(): Promise<Channel[]> {
|
||||
return this.notSupported('channels');
|
||||
}
|
||||
getPeers(): Promise<Peer[]> {
|
||||
return this.notSupported('peers');
|
||||
}
|
||||
|
||||
signMessage(_message: string): Promise<SignMessageResult> {
|
||||
return this.notSupported('message signing');
|
||||
}
|
||||
verifyMessage(_message: string, _signature: string): Promise<VerifyMessageResult> {
|
||||
return this.notSupported('message verification');
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Zeus reaches its REST backends through `react-native-blob-util` with a `trusty: true` flag, which
|
||||
// disables TLS verification wholesale so a node with a self-signed cert is reachable (backends/LND.ts).
|
||||
// Bun's fetch takes an equivalent per-request `tls` option, so no extra HTTP client is needed.
|
||||
//
|
||||
// Verification is only relaxed when the owner explicitly opts in for a specific node, and it is a real
|
||||
// trade: it buys reachability for a node with a self-signed cert at the cost of MITM protection on that
|
||||
// connection. Acceptable over loopback or a tailnet, not over the open internet — which is why it is a
|
||||
// per-wallet flag the owner sets deliberately, never a default.
|
||||
type BunFetchInit = RequestInit & { tls?: { rejectUnauthorized?: boolean } };
|
||||
|
||||
export type HttpOptions = {
|
||||
method?: string;
|
||||
path: string;
|
||||
base: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
allowSelfSigned?: boolean;
|
||||
timeoutMs?: number;
|
||||
/** Return the raw Response instead of parsed JSON. */
|
||||
raw?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** The single door every REST backend goes through. Normalizes errors into BackendError. */
|
||||
export async function httpJson<T = unknown>(opts: HttpOptions): Promise<T> {
|
||||
const base = opts.base.replace(/\/+$/, '');
|
||||
const url = new URL(`${base}${opts.path.startsWith('/') ? opts.path : `/${opts.path}`}`);
|
||||
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
||||
if (v !== undefined) url.searchParams.set(k, String(v));
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: 'application/json', ...opts.headers };
|
||||
let body: string | undefined;
|
||||
if (opts.body !== undefined) {
|
||||
body = JSON.stringify(opts.body);
|
||||
headers['Content-Type'] ??= 'application/json';
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
|
||||
const init: BunFetchInit = {
|
||||
method: opts.method ?? 'GET',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
};
|
||||
if (opts.allowSelfSigned) init.tls = { rejectUnauthorized: false };
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, init);
|
||||
} catch (err) {
|
||||
const msg = controller.signal.aborted ? `timed out after ${opts.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms` : String(err);
|
||||
throw new BackendError(`request to ${url.host} failed: ${msg}`, 502, 'UPSTREAM_UNREACHABLE');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (opts.raw) return res as unknown as T;
|
||||
|
||||
const text = await res.text();
|
||||
let parsed: unknown = null;
|
||||
if (text) {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
if (!res.ok) throw new BackendError(`upstream ${res.status}: ${text.slice(0, 200)}`, res.status);
|
||||
throw new BackendError(`upstream returned non-JSON: ${text.slice(0, 200)}`, 502);
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
// LND puts {error, code}; CLN puts {message}; LNDHub puts {error, message}. Try all three.
|
||||
const p = parsed as { error?: string; message?: string; code?: number } | null;
|
||||
const msg = p?.error ?? p?.message ?? `upstream returned ${res.status}`;
|
||||
throw new BackendError(msg, res.status, p?.code != null ? String(p.code) : undefined);
|
||||
}
|
||||
|
||||
return parsed as T;
|
||||
}
|
||||
|
||||
/** Hex → base64. LND's REST API takes bytes fields base64-encoded; callers hold hex. */
|
||||
export function hexToBase64(hex: string): string {
|
||||
return Buffer.from(hex, 'hex').toString('base64');
|
||||
}
|
||||
|
||||
/** Base64 → hex, for reading LND's bytes fields back out. */
|
||||
export function base64ToHex(b64: string): string {
|
||||
return Buffer.from(b64, 'base64').toString('hex');
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,859 @@
|
||||
// LND REST backend — ported from Zeus's `backends/LND.ts` (+ `utils/LndUtils.ts` for the address-type
|
||||
// mapping). Zeus's class is a thin, untyped RPC shim: every method returns the upstream JSON verbatim and
|
||||
// the view layer does the unit conversion. Here the conversion happens once, at the edge, against the
|
||||
// `WalletBackend` contract in `../types.ts`.
|
||||
//
|
||||
// INTENTIONAL BEHAVIOURAL DIFFERENCES FROM ZEUS
|
||||
//
|
||||
// 1. No Tor. Zeus routes through `doTorRequest` when `enableTor` is set. A sidecar on the owner's own box
|
||||
// reaches its node directly; if Tor is ever needed it belongs in the dispatcher, not per backend.
|
||||
// 2. No in-flight call de-duplication. Zeus keys a module-level `calls` Map by url+body so a second
|
||||
// identical request joins the first, and needs `clearCachedCalls()` to escape a poisoned entry. That
|
||||
// cache is a mobile-battery optimisation and a footgun (a failed call can be re-awaited); dropped.
|
||||
// 3. No `forcedTimeout` race. Zeus races `payLightningInvoice` against a promise that RESOLVES with a fake
|
||||
// `{payment_error: 'timed out'}` after timeout+1s — so a slow payment silently reports failure while
|
||||
// still in flight. We pass a real deadline to `httpJson` and let it abort, surfacing a 502.
|
||||
// 4. TLS verification is opt-in per node (`allowSelfSigned`), not the blanket `trusty: !certVerification`.
|
||||
// 5. Version gating is OPTIMISTIC until `getInfo()` runs. Zeus reads `nodeInfoStore.version`, which is
|
||||
// empty before the first getinfo, so every version-gated `supportsX()` answers false. Here the gated
|
||||
// capabilities (coinControl, accounts, offers) start ON and are switched OFF once getInfo reports an
|
||||
// older node — a pre-getInfo call to an old node fails upstream instead of 501-ing on a modern one.
|
||||
// 6. `offers` is declared for v0.18+. Zeus hardcodes `supportsOffers = () => false` for LND.
|
||||
// 7. Websocket-only endpoints are not ported: `openChannelStream`, `initChanAcceptor`,
|
||||
// `subscribeCustomMessages`, `subscribeInvoice`, `subscribeTransactions`. `WalletBackend` has no
|
||||
// streaming surface.
|
||||
// 8. `sendCoins` reports `feeSats: 0`. LND's SendCoinsResponse carries only `txid`; the fee is only
|
||||
// observable afterwards through GetTransactions, and we do not block the send on a second round trip.
|
||||
// 9. `SendCoinsRequest.rbf` is ignored — lnrpc.SendCoins has no replaceability flag to forward.
|
||||
// 10. `Utxo.derivationPath` is always null. Zeus reads paths from a separate ListAddresses call (v0.18+);
|
||||
// joining that per-UTXO here would double the request count for a field nothing currently consumes.
|
||||
// 11. `Peer.alias` is always null, matching Zeus's `listPeers` — /v1/peers has no alias, and resolving one
|
||||
// costs a graph lookup per peer. Channels DO get an alias, via ListChannels' own `peer_alias_lookup`.
|
||||
// 12. `InvoiceState.expired` is derived (unsettled + past `creation_date + expiry`); lnrpc's Invoice.state
|
||||
// enum has no EXPIRED member.
|
||||
// 13. `estimateFees` uses walletrpc EstimateFee. Zeus does not ask LND for on-chain fees at all — it reads
|
||||
// mempool.space in the app layer.
|
||||
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import {
|
||||
BackendError,
|
||||
type AddressType,
|
||||
type Balances,
|
||||
type BackendKind,
|
||||
type BitcoinNetwork,
|
||||
type Capability,
|
||||
type Channel,
|
||||
type CreateInvoiceRequest,
|
||||
type DecodedInvoice,
|
||||
type FeeEstimates,
|
||||
type Invoice,
|
||||
type InvoiceState,
|
||||
type KeysendRequest,
|
||||
type NewAddressRequest,
|
||||
type NodeInfo,
|
||||
type OnchainTx,
|
||||
type PayInvoiceRequest,
|
||||
type Payment,
|
||||
type PaymentStatus,
|
||||
type Peer,
|
||||
type SendCoinsRequest,
|
||||
type SendCoinsResult,
|
||||
type SignMessageResult,
|
||||
type Utxo,
|
||||
type VerifyMessageResult,
|
||||
} from '../types';
|
||||
import { BaseBackend, base64ToHex, hexToBase64, httpJson } from './base';
|
||||
|
||||
// ── upstream wire types ──────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Field names are lnrpc's own, verbatim. grpc-gateway renders every int64 as a decimal STRING and every
|
||||
// `bytes` field as base64 — both quirks are load-bearing below, so the types record them exactly.
|
||||
|
||||
type LndChain = { chain?: string; network?: string };
|
||||
|
||||
type LndGetInfo = {
|
||||
version?: string;
|
||||
identity_pubkey?: string;
|
||||
alias?: string;
|
||||
block_height?: number;
|
||||
synced_to_chain?: boolean;
|
||||
testnet?: boolean;
|
||||
chains?: LndChain[];
|
||||
};
|
||||
|
||||
type LndBlockchainBalance = {
|
||||
total_balance?: string;
|
||||
confirmed_balance?: string;
|
||||
unconfirmed_balance?: string;
|
||||
};
|
||||
|
||||
/** lnrpc.Amount — the same sat/msat pair LND uses everywhere it reports a channel-side balance. */
|
||||
type LndAmount = { sat?: string; msat?: string };
|
||||
|
||||
type LndChannelBalance = {
|
||||
balance?: string;
|
||||
local_balance?: LndAmount;
|
||||
remote_balance?: LndAmount;
|
||||
};
|
||||
|
||||
type LndTransaction = {
|
||||
tx_hash?: string;
|
||||
amount?: string;
|
||||
num_confirmations?: number;
|
||||
block_height?: number;
|
||||
time_stamp?: string;
|
||||
total_fees?: string;
|
||||
dest_addresses?: string[];
|
||||
raw_tx_hex?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
type LndUtxo = {
|
||||
address_type?: string;
|
||||
address?: string;
|
||||
amount_sat?: string;
|
||||
confirmations?: string;
|
||||
outpoint?: { txid_str?: string; output_index?: number };
|
||||
};
|
||||
|
||||
type LndEstimateFee = { sat_per_kw?: string; min_relay_fee_sat_per_kw?: string };
|
||||
|
||||
type LndInvoice = {
|
||||
memo?: string;
|
||||
r_preimage?: string;
|
||||
r_hash?: string;
|
||||
value?: string;
|
||||
value_msat?: string;
|
||||
creation_date?: string;
|
||||
settle_date?: string;
|
||||
payment_request?: string;
|
||||
expiry?: string;
|
||||
amt_paid_msat?: string;
|
||||
state?: string;
|
||||
is_keysend?: boolean;
|
||||
is_amp?: boolean;
|
||||
};
|
||||
|
||||
type LndAddInvoiceResponse = { r_hash?: string; payment_request?: string; payment_addr?: string };
|
||||
|
||||
type LndFeature = { name?: string; is_required?: boolean; is_known?: boolean };
|
||||
|
||||
type LndPayReq = {
|
||||
destination?: string;
|
||||
payment_hash?: string;
|
||||
num_msat?: string;
|
||||
num_satoshis?: string;
|
||||
timestamp?: string;
|
||||
expiry?: string;
|
||||
description?: string;
|
||||
cltv_expiry?: string;
|
||||
route_hints?: unknown[];
|
||||
features?: Record<string, LndFeature>;
|
||||
};
|
||||
|
||||
type LndHop = { pub_key?: string };
|
||||
type LndHtlcAttempt = { route?: { hops?: LndHop[] } };
|
||||
|
||||
type LndPayment = {
|
||||
payment_hash?: string;
|
||||
payment_preimage?: string;
|
||||
value_msat?: string;
|
||||
fee_msat?: string;
|
||||
status?: string;
|
||||
creation_date?: string;
|
||||
creation_time_ns?: string;
|
||||
failure_reason?: string;
|
||||
htlcs?: LndHtlcAttempt[];
|
||||
};
|
||||
|
||||
/** One newline-delimited frame of the SendPaymentV2 stream. */
|
||||
type LndRouterFrame = { result?: LndPayment; error?: { message?: string; code?: number } };
|
||||
|
||||
type LndChannel = {
|
||||
chan_id?: string;
|
||||
channel_point?: string;
|
||||
remote_pubkey?: string;
|
||||
peer_alias?: string;
|
||||
capacity?: string;
|
||||
local_balance?: string;
|
||||
remote_balance?: string;
|
||||
active?: boolean;
|
||||
private?: boolean;
|
||||
};
|
||||
|
||||
type LndPendingChannel = {
|
||||
remote_node_pub?: string;
|
||||
channel_point?: string;
|
||||
capacity?: string;
|
||||
local_balance?: string;
|
||||
remote_balance?: string;
|
||||
private?: boolean;
|
||||
};
|
||||
|
||||
type LndPendingChannels = {
|
||||
pending_open_channels?: { channel?: LndPendingChannel }[];
|
||||
pending_force_closing_channels?: { channel?: LndPendingChannel }[];
|
||||
waiting_close_channels?: { channel?: LndPendingChannel }[];
|
||||
};
|
||||
|
||||
type LndPeer = { pub_key?: string; address?: string; inbound?: boolean };
|
||||
|
||||
// ── constants ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Zeus's LndUtils.LNRPC_NEW_ADDRESS_TYPE_NAMES, verbatim. See that file for why each row exists. */
|
||||
const LNRPC_NEW_ADDRESS_TYPE_NAMES: Record<string, string> = {
|
||||
'0': 'WITNESS_PUBKEY_HASH',
|
||||
'1': 'NESTED_PUBKEY_HASH',
|
||||
'2': 'UNUSED_WITNESS_PUBKEY_HASH',
|
||||
'3': 'UNUSED_NESTED_PUBKEY_HASH',
|
||||
'4': 'TAPROOT_PUBKEY',
|
||||
'5': 'UNUSED_TAPROOT_PUBKEY',
|
||||
NESTED_WITNESS_PUBKEY_HASH: 'NESTED_PUBKEY_HASH',
|
||||
HYBRID_NESTED_WITNESS_PUBKEY_HASH: 'NESTED_PUBKEY_HASH',
|
||||
};
|
||||
|
||||
/**
|
||||
* Zeus's `toLnrpcAddressType`. The numeric strings matter: LND REST's grpc-gateway silently treats an
|
||||
* unrecognised `type` as WITNESS_PUBKEY_HASH, so sending '1' quietly yields a native segwit address.
|
||||
*/
|
||||
function toLnrpcAddressType(value: string | number | undefined | null): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
const key = String(value);
|
||||
return LNRPC_NEW_ADDRESS_TYPE_NAMES[key] ?? key;
|
||||
}
|
||||
|
||||
/** Our AddressType → the lnrpc enum index, in normal and `peek` (UNUSED_*, non-advancing) form. */
|
||||
const ADDRESS_TYPE_INDEX: Record<AddressType, { fresh: string; peek: string } | null> = {
|
||||
p2wpkh: { fresh: '0', peek: '2' },
|
||||
'p2sh-p2wpkh': { fresh: '1', peek: '3' },
|
||||
p2tr: { fresh: '4', peek: '5' },
|
||||
// lnrpc.NewAddress has no legacy p2pkh member — LND has never handed out a base58 receive address.
|
||||
p2pkh: null,
|
||||
};
|
||||
|
||||
/** walletrpc's AddressType enum (ListUnspent) → ours. The HYBRID_ variant is still a p2sh-p2wpkh. */
|
||||
const WALLETRPC_ADDRESS_TYPE: Record<string, AddressType> = {
|
||||
WITNESS_PUBKEY_HASH: 'p2wpkh',
|
||||
NESTED_WITNESS_PUBKEY_HASH: 'p2sh-p2wpkh',
|
||||
HYBRID_NESTED_WITNESS_PUBKEY_HASH: 'p2sh-p2wpkh',
|
||||
TAPROOT_PUBKEY: 'p2tr',
|
||||
};
|
||||
|
||||
/** BOLT spec TLV records for keysend: the preimage the receiver settles with, and the free-text message. */
|
||||
const KEYSEND_PREIMAGE_RECORD = '5482373484';
|
||||
const KEYSEND_MESSAGE_RECORD = '34349334';
|
||||
|
||||
/** A 32-byte all-zero preimage is LND's "not settled yet" placeholder, not a real preimage. */
|
||||
const ZERO_PREIMAGE_HEX = '0'.repeat(64);
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** int64-as-string → number. Used only for sat-denominated fields, which cannot overflow a double. */
|
||||
function toSats(value: string | number | undefined | null): number {
|
||||
if (value == null || value === '') return 0;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** int64-as-string → msat decimal string. Never widened to a number: 2.1e18 does not fit a double. */
|
||||
function toMsat(value: string | number | undefined | null): string {
|
||||
if (value == null || value === '') return '0';
|
||||
return typeof value === 'number' ? Math.round(value).toString() : value;
|
||||
}
|
||||
|
||||
/** `0.18.3-beta commit=v0.18.3-beta` → [0, 18, 3]. Zeus does the same in VersionUtils. */
|
||||
function parseVersion(version: string | null): [number, number, number] | null {
|
||||
if (!version) return null;
|
||||
const m = /(\d+)\.(\d+)\.(\d+)/.exec(version);
|
||||
if (!m) return null;
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
}
|
||||
|
||||
/** Unknown version counts as "new enough" — see difference 5 in the header. */
|
||||
function atLeast(version: string | null, min: string): boolean {
|
||||
const have = parseVersion(version);
|
||||
const want = parseVersion(min);
|
||||
if (!have || !want) return true;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const h = have[i] ?? 0;
|
||||
const w = want[i] ?? 0;
|
||||
if (h !== w) return h > w;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function toNetwork(info: LndGetInfo): BitcoinNetwork {
|
||||
const raw = info.chains?.[0]?.network ?? (info.testnet ? 'testnet' : 'mainnet');
|
||||
switch (raw) {
|
||||
case 'testnet':
|
||||
case 'testnet3':
|
||||
case 'testnet4':
|
||||
return 'testnet';
|
||||
case 'signet':
|
||||
return 'signet';
|
||||
// simnet is btcd's private-chain mode; it behaves like regtest for everything we surface.
|
||||
case 'regtest':
|
||||
case 'simnet':
|
||||
return 'regtest';
|
||||
default:
|
||||
return 'bitcoin';
|
||||
}
|
||||
}
|
||||
|
||||
function toPaymentStatus(status: string | undefined): PaymentStatus {
|
||||
switch (status) {
|
||||
case 'SUCCEEDED':
|
||||
return 'succeeded';
|
||||
case 'FAILED':
|
||||
return 'failed';
|
||||
// UNKNOWN / IN_FLIGHT / INITIATED all mean "not resolved yet".
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
export type LndConfig = {
|
||||
/** Full base URL including scheme and port, e.g. `https://192.168.1.5:8080`. */
|
||||
url: string;
|
||||
/** Admin (or narrower) macaroon, hex. Sent as `Grpc-Metadata-macaroon`. */
|
||||
macaroonHex: string;
|
||||
allowSelfSigned?: boolean;
|
||||
};
|
||||
|
||||
// ── the backend ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class LndBackend extends BaseBackend {
|
||||
readonly kind: BackendKind = 'lnd';
|
||||
|
||||
// Mutated in place by `applyVersionGates`; `capabilities` is the same object seen read-only.
|
||||
private readonly caps = new Set<Capability>([
|
||||
'onchainReceive',
|
||||
'onchainSend',
|
||||
'coinControl',
|
||||
'psbt',
|
||||
'bumpFee',
|
||||
'sweep',
|
||||
'accounts',
|
||||
'lightningReceive',
|
||||
'lightningSend',
|
||||
'keysend',
|
||||
'customPreimages',
|
||||
'offers',
|
||||
'channels',
|
||||
'peers',
|
||||
'routing',
|
||||
'signMessage',
|
||||
]);
|
||||
protected readonly capabilities: ReadonlySet<Capability> = this.caps;
|
||||
|
||||
constructor(private readonly config: LndConfig) {
|
||||
super();
|
||||
}
|
||||
|
||||
// ── transport ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private get<T>(path: string, query?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
||||
return httpJson<T>({
|
||||
base: this.config.url,
|
||||
path,
|
||||
query,
|
||||
method: 'GET',
|
||||
headers: { 'Grpc-Metadata-macaroon': this.config.macaroonHex },
|
||||
allowSelfSigned: this.config.allowSelfSigned,
|
||||
});
|
||||
}
|
||||
|
||||
private post<T>(path: string, body: unknown, timeoutMs?: number): Promise<T> {
|
||||
return httpJson<T>({
|
||||
base: this.config.url,
|
||||
path,
|
||||
method: 'POST',
|
||||
body: body ?? {},
|
||||
headers: { 'Grpc-Metadata-macaroon': this.config.macaroonHex },
|
||||
allowSelfSigned: this.config.allowSelfSigned,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SendPaymentV2 (`/v2/router/send`) is a SERVER-STREAMING rpc. grpc-gateway renders it as
|
||||
* newline-delimited JSON — one `{"result": …}` frame per payment state change — which `JSON.parse` on
|
||||
* the whole body cannot read. Zeus handles this in `restReq` by splitting on '\n' and taking
|
||||
* `split[length - 2]` (the body has a trailing newline); we take the last non-empty line, which is the
|
||||
* same frame without depending on the trailing newline being there.
|
||||
*
|
||||
* We also set `no_inflight_updates`, so in practice there is exactly one frame: the terminal one.
|
||||
*/
|
||||
private async routerSend(body: Record<string, unknown>, timeoutMs: number): Promise<LndPayment> {
|
||||
const res = await httpJson<Response>({
|
||||
base: this.config.url,
|
||||
path: '/v2/router/send',
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: { 'Grpc-Metadata-macaroon': this.config.macaroonHex },
|
||||
allowSelfSigned: this.config.allowSelfSigned,
|
||||
timeoutMs,
|
||||
raw: true,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
const lines = text.split('\n').filter((line) => line.trim().length > 0);
|
||||
const last = lines[lines.length - 1];
|
||||
|
||||
if (!last) {
|
||||
throw new BackendError(`router/send returned an empty body (HTTP ${res.status})`, res.ok ? 502 : res.status);
|
||||
}
|
||||
|
||||
let frame: LndRouterFrame;
|
||||
try {
|
||||
frame = JSON.parse(last) as LndRouterFrame;
|
||||
} catch {
|
||||
throw new BackendError(`router/send returned non-JSON: ${last.slice(0, 200)}`, res.ok ? 502 : res.status);
|
||||
}
|
||||
|
||||
// A stream error arrives as a frame, not as a non-2xx status, so check it before `res.ok`.
|
||||
if (frame.error) {
|
||||
throw new BackendError(
|
||||
frame.error.message ?? 'payment failed',
|
||||
res.ok ? 502 : res.status,
|
||||
frame.error.code != null ? String(frame.error.code) : undefined,
|
||||
);
|
||||
}
|
||||
if (!res.ok || !frame.result) {
|
||||
throw new BackendError(`router/send failed: ${last.slice(0, 200)}`, res.ok ? 502 : res.status);
|
||||
}
|
||||
return frame.result;
|
||||
}
|
||||
|
||||
/** Zeus's version predicates, evaluated once per getInfo instead of once per `supportsX()` call. */
|
||||
private applyVersionGates(version: string | null): void {
|
||||
const gate = (cap: Capability, min: string) => {
|
||||
if (atLeast(version, min)) this.caps.add(cap);
|
||||
else this.caps.delete(cap);
|
||||
};
|
||||
gate('coinControl', '0.12.0'); // Zeus: supportsCoinControl
|
||||
gate('accounts', '0.13.0'); // Zeus: supportsAccounts
|
||||
gate('offers', '0.18.0'); // Zeus hardcodes false; see difference 6
|
||||
}
|
||||
|
||||
// ── node / balances ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getInfo(): Promise<NodeInfo> {
|
||||
const info = await this.get<LndGetInfo>('/v1/getinfo');
|
||||
const version = info.version ?? null;
|
||||
this.applyVersionGates(version);
|
||||
return {
|
||||
kind: this.kind,
|
||||
pubkey: info.identity_pubkey ?? null,
|
||||
alias: info.alias ?? null,
|
||||
version,
|
||||
network: toNetwork(info),
|
||||
blockHeight: info.block_height ?? null,
|
||||
synced: info.synced_to_chain === true,
|
||||
};
|
||||
}
|
||||
|
||||
override async getBalances(): Promise<Balances> {
|
||||
const [chain, channels] = await Promise.all([
|
||||
this.get<LndBlockchainBalance>('/v1/balance/blockchain'),
|
||||
this.get<LndChannelBalance>('/v1/balance/channels'),
|
||||
]);
|
||||
return {
|
||||
onchainConfirmed: toSats(chain.confirmed_balance),
|
||||
onchainUnconfirmed: toSats(chain.unconfirmed_balance),
|
||||
// `balance` is the deprecated flat field; the nested Amount is authoritative on v0.11+.
|
||||
lightningBalance: toSats(channels.local_balance?.sat ?? channels.balance),
|
||||
lightningInbound: toSats(channels.remote_balance?.sat),
|
||||
};
|
||||
}
|
||||
|
||||
// ── on-chain ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]> {
|
||||
// `end_height=-1` is LND's "up to and including unconfirmed" sentinel — without it the mempool is
|
||||
// excluded and a just-broadcast send is invisible.
|
||||
const res = await this.get<{ transactions?: LndTransaction[] }>('/v1/transactions', {
|
||||
end_height: -1,
|
||||
max_transactions: opts?.limit ?? 500,
|
||||
});
|
||||
return (res.transactions ?? []).map((tx) => ({
|
||||
txid: tx.tx_hash ?? '',
|
||||
amount: toSats(tx.amount),
|
||||
feeSats: tx.total_fees != null ? toSats(tx.total_fees) : null,
|
||||
blockHeight: tx.block_height ? tx.block_height : null,
|
||||
timestamp: tx.time_stamp ? toSats(tx.time_stamp) : null,
|
||||
confirmations: tx.num_confirmations ?? 0,
|
||||
label: tx.label ? tx.label : null,
|
||||
destAddresses: tx.dest_addresses ?? [],
|
||||
rawHex: tx.raw_tx_hex ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
override async getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
|
||||
const type: AddressType = req?.type ?? 'p2wpkh';
|
||||
const index = ADDRESS_TYPE_INDEX[type];
|
||||
if (!index) throw new BackendError(`lnd cannot derive a ${type} address`, 400, 'UNSUPPORTED_ADDRESS_TYPE');
|
||||
|
||||
// `peek` maps onto the UNUSED_* enum members, which return the current address without advancing the
|
||||
// derivation index — LND's only form of "show me the same address again".
|
||||
const lnrpcType = toLnrpcAddressType(req?.peek ? index.peek : index.fresh);
|
||||
const res = await this.get<{ address?: string }>('/v1/newaddress', { type: lnrpcType });
|
||||
if (!res.address) throw new BackendError('lnd returned no address', 502);
|
||||
return { address: res.address, type };
|
||||
}
|
||||
|
||||
override async getUtxos(): Promise<Utxo[]> {
|
||||
// max_confs must be set explicitly: walletrpc defaults it to 0, which matches nothing.
|
||||
const res = await this.post<{ utxos?: LndUtxo[] }>('/v2/wallet/utxos', {
|
||||
min_confs: 0,
|
||||
max_confs: 0x7fffffff,
|
||||
});
|
||||
return (res.utxos ?? []).map((utxo) => ({
|
||||
txid: utxo.outpoint?.txid_str ?? '',
|
||||
vout: utxo.outpoint?.output_index ?? 0,
|
||||
amountSats: toSats(utxo.amount_sat),
|
||||
address: utxo.address ?? '',
|
||||
addressType: utxo.address_type ? (WALLETRPC_ADDRESS_TYPE[utxo.address_type] ?? null) : null,
|
||||
confirmations: toSats(utxo.confirmations),
|
||||
derivationPath: null,
|
||||
// ListUnspent already omits leased outputs, so anything we can see here is selectable.
|
||||
frozen: false,
|
||||
}));
|
||||
}
|
||||
|
||||
override async estimateFees(): Promise<FeeEstimates> {
|
||||
// walletrpc quotes sat/kw (weight units). 1 vB = 4 wu, so sat/vB = sat_per_kw / 250.
|
||||
const perVbyte = (satPerKw: string | undefined): number => Math.max(1, Math.ceil(toSats(satPerKw) / 250));
|
||||
|
||||
const targets = [2, 3, 6, 144] as const;
|
||||
const [fastest, halfHour, hour, economy] = await Promise.all(
|
||||
targets.map((target) => this.get<LndEstimateFee>(`/v2/wallet/estimatefee/${target}`)),
|
||||
);
|
||||
|
||||
return {
|
||||
fastestFee: perVbyte(fastest?.sat_per_kw),
|
||||
halfHourFee: perVbyte(halfHour?.sat_per_kw),
|
||||
hourFee: perVbyte(hour?.sat_per_kw),
|
||||
economyFee: perVbyte(economy?.sat_per_kw),
|
||||
// min_relay_fee_sat_per_kw only exists on v0.18+; 253 sat/kw is bitcoind's floor (~1 sat/vB).
|
||||
minimumFee: perVbyte(fastest?.min_relay_fee_sat_per_kw ?? '253'),
|
||||
};
|
||||
}
|
||||
|
||||
override async sendCoins(req: SendCoinsRequest): Promise<SendCoinsResult> {
|
||||
if (req.outpoints?.length && !this.supports('coinControl')) this.notSupported('coin control');
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
addr: req.address,
|
||||
sat_per_vbyte: String(req.satPerVbyte),
|
||||
spend_unconfirmed: req.spendUnconfirmed === true,
|
||||
label: req.label,
|
||||
};
|
||||
if (req.sendAll) body.send_all = true;
|
||||
else body.amount = String(req.amountSats ?? 0);
|
||||
|
||||
if (req.outpoints?.length) {
|
||||
body.outpoints = req.outpoints.map((outpoint) => {
|
||||
const [txid, vout] = outpoint.split(':');
|
||||
return { txid_str: txid, output_index: Number(vout ?? 0) };
|
||||
});
|
||||
}
|
||||
|
||||
const res = await this.post<{ txid?: string }>('/v1/transactions', body);
|
||||
if (!res.txid) throw new BackendError('lnd returned no txid', 502);
|
||||
return { txid: res.txid, feeSats: 0, rawHex: null };
|
||||
}
|
||||
|
||||
// ── lightning ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private toInvoice(inv: LndInvoice): Invoice {
|
||||
const createdAt = toSats(inv.creation_date);
|
||||
const expiry = toSats(inv.expiry);
|
||||
const expiresAt = expiry > 0 ? createdAt + expiry : null;
|
||||
const settledAt = toSats(inv.settle_date);
|
||||
const preimageHex = inv.r_preimage ? base64ToHex(inv.r_preimage) : '';
|
||||
|
||||
let state: InvoiceState;
|
||||
switch (inv.state) {
|
||||
case 'SETTLED':
|
||||
state = 'settled';
|
||||
break;
|
||||
case 'CANCELED':
|
||||
state = 'canceled';
|
||||
break;
|
||||
case 'ACCEPTED':
|
||||
state = 'accepted';
|
||||
break;
|
||||
default:
|
||||
// lnrpc has no EXPIRED member; an unsettled invoice past its deadline is reported as OPEN.
|
||||
state = expiresAt != null && expiresAt * 1000 < Date.now() ? 'expired' : 'open';
|
||||
}
|
||||
|
||||
return {
|
||||
paymentHash: inv.r_hash ? base64ToHex(inv.r_hash) : '',
|
||||
bolt11: inv.payment_request ?? '',
|
||||
amountMsat: inv.value_msat && inv.value_msat !== '0' ? toMsat(inv.value_msat) : null,
|
||||
amountPaidMsat: inv.amt_paid_msat && inv.amt_paid_msat !== '0' ? toMsat(inv.amt_paid_msat) : null,
|
||||
memo: inv.memo ? inv.memo : null,
|
||||
state,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
settledAt: settledAt > 0 ? settledAt : null,
|
||||
// LND ships an all-zero r_preimage for anything it has not settled — that is a placeholder, not a
|
||||
// secret, and passing it on would let a caller believe it can claim the HTLC.
|
||||
preimage: preimageHex && preimageHex !== ZERO_PREIMAGE_HEX ? preimageHex : null,
|
||||
isKeysend: inv.is_keysend === true,
|
||||
isAmp: inv.is_amp === true,
|
||||
};
|
||||
}
|
||||
|
||||
override async getInvoices(opts?: { limit?: number }): Promise<Invoice[]> {
|
||||
// reversed=true walks the add_index backwards, i.e. newest first.
|
||||
const res = await this.get<{ invoices?: LndInvoice[] }>('/v1/invoices', {
|
||||
reversed: true,
|
||||
num_max_invoices: opts?.limit ?? 500,
|
||||
});
|
||||
return (res.invoices ?? []).map((inv) => this.toInvoice(inv));
|
||||
}
|
||||
|
||||
override async createInvoice(req: CreateInvoiceRequest): Promise<Invoice> {
|
||||
const res = await this.post<LndAddInvoiceResponse>('/v1/invoices', {
|
||||
memo: req.memo,
|
||||
value_msat: req.amountMsat,
|
||||
expiry: req.expirySeconds != null ? String(req.expirySeconds) : undefined,
|
||||
is_amp: req.isAmp,
|
||||
private: req.private,
|
||||
r_preimage: req.preimage ? hexToBase64(req.preimage) : undefined,
|
||||
});
|
||||
|
||||
const paymentHash = res.r_hash ? base64ToHex(res.r_hash) : '';
|
||||
|
||||
// AddInvoiceResponse carries only the hash, the request string and the payment address — no
|
||||
// timestamps and no state — so read the invoice back to answer with a complete record.
|
||||
const stored = paymentHash ? await this.lookupInvoice(paymentHash).catch(() => null) : null;
|
||||
if (stored) return stored;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
paymentHash,
|
||||
bolt11: res.payment_request ?? '',
|
||||
amountMsat: req.amountMsat ?? null,
|
||||
amountPaidMsat: null,
|
||||
memo: req.memo ?? null,
|
||||
state: 'open',
|
||||
createdAt: now,
|
||||
expiresAt: req.expirySeconds != null ? now + req.expirySeconds : null,
|
||||
settledAt: null,
|
||||
preimage: null,
|
||||
isKeysend: false,
|
||||
isAmp: req.isAmp === true,
|
||||
};
|
||||
}
|
||||
|
||||
override async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
|
||||
try {
|
||||
// The path segment is `r_hash_str` — hex, not the base64 used by the body fields.
|
||||
const inv = await this.get<LndInvoice>(`/v1/invoice/${paymentHash}`);
|
||||
return this.toInvoice(inv);
|
||||
} catch (err) {
|
||||
if (err instanceof BackendError && err.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
override async decodeInvoice(bolt11: string): Promise<DecodedInvoice> {
|
||||
const res = await this.get<LndPayReq>(`/v1/payreq/${encodeURIComponent(bolt11)}`);
|
||||
return {
|
||||
bolt11,
|
||||
// PayReq.payment_hash is a proto `string` (already hex), unlike Invoice.r_hash which is `bytes`.
|
||||
paymentHash: res.payment_hash ?? '',
|
||||
amountMsat: res.num_msat && res.num_msat !== '0' ? toMsat(res.num_msat) : null,
|
||||
description: res.description ? res.description : null,
|
||||
destination: res.destination ?? '',
|
||||
timestamp: toSats(res.timestamp),
|
||||
expiry: toSats(res.expiry),
|
||||
cltvExpiry: res.cltv_expiry != null ? toSats(res.cltv_expiry) : null,
|
||||
routeHints: (res.route_hints ?? []).length > 0,
|
||||
features: Object.values(res.features ?? {})
|
||||
.map((feature) => feature.name)
|
||||
.filter((name): name is string => !!name),
|
||||
};
|
||||
}
|
||||
|
||||
private toPayment(payment: LndPayment): Payment {
|
||||
const preimage = payment.payment_preimage ?? '';
|
||||
// creation_date (seconds) was deprecated in favour of creation_time_ns; accept either.
|
||||
const createdAt = payment.creation_date
|
||||
? toSats(payment.creation_date)
|
||||
: Math.floor(toSats(payment.creation_time_ns) / 1e9);
|
||||
const hops = payment.htlcs?.[0]?.route?.hops ?? [];
|
||||
|
||||
return {
|
||||
// ListPayments renders payment_hash/payment_preimage as proto `string`s — already hex.
|
||||
paymentHash: payment.payment_hash ?? '',
|
||||
preimage: preimage && preimage !== ZERO_PREIMAGE_HEX ? preimage : null,
|
||||
amountMsat: toMsat(payment.value_msat),
|
||||
feeMsat: toMsat(payment.fee_msat),
|
||||
status: toPaymentStatus(payment.status),
|
||||
createdAt,
|
||||
// lnrpc.Payment has no destination field; the last hop of the first attempt's route is the payee.
|
||||
destination: hops[hops.length - 1]?.pub_key ?? null,
|
||||
// Recovering the memo would mean decoding payment_request on every row.
|
||||
memo: null,
|
||||
failureReason:
|
||||
payment.failure_reason && payment.failure_reason !== 'FAILURE_REASON_NONE' ? payment.failure_reason : null,
|
||||
};
|
||||
}
|
||||
|
||||
override async getPayments(opts?: { limit?: number }): Promise<Payment[]> {
|
||||
const res = await this.get<{ payments?: LndPayment[] }>('/v1/payments', {
|
||||
include_incomplete: true,
|
||||
max_payments: opts?.limit ?? 500,
|
||||
reversed: true,
|
||||
});
|
||||
return (res.payments ?? []).map((payment) => this.toPayment(payment));
|
||||
}
|
||||
|
||||
override async payInvoice(req: PayInvoiceRequest): Promise<Payment> {
|
||||
if (req.feeLimitMsat && req.feeLimitPercent != null) {
|
||||
throw new BackendError('feeLimitMsat and feeLimitPercent are mutually exclusive', 400);
|
||||
}
|
||||
|
||||
const timeoutSeconds = req.timeoutSeconds ?? 60;
|
||||
const body: Record<string, unknown> = {
|
||||
payment_request: req.bolt11,
|
||||
timeout_seconds: timeoutSeconds,
|
||||
// Zeus sets this so a payment to one's own node is not rejected as a self-payment.
|
||||
allow_self_payment: true,
|
||||
// Collapses the stream to a single terminal frame — see `routerSend`.
|
||||
no_inflight_updates: true,
|
||||
};
|
||||
if (req.amountMsat) body.amt_msat = req.amountMsat;
|
||||
|
||||
if (req.feeLimitMsat) {
|
||||
body.fee_limit_msat = req.feeLimitMsat;
|
||||
} else if (req.feeLimitPercent != null) {
|
||||
// SendPaymentRequest has no percentage form (that is a CLN concept), so resolve the invoice amount
|
||||
// and turn the percentage into the absolute cap LND wants.
|
||||
const amountMsat = req.amountMsat ?? (await this.decodeInvoice(req.bolt11)).amountMsat;
|
||||
if (!amountMsat) {
|
||||
throw new BackendError('feeLimitPercent needs an amount: the invoice is zero-amount', 400);
|
||||
}
|
||||
const limit = (BigInt(amountMsat) * BigInt(Math.round(req.feeLimitPercent * 100))) / 10_000n;
|
||||
body.fee_limit_msat = limit.toString();
|
||||
}
|
||||
|
||||
// Give the HTTP call a little more room than LND's own deadline so the node, not the socket, decides.
|
||||
const result = await this.routerSend(body, (timeoutSeconds + 10) * 1000);
|
||||
return this.toPayment(result);
|
||||
}
|
||||
|
||||
override async sendKeysend(req: KeysendRequest): Promise<Payment> {
|
||||
// Keysend is a spontaneous payment: WE pick the preimage, hash it ourselves, and ship the preimage to
|
||||
// the receiver in TLV 5482373484 so it can settle an HTLC it never issued an invoice for.
|
||||
const preimage = randomBytes(32);
|
||||
const paymentHash = createHash('sha256').update(preimage).digest();
|
||||
|
||||
const customRecords: Record<string, string> = {
|
||||
[KEYSEND_PREIMAGE_RECORD]: preimage.toString('base64'),
|
||||
};
|
||||
if (req.message) customRecords[KEYSEND_MESSAGE_RECORD] = Buffer.from(req.message, 'utf8').toString('base64');
|
||||
|
||||
const timeoutSeconds = 60;
|
||||
const body: Record<string, unknown> = {
|
||||
dest: hexToBase64(req.destination),
|
||||
amt_msat: req.amountMsat,
|
||||
payment_hash: paymentHash.toString('base64'),
|
||||
dest_custom_records: customRecords,
|
||||
// Without a route hint there is no invoice to read a final CLTV delta from; 40 is LND's own default.
|
||||
final_cltv_delta: 40,
|
||||
timeout_seconds: timeoutSeconds,
|
||||
allow_self_payment: true,
|
||||
no_inflight_updates: true,
|
||||
};
|
||||
if (req.feeLimitMsat) body.fee_limit_msat = req.feeLimitMsat;
|
||||
|
||||
const result = await this.routerSend(body, (timeoutSeconds + 10) * 1000);
|
||||
return this.toPayment(result);
|
||||
}
|
||||
|
||||
// ── channels / peers ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getChannels(): Promise<Channel[]> {
|
||||
const [open, pending] = await Promise.all([
|
||||
// peer_alias_lookup makes LND resolve each peer's graph alias for us (v0.15.1+); on older nodes the
|
||||
// parameter is ignored and `peer_alias` simply comes back absent.
|
||||
this.get<{ channels?: LndChannel[] }>('/v1/channels', { peer_alias_lookup: true }),
|
||||
this.get<LndPendingChannels>('/v1/channels/pending'),
|
||||
]);
|
||||
|
||||
const channels: Channel[] = (open.channels ?? []).map((chan) => ({
|
||||
channelId: chan.chan_id ?? chan.channel_point ?? '',
|
||||
channelPoint: chan.channel_point ?? null,
|
||||
remotePubkey: chan.remote_pubkey ?? '',
|
||||
remoteAlias: chan.peer_alias ? chan.peer_alias : null,
|
||||
capacitySats: toSats(chan.capacity),
|
||||
localBalanceSats: toSats(chan.local_balance),
|
||||
remoteBalanceSats: toSats(chan.remote_balance),
|
||||
active: chan.active === true,
|
||||
private: chan.private === true,
|
||||
status: 'open',
|
||||
}));
|
||||
|
||||
const fromPending = (entries: { channel?: LndPendingChannel }[] | undefined, status: string): Channel[] =>
|
||||
(entries ?? [])
|
||||
.map((entry) => entry.channel)
|
||||
.filter((chan): chan is LndPendingChannel => !!chan)
|
||||
.map((chan) => ({
|
||||
// A pending channel has no short channel id yet — the funding outpoint is its only identity.
|
||||
channelId: chan.channel_point ?? '',
|
||||
channelPoint: chan.channel_point ?? null,
|
||||
remotePubkey: chan.remote_node_pub ?? '',
|
||||
remoteAlias: null,
|
||||
capacitySats: toSats(chan.capacity),
|
||||
localBalanceSats: toSats(chan.local_balance),
|
||||
remoteBalanceSats: toSats(chan.remote_balance),
|
||||
active: false,
|
||||
private: chan.private === true,
|
||||
status,
|
||||
}));
|
||||
|
||||
return [
|
||||
...channels,
|
||||
...fromPending(pending.pending_open_channels, 'pending-open'),
|
||||
// "waiting close" is a cooperative close whose closing tx has not confirmed; force-closing is the
|
||||
// unilateral path with a timelock still to run.
|
||||
...fromPending(pending.waiting_close_channels, 'pending-close'),
|
||||
...fromPending(pending.pending_force_closing_channels, 'force-closing'),
|
||||
];
|
||||
}
|
||||
|
||||
override async getPeers(): Promise<Peer[]> {
|
||||
const res = await this.get<{ peers?: LndPeer[] }>('/v1/peers');
|
||||
return (res.peers ?? []).map((peer) => ({
|
||||
pubkey: peer.pub_key ?? '',
|
||||
address: peer.address ?? '',
|
||||
alias: null,
|
||||
inbound: peer.inbound === true,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── signing ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async signMessage(message: string): Promise<SignMessageResult> {
|
||||
// lnrpc.SignMessageRequest.msg is `bytes`, so the payload is base64 even though it is plain text.
|
||||
const res = await this.post<{ signature?: string }>('/v1/signmessage', {
|
||||
msg: Buffer.from(message, 'utf8').toString('base64'),
|
||||
});
|
||||
if (!res.signature) throw new BackendError('lnd returned no signature', 502);
|
||||
return { signature: res.signature };
|
||||
}
|
||||
|
||||
override async verifyMessage(message: string, signature: string): Promise<VerifyMessageResult> {
|
||||
const res = await this.post<{ valid?: boolean; pubkey?: string }>('/v1/verifymessage', {
|
||||
msg: Buffer.from(message, 'utf8').toString('base64'),
|
||||
signature,
|
||||
});
|
||||
return { valid: res.valid === true, pubkey: res.pubkey ?? null };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
// LNDHub — port of Zeus's backends/LndHub.ts (which `extends LND` and overrides ~10 methods).
|
||||
//
|
||||
// LNDHub is a CUSTODIAL account API, not a node. The server holds the keys; this backend is a thin
|
||||
// client over a REST facade (BlueWallet/LndHub, LNbits' lndhub extension, Alby, lntxbot, …). There is
|
||||
// no node identity, no channel view, no UTXO set and no on-chain spend — only a balance, invoices,
|
||||
// payments and (on some deployments) a single deposit address.
|
||||
//
|
||||
// Differences from Zeus, all deliberate:
|
||||
//
|
||||
// 1. AUTH. Zeus logs in from SettingsStore (SettingsStore.ts:2090-2130), parks `access_token` in an
|
||||
// observable and then never refreshes it — an expired token surfaces as a "bad auth" error string
|
||||
// in the UI. Here the token is cached in memory, acquired lazily, and re-acquired transparently on
|
||||
// the first auth failure of any request, through a single-flight promise so N concurrent calls
|
||||
// produce one login rather than N.
|
||||
// 2. ERRORS. LNDHub reports failure as **HTTP 200 with `{error, code, message}`** (Zeus checks for
|
||||
// this ad hoc in three different stores: InvoicesStore.ts:410, TransactionsStore.ts:795). httpJson
|
||||
// only maps non-2xx, so every response goes through `unwrap()` first. Error code 1 ("bad auth") is
|
||||
// re-raised as a 401 so it feeds the same re-auth path as a real 401.
|
||||
// 3. lookupInvoice / getTransactions. Zeus *inherits* LND's `/v1/invoice/:r_hash` and
|
||||
// `/v1/transactions`, neither of which exists on an LNDHub server — those calls simply fail
|
||||
// upstream. lookupInvoice here scans `/getuserinvoices`; on-chain history is read out of the
|
||||
// `bitcoind_tx` entries that `/gettxs` interleaves with lightning payments.
|
||||
// 4. decodeInvoice. Zeus decodes BOLT11 locally (Bolt11Utils). The sidecar has no bolt11 decoder, so
|
||||
// this uses the server's own `/decodeinvoice`, which returns LND's payreq shape.
|
||||
// 5. The `lnurlAuth` signing modes (Alby vs BlueWallet key derivation) are not ported — LNURL-auth is
|
||||
// not part of the WalletBackend contract.
|
||||
//
|
||||
// UNITS. LNDHub is sloppy here and each field has to be taken on its own terms:
|
||||
// • /balance → BTC.AvailableBalance, satoshis
|
||||
// • /getuserinvoices → `amt`, satoshis
|
||||
// • /gettxs (payment) → `value` and `fee`, satoshis, where `value` already includes `fee`
|
||||
// • /gettxs (onchain) → `amount`, **BTC** as a float (verbatim from bitcoind's listtransactions)
|
||||
// • /decodeinvoice → `num_satoshis` and `num_msat` side by side
|
||||
// Everything is normalised to the contract's sats-as-number / msats-as-decimal-string rule on the way out.
|
||||
|
||||
import {
|
||||
BackendError,
|
||||
type AddressType,
|
||||
type Balances,
|
||||
type BackendKind,
|
||||
type Capability,
|
||||
type CreateInvoiceRequest,
|
||||
type DecodedInvoice,
|
||||
type Invoice,
|
||||
type InvoiceState,
|
||||
type NewAddressRequest,
|
||||
type NodeInfo,
|
||||
type OnchainTx,
|
||||
type PayInvoiceRequest,
|
||||
type Payment,
|
||||
type BitcoinNetwork,
|
||||
} from '../types';
|
||||
import { BaseBackend, base64ToHex, httpJson } from './base';
|
||||
import { decodeBolt11 } from '../bolt11';
|
||||
|
||||
export type LndHubConfig = {
|
||||
/** Base URL of the LNDHub server, e.g. https://lndhub.example.com or https://ln.getalby.com/lndhub. */
|
||||
url: string;
|
||||
login: string;
|
||||
password: string;
|
||||
/** Opt-in TLS relaxation for a self-hosted server with a self-signed cert. Never default. */
|
||||
allowSelfSigned?: boolean;
|
||||
};
|
||||
|
||||
// ── upstream wire shapes ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Every LNDHub response can be this instead of the documented shape, with HTTP 200. */
|
||||
type LndHubErrorBody = { error?: string | boolean | number; code?: number; message?: string };
|
||||
|
||||
/** node's `JSON.stringify(Buffer)` — LNDHub leaks raw Buffers for hash/preimage fields. */
|
||||
type LndHubBuffer = { type?: string; data?: number[] };
|
||||
type LndHubBytes = string | LndHubBuffer;
|
||||
|
||||
type LndHubAuth = { access_token?: string; refresh_token?: string };
|
||||
|
||||
type LndHubBalance = { BTC?: { AvailableBalance?: number; TotalBalance?: number } };
|
||||
|
||||
type LndHubGetInfo = {
|
||||
identity_pubkey?: string;
|
||||
alias?: string;
|
||||
version?: string;
|
||||
block_height?: number;
|
||||
testnet?: boolean;
|
||||
synced_to_chain?: boolean;
|
||||
chains?: { chain?: string; network?: string }[];
|
||||
};
|
||||
|
||||
type LndHubUserInvoice = {
|
||||
payment_request?: string;
|
||||
pay_req?: string;
|
||||
r_hash?: LndHubBytes;
|
||||
payment_hash?: LndHubBytes;
|
||||
description?: string;
|
||||
memo?: string;
|
||||
ispaid?: boolean;
|
||||
/** satoshis */
|
||||
amt?: number | string;
|
||||
amt_paid_sat?: number | string;
|
||||
amt_paid_msat?: number | string;
|
||||
/** seconds of validity, relative to `timestamp` */
|
||||
expire_time?: number;
|
||||
/** unix seconds */
|
||||
timestamp?: number | string;
|
||||
settled_at?: number;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
/** `/gettxs` interleaves outgoing lightning payments and imported on-chain deposits. */
|
||||
type LndHubTx = {
|
||||
type?: string;
|
||||
// paid_invoice
|
||||
payment_preimage?: LndHubBytes;
|
||||
payment_hash?: LndHubBytes;
|
||||
payment_request?: string;
|
||||
/** satoshis, and LNDHub folds `fee` into it (User.getTxs) */
|
||||
value?: number | string;
|
||||
/** satoshis */
|
||||
fee?: number | string;
|
||||
memo?: string;
|
||||
description?: string;
|
||||
timestamp?: number | string;
|
||||
// bitcoind_tx
|
||||
txid?: string;
|
||||
/** BTC, float, straight from bitcoind */
|
||||
amount?: number;
|
||||
confirmations?: number;
|
||||
address?: string;
|
||||
category?: string;
|
||||
time?: number;
|
||||
blockheight?: number;
|
||||
};
|
||||
|
||||
type LndHubAddress = { address?: string };
|
||||
|
||||
type LndHubDecoded = {
|
||||
destination?: string;
|
||||
payment_hash?: string;
|
||||
num_satoshis?: string | number;
|
||||
num_msat?: string | number;
|
||||
timestamp?: string | number;
|
||||
expiry?: string | number;
|
||||
description?: string;
|
||||
cltv_expiry?: string | number;
|
||||
route_hints?: unknown[];
|
||||
features?: Record<string, { name?: string }>;
|
||||
};
|
||||
|
||||
/** `/payinvoice` passes LND's sendPaymentSync response through, mostly. */
|
||||
type LndHubPayResult = {
|
||||
payment_error?: string;
|
||||
payment_preimage?: LndHubBytes;
|
||||
payment_hash?: LndHubBytes;
|
||||
payment_route?: {
|
||||
total_amt?: number | string;
|
||||
total_fees?: number | string;
|
||||
total_amt_msat?: number | string;
|
||||
total_fees_msat?: number | string;
|
||||
};
|
||||
decoded?: LndHubDecoded;
|
||||
/** some forks answer a zero-amount pay with the amount they chose */
|
||||
num_satoshis?: number | string;
|
||||
};
|
||||
|
||||
type LndHubCall = {
|
||||
path: string;
|
||||
method?: 'GET' | 'POST';
|
||||
body?: unknown;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
};
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const HEX32 = /^[0-9a-f]{64}$/i;
|
||||
|
||||
/** LNDHub returns a 32-byte field as hex, base64, url-safe base64 or a stringified Buffer. */
|
||||
function bytesToHex(value: LndHubBytes | undefined | null): string {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') {
|
||||
if (HEX32.test(value)) return value.toLowerCase();
|
||||
return base64ToHex(value.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
}
|
||||
if (Array.isArray(value.data)) return Buffer.from(value.data).toString('hex');
|
||||
return '';
|
||||
}
|
||||
|
||||
const num = (value: number | string | undefined | null): number => {
|
||||
const n = typeof value === 'string' ? Number(value) : value;
|
||||
return typeof n === 'number' && Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
const satsToMsat = (sats: number): string => (BigInt(Math.round(sats)) * 1000n).toString();
|
||||
|
||||
/** msat string → whole satoshis. LNDHub cannot express sub-satoshi amounts anywhere. */
|
||||
function msatToSats(msat: string, field: string): number {
|
||||
const value = BigInt(msat);
|
||||
if (value % 1000n !== 0n) {
|
||||
throw new BackendError(`lndhub cannot express sub-satoshi amounts (${field}=${msat}msat)`, 400, 'BAD_AMOUNT');
|
||||
}
|
||||
return Number(value / 1000n);
|
||||
}
|
||||
|
||||
/** LNDHub tells us nothing about the address it hands out, so classify it by prefix. */
|
||||
function addressType(address: string): AddressType {
|
||||
const a = address.toLowerCase();
|
||||
if (a.startsWith('bc1p') || a.startsWith('tb1p') || a.startsWith('bcrt1p')) return 'p2tr';
|
||||
if (a.startsWith('bc1') || a.startsWith('tb1') || a.startsWith('bcrt1')) return 'p2wpkh';
|
||||
if (a.startsWith('3') || a.startsWith('2')) return 'p2sh-p2wpkh';
|
||||
return 'p2pkh';
|
||||
}
|
||||
|
||||
// ── backend ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class LndHubBackend extends BaseBackend {
|
||||
readonly kind: BackendKind = 'lndhub';
|
||||
|
||||
// Custodial: receive and send over lightning, plus a deposit address on deployments that expose
|
||||
// /getbtc. No on-chain send, no coin control, no PSBT, no channels, no peers, no message signing —
|
||||
// the wallet does not hold the keys those would need.
|
||||
protected readonly capabilities: ReadonlySet<Capability> = new Set<Capability>([
|
||||
'lightningReceive',
|
||||
'lightningSend',
|
||||
'onchainReceive',
|
||||
]);
|
||||
|
||||
private accessToken: string | null = null;
|
||||
/** In-flight login, shared by every caller that needs a token — the single-flight refresh. */
|
||||
private pendingLogin: Promise<string> | null = null;
|
||||
|
||||
constructor(private readonly config: LndHubConfig) {
|
||||
super();
|
||||
}
|
||||
|
||||
// ── auth ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `POST /auth?type=auth {login, password}` → `{access_token, refresh_token}`. The refresh_token
|
||||
* grant (`type=refresh_token`) is deliberately not used: we hold the password, so a fresh login is
|
||||
* one round-trip either way and has one failure mode instead of two.
|
||||
*/
|
||||
private async login(): Promise<string> {
|
||||
const res = await httpJson<LndHubAuth & LndHubErrorBody>({
|
||||
base: this.config.url,
|
||||
path: '/auth',
|
||||
method: 'POST',
|
||||
query: { type: 'auth' },
|
||||
body: { login: this.config.login, password: this.config.password },
|
||||
allowSelfSigned: this.config.allowSelfSigned,
|
||||
});
|
||||
if (res?.error || !res?.access_token) {
|
||||
const message = res?.message ?? (typeof res?.error === 'string' ? res.error : 'lndhub rejected the login');
|
||||
throw new BackendError(message, 401, 'LNDHUB_AUTH_FAILED');
|
||||
}
|
||||
this.accessToken = res.access_token;
|
||||
return res.access_token;
|
||||
}
|
||||
|
||||
private async token(force = false): Promise<string> {
|
||||
if (!force && this.accessToken) return this.accessToken;
|
||||
// Concurrent 401s all await the same login; whoever loses the race gets the winner's token.
|
||||
this.pendingLogin ??= this.login().finally(() => {
|
||||
this.pendingLogin = null;
|
||||
});
|
||||
return this.pendingLogin;
|
||||
}
|
||||
|
||||
/** LNDHub's 200-with-`{error}` channel, folded back into the exception path. */
|
||||
private unwrap<T>(payload: T): T {
|
||||
const body = payload as LndHubErrorBody | null;
|
||||
if (body && typeof body === 'object' && !Array.isArray(body) && body.error) {
|
||||
const message = body.message || (typeof body.error === 'string' ? body.error : 'lndhub returned an error');
|
||||
// code 1 is LNDHub's "bad auth"; some forks only put the phrase in the message.
|
||||
if (body.code === 1 || /bad auth/i.test(message)) {
|
||||
throw new BackendError(message, 401, 'LNDHUB_BAD_AUTH');
|
||||
}
|
||||
throw new BackendError(message, 502, body.code != null ? `LNDHUB_${body.code}` : undefined);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
private async call<T>(req: LndHubCall): Promise<T> {
|
||||
const send = async (token: string): Promise<T> =>
|
||||
this.unwrap(
|
||||
await httpJson<T>({
|
||||
base: this.config.url,
|
||||
path: req.path,
|
||||
method: req.method ?? 'GET',
|
||||
body: req.body,
|
||||
query: req.query,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
allowSelfSigned: this.config.allowSelfSigned,
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
return await send(await this.token());
|
||||
} catch (err) {
|
||||
// One retry, and only for auth — a second 401 with a token minted seconds ago is a real failure.
|
||||
if (!(err instanceof BackendError) || err.status !== 401) throw err;
|
||||
this.accessToken = null;
|
||||
return send(await this.token(true));
|
||||
}
|
||||
}
|
||||
|
||||
// ── node / balances ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Best-effort. `/getinfo` exists on BlueWallet-derived servers but not on every fork (Alby, LNbits),
|
||||
* so a failure degrades to an anonymous descriptor rather than breaking connect. Zeus sidesteps this
|
||||
* by reporting `supportsNodeInfo() = false` and never calling it.
|
||||
*/
|
||||
override async getInfo(): Promise<NodeInfo> {
|
||||
let info: LndHubGetInfo | null = null;
|
||||
try {
|
||||
info = await this.call<LndHubGetInfo>({ path: '/getinfo' });
|
||||
} catch {
|
||||
info = null;
|
||||
}
|
||||
|
||||
const chain = info?.chains?.[0]?.network;
|
||||
const network: BitcoinNetwork =
|
||||
chain === 'testnet' || chain === 'signet' || chain === 'regtest'
|
||||
? chain
|
||||
: info?.testnet === true
|
||||
? 'testnet'
|
||||
: 'bitcoin';
|
||||
|
||||
return {
|
||||
kind: this.kind,
|
||||
// The account is custodial — the pubkey, if any, belongs to the operator's node, not to us.
|
||||
pubkey: info?.identity_pubkey ?? null,
|
||||
alias: info?.alias ?? null,
|
||||
version: info?.version ?? null,
|
||||
network,
|
||||
blockHeight: info?.block_height ?? null,
|
||||
synced: info?.synced_to_chain ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
override async getBalances(): Promise<Balances> {
|
||||
const res = await this.call<LndHubBalance>({ path: '/balance' });
|
||||
return {
|
||||
// A custodial account has no on-chain balance of its own; deposits land in the lightning balance.
|
||||
onchainConfirmed: 0,
|
||||
onchainUnconfirmed: 0,
|
||||
lightningBalance: num(res?.BTC?.AvailableBalance),
|
||||
// Inbound liquidity is the operator's problem and is never reported.
|
||||
lightningInbound: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── on-chain ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `/gettxs` mixes `paid_invoice` (lightning, outgoing) with `bitcoind_tx` (on-chain deposits copied
|
||||
* out of bitcoind's listtransactions). Only the latter belong here.
|
||||
*/
|
||||
override async getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]> {
|
||||
const txs = await this.fetchTxs(opts?.limit);
|
||||
return txs
|
||||
.filter((tx) => tx.type === 'bitcoind_tx' || (!!tx.txid && tx.payment_preimage === undefined))
|
||||
.map((tx) => ({
|
||||
txid: tx.txid ?? '',
|
||||
// `amount` here is BTC as a float, not sats. Round after scaling — 0.1 BTC is not exact.
|
||||
amount: Math.round(num(tx.amount) * 1e8),
|
||||
feeSats: null,
|
||||
blockHeight: tx.blockheight ?? null,
|
||||
timestamp: tx.time ?? (tx.timestamp != null ? num(tx.timestamp) : null),
|
||||
confirmations: tx.confirmations ?? 0,
|
||||
label: null,
|
||||
destAddresses: tx.address ? [tx.address] : [],
|
||||
rawHex: null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* `/getbtc` returns the account's single deposit address as a one-element array (Zeus reads
|
||||
* `data[0].address`, InvoicesStore.ts:620). It is static per account, so `peek` is meaningless and
|
||||
* the requested address type is ignored — the operator chose it. On the deployments that hand back an
|
||||
* empty array until an address has been allocated, `/newbtc` allocates one.
|
||||
*/
|
||||
override async getNewAddress(_req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
|
||||
let address = await this.fetchBtcAddress();
|
||||
if (!address) {
|
||||
await this.call<unknown>({ path: '/newbtc', method: 'POST', body: {} });
|
||||
address = await this.fetchBtcAddress();
|
||||
}
|
||||
if (!address) {
|
||||
throw new BackendError('lndhub server does not offer on-chain deposits', 501, 'NOT_SUPPORTED');
|
||||
}
|
||||
return { address, type: addressType(address) };
|
||||
}
|
||||
|
||||
private async fetchBtcAddress(): Promise<string> {
|
||||
const res = await this.call<LndHubAddress[] | LndHubAddress>({ path: '/getbtc' });
|
||||
if (Array.isArray(res)) return res[0]?.address ?? '';
|
||||
return res?.address ?? '';
|
||||
}
|
||||
|
||||
// ── lightning ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getInvoices(opts?: { limit?: number }): Promise<Invoice[]> {
|
||||
const res = await this.call<LndHubUserInvoice[]>({
|
||||
path: '/getuserinvoices',
|
||||
query: { limit: opts?.limit },
|
||||
});
|
||||
return (Array.isArray(res) ? res : []).map((inv) => this.toInvoice(inv));
|
||||
}
|
||||
|
||||
override async createInvoice(req: CreateInvoiceRequest): Promise<Invoice> {
|
||||
if (req.preimage) return this.notSupported('custom preimages');
|
||||
if (req.isAmp) return this.notSupported('AMP invoices');
|
||||
// LNDHub answers a zero-amount invoice request with "Bad arguments" (Zeus special-cases the string
|
||||
// in InvoicesStore.ts:425); reject it here with something legible instead.
|
||||
if (!req.amountMsat || req.amountMsat === '0') {
|
||||
throw new BackendError('lndhub requires an invoice amount', 400, 'AMOUNT_REQUIRED');
|
||||
}
|
||||
const sats = msatToSats(req.amountMsat, 'amountMsat');
|
||||
// `expirySeconds` and `private` have no equivalent — the server picks both (Zeus:
|
||||
// supportsSettingInvoiceExpiration() = false).
|
||||
const res = await this.call<LndHubUserInvoice>({
|
||||
path: '/addinvoice',
|
||||
method: 'POST',
|
||||
body: { amt: String(sats), memo: req.memo ?? '' },
|
||||
});
|
||||
|
||||
const bolt11 = res.payment_request ?? res.pay_req ?? '';
|
||||
let paymentHash = bytesToHex(res.r_hash ?? res.payment_hash);
|
||||
// A few forks omit r_hash on /addinvoice. The hash is already in the invoice we were just handed, so
|
||||
// read it locally rather than spending a second authenticated round-trip — and a second failure mode
|
||||
// — on `/decodeinvoice`, which those same forks are the least likely to implement.
|
||||
if (!paymentHash && bolt11) {
|
||||
paymentHash = decodeBolt11(bolt11).paymentHash;
|
||||
}
|
||||
|
||||
const createdAt = res.timestamp != null ? num(res.timestamp) : Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
paymentHash,
|
||||
bolt11,
|
||||
amountMsat: satsToMsat(sats),
|
||||
amountPaidMsat: null,
|
||||
memo: req.memo ?? res.description ?? null,
|
||||
state: 'open',
|
||||
createdAt,
|
||||
expiresAt: res.expire_time ? createdAt + res.expire_time : null,
|
||||
settledAt: null,
|
||||
preimage: null,
|
||||
isKeysend: false,
|
||||
isAmp: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LNDHub has no lookup endpoint — Zeus inherits LND's `/v1/invoice/:r_hash`, which 404s here. Scan
|
||||
* the invoice list instead. Bounded at 200 entries: an older invoice reports as not found rather
|
||||
* than paging the whole history on every poll.
|
||||
*/
|
||||
override async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
|
||||
const wanted = paymentHash.toLowerCase();
|
||||
const res = await this.call<LndHubUserInvoice[]>({ path: '/getuserinvoices', query: { limit: 200 } });
|
||||
const found = (Array.isArray(res) ? res : []).find((inv) => bytesToHex(inv.r_hash ?? inv.payment_hash) === wanted);
|
||||
return found ? this.toInvoice(found) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeus decodes BOLT11 client-side (Bolt11Utils) and never calls this endpoint. The sidecar has no
|
||||
* decoder, and the server's `/decodeinvoice?invoice=` answers with LND's payreq shape, which maps
|
||||
* onto DecodedInvoice one-for-one.
|
||||
*/
|
||||
override async decodeInvoice(bolt11: string): Promise<DecodedInvoice> {
|
||||
const res = await this.call<LndHubDecoded>({ path: '/decodeinvoice', query: { invoice: bolt11 } });
|
||||
const msat = res.num_msat != null ? String(num(res.num_msat)) : null;
|
||||
return {
|
||||
bolt11,
|
||||
paymentHash: res.payment_hash ?? '',
|
||||
amountMsat: msat ?? (res.num_satoshis != null ? satsToMsat(num(res.num_satoshis)) : null),
|
||||
description: res.description ?? null,
|
||||
destination: res.destination ?? '',
|
||||
timestamp: num(res.timestamp),
|
||||
expiry: res.expiry != null ? num(res.expiry) : 3600,
|
||||
cltvExpiry: res.cltv_expiry != null ? num(res.cltv_expiry) : null,
|
||||
routeHints: Array.isArray(res.route_hints) && res.route_hints.length > 0,
|
||||
features: Object.values(res.features ?? {}).map((f) => f?.name ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
override async getPayments(opts?: { limit?: number }): Promise<Payment[]> {
|
||||
const txs = await this.fetchTxs(opts?.limit);
|
||||
return txs
|
||||
.filter((tx) => tx.type === 'paid_invoice' || tx.payment_preimage !== undefined)
|
||||
.map((tx) => {
|
||||
// LNDHub's User.getTxs() sets `value = payment_route.total_amt + payment_route.total_fees`,
|
||||
// so the destination amount is `value - fee`. Both are satoshis.
|
||||
const feeSats = num(tx.fee);
|
||||
const amountSats = Math.max(num(tx.value) - feeSats, 0);
|
||||
return {
|
||||
paymentHash: bytesToHex(tx.payment_hash),
|
||||
preimage: bytesToHex(tx.payment_preimage) || null,
|
||||
amountMsat: satsToMsat(amountSats),
|
||||
feeMsat: satsToMsat(feeSats),
|
||||
// /gettxs only records payments that went through; failures are never persisted.
|
||||
status: 'succeeded' as const,
|
||||
createdAt: num(tx.timestamp ?? tx.time),
|
||||
destination: null,
|
||||
memo: tx.memo ?? tx.description ?? null,
|
||||
failureReason: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /payinvoice {invoice, amount}` — `amount` is satoshis and only consulted for a zero-amount
|
||||
* invoice. Fee limits and timeouts are the operator's policy (Zeus: supportsCustomFeeLimit() =
|
||||
* false), so feeLimitMsat / feeLimitPercent / timeoutSeconds are accepted and ignored.
|
||||
*/
|
||||
override async payInvoice(req: PayInvoiceRequest): Promise<Payment> {
|
||||
const amountSats = req.amountMsat ? msatToSats(req.amountMsat, 'amountMsat') : undefined;
|
||||
const res = await this.call<LndHubPayResult>({
|
||||
path: '/payinvoice',
|
||||
method: 'POST',
|
||||
body: { invoice: req.bolt11, amount: amountSats },
|
||||
});
|
||||
|
||||
const route = res.payment_route ?? {};
|
||||
const feeMsat =
|
||||
route.total_fees_msat != null ? String(num(route.total_fees_msat)) : satsToMsat(num(route.total_fees));
|
||||
// Prefer the invoice's own amount: `total_amt` is ambiguous across forks about whether fees are
|
||||
// included, whereas the decoded payreq is not.
|
||||
const decodedMsat =
|
||||
res.decoded?.num_msat != null
|
||||
? String(num(res.decoded.num_msat))
|
||||
: res.decoded?.num_satoshis != null
|
||||
? satsToMsat(num(res.decoded.num_satoshis))
|
||||
: null;
|
||||
const routedMsat =
|
||||
route.total_amt_msat != null ? String(num(route.total_amt_msat)) : satsToMsat(num(route.total_amt));
|
||||
|
||||
const preimage = bytesToHex(res.payment_preimage) || null;
|
||||
// Hard failures arrive as `{error, message}` and have already thrown in unwrap(). `payment_error`
|
||||
// is LND's soft channel — a routing failure, reported as a failed Payment rather than an exception.
|
||||
const failed = !!res.payment_error;
|
||||
|
||||
return {
|
||||
paymentHash: bytesToHex(res.payment_hash) || res.decoded?.payment_hash || '',
|
||||
preimage,
|
||||
amountMsat: decodedMsat ?? req.amountMsat ?? routedMsat,
|
||||
feeMsat,
|
||||
status: failed ? 'failed' : 'succeeded',
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
destination: res.decoded?.destination ?? null,
|
||||
memo: res.decoded?.description ?? null,
|
||||
failureReason: failed ? (res.payment_error ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── shared ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async fetchTxs(limit?: number): Promise<LndHubTx[]> {
|
||||
const res = await this.call<LndHubTx[]>({ path: '/gettxs', query: { limit } });
|
||||
return Array.isArray(res) ? res : [];
|
||||
}
|
||||
|
||||
private toInvoice(inv: LndHubUserInvoice): Invoice {
|
||||
const createdAt = num(inv.timestamp);
|
||||
const expiresAt = inv.expire_time ? createdAt + inv.expire_time : null;
|
||||
const paid = inv.ispaid === true;
|
||||
// LNDHub has no cancel and no hold invoices, so 'canceled' and 'accepted' are unreachable; an
|
||||
// unpaid invoice past its expiry is reported as expired rather than left open.
|
||||
const state: InvoiceState = paid
|
||||
? 'settled'
|
||||
: expiresAt != null && expiresAt < Math.floor(Date.now() / 1000)
|
||||
? 'expired'
|
||||
: 'open';
|
||||
|
||||
const amountMsat = inv.amt != null ? satsToMsat(num(inv.amt)) : null;
|
||||
const paidMsat =
|
||||
inv.amt_paid_msat != null
|
||||
? String(num(inv.amt_paid_msat))
|
||||
: inv.amt_paid_sat != null
|
||||
? satsToMsat(num(inv.amt_paid_sat))
|
||||
: paid
|
||||
? amountMsat
|
||||
: null;
|
||||
|
||||
return {
|
||||
paymentHash: bytesToHex(inv.r_hash ?? inv.payment_hash),
|
||||
bolt11: inv.payment_request ?? inv.pay_req ?? '',
|
||||
amountMsat,
|
||||
amountPaidMsat: paidMsat,
|
||||
memo: inv.description ?? inv.memo ?? null,
|
||||
state,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
// The settle time is not recorded upstream; only the ispaid flag is.
|
||||
settledAt: inv.settled_at ?? null,
|
||||
// Preimages of received payments stay with the custodian.
|
||||
preimage: null,
|
||||
isKeysend: false,
|
||||
isAmp: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// Nostr Wallet Connect (NIP-47) — port of Zeus's backends/NostrWalletConnect.ts.
|
||||
//
|
||||
// A NWC connection is a capability grant, not a node: a `nostr+walletconnect://` URI carries a wallet
|
||||
// service pubkey, one or more relays and a shared secret. Every operation is an encrypted nostr event
|
||||
// round-tripped through a relay, so the whole surface is lightning-only and the wallet decides which
|
||||
// commands it will honour.
|
||||
//
|
||||
// CAPABILITIES ARE DYNAMIC. This is the one backend whose `capabilities` set is not a constant: the
|
||||
// wallet advertises its methods in the `get_info` response (falling back to the kind-13194 info event),
|
||||
// and the set is rebuilt from that list on connect. Zeus hardcodes `supportsKeysend() = false` even
|
||||
// though plenty of NWC wallets do keysend; here `keysend` appears iff the wallet lists `pay_keysend`.
|
||||
// Before the first connect the set holds the NIP-47 baseline (make_invoice + pay_invoice), because
|
||||
// `supports()` is synchronous and cannot await the handshake.
|
||||
//
|
||||
// Differences from Zeus, all deliberate:
|
||||
//
|
||||
// 1. TRANSPORT. Zeus drives `NostrWebLNProvider` from `@getalby/sdk`, a WebLN shim that silently
|
||||
// converts sats↔msats. This uses `NWCClient` from the same package (v8), which speaks raw NIP-47
|
||||
// — every amount in and out is millisatoshis, matching this contract's msat-as-string rule with
|
||||
// no lossy hop through sats. Bun/Node 22 both provide the global WebSocket that nostr-tools needs.
|
||||
// 2. lookupInvoice. Zeus passes `Base64Utils.hexToBase64(r_hash)` (NostrWalletConnect.ts:78) — NIP-47
|
||||
// specifies payment_hash as **hex**, so that call cannot match. Hex is sent here.
|
||||
// 3. INVOICE/PAYMENT SPLIT. Zeus calls `list_transactions` and filters `type` client-side, twice.
|
||||
// NIP-47 takes `type` as a request parameter; the filter is pushed to the wallet, with the
|
||||
// client-side filter kept as a guard for wallets that ignore it.
|
||||
// 4. payInvoice. NIP-47 answers a payment with `{preimage, fees_paid}` and nothing else, so Zeus
|
||||
// returns a Payment with no hash. The payment hash is sha256(preimage) by definition, so it is
|
||||
// computed locally; the amount is filled from a best-effort `lookup_invoice` when the wallet
|
||||
// supports it, otherwise from the request.
|
||||
// 5. decodeInvoice is LOCAL. NIP-47 has no decode command, so this is the one operation that never
|
||||
// touches the relay: `../bolt11` (ported from Zeus's Bolt11Utils) decodes in-process. On-chain,
|
||||
// channels, peers and message signing do stay at BaseBackend's 501 — NIP-47 `sign_message` signs
|
||||
// with the node key, which is not what the contract's signMessage means.
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Nip47Error, Nip47TimeoutError, NWCClient, type Nip47Method, type Nip47Transaction } from '@getalby/sdk/nwc';
|
||||
import { decodeBolt11 } from '../bolt11';
|
||||
import {
|
||||
BackendError,
|
||||
type Balances,
|
||||
type BackendKind,
|
||||
type BitcoinNetwork,
|
||||
type Capability,
|
||||
type CreateInvoiceRequest,
|
||||
type DecodedInvoice,
|
||||
type Invoice,
|
||||
type InvoiceState,
|
||||
type KeysendRequest,
|
||||
type NodeInfo,
|
||||
type PayInvoiceRequest,
|
||||
type Payment,
|
||||
type PaymentStatus,
|
||||
} from '../types';
|
||||
import { BaseBackend } from './base';
|
||||
|
||||
export type NwcConfig = {
|
||||
/** A `nostr+walletconnect://<wallet-pubkey>?relay=…&secret=…` URI. */
|
||||
connectionUri: string;
|
||||
};
|
||||
|
||||
// ── upstream wire shapes ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The SDK types `state` as required, but it was added late to NIP-47 and pre-1.0 wallets omit it —
|
||||
* their only settlement signal is a non-zero `settled_at`. Amounts are millisatoshis throughout.
|
||||
*/
|
||||
type NwcTransaction = Omit<Nip47Transaction, 'state'> & { state?: Nip47Transaction['state'] };
|
||||
|
||||
/** NIP-47 error codes, mapped onto HTTP so routes.ts can answer honestly. */
|
||||
const ERROR_STATUS: Record<string, number> = {
|
||||
NOT_IMPLEMENTED: 501,
|
||||
UNSUPPORTED_ENCRYPTION: 501,
|
||||
UNAUTHORIZED: 403,
|
||||
RESTRICTED: 403,
|
||||
INSUFFICIENT_BALANCE: 402,
|
||||
QUOTA_EXCEEDED: 402,
|
||||
PAYMENT_FAILED: 502,
|
||||
NOT_FOUND: 404,
|
||||
RATE_LIMITED: 429,
|
||||
INTERNAL: 502,
|
||||
OTHER: 502,
|
||||
};
|
||||
|
||||
/** The keysend TLV that carries a human-readable message (the de-facto standard record). */
|
||||
const KEYSEND_MESSAGE_TLV = 34349334;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const now = (): number => Math.floor(Date.now() / 1000);
|
||||
|
||||
/** payment_hash = sha256(preimage) — how NIP-47 lets us recover a hash it never sends back. */
|
||||
function hashFromPreimage(preimage: string | undefined): string {
|
||||
if (!preimage) return '';
|
||||
return createHash('sha256').update(Buffer.from(preimage, 'hex')).digest('hex');
|
||||
}
|
||||
|
||||
/** msat decimal string → the integer msats NIP-47 wants, with the JS-safe range enforced. */
|
||||
function toMsatNumber(msat: string, field: string): number {
|
||||
const value = BigInt(msat);
|
||||
if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||
throw new BackendError(`${field}=${msat}msat is out of range for NIP-47`, 400, 'BAD_AMOUNT');
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function toBackendError(err: unknown, op: string): BackendError {
|
||||
if (err instanceof BackendError) return err;
|
||||
if (err instanceof Nip47TimeoutError) {
|
||||
return new BackendError(`nwc ${op} timed out: ${err.message}`, 504, err.code);
|
||||
}
|
||||
if (err instanceof Nip47Error) {
|
||||
return new BackendError(`nwc ${op} failed: ${err.message}`, ERROR_STATUS[err.code] ?? 502, err.code);
|
||||
}
|
||||
return new BackendError(`nwc ${op} failed: ${String(err)}`, 502, 'NWC_ERROR');
|
||||
}
|
||||
|
||||
function toInvoiceState(tx: NwcTransaction): InvoiceState {
|
||||
if (tx.state === 'settled') return 'settled';
|
||||
if (tx.state === 'accepted') return 'accepted';
|
||||
// NIP-47 has no explicit cancel; a hold invoice that was released comes back as failed.
|
||||
if (tx.state === 'failed') return 'canceled';
|
||||
if (tx.settled_at) return 'settled';
|
||||
if (tx.expires_at && tx.expires_at < now()) return 'expired';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function toPaymentStatus(tx: NwcTransaction): PaymentStatus {
|
||||
if (tx.state === 'settled') return 'succeeded';
|
||||
if (tx.state === 'failed') return 'failed';
|
||||
if (tx.state) return 'pending';
|
||||
return tx.settled_at ? 'succeeded' : 'pending';
|
||||
}
|
||||
|
||||
function toInvoice(tx: NwcTransaction): Invoice {
|
||||
const amountMsat = tx.amount != null ? String(tx.amount) : null;
|
||||
const state = toInvoiceState(tx);
|
||||
return {
|
||||
paymentHash: tx.payment_hash ?? '',
|
||||
bolt11: tx.invoice ?? '',
|
||||
amountMsat,
|
||||
amountPaidMsat: state === 'settled' ? amountMsat : null,
|
||||
memo: tx.description || null,
|
||||
state,
|
||||
createdAt: tx.created_at ?? now(),
|
||||
expiresAt: tx.expires_at || null,
|
||||
settledAt: tx.settled_at || null,
|
||||
preimage: tx.preimage || null,
|
||||
// An incoming payment with no BOLT11 attached to it can only have been a keysend.
|
||||
isKeysend: !tx.invoice && !!tx.payment_hash,
|
||||
// NIP-47 has no AMP concept.
|
||||
isAmp: false,
|
||||
};
|
||||
}
|
||||
|
||||
function toPayment(tx: NwcTransaction): Payment {
|
||||
return {
|
||||
paymentHash: tx.payment_hash ?? '',
|
||||
preimage: tx.preimage || null,
|
||||
amountMsat: String(tx.amount ?? 0),
|
||||
feeMsat: String(tx.fees_paid ?? 0),
|
||||
status: toPaymentStatus(tx),
|
||||
createdAt: tx.created_at ?? now(),
|
||||
// The wallet never names the payee; only the invoice it paid.
|
||||
destination: null,
|
||||
memo: tx.description || null,
|
||||
failureReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── backend ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class NwcBackend extends BaseBackend {
|
||||
readonly kind: BackendKind = 'nwc';
|
||||
|
||||
/**
|
||||
* Mutable behind a ReadonlySet view — `capabilities` and `caps` are the same object, so rebuilding
|
||||
* the set after the get_info handshake is visible through `supports()` without reassigning a
|
||||
* readonly field. Seeded with the NIP-47 baseline until the wallet says otherwise.
|
||||
*/
|
||||
private readonly caps = new Set<Capability>(['lightningReceive', 'lightningSend']);
|
||||
protected readonly capabilities: ReadonlySet<Capability> = this.caps;
|
||||
|
||||
private client: NWCClient | null = null;
|
||||
private connecting: Promise<NWCClient> | null = null;
|
||||
private methods: ReadonlySet<Nip47Method> = new Set<Nip47Method>();
|
||||
|
||||
constructor(private readonly config: NwcConfig) {
|
||||
super();
|
||||
}
|
||||
|
||||
// ── connection ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Single-flight connect: the relay subscription and the get_info handshake happen exactly once. */
|
||||
private async connect(): Promise<NWCClient> {
|
||||
if (this.client) return this.client;
|
||||
this.connecting ??= this.open().finally(() => {
|
||||
this.connecting = null;
|
||||
});
|
||||
return this.connecting;
|
||||
}
|
||||
|
||||
private async open(): Promise<NWCClient> {
|
||||
let client: NWCClient;
|
||||
try {
|
||||
client = new NWCClient({ nostrWalletConnectUrl: this.config.connectionUri });
|
||||
} catch (err) {
|
||||
throw new BackendError(`invalid NWC connection URI: ${String(err)}`, 400, 'BAD_CONFIG');
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await client.getInfo();
|
||||
this.applyMethods(info.methods);
|
||||
} catch (err) {
|
||||
// Wallets that do not implement get_info still publish a kind-13194 info event listing their
|
||||
// capabilities. If neither is reachable the connection itself is broken — surface that.
|
||||
try {
|
||||
const service = await client.getWalletServiceInfo();
|
||||
this.applyMethods(service.capabilities.filter((cap): cap is Nip47Method => cap !== 'notifications'));
|
||||
} catch {
|
||||
client.close();
|
||||
throw toBackendError(err, 'connect');
|
||||
}
|
||||
}
|
||||
|
||||
this.client = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Capability negotiation: the whole point of NWC's get_info. */
|
||||
private applyMethods(methods: Nip47Method[] | undefined): void {
|
||||
if (!methods?.length) return;
|
||||
this.methods = new Set(methods);
|
||||
this.caps.clear();
|
||||
if (this.methods.has('make_invoice')) this.caps.add('lightningReceive');
|
||||
if (this.methods.has('pay_invoice')) this.caps.add('lightningSend');
|
||||
if (this.methods.has('pay_keysend')) this.caps.add('keysend');
|
||||
}
|
||||
|
||||
private async run<T>(op: string, fn: (client: NWCClient) => Promise<T>): Promise<T> {
|
||||
const client = await this.connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
} catch (err) {
|
||||
throw toBackendError(err, op);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the relay subscription. Not part of WalletBackend; the factory calls it on teardown. */
|
||||
close(): void {
|
||||
this.client?.close();
|
||||
this.client = null;
|
||||
this.methods = new Set<Nip47Method>();
|
||||
}
|
||||
|
||||
// ── node / balances ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getInfo(): Promise<NodeInfo> {
|
||||
const info = await this.run('get_info', (client) => client.getInfo());
|
||||
const network = info.network === 'mainnet' || !info.network ? 'bitcoin' : info.network;
|
||||
return {
|
||||
kind: this.kind,
|
||||
// The pubkey belongs to the wallet service's node, not to this connection.
|
||||
pubkey: info.pubkey || null,
|
||||
alias: info.alias || null,
|
||||
// NIP-47 carries no implementation version.
|
||||
version: null,
|
||||
network: (['bitcoin', 'testnet', 'signet', 'regtest'] as const).includes(network as BitcoinNetwork)
|
||||
? (network as BitcoinNetwork)
|
||||
: 'bitcoin',
|
||||
blockHeight: info.block_height ?? null,
|
||||
// A wallet that answers at all is by definition usable; there is no sync flag in NIP-47.
|
||||
synced: true,
|
||||
};
|
||||
}
|
||||
|
||||
override async getBalances(): Promise<Balances> {
|
||||
const res = await this.run('get_balance', (client) => client.getBalance());
|
||||
return {
|
||||
// NWC is lightning-only; there is no on-chain side to report.
|
||||
onchainConfirmed: 0,
|
||||
onchainUnconfirmed: 0,
|
||||
// `balance` is msats (the WebLN shim Zeus uses divides by 1000 before the UI ever sees it).
|
||||
lightningBalance: Math.floor((res.balance ?? 0) / 1000),
|
||||
lightningInbound: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── lightning ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getInvoices(opts?: { limit?: number }): Promise<Invoice[]> {
|
||||
const res = await this.run('list_transactions', (client) =>
|
||||
client.listTransactions({ type: 'incoming', limit: opts?.limit, unpaid: true }),
|
||||
);
|
||||
// The `type` filter is a request parameter, but older wallets ignore it — filter again.
|
||||
return (res.transactions ?? []).filter((tx) => tx.type !== 'outgoing').map(toInvoice);
|
||||
}
|
||||
|
||||
override async createInvoice(req: CreateInvoiceRequest): Promise<Invoice> {
|
||||
if (!this.caps.has('lightningReceive')) return this.notSupported('lightning receive');
|
||||
// No NIP-47 wallet accepts a caller-supplied preimage or an AMP invoice, and `private` has no
|
||||
// equivalent — the wallet picks its own route hints.
|
||||
if (req.preimage) return this.notSupported('custom preimages');
|
||||
if (req.isAmp) return this.notSupported('AMP invoices');
|
||||
if (!req.amountMsat || req.amountMsat === '0') {
|
||||
throw new BackendError('nwc requires an invoice amount', 400, 'AMOUNT_REQUIRED');
|
||||
}
|
||||
const amount = toMsatNumber(req.amountMsat, 'amountMsat');
|
||||
|
||||
const tx = await this.run('make_invoice', (client) =>
|
||||
client.makeInvoice({ amount, description: req.memo, expiry: req.expirySeconds }),
|
||||
);
|
||||
return toInvoice(tx);
|
||||
}
|
||||
|
||||
override async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
|
||||
if (!this.methods.size || this.methods.has('lookup_invoice')) {
|
||||
const tx = await this.lookup({ payment_hash: paymentHash });
|
||||
return tx ? toInvoice(tx) : null;
|
||||
}
|
||||
return this.notSupported('invoice lookup');
|
||||
}
|
||||
|
||||
/**
|
||||
* Purely local — no relay round-trip, and it works while the wallet is unreachable. The invoice is
|
||||
* self-describing, so there is nothing the wallet service could add beyond what the bytes already say.
|
||||
*/
|
||||
override async decodeInvoice(bolt11: string): Promise<DecodedInvoice> {
|
||||
return decodeBolt11(bolt11);
|
||||
}
|
||||
|
||||
override async getPayments(opts?: { limit?: number }): Promise<Payment[]> {
|
||||
const res = await this.run('list_transactions', (client) =>
|
||||
client.listTransactions({ type: 'outgoing', limit: opts?.limit, unpaid_outgoing: true }),
|
||||
);
|
||||
return (res.transactions ?? []).filter((tx) => tx.type !== 'incoming').map(toPayment);
|
||||
}
|
||||
|
||||
override async payInvoice(req: PayInvoiceRequest): Promise<Payment> {
|
||||
if (!this.caps.has('lightningSend')) return this.notSupported('lightning send');
|
||||
// Fee limits and timeouts are the wallet's own budget policy; NIP-47 has no field for either.
|
||||
const amount = req.amountMsat ? toMsatNumber(req.amountMsat, 'amountMsat') : undefined;
|
||||
const res = await this.run('pay_invoice', (client) => client.payInvoice({ invoice: req.bolt11, amount }));
|
||||
|
||||
const paymentHash = hashFromPreimage(res.preimage);
|
||||
// pay_invoice returns only {preimage, fees_paid}. Recover the rest from the wallet's own record
|
||||
// when it keeps one — best effort, never fatal, since the payment has already settled.
|
||||
const record = paymentHash ? await this.lookupQuietly({ payment_hash: paymentHash }) : null;
|
||||
|
||||
return {
|
||||
paymentHash,
|
||||
preimage: res.preimage || null,
|
||||
amountMsat: record?.amount != null ? String(record.amount) : (req.amountMsat ?? '0'),
|
||||
feeMsat: String(res.fees_paid ?? record?.fees_paid ?? 0),
|
||||
// A NIP-47 pay_invoice that resolves has succeeded; a failure arrives as a Nip47WalletError.
|
||||
status: 'succeeded',
|
||||
createdAt: record?.created_at ?? now(),
|
||||
destination: null,
|
||||
memo: record?.description || null,
|
||||
failureReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
override async sendKeysend(req: KeysendRequest): Promise<Payment> {
|
||||
if (!this.caps.has('keysend')) return this.notSupported('keysend');
|
||||
const amount = toMsatNumber(req.amountMsat, 'amountMsat');
|
||||
// NIP-47 carries TLV values as hex.
|
||||
const tlvRecords = req.message
|
||||
? [{ type: KEYSEND_MESSAGE_TLV, value: Buffer.from(req.message, 'utf8').toString('hex') }]
|
||||
: undefined;
|
||||
|
||||
const res = await this.run('pay_keysend', (client) =>
|
||||
client.payKeysend({ pubkey: req.destination, amount, tlv_records: tlvRecords }),
|
||||
);
|
||||
return {
|
||||
paymentHash: hashFromPreimage(res.preimage),
|
||||
preimage: res.preimage || null,
|
||||
amountMsat: req.amountMsat,
|
||||
feeMsat: String(res.fees_paid ?? 0),
|
||||
status: 'succeeded',
|
||||
createdAt: now(),
|
||||
destination: req.destination,
|
||||
memo: req.message ?? null,
|
||||
failureReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── shared ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async lookup(request: { payment_hash?: string; invoice?: string }): Promise<NwcTransaction | null> {
|
||||
try {
|
||||
return await this.run('lookup_invoice', (client) => client.lookupInvoice(request));
|
||||
} catch (err) {
|
||||
// A wallet that has never seen the hash answers NOT_FOUND; that is an absence, not a failure.
|
||||
if (err instanceof BackendError && err.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async lookupQuietly(request: { payment_hash?: string; invoice?: string }): Promise<NwcTransaction | null> {
|
||||
if (this.methods.size && !this.methods.has('lookup_invoice')) return null;
|
||||
try {
|
||||
return await this.lookup(request);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
// The native on-chain wallet backend.
|
||||
//
|
||||
// This is the server-side replacement for Zeus's two embedded backends. EmbeddedLND
|
||||
// (backends/EmbeddedLND.ts) and LdkNode (backends/LdkNode.ts) are thin JS shims over React Native
|
||||
// native modules — `lndmobile`, `ldk-node-rn` — that bundle an actual node into the app process.
|
||||
// Neither exists off a phone, so neither can be ported into a Bun sidecar. Instead this backend is a
|
||||
// real wallet in its own right: it derives addresses from a BIP32 account xpub, reads the chain from an
|
||||
// Esplora HTTP API (chain.ts), and builds/signs its own transactions with bitcoinjs-lib (psbt.ts).
|
||||
//
|
||||
// WATCH-ONLY WHILE LOCKED is the load-bearing design property. Everything a UI polls —
|
||||
// getInfo / getBalances / getTransactions / getNewAddress / getUtxos / estimateFees — is derived from
|
||||
// the account XPUB and public chain data, so it works with the wallet locked and no key material in
|
||||
// memory. `signer.withRoot` is reached from exactly two methods, sendCoins and signMessage, and both
|
||||
// fail fast with WalletLockedError before doing any work. Nothing else in this file touches the signer.
|
||||
//
|
||||
// There is no lightning here at all: the capability set omits every lightning flag, so invoices,
|
||||
// payments, channels and peers all fall through to BaseBackend's 501.
|
||||
|
||||
import { HDKey } from '@scure/bip32';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371';
|
||||
import * as ecc from '@bitcoinerlab/secp256k1';
|
||||
import type { EsploraAddress, EsploraChain, EsploraTx } from '../chain';
|
||||
import {
|
||||
buildPsbt,
|
||||
coinTypeFor,
|
||||
initEcc,
|
||||
networkFor,
|
||||
outputScriptFor,
|
||||
scriptType,
|
||||
selectCoins,
|
||||
signAndFinalize,
|
||||
type PsbtInputSource,
|
||||
type PsbtOutputSpec,
|
||||
type SpendableUtxo,
|
||||
} from '../psbt';
|
||||
import {
|
||||
BackendError,
|
||||
WalletLockedError,
|
||||
type AddressType,
|
||||
type Balances,
|
||||
type BackendKind,
|
||||
type BitcoinNetwork,
|
||||
type Capability,
|
||||
type FeeEstimates,
|
||||
type NewAddressRequest,
|
||||
type NodeInfo,
|
||||
type OnchainTx,
|
||||
type SendCoinsRequest,
|
||||
type SendCoinsResult,
|
||||
type SignMessageResult,
|
||||
type Utxo,
|
||||
type VerifyMessageResult,
|
||||
} from '../types';
|
||||
import { BaseBackend } from './base';
|
||||
|
||||
// ── the signer boundary ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The wallet's key custodian, implemented by keys.ts and injected here.
|
||||
*
|
||||
* This backend never sees a seed, a mnemonic, a passphrase or a file path. It receives a root HDKey for
|
||||
* the duration of one synchronous callback and nothing more, which is what keeps the watch-only path
|
||||
* genuinely key-free rather than key-free by convention.
|
||||
*
|
||||
* `withRoot` MUST throw `WalletLockedError` when the wallet is locked, and MUST NOT be async — an async
|
||||
* borrow would pin the root in memory across arbitrary awaits.
|
||||
*/
|
||||
export interface WalletSigner {
|
||||
isUnlocked(): boolean;
|
||||
withRoot<T>(fn: (root: HDKey) => T): T;
|
||||
}
|
||||
|
||||
// ── extended key parsing ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* SLIP-132 version bytes. @scure/bip32 refuses an extended key whose version does not match the
|
||||
* `versions` it was handed, so the prefix has to be recognised before the key can be imported. Wallets
|
||||
* export the purpose-tagged forms (zpub for BIP84, ypub for BIP49, vpub/upub on testnet) as often as
|
||||
* they export a plain xpub, and all of them are the same key with different four leading bytes.
|
||||
*/
|
||||
const XPUB_VERSIONS: Record<string, { public: number; private: number }> = {
|
||||
xpub: { public: 0x0488b21e, private: 0x0488ade4 },
|
||||
ypub: { public: 0x049d7cb2, private: 0x049d7878 },
|
||||
zpub: { public: 0x04b24746, private: 0x04b2430c },
|
||||
tpub: { public: 0x043587cf, private: 0x04358394 },
|
||||
upub: { public: 0x044a5262, private: 0x044a4e28 },
|
||||
vpub: { public: 0x045f1cf6, private: 0x045f18bc },
|
||||
};
|
||||
|
||||
function parseAccountXpub(key: string): HDKey {
|
||||
const prefix = key.slice(0, 4).toLowerCase();
|
||||
const versions = XPUB_VERSIONS[prefix];
|
||||
if (!versions) throw new BackendError(`unrecognised extended key prefix '${prefix}'`, 400, 'BAD_XPUB');
|
||||
try {
|
||||
return HDKey.fromExtendedKey(key, versions);
|
||||
} catch (err) {
|
||||
throw new BackendError(`could not parse the account extended key: ${String(err)}`, 400, 'BAD_XPUB');
|
||||
}
|
||||
}
|
||||
|
||||
// ── derivation ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** BIP44 purpose per script type: 44' legacy, 49' wrapped segwit, 84' native segwit, 86' taproot. */
|
||||
const PURPOSE: Record<AddressType, number> = {
|
||||
p2pkh: 44,
|
||||
'p2sh-p2wpkh': 49,
|
||||
p2wpkh: 84,
|
||||
p2tr: 86,
|
||||
};
|
||||
|
||||
/** Preference order when the caller does not name a script type. Native segwit first. */
|
||||
const TYPE_PREFERENCE: readonly AddressType[] = ['p2wpkh', 'p2tr', 'p2sh-p2wpkh', 'p2pkh'];
|
||||
|
||||
/** BIP44 chain index: 0 is the receive chain, 1 the internal (change) chain. */
|
||||
type ChainIndex = 0 | 1;
|
||||
|
||||
type Account = {
|
||||
type: AddressType;
|
||||
node: HDKey;
|
||||
/** Account-level path from the wallet root, e.g. `m/84'/0'/0'`. */
|
||||
basePath: string;
|
||||
};
|
||||
|
||||
type AddressEntry = {
|
||||
type: AddressType;
|
||||
chain: ChainIndex;
|
||||
index: number;
|
||||
address: string;
|
||||
/** Compressed 33-byte pubkey, hex. */
|
||||
pubkeyHex: string;
|
||||
scriptPubKeyHex: string;
|
||||
/** Full path from the wallet root — what the signer derives with. */
|
||||
path: string;
|
||||
/** Path relative to the account xpub, which is what the public Utxo type carries. */
|
||||
relPath: string;
|
||||
};
|
||||
|
||||
type ScannedAddress = AddressEntry & {
|
||||
confirmedSats: number;
|
||||
unconfirmedSats: number;
|
||||
txCount: number;
|
||||
used: boolean;
|
||||
};
|
||||
|
||||
type WalletScan = {
|
||||
at: number;
|
||||
tipHeight: number;
|
||||
addresses: ScannedAddress[];
|
||||
/** scriptPubKey hex → the entry that owns it. Used to score transactions as ours. */
|
||||
byScript: Map<string, ScannedAddress>;
|
||||
};
|
||||
|
||||
// ── tuning ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** BIP44's standard gap limit: 20 consecutive unused addresses ends the scan for a chain. */
|
||||
const GAP_LIMIT = 20;
|
||||
|
||||
/** How long a discovery scan stays fresh. Short enough to feel live, long enough that a dashboard
|
||||
* polling getInfo/getBalances/getTransactions together costs one scan rather than three. */
|
||||
const SCAN_TTL_MS = 30_000;
|
||||
|
||||
/** Parallel Esplora requests. Public instances rate-limit, so this stays modest. */
|
||||
const REQUEST_CONCURRENCY = 6;
|
||||
|
||||
/** Hard stop on a runaway scan — a misconfigured xpub against a busy chain must not loop forever. */
|
||||
const MAX_SCAN_INDEX = 1_000;
|
||||
|
||||
const DEFAULT_TX_LIMIT = 100;
|
||||
|
||||
// ── the backend ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type OnchainBackendOptions = {
|
||||
chain: EsploraChain;
|
||||
network: BitcoinNetwork;
|
||||
/**
|
||||
* The account-level extended public key. A bare string is taken as the BIP84 (p2wpkh) account; pass a
|
||||
* map to enable more than one script type, e.g. `{ p2wpkh: 'zpub…', p2tr: 'xpub…' }`.
|
||||
*/
|
||||
accountXpub: string | Partial<Record<AddressType, string>>;
|
||||
signer: WalletSigner;
|
||||
};
|
||||
|
||||
export class OnchainBackend extends BaseBackend {
|
||||
readonly kind: BackendKind = 'onchain';
|
||||
|
||||
protected readonly capabilities: ReadonlySet<Capability> = new Set<Capability>([
|
||||
'onchainReceive',
|
||||
'onchainSend',
|
||||
'coinControl',
|
||||
'psbt',
|
||||
'sweep',
|
||||
'signMessage',
|
||||
]);
|
||||
|
||||
private readonly chain: EsploraChain;
|
||||
private readonly network: BitcoinNetwork;
|
||||
private readonly btcNetwork: bitcoin.Network;
|
||||
private readonly signer: WalletSigner;
|
||||
private readonly accounts: Map<AddressType, Account>;
|
||||
private readonly defaultType: AddressType;
|
||||
|
||||
/** Derivation is pure EC math over a fixed xpub, so every address is derived at most once. */
|
||||
private readonly derived = new Map<string, AddressEntry>();
|
||||
|
||||
/** In-memory issuance high-water mark per `type:chain`, so consecutive getNewAddress calls advance.
|
||||
* Deliberately not persisted: after a restart the first unused address is recomputed from the chain,
|
||||
* which is correct as soon as an issued address has actually been paid. */
|
||||
private readonly issued = new Map<string, number>();
|
||||
|
||||
private scanCache: WalletScan | null = null;
|
||||
private scanInflight: Promise<WalletScan> | null = null;
|
||||
|
||||
constructor(opts: OnchainBackendOptions) {
|
||||
super();
|
||||
initEcc();
|
||||
this.chain = opts.chain;
|
||||
this.network = opts.network;
|
||||
this.btcNetwork = networkFor(opts.network);
|
||||
this.signer = opts.signer;
|
||||
|
||||
const coin = coinTypeFor(opts.network);
|
||||
const raw = typeof opts.accountXpub === 'string' ? { p2wpkh: opts.accountXpub } : opts.accountXpub;
|
||||
|
||||
this.accounts = new Map();
|
||||
for (const type of TYPE_PREFERENCE) {
|
||||
const xpub = raw[type];
|
||||
if (!xpub) continue;
|
||||
this.accounts.set(type, {
|
||||
type,
|
||||
node: parseAccountXpub(xpub),
|
||||
basePath: `m/${PURPOSE[type]}'/${coin}'/0'`,
|
||||
});
|
||||
}
|
||||
|
||||
const first = TYPE_PREFERENCE.find((t) => this.accounts.has(t));
|
||||
if (!first) throw new BackendError('the on-chain backend needs at least one account xpub', 400, 'BAD_XPUB');
|
||||
this.defaultType = first;
|
||||
}
|
||||
|
||||
// ── derivation helpers ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private accountFor(type: AddressType): Account {
|
||||
const account = this.accounts.get(type);
|
||||
if (!account) {
|
||||
const have = [...this.accounts.keys()].join(', ') || 'none';
|
||||
throw new BackendError(`no ${type} account is configured (have: ${have})`, 400, 'NO_ACCOUNT');
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
private derive(type: AddressType, chain: ChainIndex, index: number): AddressEntry {
|
||||
const cacheKey = `${type}:${chain}:${index}`;
|
||||
const hit = this.derived.get(cacheKey);
|
||||
if (hit) return hit;
|
||||
|
||||
const account = this.accountFor(type);
|
||||
const node = account.node.derive(`m/${chain}/${index}`);
|
||||
const pub = node.publicKey;
|
||||
if (!pub) throw new BackendError(`failed to derive ${type} ${chain}/${index}`, 500);
|
||||
const pubkey = Buffer.from(pub);
|
||||
const network = this.btcNetwork;
|
||||
|
||||
const payment =
|
||||
type === 'p2wpkh'
|
||||
? bitcoin.payments.p2wpkh({ pubkey, network })
|
||||
: type === 'p2tr'
|
||||
? bitcoin.payments.p2tr({ internalPubkey: toXOnly(pubkey), network })
|
||||
: type === 'p2sh-p2wpkh'
|
||||
? bitcoin.payments.p2sh({ redeem: bitcoin.payments.p2wpkh({ pubkey, network }), network })
|
||||
: bitcoin.payments.p2pkh({ pubkey, network });
|
||||
|
||||
if (!payment.address || !payment.output) throw new BackendError(`failed to build a ${type} address`, 500);
|
||||
|
||||
const entry: AddressEntry = {
|
||||
type,
|
||||
chain,
|
||||
index,
|
||||
address: payment.address,
|
||||
pubkeyHex: pubkey.toString('hex'),
|
||||
scriptPubKeyHex: payment.output.toString('hex'),
|
||||
path: `${account.basePath}/${chain}/${index}`,
|
||||
relPath: `${chain}/${index}`,
|
||||
};
|
||||
this.derived.set(cacheKey, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ── discovery scan ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Gap-limit scan of every configured account across both chains, memoised for SCAN_TTL_MS. Concurrent
|
||||
* callers share one in-flight scan rather than each starting their own — without that, the three
|
||||
* queries a wallet screen fires on mount would triple the request count against Esplora.
|
||||
*/
|
||||
private async scan(): Promise<WalletScan> {
|
||||
const fresh = this.scanCache;
|
||||
if (fresh && Date.now() - fresh.at < SCAN_TTL_MS) return fresh;
|
||||
if (this.scanInflight) return this.scanInflight;
|
||||
|
||||
const run = this.runScan().then(
|
||||
(result) => {
|
||||
this.scanCache = result;
|
||||
this.scanInflight = null;
|
||||
return result;
|
||||
},
|
||||
(err: unknown) => {
|
||||
this.scanInflight = null;
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
this.scanInflight = run;
|
||||
return run;
|
||||
}
|
||||
|
||||
/** Drop the cache after a send, so the spent coins disappear from the next read immediately. */
|
||||
private invalidateScan(): void {
|
||||
this.scanCache = null;
|
||||
}
|
||||
|
||||
private async runScan(): Promise<WalletScan> {
|
||||
const tipHeight = await this.chain.getTipHeight();
|
||||
const addresses: ScannedAddress[] = [];
|
||||
|
||||
// A wallet holds an account xpub for all four BIP purposes, but a full gap-limit walk of every one
|
||||
// costs 4 types x 2 chains x 20 addresses = 160 requests, which trips the rate limit on every public
|
||||
// Esplora instance. Only the wallet's own script type is walked unconditionally; the others are
|
||||
// probed at receive index 0 first (one request each) and walked only if that address has ever been
|
||||
// used. A freshly generated seed therefore costs 43 requests instead of 160, while a seed recovered
|
||||
// from a wallet that used a different script type is still found rather than silently reported empty.
|
||||
for (const type of this.accounts.keys()) {
|
||||
if (type !== this.defaultType && !(await this.hasHistory(type))) continue;
|
||||
for (const chain of [0, 1] as ChainIndex[]) {
|
||||
addresses.push(...(await this.scanChain(type, chain)));
|
||||
}
|
||||
}
|
||||
|
||||
const byScript = new Map<string, ScannedAddress>();
|
||||
for (const entry of addresses) byScript.set(entry.scriptPubKeyHex, entry);
|
||||
|
||||
return { at: Date.now(), tipHeight, addresses, byScript };
|
||||
}
|
||||
|
||||
/**
|
||||
* Has this script type ever been used at all? One request against receive index 0, which is the
|
||||
* address any wallet hands out first — so a used account is essentially never missed, and an unused
|
||||
* one costs a single call instead of a forty-address walk.
|
||||
*/
|
||||
private async hasHistory(type: AddressType): Promise<boolean> {
|
||||
const stat = await this.chain.getAddress(this.derive(type, 0, 0).address);
|
||||
return toScannedAddress(this.derive(type, 0, 0), stat).used;
|
||||
}
|
||||
|
||||
/** Walk one (type, chain) pair a gap-limit window at a time until GAP_LIMIT consecutive misses. */
|
||||
private async scanChain(type: AddressType, chain: ChainIndex): Promise<ScannedAddress[]> {
|
||||
const found: ScannedAddress[] = [];
|
||||
let index = 0;
|
||||
let gap = 0;
|
||||
|
||||
while (gap < GAP_LIMIT && index < MAX_SCAN_INDEX) {
|
||||
const window = Array.from({ length: GAP_LIMIT }, (_, i) => this.derive(type, chain, index + i));
|
||||
const stats = await mapLimit(window, REQUEST_CONCURRENCY, (entry) => this.chain.getAddress(entry.address));
|
||||
|
||||
for (let i = 0; i < window.length; i++) {
|
||||
const entry = window[i];
|
||||
const stat = stats[i];
|
||||
if (!entry || !stat) continue;
|
||||
const scanned = toScannedAddress(entry, stat);
|
||||
found.push(scanned);
|
||||
gap = scanned.used ? 0 : gap + 1;
|
||||
if (gap >= GAP_LIMIT) break;
|
||||
}
|
||||
index += window.length;
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
// ── watch-only reads (no key material required) ────────────────────────────────────────────────
|
||||
|
||||
async getInfo(): Promise<NodeInfo> {
|
||||
// Deliberately does not force a scan: this is the cheapest liveness probe the wallet has.
|
||||
const blockHeight = await this.chain.getTipHeight();
|
||||
return {
|
||||
kind: 'onchain',
|
||||
// An Esplora-backed wallet has no node identity, no alias and no upstream version to report.
|
||||
pubkey: null,
|
||||
alias: null,
|
||||
version: null,
|
||||
network: this.network,
|
||||
blockHeight,
|
||||
synced: true,
|
||||
};
|
||||
}
|
||||
|
||||
override async getBalances(): Promise<Balances> {
|
||||
const scan = await this.scan();
|
||||
let confirmed = 0;
|
||||
let unconfirmed = 0;
|
||||
for (const entry of scan.addresses) {
|
||||
confirmed += entry.confirmedSats;
|
||||
unconfirmed += entry.unconfirmedSats;
|
||||
}
|
||||
return {
|
||||
onchainConfirmed: confirmed,
|
||||
onchainUnconfirmed: unconfirmed,
|
||||
// No channels exist, and null is the contract's "this backend has no lightning" value.
|
||||
lightningBalance: null,
|
||||
lightningInbound: null,
|
||||
};
|
||||
}
|
||||
|
||||
override async estimateFees(): Promise<FeeEstimates> {
|
||||
return this.chain.getFeeEstimates();
|
||||
}
|
||||
|
||||
override async getUtxos(): Promise<Utxo[]> {
|
||||
const scan = await this.scan();
|
||||
const utxos = await this.collectUtxos(scan);
|
||||
return utxos.map((u) => ({
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
amountSats: u.amountSats,
|
||||
address: u.address,
|
||||
addressType: u.addressType,
|
||||
confirmations: u.confirmations,
|
||||
// The public contract wants the path relative to the account xpub, not the absolute one the
|
||||
// signer uses, so it is recomputed from the tail of the full path.
|
||||
derivationPath: u.derivationPath.split('/').slice(-2).join('/'),
|
||||
// Freezing is a policy layer above this backend; nothing on-chain marks a coin frozen.
|
||||
frozen: false,
|
||||
}));
|
||||
}
|
||||
|
||||
override async getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
|
||||
const type = req?.type ?? this.defaultType;
|
||||
this.accountFor(type);
|
||||
const entry = await this.nextUnused(type, 0, req?.peek === true);
|
||||
return { address: entry.address, type };
|
||||
}
|
||||
|
||||
override async getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]> {
|
||||
const scan = await this.scan();
|
||||
const limit = opts?.limit ?? DEFAULT_TX_LIMIT;
|
||||
|
||||
// Only addresses that have ever been touched can appear in history.
|
||||
const touched = scan.addresses.filter((a) => a.used);
|
||||
const pages = await mapLimit(touched, REQUEST_CONCURRENCY, (a) => this.chain.getAddressTxs(a.address));
|
||||
|
||||
const seen = new Map<string, EsploraTx>();
|
||||
for (const page of pages) {
|
||||
for (const tx of page) if (!seen.has(tx.txid)) seen.set(tx.txid, tx);
|
||||
}
|
||||
|
||||
const txs = [...seen.values()].map((tx) => this.toOnchainTx(tx, scan));
|
||||
txs.sort((a, b) => {
|
||||
// Unconfirmed first (they have no height), then newest block, then newest timestamp.
|
||||
const ah = a.blockHeight ?? Number.MAX_SAFE_INTEGER;
|
||||
const bh = b.blockHeight ?? Number.MAX_SAFE_INTEGER;
|
||||
if (ah !== bh) return bh - ah;
|
||||
return (b.timestamp ?? 0) - (a.timestamp ?? 0);
|
||||
});
|
||||
return txs.slice(0, limit);
|
||||
}
|
||||
|
||||
/** Score one Esplora transaction against the wallet's own scripts. */
|
||||
private toOnchainTx(tx: EsploraTx, scan: WalletScan): OnchainTx {
|
||||
let credit = 0;
|
||||
let debit = 0;
|
||||
const ours: string[] = [];
|
||||
const theirs: string[] = [];
|
||||
|
||||
for (const out of tx.vout) {
|
||||
const mine = scan.byScript.get(out.scriptpubkey);
|
||||
if (mine) {
|
||||
credit += out.value;
|
||||
ours.push(mine.address);
|
||||
} else if (out.scriptpubkey_address) {
|
||||
theirs.push(out.scriptpubkey_address);
|
||||
}
|
||||
}
|
||||
for (const input of tx.vin) {
|
||||
const prevout = input.prevout;
|
||||
if (prevout && scan.byScript.has(prevout.scriptpubkey)) debit += prevout.value;
|
||||
}
|
||||
|
||||
const height = tx.status.confirmed ? (tx.status.block_height ?? null) : null;
|
||||
const confirmations = height === null ? 0 : Math.max(0, scan.tipHeight - height + 1);
|
||||
const amount = credit - debit;
|
||||
|
||||
return {
|
||||
txid: tx.txid,
|
||||
amount,
|
||||
// The fee is only ours to report when we funded an input; for an incoming payment the sender
|
||||
// paid it and attributing it to this wallet would be a lie.
|
||||
feeSats: debit > 0 ? tx.fee : null,
|
||||
blockHeight: height,
|
||||
timestamp: tx.status.block_time ?? null,
|
||||
confirmations,
|
||||
// No label store in this backend; SendCoinsRequest.label is accepted and dropped.
|
||||
label: null,
|
||||
destAddresses: amount < 0 ? (theirs.length > 0 ? theirs : ours) : ours,
|
||||
// Fetching /tx/{txid}/hex per transaction would double the request count for a list view. The
|
||||
// contract permits null, and the raw hex is fetched on demand where it is actually needed.
|
||||
rawHex: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── spending (requires the root key) ───────────────────────────────────────────────────────────
|
||||
|
||||
override async sendCoins(req: SendCoinsRequest): Promise<SendCoinsResult> {
|
||||
// Fail before any network work rather than after building a PSBT we cannot sign.
|
||||
if (!this.signer.isUnlocked()) throw new WalletLockedError();
|
||||
if (!Number.isFinite(req.satPerVbyte) || req.satPerVbyte <= 0) {
|
||||
throw new BackendError('satPerVbyte must be a positive number', 400, 'INVALID_FEE_RATE');
|
||||
}
|
||||
|
||||
const recipientScript = outputScriptFor(req.address, this.btcNetwork);
|
||||
const recipientType = scriptType(recipientScript);
|
||||
|
||||
const scan = await this.scan();
|
||||
const spendable = await this.collectUtxos(scan);
|
||||
|
||||
const selection = selectCoins({
|
||||
utxos: spendable,
|
||||
targetSats: req.amountSats ?? 0,
|
||||
sendAll: req.sendAll === true,
|
||||
satPerVbyte: req.satPerVbyte,
|
||||
recipientType,
|
||||
changeType: this.defaultType,
|
||||
outpoints: req.outpoints,
|
||||
spendUnconfirmed: req.spendUnconfirmed,
|
||||
});
|
||||
|
||||
// Legacy inputs commit to the whole previous transaction, so it has to be fetched; segwit inputs
|
||||
// carry their own value and script in the PSBT and need nothing extra.
|
||||
const inputs = await mapLimit<SpendableUtxo, PsbtInputSource>(selection.inputs, REQUEST_CONCURRENCY, async (u) => ({
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
amountSats: u.amountSats,
|
||||
addressType: u.addressType,
|
||||
scriptPubKeyHex: u.scriptPubKeyHex,
|
||||
pubkeyHex: u.pubkeyHex,
|
||||
derivationPath: u.derivationPath,
|
||||
prevTxHex: u.addressType === 'p2pkh' ? await this.chain.getTxHex(u.txid) : undefined,
|
||||
}));
|
||||
|
||||
const outputs: PsbtOutputSpec[] = [{ address: req.address, amountSats: selection.outputSats }];
|
||||
if (selection.changeSats !== null) {
|
||||
const change = await this.nextUnused(this.defaultType, 1, false);
|
||||
outputs.push({ address: change.address, amountSats: selection.changeSats });
|
||||
// Change-is-always-last is a well-known chain-analysis heuristic. One shuffle removes it.
|
||||
if (Math.random() < 0.5) outputs.reverse();
|
||||
}
|
||||
|
||||
const { psbt, inputPaths } = buildPsbt({
|
||||
network: this.btcNetwork,
|
||||
inputs,
|
||||
outputs,
|
||||
rbf: req.rbf,
|
||||
});
|
||||
|
||||
// The only place a root key enters this file. Synchronous, so the key is not held across an await.
|
||||
const signed = this.signer.withRoot((root) => signAndFinalize(psbt, root, inputPaths));
|
||||
|
||||
const txid = await this.chain.broadcast(signed.rawHex);
|
||||
this.invalidateScan();
|
||||
return { txid, feeSats: signed.feeSats, rawHex: signed.rawHex };
|
||||
}
|
||||
|
||||
override async signMessage(message: string): Promise<SignMessageResult> {
|
||||
if (!this.signer.isUnlocked()) throw new WalletLockedError();
|
||||
// BIP137-style: the identity key is the first receive key of the default account, which is what
|
||||
// every other wallet that signs with a derived key uses.
|
||||
const entry = this.derive(this.defaultType, 0, 0);
|
||||
const hash = bitcoinMessageHash(message, this.btcNetwork);
|
||||
|
||||
const signature = this.signer.withRoot((root) => {
|
||||
const node = root.derive(entry.path.replace(/[hH]/g, "'"));
|
||||
const priv = node.privateKey;
|
||||
if (!priv) throw new BackendError('derived node has no private key', 500);
|
||||
const { signature: sig, recoveryId } = ecc.signRecoverable(hash, priv);
|
||||
// Header byte: 27 + recovery id, +4 because the key is compressed.
|
||||
const header = Buffer.from([27 + recoveryId + 4]);
|
||||
return Buffer.concat([header, Buffer.from(sig)]).toString('base64');
|
||||
});
|
||||
|
||||
return { signature };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verification needs no key material — only the xpub — so it stays available while locked.
|
||||
*
|
||||
* The contract takes no claimed address, and a recoverable signature *always* recovers some pubkey,
|
||||
* so "did this recover" is not a meaningful answer: a tampered message would still report valid.
|
||||
* `valid` therefore means "this wallet signed this message" — the recovered key is compared against
|
||||
* the identity key `signMessage` uses. The recovered pubkey is returned either way, so a caller
|
||||
* verifying a third party's signature can do its own comparison.
|
||||
*/
|
||||
override async verifyMessage(message: string, signature: string): Promise<VerifyMessageResult> {
|
||||
const sig = Buffer.from(signature, 'base64');
|
||||
if (sig.length !== 65) return { valid: false, pubkey: null };
|
||||
|
||||
const header = sig[0];
|
||||
if (header === undefined || header < 27 || header > 42) return { valid: false, pubkey: null };
|
||||
const recoveryId = ((header - 27) & 3) as 0 | 1 | 2 | 3;
|
||||
const compressed = ((header - 27) & 4) !== 0;
|
||||
const compact = sig.subarray(1);
|
||||
const hash = bitcoinMessageHash(message, this.btcNetwork);
|
||||
|
||||
try {
|
||||
const recovered = ecc.recover(hash, compact, recoveryId, compressed);
|
||||
if (!recovered || !ecc.verify(hash, recovered, compact)) return { valid: false, pubkey: null };
|
||||
const pubkey = Buffer.from(recovered).toString('hex');
|
||||
return { valid: pubkey === this.derive(this.defaultType, 0, 0).pubkeyHex, pubkey };
|
||||
} catch {
|
||||
return { valid: false, pubkey: null };
|
||||
}
|
||||
}
|
||||
|
||||
// ── shared internals ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch UTXOs for every scanned address that still holds a balance. */
|
||||
private async collectUtxos(scan: WalletScan): Promise<SpendableUtxo[]> {
|
||||
// An address whose funded and spent counts match holds nothing; asking Esplora about it is a
|
||||
// wasted round trip, and on a wallet with long history that is most of the address set.
|
||||
const funded = scan.addresses.filter((a) => a.confirmedSats + a.unconfirmedSats > 0);
|
||||
const sets = await mapLimit(funded, REQUEST_CONCURRENCY, async (entry) => {
|
||||
const utxos = await this.chain.getAddressUtxos(entry.address);
|
||||
return utxos.map<SpendableUtxo>((u) => ({
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
amountSats: u.value,
|
||||
address: entry.address,
|
||||
addressType: entry.type,
|
||||
confirmations:
|
||||
u.status.confirmed && u.status.block_height !== undefined
|
||||
? Math.max(0, scan.tipHeight - u.status.block_height + 1)
|
||||
: 0,
|
||||
derivationPath: entry.path,
|
||||
frozen: false,
|
||||
scriptPubKeyHex: entry.scriptPubKeyHex,
|
||||
pubkeyHex: entry.pubkeyHex,
|
||||
}));
|
||||
});
|
||||
return sets.flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* First address on a chain that the blockchain has never seen, at or beyond the in-memory issuance
|
||||
* mark. `peek` reads without consuming; otherwise the mark advances so the next call hands out a
|
||||
* different address even before the current one is paid.
|
||||
*/
|
||||
private async nextUnused(type: AddressType, chain: ChainIndex, peek: boolean): Promise<AddressEntry> {
|
||||
const scan = await this.scan();
|
||||
const key = `${type}:${chain}`;
|
||||
const hint = this.issued.get(key) ?? 0;
|
||||
|
||||
const used = new Set<number>();
|
||||
let highest = -1;
|
||||
for (const entry of scan.addresses) {
|
||||
if (entry.type !== type || entry.chain !== chain) continue;
|
||||
highest = Math.max(highest, entry.index);
|
||||
if (entry.used) used.add(entry.index);
|
||||
}
|
||||
|
||||
let index = hint;
|
||||
// Anything past the scanned window is unused by definition — the gap limit is what ended the scan.
|
||||
while (index <= highest && used.has(index)) index++;
|
||||
|
||||
if (!peek) this.issued.set(key, index + 1);
|
||||
return this.derive(type, chain, index);
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function toScannedAddress(entry: AddressEntry, stat: EsploraAddress): ScannedAddress {
|
||||
const chainBalance = stat.chain_stats.funded_txo_sum - stat.chain_stats.spent_txo_sum;
|
||||
// The mempool delta is signed: an unconfirmed spend of a confirmed coin reads negative here, which is
|
||||
// exactly what the Balances contract wants in onchainUnconfirmed.
|
||||
const mempoolBalance = stat.mempool_stats.funded_txo_sum - stat.mempool_stats.spent_txo_sum;
|
||||
const txCount = stat.chain_stats.tx_count + stat.mempool_stats.tx_count;
|
||||
return {
|
||||
...entry,
|
||||
confirmedSats: chainBalance,
|
||||
unconfirmedSats: mempoolBalance,
|
||||
txCount,
|
||||
used: txCount > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Varint, for the length prefix in the Bitcoin signed-message preimage. */
|
||||
function varint(n: number): Buffer {
|
||||
if (n < 0xfd) return Buffer.from([n]);
|
||||
if (n <= 0xffff) {
|
||||
const b = Buffer.alloc(3);
|
||||
b[0] = 0xfd;
|
||||
b.writeUInt16LE(n, 1);
|
||||
return b;
|
||||
}
|
||||
const b = Buffer.alloc(5);
|
||||
b[0] = 0xfe;
|
||||
b.writeUInt32LE(n, 1);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** sha256d(messagePrefix || varint(len) || message) — the standard signed-message preimage. */
|
||||
function bitcoinMessageHash(message: string, network: bitcoin.Network): Buffer {
|
||||
const prefix = Buffer.isBuffer(network.messagePrefix)
|
||||
? network.messagePrefix
|
||||
: Buffer.from(network.messagePrefix, 'utf8');
|
||||
const body = Buffer.from(message, 'utf8');
|
||||
return bitcoin.crypto.hash256(Buffer.concat([prefix, varint(body.length), body]));
|
||||
}
|
||||
|
||||
/** Bounded-concurrency map that preserves input order. Esplora is per-address, so scans fan out wide. */
|
||||
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const out = new Array<R>(items.length);
|
||||
let cursor = 0;
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const i = cursor++;
|
||||
if (i >= items.length) return;
|
||||
const item = items[i];
|
||||
if (item === undefined) continue;
|
||||
out[i] = await fn(item);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// Vectors: the BOLT 11 spec examples (lightning/bolts, 11-payment-encoding.md § Examples), plus the
|
||||
// regtest and signet invoices Zeus uses in its own utils/Bolt11Utils.test.ts. Every spec invoice below is
|
||||
// signed with priv_key e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734.
|
||||
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { clearBolt11Cache, decodeBolt11, decodeBolt11Invoice } from './bolt11';
|
||||
import { BackendError } from './types';
|
||||
|
||||
// ── spec vectors ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** no amount, description "Please consider supporting this project" */
|
||||
const NO_AMOUNT =
|
||||
'lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql';
|
||||
|
||||
/** 2500u, description "1 cup coffee", expiry 60 */
|
||||
const COFFEE_250U =
|
||||
'lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh';
|
||||
|
||||
/** 2500u, UTF-8 description, expiry 60 */
|
||||
const NONSENSE_250U =
|
||||
'lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpu9qrsgqhtjpauu9ur7fw2thcl4y9vfvh4m9wlfyz2gem29g5ghe2aak2pm3ps8fdhtceqsaagty2vph7utlgj48u0ged6a337aewvraedendscp573dxr';
|
||||
|
||||
/** 20m, description_hash instead of a description */
|
||||
const HASHED_20M =
|
||||
'lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqs9qrsgq7ea976txfraylvgzuxs8kgcw23ezlrszfnh8r6qtfpr6cxga50aj6txm9rxrydzd06dfeawfk6swupvz4erwnyutnjq7x39ymw6j38gp7ynn44';
|
||||
|
||||
/** the same on testnet, with a P2PKH fallback address */
|
||||
const TESTNET_20M_FALLBACK =
|
||||
'lntb20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfpp3x9et2e20v6pu37c5d9vax37wxq72un989qrsgqdj545axuxtnfemtpwkc45hx9d2ft7x04mt8q7y6t0k2dge9e7h8kpy9p34ytyslj3yu569aalz2xdk8xkd7ltxqld94u8h2esmsmacgpghe9k8';
|
||||
|
||||
/** 20m with a fallback address and a two-hop route hint */
|
||||
const MAINNET_20M_ROUTES =
|
||||
'lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfpp3qjmp7lwpagxun9pygexvgpjdc4jdj85fr9yq20q82gphp2nflc7jtzrcazrra7wwgzxqc8u7754cdlpfrmccae92qgzqvzq2ps8pqqqqqqpqqqqq9qqqvpeuqafqxu92d8lr6fvg0r5gv0heeeqgcrqlnm6jhphu9y00rrhy4grqszsvpcgpy9qqqqqqgqqqqq7qqzq9qrsgqdfjcdk6w3ak5pca9hwfwfh63zrrz06wwfya0ydlzpgzxkn5xagsqz7x9j4jwe7yj7vaf2k9lqsdk45kts2fd0fkr28am0u4w95tt2nsq76cqw0';
|
||||
|
||||
/** 9678785340p — a msat amount that is not a whole number of sats */
|
||||
const PICO_9678785340P =
|
||||
'lnbc9678785340p1pwmna7lpp5gc3xfm08u9qy06djf8dfflhugl6p7lgza6dsjxq454gxhj9t7a0sd8dgfkx7cmtwd68yetpd5s9xar0wfjn5gpc8qhrsdfq24f5ggrxdaezqsnvda3kkum5wfjkzmfqf3jkgem9wgsyuctwdus9xgrcyqcjcgpzgfskx6eqf9hzqnteypzxz7fzypfhg6trddjhygrcyqezcgpzfysywmm5ypxxjemgw3hxjmn8yptk7untd9hxwg3q2d6xjcmtv4ezq7pqxgsxzmnyyqcjqmt0wfjjq6t5v4khxsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsxqyjw5qcqp2rzjq0gxwkzc8w6323m55m4jyxcjwmy7stt9hwkwe2qxmy8zpsgg7jcuwz87fcqqeuqqqyqqqqlgqqqqn3qq9q9qrsgqrvgkpnmps664wgkp43l22qsgdw4ve24aca4nymnxddlnp8vh9v2sdxlu5ywdxefsfvm0fq3sesf08uf6q9a2ke0hc9j6z6wlxg5z5kqpu2v9wz';
|
||||
|
||||
/** 25m advertising features 8, 14 and 99 */
|
||||
const FEATURES_25M =
|
||||
'lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqqsgq2a25dxl5hrntdtn6zvydt7d66hyzsyhqs4wdynavys42xgl6sgx9c4g7me86a27t07mdtfry458rtjr0v92cnmswpsjscgt2vcse3sgpz3uapa';
|
||||
|
||||
/** the same invoice in the all-uppercase QR form */
|
||||
const FEATURES_25M_UPPER =
|
||||
'LNBC25M1PVJLUEZPP5QQQSYQCYQ5RQWZQFQQQSYQCYQ5RQWZQFQQQSYQCYQ5RQWZQFQYPQDQ5VDHKVEN9V5SXYETPDEESSP5ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYGS9Q5SQQQQQQQQQQQQQQQQSGQ2A25DXL5HRNTDTN6ZVYDT7D66HYZSYHQS4WDYNAVYS42XGL6SGX9C4G7ME86A27T07MDTFRY458RTJR0V92CNMSWPSJSCGT2VCSE3SGPZ3UAPA';
|
||||
|
||||
/** 10m carrying payment metadata 0x01fafaf0 */
|
||||
const METADATA_10M =
|
||||
'lnbc10m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdp9wpshjmt9de6zqmt9w3skgct5vysxjmnnd9jx2mq8q8a04uqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q2gqqqqqqsgq7hf8he7ecf7n4ffphs6awl9t6676rrclv9ckg3d3ncn7fct63p6s365duk5wrk202cfy3aj5xnnp5gs3vrdvruverwwq7yzhkf5a3xqpd05wjc';
|
||||
|
||||
/** a valid invoice whose signature is high-S */
|
||||
const HIGH_S_SIGNATURE =
|
||||
'lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap2r09nt4ndd0unm3z9u5t48y6ucv4r5sg7lk98c77ctvjczkspk5qprc90gx';
|
||||
|
||||
/** a high-S signature that does not match the invoice's own `n` payee field */
|
||||
const PAYEE_MISMATCH =
|
||||
'lnbc25m1p70xwfzpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaqnp4q0n326hr8v9zprg8gsvezcch06gfaqqhde2aj730yg0durunfhv66sp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsp5cfzp9ugllvk03rltd6hvndxj26ux6gcxc5azyxk060rj9tzghct5zvjlps76gx8wpq5yuu79688k8gnm2c0al6v608s96l0xzrrlqqwnzxmu';
|
||||
|
||||
/** bech32 checksum is invalid */
|
||||
const BAD_CHECKSUM =
|
||||
'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrnt';
|
||||
|
||||
/** signature is not recoverable */
|
||||
const UNRECOVERABLE_SIGNATURE =
|
||||
'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqwgt7mcn5yqw3yx0w94pswkpq6j9uh6xfqqqtsk4tnarugeektd4hg5975x9am52rz4qskukxdmjemg92vvqz8nvmsye63r5ykel43pgz7zq0g2';
|
||||
|
||||
/** string is too short */
|
||||
const TOO_SHORT =
|
||||
'lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6na6hlh';
|
||||
|
||||
/** invalid amount multiplier */
|
||||
const BAD_MULTIPLIER =
|
||||
'lnbc2500x1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqrrzc4cvfue4zp3hggxp47ag7xnrlr8vgcmkjxk3j5jqethnumgkpqp23z9jclu3v0a7e0aruz366e9wqdykw6dxhdzcjjhldxq0w6wgqcnu43j';
|
||||
|
||||
/** invalid sub-millisatoshi precision */
|
||||
const SUB_MSAT_PRECISION =
|
||||
'lnbc2500000001p1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgq0lzc236j96a95uv0m3umg28gclm5lqxtqqwk32uuk4k6673k6n5kfvx3d2h8s295fad45fdhmusm8sjudfhlf6dcsxmfvkeywmjdkxcp99202x';
|
||||
|
||||
// ── Zeus fixtures (utils/Bolt11Utils.test.ts) ────────────────────────────────────────────────────
|
||||
|
||||
/** regtest, 1230n = 123 sat, no expiry field */
|
||||
const REGTEST_1230N =
|
||||
'lnbcrt1230n1pj429x7pp57t97q4awqj3f529snr0pa6senk83sq5pp760qf5a4jzvd7xgwcksdqqcqzzsxqrrsssp57eqtv7vxr46arupna3w4ct0lkf2mqmz9wt044cwkks0rwlnhfr5s9qyyssqragwpwav7nfwv2xyuuamxxj4pnnpzv2hlw7j473repd3sq7st698ta9kmzmygt0w7tmncl56a6mnma0w7e5dlpqd0wy6x3v35rssldspjhh8p0';
|
||||
|
||||
/** signet, 567780n = 56778 sat */
|
||||
const SIGNET_567780N =
|
||||
'lntbs567780n1pnqr26ypp5c0wcrpzwxwqnwu2nld5q36dfc9yjrfdp87nn9d5y093jjncvqresdq0w3jhxar8v3n8xeccqzpuxqrrsssp5r94e3nwnw63gjaxc8wex38ufv2m6442vnrw49m7dad9jdum3tdsq9qyyssqk4dvvuk7zhhju8ztf7nfc2hzqq9gqtzuyc0ljz8nl93laxwv4869lt9fsxkxacje6eh4ur5ymg83hvakn4tfpzdu6fq49705sar7fxspga8qjp';
|
||||
|
||||
/** Every spec vector shares this payee, payment hash and timestamp. */
|
||||
const SPEC_PAYEE = '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad';
|
||||
const SPEC_PAYMENT_HASH = '0001020304050607080900010203040506070809000102030405060708090102';
|
||||
const SPEC_TIMESTAMP = 1496314658;
|
||||
// ── amounts ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decodeBolt11 amounts', () => {
|
||||
it('returns null for a zero-amount invoice', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).amountMsat).toBeNull();
|
||||
});
|
||||
|
||||
it('parses every multiplier as an exact msat string', () => {
|
||||
// 20m = 0.02 BTC, 2500u = 0.0025 BTC, 1230n = 123 sat, 9678785340p = 967878534 msat.
|
||||
expect(decodeBolt11(HASHED_20M).amountMsat).toBe('2000000000');
|
||||
expect(decodeBolt11(COFFEE_250U).amountMsat).toBe('250000000');
|
||||
expect(decodeBolt11(REGTEST_1230N).amountMsat).toBe('123000');
|
||||
expect(decodeBolt11(PICO_9678785340P).amountMsat).toBe('967878534');
|
||||
});
|
||||
|
||||
it('keeps amountMsat a string and never widens it to a number', () => {
|
||||
const amount = decodeBolt11(PICO_9678785340P).amountMsat;
|
||||
expect(typeof amount).toBe('string');
|
||||
// A pico amount need not be a whole sat: the msat string is exact where `satoshis` cannot be.
|
||||
expect(decodeBolt11Invoice(PICO_9678785340P).satoshis).toBeNull();
|
||||
expect(decodeBolt11Invoice(REGTEST_1230N).satoshis).toBe(123);
|
||||
});
|
||||
|
||||
it('rejects an unknown multiplier', () => {
|
||||
expect(() => decodeBolt11(BAD_MULTIPLIER)).toThrow(BackendError);
|
||||
});
|
||||
|
||||
it('rejects sub-millisatoshi precision instead of rounding it', () => {
|
||||
expect(() => decodeBolt11(SUB_MSAT_PRECISION)).toThrow(BackendError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── fields ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decodeBolt11 fields', () => {
|
||||
it('extracts the payment hash, destination and timestamp', () => {
|
||||
const decoded = decodeBolt11(NO_AMOUNT);
|
||||
expect(decoded.paymentHash).toBe(SPEC_PAYMENT_HASH);
|
||||
expect(decoded.destination).toBe(SPEC_PAYEE);
|
||||
expect(decoded.timestamp).toBe(SPEC_TIMESTAMP);
|
||||
});
|
||||
|
||||
it('recovers a key from a high-S signature instead of rejecting it', () => {
|
||||
// The spec's high-S vector is the donation invoice re-signed with s negated while keeping recovery
|
||||
// flag 1, so it recovers a *different* (valid) key. What matters is that recovery runs at all —
|
||||
// implementations that enforce low-S during recovery fail here.
|
||||
expect(decodeBolt11(HIGH_S_SIGNATURE).destination).toMatch(/^0[23][0-9a-f]{64}$/);
|
||||
expect(decodeBolt11(HIGH_S_SIGNATURE).destination).not.toBe(SPEC_PAYEE);
|
||||
expect(decodeBolt11(HIGH_S_SIGNATURE).paymentHash).toBe(SPEC_PAYMENT_HASH);
|
||||
});
|
||||
|
||||
it('returns the description for a `d` invoice and null for an `h` one', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).description).toBe('Please consider supporting this project');
|
||||
expect(decodeBolt11(COFFEE_250U).description).toBe('1 cup coffee');
|
||||
expect(decodeBolt11(NONSENSE_250U).description).toBe('ナンセンス 1杯');
|
||||
|
||||
const hashed = decodeBolt11Invoice(HASHED_20M);
|
||||
expect(decodeBolt11(HASHED_20M).description).toBeNull();
|
||||
expect(hashed.descriptionHash).toBe('3925b6f67e2c340036ed12093dd44e0368df1b6ea26c53dbe4811f58fd5db8c1');
|
||||
});
|
||||
|
||||
it('defaults expiry to 3600 when there is no `x` field', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).expiry).toBe(3600);
|
||||
expect(decodeBolt11Invoice(NO_AMOUNT).expiry).toBeNull();
|
||||
expect(decodeBolt11(REGTEST_1230N).expiry).toBe(3600);
|
||||
});
|
||||
|
||||
it('reads an explicit expiry', () => {
|
||||
expect(decodeBolt11(COFFEE_250U).expiry).toBe(60);
|
||||
expect(decodeBolt11(PICO_9678785340P).expiry).toBe(604800);
|
||||
expect(decodeBolt11Invoice(COFFEE_250U).expiresAt).toBe(SPEC_TIMESTAMP + 60);
|
||||
});
|
||||
|
||||
it('expands the feature bitmap to the indices of the set bits', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).features).toEqual(['8', '14']);
|
||||
expect(decodeBolt11(FEATURES_25M).features).toEqual(['8', '14', '99']);
|
||||
});
|
||||
|
||||
it('reports route hints and parses their hops', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).routeHints).toBe(false);
|
||||
|
||||
const decoded = decodeBolt11Invoice(MAINNET_20M_ROUTES);
|
||||
expect(decodeBolt11(MAINNET_20M_ROUTES).routeHints).toBe(true);
|
||||
expect(decoded.routes).toHaveLength(1);
|
||||
expect(decoded.routes[0]).toEqual([
|
||||
{
|
||||
pubkey: '029e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255',
|
||||
shortChannelId: '66051x263430x1800',
|
||||
feeBaseMsat: 1,
|
||||
feeProportionalMillionths: 20,
|
||||
cltvExpiryDelta: 3,
|
||||
},
|
||||
{
|
||||
pubkey: '039e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255',
|
||||
shortChannelId: '197637x395016x2314',
|
||||
feeBaseMsat: 2,
|
||||
feeProportionalMillionths: 30,
|
||||
cltvExpiryDelta: 4,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses payment secret, metadata and fallback addresses', () => {
|
||||
expect(decodeBolt11Invoice(NO_AMOUNT).paymentSecret).toBe(
|
||||
'1111111111111111111111111111111111111111111111111111111111111111',
|
||||
);
|
||||
expect(decodeBolt11Invoice(METADATA_10M).metadata).toBe('01fafaf0');
|
||||
|
||||
const fallback = decodeBolt11Invoice(TESTNET_20M_FALLBACK).fallbacks[0];
|
||||
// 17 is the P2PKH marker; the program is the 20-byte hash160 of mk2QpYatsKicvFVuTAQLBryyccRXMUaGHP.
|
||||
expect(fallback?.version).toBe(17);
|
||||
expect(fallback?.programHex).toHaveLength(40);
|
||||
});
|
||||
|
||||
it('leaves cltvExpiry null when the invoice omits `c`', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).cltvExpiry).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── networks ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decodeBolt11 networks', () => {
|
||||
it('resolves every bech32 network prefix', () => {
|
||||
expect(decodeBolt11Invoice(NO_AMOUNT).network).toBe('bitcoin');
|
||||
expect(decodeBolt11Invoice(TESTNET_20M_FALLBACK).network).toBe('testnet');
|
||||
expect(decodeBolt11Invoice(SIGNET_567780N).network).toBe('signet');
|
||||
expect(decodeBolt11Invoice(REGTEST_1230N).network).toBe('regtest');
|
||||
});
|
||||
|
||||
it('parses the amount out of a multi-letter network prefix', () => {
|
||||
// `lnbcrt1230n` and `lntbs567780n` are the cases where a greedy hrp match eats the amount.
|
||||
expect(decodeBolt11(REGTEST_1230N).amountMsat).toBe('123000');
|
||||
expect(decodeBolt11(SIGNET_567780N).amountMsat).toBe('56778000');
|
||||
});
|
||||
|
||||
it('rejects an unknown network prefix', () => {
|
||||
expect(() => decodeBolt11(NO_AMOUNT.replace(/^lnbc/, 'lnxyz'))).toThrow(BackendError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── malformed input ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decodeBolt11 rejections', () => {
|
||||
const expectBadInvoice = (invoice: string) => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
decodeBolt11(invoice);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(BackendError);
|
||||
expect((caught as BackendError).status).toBe(400);
|
||||
expect((caught as BackendError).code).toBe('BAD_INVOICE');
|
||||
};
|
||||
|
||||
it('throws BackendError(400, BAD_INVOICE) on a corrupted checksum', () => {
|
||||
expectBadInvoice(BAD_CHECKSUM);
|
||||
// The same invoice with one data character flipped.
|
||||
expectBadInvoice(`${COFFEE_250U.slice(0, -1)}${COFFEE_250U.endsWith('h') ? 'w' : 'h'}`);
|
||||
});
|
||||
|
||||
it('throws on a signature that cannot be recovered', () => {
|
||||
expectBadInvoice(UNRECOVERABLE_SIGNATURE);
|
||||
});
|
||||
|
||||
it('throws when the recovered key contradicts an explicit `n` payee field', () => {
|
||||
expectBadInvoice(PAYEE_MISMATCH);
|
||||
});
|
||||
|
||||
it('throws on a truncated payment request', () => {
|
||||
expectBadInvoice(TOO_SHORT);
|
||||
});
|
||||
|
||||
it('throws on input that is not a payment request at all', () => {
|
||||
expectBadInvoice('');
|
||||
expectBadInvoice('not an invoice');
|
||||
expectBadInvoice(NO_AMOUNT.replace(/^ln/, ''));
|
||||
});
|
||||
|
||||
it('throws on a mixed-case payment request but accepts the uppercase QR form', () => {
|
||||
expectBadInvoice(`LNBC${NO_AMOUNT.slice(4)}`);
|
||||
expect(decodeBolt11(FEATURES_25M_UPPER)).toEqual(decodeBolt11(FEATURES_25M));
|
||||
// The contract's `bolt11` is always the canonical lowercase form.
|
||||
expect(decodeBolt11(FEATURES_25M_UPPER).bolt11).toBe(FEATURES_25M);
|
||||
});
|
||||
});
|
||||
|
||||
// ── cache ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('the LRU cache', () => {
|
||||
it('returns the same object for a repeat decode and survives a clear', () => {
|
||||
clearBolt11Cache();
|
||||
const first = decodeBolt11Invoice(COFFEE_250U);
|
||||
expect(decodeBolt11Invoice(COFFEE_250U)).toBe(first);
|
||||
expect(decodeBolt11Invoice(COFFEE_250U.toUpperCase())).toBe(first);
|
||||
|
||||
clearBolt11Cache();
|
||||
const afterClear = decodeBolt11Invoice(COFFEE_250U);
|
||||
expect(afterClear).not.toBe(first);
|
||||
expect(afterClear).toEqual(first);
|
||||
});
|
||||
|
||||
it('hands every decodeBolt11 caller its own object', () => {
|
||||
const first = decodeBolt11(COFFEE_250U);
|
||||
const second = decodeBolt11(COFFEE_250U);
|
||||
expect(second).not.toBe(first);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,516 @@
|
||||
// BOLT11 invoice decoding, in-process — no node round-trip.
|
||||
//
|
||||
// Ported from Zeus's `utils/Bolt11Utils.ts`, which is itself derived from **light-bolt11-decoder**:
|
||||
// Copyright (c) 2021 bitcoinjs contributors, fiatjaf. MIT licence.
|
||||
// The bech32 walk, the human-readable-part amount grammar and the signature preimage construction are
|
||||
// that upstream's work and are preserved verbatim in behaviour.
|
||||
//
|
||||
// INTENTIONAL DIFFERENCES from Zeus's port:
|
||||
//
|
||||
// 1. ERRORS. Zeus mixes `throw new Error(...)` with silent `undefined` returns (a bad multiplier sets
|
||||
// `satoshis = null` and carries on; a `fromWordsUnsafe` failure yields `undefined`). Every failure
|
||||
// here is a `BackendError(msg, 400, 'BAD_INVOICE')`, so routes.ts answers 400 rather than leaking a
|
||||
// half-decoded invoice with null amounts into a payment flow.
|
||||
// 2. NO bignumber.js. All amount arithmetic is BigInt: msat tops out at 2.1e18, which a double cannot
|
||||
// hold, and BigInt does the same job exactly with one less dependency.
|
||||
// 3. NO @noble/hashes. The single sha256 comes from `node:crypto`, which Bun provides natively.
|
||||
// 4. EAGER signature recovery. Zeus installs lazy getters for `destination`/`signature` because its
|
||||
// Activity list decodes hundreds of invoices to read only the timestamp. Here `DecodedInvoice`
|
||||
// requires a non-null `destination` on every call, so laziness would never pay off; the LRU cache
|
||||
// (which is what actually collapses repeat recoveries) is kept and sized for a server.
|
||||
// 5. NO `sections` array. Zeus keeps light-bolt11-decoder's positional `{name, letters, value?: any}`
|
||||
// list for back-compat. Nothing here consumes it, and it was the source of the `any`. Every tag is
|
||||
// exposed as a named, typed field instead, with anything unrecognised in `unknownTags`.
|
||||
// 6. MORE TAGS PARSED. Zeus leaves `r` (route hints), `9` (features) and `f` (fallback address) as
|
||||
// unknown tags. All three are parsed, because the contract's `routeHints` and `features` fields need
|
||||
// them. Feature bits are expanded to the indices of the set bits, matching how backends/clnrest.ts
|
||||
// renders CLN's bitmap.
|
||||
// 7. SPEC-CONFORMANT LENIENCY/STRICTNESS. Known tags whose `data_length` is wrong are skipped and
|
||||
// unknown tags ignored (BOLT 11 requires both). An all-uppercase invoice — the QR form — is accepted
|
||||
// and lowercased; a MIXED-case one is rejected, as the spec requires and Zeus's blanket
|
||||
// `.toLowerCase()` did not. An explicit `n` payee is checked against the recovered key rather than
|
||||
// trusted blindly, which is the check Zeus skips.
|
||||
// 8. NO SIMNET. Zeus carries the btcd-only `sb` prefix; `BitcoinNetwork` has no member for it.
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { bech32 } from 'bech32';
|
||||
import { recoverPublicKey } from '@noble/secp256k1';
|
||||
import { BackendError, type BitcoinNetwork, type DecodedInvoice } from './types';
|
||||
|
||||
// ── shape ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Bolt11NetworkParams = {
|
||||
/** The bech32 human-readable part that follows `ln`. */
|
||||
readonly bech32: string;
|
||||
readonly network: BitcoinNetwork;
|
||||
readonly pubKeyHash: number;
|
||||
readonly scriptHash: number;
|
||||
readonly validWitnessVersions: readonly number[];
|
||||
};
|
||||
|
||||
/** One hop of an `r` field. Multiple `r` fields each describe a separate route. */
|
||||
export type Bolt11RouteHop = {
|
||||
readonly pubkey: string;
|
||||
/** `block x tx x output`, the standard short_channel_id rendering. */
|
||||
readonly shortChannelId: string;
|
||||
readonly feeBaseMsat: number;
|
||||
readonly feeProportionalMillionths: number;
|
||||
readonly cltvExpiryDelta: number;
|
||||
};
|
||||
|
||||
/** An `f` field. Left as witness-version + program rather than re-encoded to an address string. */
|
||||
export type Bolt11Fallback = {
|
||||
/** 0-16 are segwit witness versions; 17 is P2PKH and 18 is P2SH. */
|
||||
readonly version: number;
|
||||
readonly programHex: string;
|
||||
};
|
||||
|
||||
export type Bolt11UnknownTag = { readonly tagCode: number; readonly words: readonly number[] };
|
||||
|
||||
/** The full decode. `decodeBolt11` projects this onto the narrower `DecodedInvoice` contract. */
|
||||
export type Bolt11Invoice = {
|
||||
/** The invoice, lowercased. */
|
||||
readonly paymentRequest: string;
|
||||
/** The whole human-readable part, e.g. `lnbc2500u`. */
|
||||
readonly prefix: string;
|
||||
readonly network: BitcoinNetwork;
|
||||
readonly networkParams: Bolt11NetworkParams;
|
||||
readonly timestamp: number;
|
||||
/** Decimal msat string, or null for a zero-amount ("any amount") invoice. Never a number. */
|
||||
readonly millisatoshis: string | null;
|
||||
/** Convenience only, and null whenever the amount is not a whole number of sats. */
|
||||
readonly satoshis: number | null;
|
||||
readonly paymentHash: string;
|
||||
readonly paymentSecret: string | null;
|
||||
readonly description: string | null;
|
||||
readonly descriptionHash: string | null;
|
||||
/** The explicit `n` payee field, when the invoice carries one. */
|
||||
readonly payee: string | null;
|
||||
/** `payee` when present, else recovered from the signature. 33-byte compressed pubkey, hex. */
|
||||
readonly destination: string;
|
||||
/** The raw `x` field; null when absent. `expirySeconds` applies the spec's 3600 default. */
|
||||
readonly expiry: number | null;
|
||||
readonly expirySeconds: number;
|
||||
readonly expiresAt: number;
|
||||
readonly cltvExpiry: number | null;
|
||||
readonly metadata: string | null;
|
||||
/** Indices of the set feature bits, as decimal strings. */
|
||||
readonly featureBits: readonly string[];
|
||||
readonly routes: readonly (readonly Bolt11RouteHop[])[];
|
||||
readonly fallbacks: readonly Bolt11Fallback[];
|
||||
/** 64-byte compact signature, hex. */
|
||||
readonly signature: string;
|
||||
readonly recoveryFlag: number;
|
||||
readonly unknownTags: readonly Bolt11UnknownTag[];
|
||||
};
|
||||
|
||||
// ── constants ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Zeus caches 256 entries for a phone's Activity list. A server decodes the same invoice from several
|
||||
* call sites (list → detail → pay), so the cache earns its keep the same way; 1024 entries of a few
|
||||
* hundred bytes each is a rounding error against a Bun heap.
|
||||
*/
|
||||
const CACHE_LIMIT = 1024;
|
||||
const cache = new Map<string, Bolt11Invoice>();
|
||||
|
||||
const MSAT_PER_BTC = 100_000_000_000n;
|
||||
const MAX_MSAT = 2_100_000_000_000_000_000n;
|
||||
const DIVISORS: Readonly<Record<string, bigint>> = {
|
||||
m: 1_000n,
|
||||
u: 1_000_000n,
|
||||
n: 1_000_000_000n,
|
||||
p: 1_000_000_000_000n,
|
||||
};
|
||||
|
||||
const NETWORKS: readonly Bolt11NetworkParams[] = [
|
||||
{ bech32: 'bc', network: 'bitcoin', pubKeyHash: 0x00, scriptHash: 0x05, validWitnessVersions: [0, 1] },
|
||||
{ bech32: 'tb', network: 'testnet', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] },
|
||||
{ bech32: 'tbs', network: 'signet', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] },
|
||||
{ bech32: 'bcrt', network: 'regtest', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] },
|
||||
];
|
||||
|
||||
const TAG_NAMES = {
|
||||
1: 'payment_hash',
|
||||
3: 'route_hint',
|
||||
5: 'feature_bits',
|
||||
6: 'expiry',
|
||||
9: 'fallback_address',
|
||||
13: 'description',
|
||||
16: 'payment_secret',
|
||||
19: 'payee',
|
||||
23: 'description_hash',
|
||||
24: 'min_final_cltv_expiry',
|
||||
27: 'metadata',
|
||||
} as const satisfies Record<number, string>;
|
||||
|
||||
/** BOLT 11: a reader MUST skip a `p`, `s`, `h` or `n` field whose data_length is not the fixed size. */
|
||||
const FIXED_TAG_WORDS: Readonly<Record<number, number>> = { 1: 52, 16: 52, 23: 52, 19: 53 };
|
||||
|
||||
/** The signature occupies the last 104 words — 65 bytes: 64-byte compact sig + 1 recovery byte. */
|
||||
const SIGNATURE_WORDS = 104;
|
||||
const TIMESTAMP_WORDS = 7;
|
||||
|
||||
/** BOLT 11 default when no `x` field is present. */
|
||||
const DEFAULT_EXPIRY_SECONDS = 3600;
|
||||
|
||||
const ROUTE_HOP_BYTES = 51;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function badInvoice(message: string): never {
|
||||
throw new BackendError(`invalid bolt11 invoice: ${message}`, 400, 'BAD_INVOICE');
|
||||
}
|
||||
|
||||
/** Big-endian base-32. BigInt because a hostile tag can claim far more than 53 bits. */
|
||||
function wordsToInt(words: readonly number[]): number {
|
||||
let value = 0n;
|
||||
for (const word of words) value = value * 32n + BigInt(word);
|
||||
if (value > BigInt(Number.MAX_SAFE_INTEGER)) badInvoice('a numeric field is out of range');
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function wordsToBytes(words: readonly number[]): Uint8Array {
|
||||
const bytes = bech32.fromWordsUnsafe(words);
|
||||
if (bytes == null) badInvoice('a field is not a whole number of bytes');
|
||||
return Uint8Array.from(bytes);
|
||||
}
|
||||
|
||||
const wordsToHex = (words: readonly number[]): string => Buffer.from(wordsToBytes(words)).toString('hex');
|
||||
|
||||
const wordsToUtf8 = (words: readonly number[]): string => Buffer.from(wordsToBytes(words)).toString('utf8');
|
||||
|
||||
/**
|
||||
* The `9` field is a big-endian bit vector whose LAST bit is feature 0, so a word's position from the
|
||||
* end fixes the base index. Rendered as decimal strings to match backends/clnrest.ts's `featureBits`.
|
||||
*/
|
||||
function wordsToFeatureBits(words: readonly number[]): string[] {
|
||||
const bits: string[] = [];
|
||||
for (let i = words.length - 1; i >= 0; i--) {
|
||||
const word = words[i] ?? 0;
|
||||
const base = (words.length - 1 - i) * 5;
|
||||
for (let bit = 0; bit < 5; bit++) {
|
||||
if (word & (1 << bit)) bits.push(String(base + bit));
|
||||
}
|
||||
}
|
||||
return bits.sort((a, b) => Number(a) - Number(b));
|
||||
}
|
||||
|
||||
function wordsToRoute(words: readonly number[]): Bolt11RouteHop[] {
|
||||
const bytes = Buffer.from(wordsToBytes(words));
|
||||
if (bytes.length === 0 || bytes.length % ROUTE_HOP_BYTES !== 0) badInvoice('a route hint is malformed');
|
||||
|
||||
const hops: Bolt11RouteHop[] = [];
|
||||
for (let offset = 0; offset < bytes.length; offset += ROUTE_HOP_BYTES) {
|
||||
const hop = bytes.subarray(offset, offset + ROUTE_HOP_BYTES);
|
||||
const scid = hop.subarray(33, 41);
|
||||
const block = scid.readUIntBE(0, 3);
|
||||
const tx = scid.readUIntBE(3, 3);
|
||||
const output = scid.readUInt16BE(6);
|
||||
hops.push({
|
||||
pubkey: hop.subarray(0, 33).toString('hex'),
|
||||
shortChannelId: `${block}x${tx}x${output}`,
|
||||
feeBaseMsat: hop.readUInt32BE(41),
|
||||
feeProportionalMillionths: hop.readUInt32BE(45),
|
||||
cltvExpiryDelta: hop.readUInt16BE(49),
|
||||
});
|
||||
}
|
||||
return hops;
|
||||
}
|
||||
|
||||
function wordsToFallback(words: readonly number[]): Bolt11Fallback | null {
|
||||
const version = words[0];
|
||||
// A fallback with no program is meaningless; the spec says to ignore an unparseable one.
|
||||
if (version === undefined || words.length < 2) return null;
|
||||
return { version, programHex: wordsToHex(words.slice(1)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The amount grammar from the human-readable part: `<digits><multiplier?>`, where the multiplier
|
||||
* divides one bitcoin. Integer arithmetic throughout — a `p` amount that is not a multiple of 10 would
|
||||
* be sub-millisatoshi and is invalid rather than rounded.
|
||||
*/
|
||||
function hrpToMsat(amount: string, multiplier: string): string {
|
||||
if (!/^\d+$/.test(amount)) badInvoice(`"${amount}" is not a valid amount`);
|
||||
if (multiplier && !(multiplier in DIVISORS)) badInvoice(`"${multiplier}" is not a valid amount multiplier`);
|
||||
|
||||
const value = BigInt(amount);
|
||||
const divisor = multiplier ? DIVISORS[multiplier] : undefined;
|
||||
const msat = divisor === undefined ? value * MSAT_PER_BTC : (value * MSAT_PER_BTC) / divisor;
|
||||
|
||||
if (multiplier === 'p' && value % 10n !== 0n) badInvoice('amount has sub-millisatoshi precision');
|
||||
if (msat > MAX_MSAT) badInvoice('amount is outside of valid range');
|
||||
return msat.toString();
|
||||
}
|
||||
|
||||
/** 5-bit → 8-bit, right-padded with zeros: the preimage the signature covers. */
|
||||
function convertBits(words: readonly number[]): Uint8Array {
|
||||
let value = 0;
|
||||
let bits = 0;
|
||||
const result: number[] = [];
|
||||
for (const word of words) {
|
||||
value = (value << 5) | word;
|
||||
bits += 5;
|
||||
while (bits >= 8) {
|
||||
bits -= 8;
|
||||
result.push((value >> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
if (bits > 0) result.push((value << (8 - bits)) & 0xff);
|
||||
return Uint8Array.from(result);
|
||||
}
|
||||
|
||||
type Recovered = { destination: string; signature: string; recoveryFlag: number };
|
||||
|
||||
/** The signed message is sha256( utf8(prefix) || convertBits(dataWords) ). SEC1 recovery from there. */
|
||||
function recoverPayee(prefix: string, sigWords: readonly number[], signedWords: readonly number[]): Recovered {
|
||||
const sigBytes = wordsToBytes(sigWords);
|
||||
const recoveryFlag = sigBytes[64];
|
||||
if (sigBytes.length !== 65 || recoveryFlag === undefined || recoveryFlag > 3) {
|
||||
badInvoice('signature is malformed');
|
||||
}
|
||||
|
||||
const preimage = Buffer.concat([Buffer.from(prefix, 'utf8'), Buffer.from(convertBits(signedWords))]);
|
||||
const hash = createHash('sha256').update(preimage).digest();
|
||||
|
||||
// @noble/secp256k1 v3 wants the recovery byte FIRST; bech32 carries it last.
|
||||
const recoverable = Buffer.concat([Buffer.from([recoveryFlag]), Buffer.from(sigBytes.subarray(0, 64))]);
|
||||
|
||||
let pubkey: Uint8Array;
|
||||
try {
|
||||
pubkey = recoverPublicKey(recoverable, hash, { prehash: false });
|
||||
} catch {
|
||||
badInvoice('signature is not recoverable');
|
||||
}
|
||||
|
||||
return {
|
||||
destination: Buffer.from(pubkey).toString('hex'),
|
||||
signature: Buffer.from(sigBytes.subarray(0, 64)).toString('hex'),
|
||||
recoveryFlag,
|
||||
};
|
||||
}
|
||||
|
||||
/** `lnbc2500u` → the network params and the amount. */
|
||||
function parsePrefix(prefix: string): { params: Bolt11NetworkParams; millisatoshis: string | null } {
|
||||
// Non-greedy on the hrp so the trailing digits+multiplier win: `lnbcrt500u` → bcrt / 500 / u. A prefix
|
||||
// with no amount ends in a letter the first pattern would eat, hence the amount-less second attempt.
|
||||
let matches = /^ln(\S+?)(\d+)([a-z]?)$/.exec(prefix);
|
||||
let amount = matches?.[2] ?? '';
|
||||
let multiplier = matches?.[3] ?? '';
|
||||
if (!matches) {
|
||||
matches = /^ln(\S+)$/.exec(prefix);
|
||||
amount = '';
|
||||
multiplier = '';
|
||||
}
|
||||
|
||||
const hrp = matches?.[1];
|
||||
if (!hrp) badInvoice('not a lightning payment request');
|
||||
|
||||
const params = NETWORKS.find((candidate) => candidate.bech32 === hrp);
|
||||
if (!params) badInvoice(`unknown network prefix "ln${hrp}"`);
|
||||
|
||||
return { params, millisatoshis: amount ? hrpToMsat(amount, multiplier) : null };
|
||||
}
|
||||
|
||||
type Tags = {
|
||||
paymentHash: string | null;
|
||||
paymentSecret: string | null;
|
||||
description: string | null;
|
||||
descriptionHash: string | null;
|
||||
payee: string | null;
|
||||
expiry: number | null;
|
||||
cltvExpiry: number | null;
|
||||
metadata: string | null;
|
||||
featureBits: string[];
|
||||
routes: Bolt11RouteHop[][];
|
||||
fallbacks: Bolt11Fallback[];
|
||||
unknownTags: Bolt11UnknownTag[];
|
||||
};
|
||||
|
||||
/** Walks the tagged fields. First occurrence of a field wins; unknown and wrong-length fields are skipped. */
|
||||
function parseTags(dataWords: readonly number[]): Tags {
|
||||
const tags: Tags = {
|
||||
paymentHash: null,
|
||||
paymentSecret: null,
|
||||
description: null,
|
||||
descriptionHash: null,
|
||||
payee: null,
|
||||
expiry: null,
|
||||
cltvExpiry: null,
|
||||
metadata: null,
|
||||
featureBits: [],
|
||||
routes: [],
|
||||
fallbacks: [],
|
||||
unknownTags: [],
|
||||
};
|
||||
|
||||
let words = dataWords;
|
||||
while (words.length > 0) {
|
||||
const tagCode = words[0];
|
||||
if (tagCode === undefined) break;
|
||||
if (words.length < 3) badInvoice('a tagged field is truncated');
|
||||
|
||||
const length = wordsToInt(words.slice(1, 3));
|
||||
words = words.slice(3);
|
||||
if (length > words.length) badInvoice('a tagged field overruns the invoice');
|
||||
const tagWords = words.slice(0, length);
|
||||
words = words.slice(length);
|
||||
|
||||
const expected = FIXED_TAG_WORDS[tagCode];
|
||||
if (expected !== undefined && length !== expected) continue;
|
||||
|
||||
const name: string | undefined = TAG_NAMES[tagCode as keyof typeof TAG_NAMES];
|
||||
switch (name) {
|
||||
case 'payment_hash':
|
||||
tags.paymentHash ??= wordsToHex(tagWords);
|
||||
break;
|
||||
case 'payment_secret':
|
||||
tags.paymentSecret ??= wordsToHex(tagWords);
|
||||
break;
|
||||
case 'description':
|
||||
tags.description ??= wordsToUtf8(tagWords);
|
||||
break;
|
||||
case 'description_hash':
|
||||
tags.descriptionHash ??= wordsToHex(tagWords);
|
||||
break;
|
||||
case 'payee':
|
||||
tags.payee ??= wordsToHex(tagWords);
|
||||
break;
|
||||
case 'expiry':
|
||||
tags.expiry ??= wordsToInt(tagWords);
|
||||
break;
|
||||
case 'min_final_cltv_expiry':
|
||||
tags.cltvExpiry ??= wordsToInt(tagWords);
|
||||
break;
|
||||
case 'metadata':
|
||||
tags.metadata ??= wordsToHex(tagWords);
|
||||
break;
|
||||
case 'feature_bits':
|
||||
if (tags.featureBits.length === 0) tags.featureBits = wordsToFeatureBits(tagWords);
|
||||
break;
|
||||
case 'route_hint':
|
||||
tags.routes.push(wordsToRoute(tagWords));
|
||||
break;
|
||||
case 'fallback_address': {
|
||||
const fallback = wordsToFallback(tagWords);
|
||||
if (fallback) tags.fallbacks.push(fallback);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
tags.unknownTags.push({ tagCode, words: tagWords });
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
// ── the decoder ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Full decode, cached. The returned object is shared with every other caller for the same invoice and is
|
||||
* typed readonly throughout — treat it as frozen.
|
||||
*/
|
||||
export function decodeBolt11Invoice(paymentRequest: string): Bolt11Invoice {
|
||||
if (typeof paymentRequest !== 'string') badInvoice('expected a string');
|
||||
|
||||
const trimmed = paymentRequest.trim();
|
||||
// The QR form is all-uppercase and legal; a mixed-case string is not (BOLT 11 / BIP-173).
|
||||
if (/[a-z]/.test(trimmed) && /[A-Z]/.test(trimmed)) badInvoice('mixed-case payment request');
|
||||
const normalized = trimmed.toLowerCase();
|
||||
|
||||
const cached = cache.get(normalized);
|
||||
if (cached) {
|
||||
// Re-insert to move to the MRU end of the Map's insertion order.
|
||||
cache.delete(normalized);
|
||||
cache.set(normalized, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (!normalized.startsWith('ln')) badInvoice('not a lightning payment request');
|
||||
|
||||
let decoded: { prefix: string; words: number[] };
|
||||
try {
|
||||
decoded = bech32.decode(normalized, Number.MAX_SAFE_INTEGER);
|
||||
} catch (err) {
|
||||
badInvoice(err instanceof Error ? err.message.toLowerCase() : 'bech32 decode failed');
|
||||
}
|
||||
|
||||
if (decoded.words.length < SIGNATURE_WORDS + TIMESTAMP_WORDS) badInvoice('payment request is too short');
|
||||
|
||||
const { params, millisatoshis } = parsePrefix(decoded.prefix);
|
||||
|
||||
const signedWords = decoded.words.slice(0, -SIGNATURE_WORDS);
|
||||
const sigWords = decoded.words.slice(-SIGNATURE_WORDS);
|
||||
const timestamp = wordsToInt(signedWords.slice(0, TIMESTAMP_WORDS));
|
||||
const tags = parseTags(signedWords.slice(TIMESTAMP_WORDS));
|
||||
|
||||
if (!tags.paymentHash) badInvoice('no payment hash');
|
||||
|
||||
const recovered = recoverPayee(decoded.prefix, sigWords, signedWords);
|
||||
// BOLT 11: when an `n` field is present a reader MUST still check the signature against it. A correct
|
||||
// signer always recovers to its own key, so a mismatch means the invoice was tampered with.
|
||||
if (tags.payee && tags.payee !== recovered.destination) badInvoice('signature does not match the payee');
|
||||
|
||||
const msat = millisatoshis === null ? null : BigInt(millisatoshis);
|
||||
const expirySeconds = tags.expiry ?? DEFAULT_EXPIRY_SECONDS;
|
||||
|
||||
const invoice: Bolt11Invoice = {
|
||||
paymentRequest: normalized,
|
||||
prefix: decoded.prefix,
|
||||
network: params.network,
|
||||
networkParams: params,
|
||||
timestamp,
|
||||
millisatoshis,
|
||||
satoshis: msat !== null && msat % 1000n === 0n ? Number(msat / 1000n) : null,
|
||||
paymentHash: tags.paymentHash,
|
||||
paymentSecret: tags.paymentSecret,
|
||||
description: tags.description,
|
||||
descriptionHash: tags.descriptionHash,
|
||||
payee: tags.payee,
|
||||
destination: tags.payee ?? recovered.destination,
|
||||
expiry: tags.expiry,
|
||||
expirySeconds,
|
||||
expiresAt: timestamp + expirySeconds,
|
||||
cltvExpiry: tags.cltvExpiry,
|
||||
metadata: tags.metadata,
|
||||
featureBits: tags.featureBits,
|
||||
routes: tags.routes,
|
||||
fallbacks: tags.fallbacks,
|
||||
signature: recovered.signature,
|
||||
recoveryFlag: recovered.recoveryFlag,
|
||||
unknownTags: tags.unknownTags,
|
||||
};
|
||||
|
||||
if (cache.size >= CACHE_LIMIT) {
|
||||
const oldest = cache.keys().next();
|
||||
if (!oldest.done) cache.delete(oldest.value);
|
||||
}
|
||||
cache.set(normalized, invoice);
|
||||
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* The contract surface: a BOLT11 payment request as the backends report it. Throws
|
||||
* `BackendError(…, 400, 'BAD_INVOICE')` for anything that does not decode.
|
||||
*/
|
||||
export function decodeBolt11(bolt11: string): DecodedInvoice {
|
||||
const invoice = decodeBolt11Invoice(bolt11);
|
||||
return {
|
||||
bolt11: invoice.paymentRequest,
|
||||
paymentHash: invoice.paymentHash,
|
||||
amountMsat: invoice.millisatoshis,
|
||||
description: invoice.description,
|
||||
destination: invoice.destination,
|
||||
timestamp: invoice.timestamp,
|
||||
expiry: invoice.expirySeconds,
|
||||
cltvExpiry: invoice.cltvExpiry,
|
||||
routeHints: invoice.routes.length > 0,
|
||||
features: [...invoice.featureBits],
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam: the LRU is process-global, so a test that measures recovery cost needs to reset it. */
|
||||
export function clearBolt11Cache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// Esplora REST client — the chain data source for the native on-chain wallet backend.
|
||||
//
|
||||
// Zeus's embedded backends (EmbeddedLND, LdkNode) get chain data from a node bound to a React Native
|
||||
// native module. Nothing about that ports to a Bun server process, so the server-side wallet reads the
|
||||
// chain over Esplora's HTTP API instead: mempool.space, blockstream.info, or a self-hosted esplora.
|
||||
// Every call is a plain unauthenticated GET, which is why the backend can serve watch-only requests
|
||||
// without ever touching key material.
|
||||
//
|
||||
// Two Esplora endpoints are NOT JSON and are handled explicitly below:
|
||||
// GET /tx/{txid}/hex → the raw transaction as a bare hex string
|
||||
// POST /tx → request body is bare hex, response body is the bare txid
|
||||
// Everything else is JSON, and every wire shape it returns is typed in this file.
|
||||
|
||||
import { BackendError, type BitcoinNetwork, type FeeEstimates } from './types';
|
||||
|
||||
// ── configuration ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type EsploraConfig = {
|
||||
/** Base URL of the Esplora API root, e.g. `https://mempool.space/api`. Trailing slashes are trimmed. */
|
||||
baseUrl: string;
|
||||
network: BitcoinNetwork;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 20_000;
|
||||
|
||||
// ── wire shapes ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Confirmation status shared by /tx, /address/{a}/txs and /address/{a}/utxo. */
|
||||
export type EsploraTxStatus = {
|
||||
confirmed: boolean;
|
||||
block_height?: number;
|
||||
block_hash?: string;
|
||||
/** Unix seconds of the containing block. Absent while unconfirmed. */
|
||||
block_time?: number;
|
||||
};
|
||||
|
||||
/** Aggregate funded/spent counters. Esplora reports one set for the chain and one for the mempool. */
|
||||
export type EsploraAddressStats = {
|
||||
funded_txo_count: number;
|
||||
funded_txo_sum: number;
|
||||
spent_txo_count: number;
|
||||
spent_txo_sum: number;
|
||||
tx_count: number;
|
||||
};
|
||||
|
||||
export type EsploraAddress = {
|
||||
address: string;
|
||||
chain_stats: EsploraAddressStats;
|
||||
mempool_stats: EsploraAddressStats;
|
||||
};
|
||||
|
||||
export type EsploraVout = {
|
||||
/** scriptPubKey, hex. */
|
||||
scriptpubkey: string;
|
||||
scriptpubkey_asm: string;
|
||||
/** Esplora's own classification: 'v0_p2wpkh' | 'v1_p2tr' | 'p2pkh' | 'p2sh' | 'op_return' | … */
|
||||
scriptpubkey_type: string;
|
||||
scriptpubkey_address?: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type EsploraVin = {
|
||||
txid: string;
|
||||
vout: number;
|
||||
/** The output being spent. Null for coinbase inputs. */
|
||||
prevout: EsploraVout | null;
|
||||
scriptsig: string;
|
||||
scriptsig_asm: string;
|
||||
witness?: string[];
|
||||
is_coinbase: boolean;
|
||||
sequence: number;
|
||||
inner_redeemscript_asm?: string;
|
||||
inner_witnessscript_asm?: string;
|
||||
};
|
||||
|
||||
export type EsploraTx = {
|
||||
txid: string;
|
||||
version: number;
|
||||
locktime: number;
|
||||
vin: EsploraVin[];
|
||||
vout: EsploraVout[];
|
||||
size: number;
|
||||
weight: number;
|
||||
/** Absolute fee in sats. Zero for coinbase. */
|
||||
fee: number;
|
||||
status: EsploraTxStatus;
|
||||
};
|
||||
|
||||
export type EsploraUtxo = {
|
||||
txid: string;
|
||||
vout: number;
|
||||
value: number;
|
||||
status: EsploraTxStatus;
|
||||
};
|
||||
|
||||
/** /fee-estimates: confirmation target (in blocks, as a string key) → sat/vB, as a float. */
|
||||
export type EsploraFeeEstimates = Record<string, number>;
|
||||
|
||||
// ── fee mapping ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Esplora publishes an estimate per confirmation target; FeeEstimates in types.ts is mempool.space's
|
||||
// five named tiers. These are the targets each tier maps onto.
|
||||
const FEE_TARGETS = {
|
||||
fastestFee: 1,
|
||||
halfHourFee: 3,
|
||||
hourFee: 6,
|
||||
economyFee: 144,
|
||||
minimumFee: 1008,
|
||||
} as const satisfies Record<keyof FeeEstimates, number>;
|
||||
|
||||
/** Nothing below the default minimum relay fee will propagate, so 1 sat/vB is the hard floor. */
|
||||
const MIN_SAT_VB = 1;
|
||||
|
||||
/**
|
||||
* Pick the estimate for a confirmation target. Esplora only publishes some targets (1..25, then 144,
|
||||
* 504, 1008), so when the exact key is missing we take the largest published target *at or below* the
|
||||
* one asked for — a shorter target always quotes a higher rate, so this errs towards confirming.
|
||||
*/
|
||||
function pickForTarget(raw: EsploraFeeEstimates, target: number): number | null {
|
||||
const exact = raw[String(target)];
|
||||
if (typeof exact === 'number' && Number.isFinite(exact)) return exact;
|
||||
|
||||
let best: number | null = null;
|
||||
let bestKey = -1;
|
||||
for (const [key, rate] of Object.entries(raw)) {
|
||||
const k = Number(key);
|
||||
if (!Number.isFinite(k) || !Number.isFinite(rate)) continue;
|
||||
if (k <= target && k > bestKey) {
|
||||
bestKey = k;
|
||||
best = rate;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Esplora's target→rate map onto the five named tiers. Exported separately from the client so the
|
||||
* mapping can be unit-tested against a captured /fee-estimates payload with no network involved.
|
||||
*/
|
||||
export function mapFeeEstimates(raw: EsploraFeeEstimates): FeeEstimates {
|
||||
const at = (target: number): number => Math.max(MIN_SAT_VB, Math.ceil(pickForTarget(raw, target) ?? MIN_SAT_VB));
|
||||
|
||||
const fastestFee = at(FEE_TARGETS.fastestFee);
|
||||
// A tier must never quote more than a shorter-target tier: some esplora deployments serve a stale or
|
||||
// partial map where a longer target reads higher, and a UI that shows "1 hour" above "fastest" is a
|
||||
// bug report. Clamp each tier down to its faster neighbour.
|
||||
const halfHourFee = Math.min(fastestFee, at(FEE_TARGETS.halfHourFee));
|
||||
const hourFee = Math.min(halfHourFee, at(FEE_TARGETS.hourFee));
|
||||
const economyFee = Math.min(hourFee, at(FEE_TARGETS.economyFee));
|
||||
const minimumFee = Math.min(economyFee, at(FEE_TARGETS.minimumFee));
|
||||
|
||||
return { fastestFee, halfHourFee, hourFee, economyFee, minimumFee };
|
||||
}
|
||||
|
||||
// ── the client ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type RequestInitLite = {
|
||||
method?: 'GET' | 'POST';
|
||||
/** Sent verbatim as the body — Esplora's POST /tx takes bare hex, not JSON. */
|
||||
body?: string;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export class EsploraChain {
|
||||
readonly network: BitcoinNetwork;
|
||||
private readonly baseUrl: string;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(cfg: EsploraConfig) {
|
||||
this.baseUrl = cfg.baseUrl.replace(/\/+$/, '');
|
||||
this.network = cfg.network;
|
||||
this.timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/** Every request goes through here so timeouts and upstream errors become one BackendError shape. */
|
||||
private async text(path: string, init: RequestInitLite = {}): Promise<string> {
|
||||
const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: init.method ?? 'GET',
|
||||
body: init.body,
|
||||
headers: init.contentType ? { 'Content-Type': init.contentType } : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = controller.signal.aborted ? `timed out after ${this.timeoutMs}ms` : String(err);
|
||||
throw new BackendError(`esplora request ${path} failed: ${msg}`, 502, 'UPSTREAM_UNREACHABLE');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const body = await res.text();
|
||||
if (!res.ok) {
|
||||
// Esplora reports every failure as plain text: 'Transaction not found', 'sendrawtransaction RPC
|
||||
// error: {"code":-26,...}'. There is no JSON envelope to unwrap, so the text is the message.
|
||||
throw new BackendError(`esplora ${res.status} on ${path}: ${body.slice(0, 300)}`, res.status, 'ESPLORA_ERROR');
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private async json<T>(path: string): Promise<T> {
|
||||
const body = await this.text(path);
|
||||
try {
|
||||
return JSON.parse(body) as T;
|
||||
} catch {
|
||||
throw new BackendError(`esplora returned non-JSON on ${path}: ${body.slice(0, 200)}`, 502, 'ESPLORA_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
// ── endpoints ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /address/{addr} — funded/spent counters for the chain and the mempool. */
|
||||
getAddress(address: string): Promise<EsploraAddress> {
|
||||
return this.json<EsploraAddress>(`/address/${encodeURIComponent(address)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /address/{addr}/txs — the newest ~50 transactions (all mempool ones, then 25 confirmed).
|
||||
* Pass `afterTxid` to page further back through confirmed history via /txs/chain/{last_seen_txid}.
|
||||
*/
|
||||
getAddressTxs(address: string, afterTxid?: string): Promise<EsploraTx[]> {
|
||||
const addr = encodeURIComponent(address);
|
||||
const path = afterTxid ? `/address/${addr}/txs/chain/${encodeURIComponent(afterTxid)}` : `/address/${addr}/txs`;
|
||||
return this.json<EsploraTx[]>(path);
|
||||
}
|
||||
|
||||
/** GET /address/{addr}/utxo — unspent outputs, confirmed and unconfirmed. */
|
||||
getAddressUtxos(address: string): Promise<EsploraUtxo[]> {
|
||||
return this.json<EsploraUtxo[]>(`/address/${encodeURIComponent(address)}/utxo`);
|
||||
}
|
||||
|
||||
/** GET /tx/{txid}. */
|
||||
getTx(txid: string): Promise<EsploraTx> {
|
||||
return this.json<EsploraTx>(`/tx/${encodeURIComponent(txid)}`);
|
||||
}
|
||||
|
||||
/** GET /tx/{txid}/hex — plain text, not JSON. Needed as `nonWitnessUtxo` for legacy p2pkh inputs. */
|
||||
async getTxHex(txid: string): Promise<string> {
|
||||
const hex = (await this.text(`/tx/${encodeURIComponent(txid)}/hex`)).trim();
|
||||
if (!/^[0-9a-fA-F]+$/.test(hex)) {
|
||||
throw new BackendError(`esplora returned a non-hex body for tx ${txid}`, 502, 'ESPLORA_ERROR');
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
/** GET /blocks/tip/height — plain integer in the body. */
|
||||
async getTipHeight(): Promise<number> {
|
||||
const body = (await this.text('/blocks/tip/height')).trim();
|
||||
const height = Number(body);
|
||||
if (!Number.isInteger(height) || height < 0) {
|
||||
throw new BackendError(`esplora returned a bad tip height: ${body.slice(0, 60)}`, 502, 'ESPLORA_ERROR');
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
/** GET /fee-estimates, folded onto the five named tiers. */
|
||||
async getFeeEstimates(): Promise<FeeEstimates> {
|
||||
return mapFeeEstimates(await this.json<EsploraFeeEstimates>('/fee-estimates'));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /tx — the body is the raw transaction hex with no JSON wrapper, and the 200 response body is
|
||||
* the bare txid. A rejection comes back as a 400 with bitcoind's `sendrawtransaction RPC error` text,
|
||||
* which `text()` above surfaces verbatim; that message is the only diagnosis a caller gets.
|
||||
*/
|
||||
async broadcast(rawHex: string): Promise<string> {
|
||||
const txid = (await this.text('/tx', { method: 'POST', body: rawHex, contentType: 'text/plain' })).trim();
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(txid)) {
|
||||
throw new BackendError(`broadcast did not return a txid: ${txid.slice(0, 200)}`, 502, 'ESPLORA_ERROR');
|
||||
}
|
||||
return txid;
|
||||
}
|
||||
}
|
||||
|
||||
/** Alias kept for call sites that read better as a client than as a chain source. */
|
||||
export { EsploraChain as EsploraClient };
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { handleOfficerRoute } from './routes';
|
||||
import { getConfig, hasStoreKey } from './upstream';
|
||||
import { lockAll } from './keys';
|
||||
import { invalidateAll } from './resolve';
|
||||
|
||||
// The officer-wallet sidecar. A bitcoin wallet in the shape Zeus models one — several interchangeable
|
||||
// backends behind one interface — but server-side, with the key material held here and nowhere else.
|
||||
//
|
||||
// WHY THIS PROCESS EXISTS SEPARATELY. Seeds and node credentials never enter the main Officer process.
|
||||
// The platform is a thin auth proxy (src/servers/api/wallet/router.ts) that forwards to this port and
|
||||
// holds nothing: no seed, no macaroon, no xpub. Compromising `officer` gets an attacker the ability to
|
||||
// *call* this sidecar as the authenticated owner — it does not get them a key, and it cannot spend from a
|
||||
// locked wallet, because a locked wallet has no key material in memory at all.
|
||||
//
|
||||
// BACKENDS (mirroring _references/zeus/backends/):
|
||||
// onchain — self-custodial. BIP39 seed sealed under an owner passphrase (keys.ts), BIP84/86/49
|
||||
// derivation, Esplora for chain data, bitcoinjs-lib for PSBT construction. This is the
|
||||
// server analogue of Zeus's EmbeddedLND/LdkNode, which are native-module-bound and cannot
|
||||
// be ported. Watch-only while locked; unlock only to sign.
|
||||
// lnd — LND REST + macaroon (ported from backends/LND.ts)
|
||||
// cln-rest — Core Lightning CLNRest + rune (ported from backends/CLNRest.ts)
|
||||
// lndhub — custodial LNDHub/BlueWallet REST (ported from backends/LndHub.ts)
|
||||
// nwc — Nostr Wallet Connect, NIP-47 (ported from backends/NostrWalletConnect.ts)
|
||||
//
|
||||
// When the owner runs their own node, it registers as an `lnd` or `cln-rest` wallet — no code change.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP CONTRACT — the platform strips its /api/wallet mount prefix before forwarding.
|
||||
//
|
||||
// GET /_health ours. Reports network, chain reachability, store key.
|
||||
// GET /_officer/config network + esplora + unlock TTL + storeKeyConfigured
|
||||
//
|
||||
// GET /_officer/wallets list. Never includes secrets.
|
||||
// POST /_officer/wallets create. Seeded wallets return the mnemonic ONCE,
|
||||
// and only when the sidecar generated it.
|
||||
// GET /_officer/wallets/active the currently selected wallet
|
||||
// GET /_officer/wallets/:id one wallet
|
||||
// PATCH /_officer/wallets/:id rename / defaultBip / config
|
||||
// DELETE /_officer/wallets/:id {passphrase} required when the wallet holds a seed
|
||||
// POST /_officer/wallets/:id/activate
|
||||
//
|
||||
// GET /_officer/wallets/:id/lock-state {hasSeed, unlocked, secondsRemaining}
|
||||
// POST /_officer/wallets/:id/unlock {passphrase, ttlSec?}
|
||||
// POST /_officer/wallets/:id/lock
|
||||
// POST /_officer/wallets/:id/passphrase {oldPassphrase, newPassphrase}
|
||||
// POST /_officer/wallets/:id/export-seed {passphrase} → the mnemonic. Logged as a warning.
|
||||
//
|
||||
// GET /_officer/wallets/:id/capabilities what this backend can actually do
|
||||
// GET /_officer/wallets/:id/info node/chain identity + sync state
|
||||
// GET /_officer/wallets/:id/balances on-chain confirmed/unconfirmed + lightning local/inbound
|
||||
// GET /_officer/wallets/:id/transactions?limit on-chain history, owner labels overlaid
|
||||
// GET /_officer/wallets/:id/address?peek fresh receive address
|
||||
// GET /_officer/wallets/:id/utxos coin control view, freeze flags + labels overlaid
|
||||
// POST /_officer/wallets/:id/utxos/freeze {outpoint, frozen, reason?}
|
||||
// GET /_officer/wallets/:id/fees sat/vB estimates
|
||||
// POST /_officer/wallets/:id/send on-chain spend. Requires an unlocked wallet.
|
||||
// GET /_officer/wallets/:id/invoices?limit
|
||||
// POST /_officer/wallets/:id/invoices create
|
||||
// GET /_officer/wallets/:id/invoices/:hash lookup
|
||||
// POST /_officer/wallets/:id/decode {bolt11}
|
||||
// GET /_officer/wallets/:id/payments?limit
|
||||
// POST /_officer/wallets/:id/pay {bolt11, amountMsat?, feeLimit…}
|
||||
// POST /_officer/wallets/:id/keysend {destination, amountMsat}
|
||||
// GET /_officer/wallets/:id/channels
|
||||
// GET /_officer/wallets/:id/peers
|
||||
// POST /_officer/wallets/:id/sign {message}
|
||||
// POST /_officer/wallets/:id/verify {message, signature}
|
||||
// GET|POST /_officer/wallets/:id/labels owner annotations for addresses and txids
|
||||
//
|
||||
// anything else 404
|
||||
//
|
||||
// Operations a backend cannot perform return 501 with code NOT_SUPPORTED, checked against its declared
|
||||
// capability set before dispatch — never a confusing upstream error. A locked wallet returns 423
|
||||
// WALLET_LOCKED from signing paths only; every read above keeps working.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
function getFreePort(): number {
|
||||
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
const p = probeServer.port;
|
||||
probeServer.stop(true);
|
||||
if (p == null) throw new Error('failed to acquire a free port');
|
||||
return p;
|
||||
}
|
||||
|
||||
const port = getFreePort();
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: '127.0.0.1',
|
||||
// Wallet payloads are small — PSBTs and invoices, never file uploads. A tight cap is free hardening.
|
||||
maxRequestBodySize: 1 * 1024 * 1024,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname === '/_health') {
|
||||
const cfg = getConfig();
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${cfg.esploraUrl}/blocks/tip/height`, {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
const height = res.ok ? Number(await res.text()) : null;
|
||||
return Response.json({
|
||||
ok: res.ok,
|
||||
network: cfg.network,
|
||||
esplora: cfg.esploraUrl,
|
||||
blockHeight: Number.isFinite(height) ? height : null,
|
||||
// Surfaced because wallet creation is refused without it, and that failure would otherwise
|
||||
// look like a bug rather than a missing config line.
|
||||
storeKeyConfigured: hasStoreKey(),
|
||||
ms: Date.now() - started,
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
network: cfg.network,
|
||||
esplora: cfg.esploraUrl,
|
||||
error: String(err),
|
||||
storeKeyConfigured: hasStoreKey(),
|
||||
ms: Date.now() - started,
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/_officer/')) {
|
||||
try {
|
||||
const res = await handleOfficerRoute(req, url);
|
||||
if (res) return res;
|
||||
return Response.json({ error: 'not found' }, { status: 404 });
|
||||
} catch (err) {
|
||||
// Method and path only. Bodies on this sidecar carry passphrases and mnemonics.
|
||||
console.error(`[wallet] ${req.method} ${url.pathname} failed`, err instanceof Error ? err.message : err);
|
||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ error: 'not found' }, { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = getConfig();
|
||||
console.log(`[wallet] listening on 127.0.0.1:${port} — network=${cfg.network} esplora=${cfg.esploraUrl}`);
|
||||
if (!hasStoreKey()) {
|
||||
console.warn('[wallet] VAULT_STORE_KEY is unset — wallet creation will be refused until it is configured');
|
||||
}
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
id: (cmd as SidecarCommand).id,
|
||||
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'wallet',
|
||||
capabilities: ['wallet'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
onConnected() {
|
||||
connection.send({ type: 'wallet:server', port });
|
||||
console.log(`[wallet] reported server port ${port} to API`);
|
||||
},
|
||||
});
|
||||
|
||||
function shutdown(signal: string) {
|
||||
console.log(`[wallet] ${signal} received, locking all wallets and shutting down...`);
|
||||
// Wipe key material before anything else. This is best-effort — see the caveat in keys.ts — but it
|
||||
// costs nothing and closes the obvious window on a graceful restart.
|
||||
lockAll();
|
||||
invalidateAll();
|
||||
try {
|
||||
server.stop(true);
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
connection.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
sealSeed,
|
||||
generateSeed,
|
||||
deriveAccountXpubs,
|
||||
UnlockSession,
|
||||
verifyPassphrase,
|
||||
exportMnemonic,
|
||||
changePassphrase,
|
||||
} from './keys';
|
||||
|
||||
/**
|
||||
* The seed custody core. This is the one file in the wallet where a bug is unrecoverable rather than
|
||||
* merely wrong — a mistake here either loses the owner's coins or leaks the key that spends them, so the
|
||||
* properties below are asserted rather than assumed.
|
||||
*
|
||||
* scrypt at N=2^17 makes each seal/open deliberately expensive (~1s), which is the entire point of the
|
||||
* parameter and also why this file carries a raised timeout instead of a smaller N.
|
||||
*/
|
||||
|
||||
// BIP39's canonical all-`abandon` vector, and the BIP32 root fingerprint it must produce.
|
||||
const VECTOR = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
const ROOT_FINGERPRINT = '73c5da0a';
|
||||
const PASS = 'correct horse battery staple';
|
||||
|
||||
const SLOW = 60_000;
|
||||
|
||||
describe('sealSeed / deriveAccountXpubs', () => {
|
||||
test(
|
||||
'seals the vector and derives all four accounts',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
const { fingerprint, xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');
|
||||
|
||||
expect(env.v).toBe(1);
|
||||
expect(fingerprint).toBe(ROOT_FINGERPRINT);
|
||||
for (const bip of [44, 49, 84, 86] as const) expect(xpubs[bip]).toBeTruthy();
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'the envelope never contains the plaintext mnemonic',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
expect(JSON.stringify(env)).not.toContain('abandon');
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'sealing is non-deterministic but derivation is stable',
|
||||
async () => {
|
||||
// Fresh salt and nonce per seal, so two seals of one seed must not be byte-identical — otherwise a
|
||||
// DB dump would reveal which wallets share a seed.
|
||||
const [a, b] = [await sealSeed(VECTOR, PASS), await sealSeed(VECTOR, PASS)];
|
||||
expect(JSON.stringify(a)).not.toBe(JSON.stringify(b));
|
||||
|
||||
const [xa, xb] = [await deriveAccountXpubs(a, PASS, 'bitcoin'), await deriveAccountXpubs(b, PASS, 'bitcoin')];
|
||||
expect(xb.xpubs[84]).toBe(xa.xpubs[84]);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'rejects a mnemonic that fails its checksum',
|
||||
async () => {
|
||||
const bad = VECTOR.replace(/about$/, 'abandon');
|
||||
await expect(sealSeed(bad, PASS)).rejects.toThrow(/BIP39/i);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'rejects a passphrase under the minimum length',
|
||||
async () => {
|
||||
await expect(sealSeed(VECTOR, 'short')).rejects.toThrow(/8 characters/);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'testnet derives a different account under the same root',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
const [main, test_] = [
|
||||
await deriveAccountXpubs(env, PASS, 'bitcoin'),
|
||||
await deriveAccountXpubs(env, PASS, 'testnet'),
|
||||
];
|
||||
// Coin type 1' vs 0' — a different account, but the same seed, so the same root fingerprint.
|
||||
expect(test_.xpubs[84]).not.toBe(main.xpubs[84]);
|
||||
expect(test_.fingerprint).toBe(main.fingerprint);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
|
||||
describe('passphrase handling', () => {
|
||||
test(
|
||||
'accepts the right passphrase and rejects a wrong one',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
expect(await verifyPassphrase(env, PASS)).toBe(true);
|
||||
expect(await verifyPassphrase(env, 'not the passphrase')).toBe(false);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'round-trips the mnemonic exactly',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
expect(await exportMnemonic(env, PASS)).toBe(VECTOR);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'rotation swaps the passphrase without disturbing the seed',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
const next = 'an entirely different passphrase';
|
||||
const rotated = await changePassphrase(env, PASS, next);
|
||||
|
||||
expect(await verifyPassphrase(rotated, next)).toBe(true);
|
||||
expect(await verifyPassphrase(rotated, PASS)).toBe(false);
|
||||
expect(await exportMnemonic(rotated, next)).toBe(VECTOR);
|
||||
|
||||
// The xpubs are stored alongside the envelope; if rotation changed them the wallet would silently
|
||||
// start watching a different account and report a zero balance.
|
||||
const [before, after] = [
|
||||
await deriveAccountXpubs(env, PASS, 'bitcoin'),
|
||||
await deriveAccountXpubs(rotated, next, 'bitcoin'),
|
||||
];
|
||||
expect(after.xpubs[84]).toBe(before.xpubs[84]);
|
||||
expect(after.fingerprint).toBe(before.fingerprint);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
|
||||
describe('UnlockSession', () => {
|
||||
test('holds no key material until unlocked', async () => {
|
||||
const s = new UnlockSession(1);
|
||||
expect(s.isUnlocked()).toBe(false);
|
||||
expect(() => s.withRoot((r) => r.publicExtendedKey)).toThrow(/locked/i);
|
||||
});
|
||||
|
||||
test(
|
||||
'unlocks, derives the stored xpub, then relocks',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
const { xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');
|
||||
|
||||
const s = new UnlockSession(1);
|
||||
await s.unlock(env, PASS, 60);
|
||||
expect(s.isUnlocked()).toBe(true);
|
||||
expect(s.secondsRemaining()).toBeGreaterThan(0);
|
||||
expect(s.secondsRemaining()).toBeLessThanOrEqual(60);
|
||||
|
||||
// Proves the unlocked root is the same key the watch-only xpub came from.
|
||||
expect(s.withRoot((r) => r.derive("m/84'/0'/0'").publicExtendedKey)).toBe(xpubs[84]);
|
||||
|
||||
s.lock();
|
||||
expect(s.isUnlocked()).toBe(false);
|
||||
expect(() => s.withRoot((r) => r.publicExtendedKey)).toThrow(/locked/i);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'a failed unlock leaves the session locked',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
const s = new UnlockSession(1);
|
||||
await expect(s.unlock(env, 'the wrong passphrase', 60)).rejects.toThrow();
|
||||
expect(s.isUnlocked()).toBe(false);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
|
||||
describe('generateSeed', () => {
|
||||
test(
|
||||
'produces a distinct, valid mnemonic that survives a round trip',
|
||||
async () => {
|
||||
const mnemonic = generateSeed();
|
||||
expect([12, 24]).toContain(mnemonic.split(' ').length);
|
||||
expect(generateSeed()).not.toBe(mnemonic);
|
||||
|
||||
const env = await sealSeed(mnemonic, 'a sufficiently long passphrase');
|
||||
expect(await exportMnemonic(env, 'a sufficiently long passphrase')).toBe(mnemonic);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,384 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
import { HDKey } from '@scure/bip32';
|
||||
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||
import type { BitcoinNetwork } from './types';
|
||||
import { BackendError, WalletLockedError } from './types';
|
||||
|
||||
// Seed custody for the wallet sidecar.
|
||||
//
|
||||
// THE THREAT MODEL, stated plainly, because it is the whole point of this file:
|
||||
//
|
||||
// Zeus stores its seed phrases as plaintext inside a JSON settings blob and leans entirely on the OS
|
||||
// keychain (storage/index.ts + stores/SettingsStore.ts:32-67 — `seedPhrase?: string[]`). A phone has a
|
||||
// secure enclave and a screen lock; a server has neither. So none of Zeus's key handling is reusable
|
||||
// here and this is written from scratch.
|
||||
//
|
||||
// The seed is protected by TWO independent secrets, and an attacker needs BOTH:
|
||||
//
|
||||
// 1. An owner passphrase, which is never persisted anywhere. It derives a KEK via scrypt and that
|
||||
// KEK wraps the random per-wallet DEK that actually encrypts the mnemonic.
|
||||
// 2. VAULT_STORE_KEY from the environment, applied by queries/wallet.ts (../../databases/officer_db)
|
||||
// over the already-encrypted envelope before it touches Postgres.
|
||||
//
|
||||
// Consequence: a stolen database dump is useless without .env, a stolen .env is useless without the
|
||||
// passphrase, and a full server compromise still cannot spend while the wallet is locked, because a
|
||||
// locked wallet holds no key material in memory at all.
|
||||
//
|
||||
// WATCH-ONLY WHILE LOCKED. The account xpubs are stored in the clear on purpose. Balances, history and
|
||||
// receive addresses therefore work with the wallet locked and the passphrase nowhere on the machine —
|
||||
// unlocking is required only to SIGN. This is the single most important property here: the wallet spends
|
||||
// almost all of its life locked and still fully readable.
|
||||
//
|
||||
// WHAT THIS CANNOT DO. Once unlocked, the root key is in the Bun process's heap and Node gives no way to
|
||||
// pin or reliably wipe it — GC may have copied it. `zeroize()` scrubs the buffers we own, which shrinks
|
||||
// the window but does not close it. That is why the unlock TTL is short and defaults tight.
|
||||
|
||||
const scrypt = promisify(scryptCb) as (
|
||||
password: string | Buffer,
|
||||
salt: Buffer,
|
||||
keylen: number,
|
||||
options: { N: number; r: number; p: number; maxmem: number },
|
||||
) => Promise<Buffer>;
|
||||
|
||||
// N=2^17 / r=8 / p=1 → ~128 MiB and ~1s per attempt on this class of hardware. Deliberately painful:
|
||||
// this is the only thing standing between a leaked database + .env and the coins. Node's default maxmem
|
||||
// is 32 MiB, which these parameters blow through, so it must be raised explicitly or scrypt throws.
|
||||
const SCRYPT_N = 1 << 17;
|
||||
const SCRYPT_R = 8;
|
||||
const SCRYPT_P = 1;
|
||||
const SCRYPT_MAXMEM = 256 * 1024 * 1024;
|
||||
const KEY_LEN = 32;
|
||||
|
||||
/** Bump when the KDF parameters or envelope layout change, so old envelopes can be migrated on unlock. */
|
||||
const ENVELOPE_VERSION = 1;
|
||||
|
||||
export type SeedEnvelope = {
|
||||
v: number;
|
||||
/** base64, 16 bytes — scrypt salt for the KEK. */
|
||||
salt: string;
|
||||
/** base64(iv[12] | tag[16] | ciphertext) — the DEK, wrapped under the passphrase-derived KEK. */
|
||||
wrappedDek: string;
|
||||
/** base64(iv[12] | tag[16] | ciphertext) — the BIP39 mnemonic, encrypted under the DEK. */
|
||||
seed: string;
|
||||
/** Whether a BIP39 passphrase (the "25th word") is part of this seed. Affects derivation, not secrecy. */
|
||||
hasBip39Passphrase: boolean;
|
||||
};
|
||||
|
||||
// ── AES-256-GCM primitives ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
function gcmEncrypt(key: Buffer, plaintext: Buffer): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString('base64');
|
||||
}
|
||||
|
||||
function gcmDecrypt(key: Buffer, blob: string): Buffer {
|
||||
const buf = Buffer.from(blob, 'base64');
|
||||
if (buf.length < 29) throw new BackendError('malformed ciphertext', 500, 'BAD_ENVELOPE');
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, buf.subarray(0, 12));
|
||||
decipher.setAuthTag(buf.subarray(12, 28));
|
||||
return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]);
|
||||
}
|
||||
|
||||
/** Best-effort scrub of a buffer we own. See the caveat in the header comment. */
|
||||
function zeroize(buf: Buffer | null): void {
|
||||
if (buf) buf.fill(0);
|
||||
}
|
||||
|
||||
// ── envelope construction ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function deriveKek(passphrase: string, salt: Buffer): Promise<Buffer> {
|
||||
return scrypt(passphrase.normalize('NFKD'), salt, KEY_LEN, {
|
||||
N: SCRYPT_N,
|
||||
r: SCRYPT_R,
|
||||
p: SCRYPT_P,
|
||||
maxmem: SCRYPT_MAXMEM,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a mnemonic into a sealed envelope. The mnemonic is validated against the BIP39 wordlist first —
|
||||
* importing a typo'd phrase silently produces a valid-but-wrong wallet that shows a zero balance, which
|
||||
* is a genuinely awful failure mode to debug.
|
||||
*/
|
||||
export async function sealSeed(
|
||||
mnemonic: string,
|
||||
ownerPassphrase: string,
|
||||
bip39Passphrase?: string,
|
||||
): Promise<SeedEnvelope> {
|
||||
const normalized = mnemonic.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
if (!validateMnemonic(normalized, wordlist)) {
|
||||
throw new BackendError('not a valid BIP39 mnemonic (checksum or wordlist mismatch)', 400, 'BAD_MNEMONIC');
|
||||
}
|
||||
if (ownerPassphrase.length < 8) {
|
||||
throw new BackendError('unlock passphrase must be at least 8 characters', 400, 'WEAK_PASSPHRASE');
|
||||
}
|
||||
|
||||
const salt = randomBytes(16);
|
||||
const kek = await deriveKek(ownerPassphrase, salt);
|
||||
const dek = randomBytes(KEY_LEN);
|
||||
// The BIP39 passphrase lives inside the encrypted payload, not beside it: it is as sensitive as the
|
||||
// words themselves, since together they are the wallet.
|
||||
const payload = Buffer.from(JSON.stringify({ mnemonic: normalized, bip39Passphrase: bip39Passphrase ?? '' }), 'utf8');
|
||||
|
||||
try {
|
||||
return {
|
||||
v: ENVELOPE_VERSION,
|
||||
salt: salt.toString('base64'),
|
||||
wrappedDek: gcmEncrypt(kek, dek),
|
||||
seed: gcmEncrypt(dek, payload),
|
||||
hasBip39Passphrase: Boolean(bip39Passphrase),
|
||||
};
|
||||
} finally {
|
||||
zeroize(kek);
|
||||
zeroize(dek);
|
||||
zeroize(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a fresh 12- or 24-word mnemonic. 24 words (256 bits) is the default. */
|
||||
export function generateSeed(words: 12 | 24 = 24): string {
|
||||
return generateMnemonic(wordlist, words === 12 ? 128 : 256);
|
||||
}
|
||||
|
||||
type OpenedSeed = { mnemonic: string; bip39Passphrase: string };
|
||||
|
||||
async function openEnvelope(env: SeedEnvelope, ownerPassphrase: string): Promise<OpenedSeed> {
|
||||
if (env.v !== ENVELOPE_VERSION) {
|
||||
throw new BackendError(`unsupported seed envelope version ${env.v}`, 500, 'BAD_ENVELOPE');
|
||||
}
|
||||
const kek = await deriveKek(ownerPassphrase, Buffer.from(env.salt, 'base64'));
|
||||
let dek: Buffer | null = null;
|
||||
try {
|
||||
// A wrong passphrase fails here, as a GCM tag mismatch. That is the ONLY signal — we never store a
|
||||
// verifier hash of the passphrase, because a verifier is an offline-crackable oracle.
|
||||
dek = gcmDecrypt(kek, env.wrappedDek);
|
||||
const payload = gcmDecrypt(dek, env.seed);
|
||||
try {
|
||||
return JSON.parse(payload.toString('utf8')) as OpenedSeed;
|
||||
} finally {
|
||||
zeroize(payload);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof BackendError) throw err;
|
||||
throw new BackendError('incorrect passphrase', 401, 'BAD_PASSPHRASE');
|
||||
} finally {
|
||||
zeroize(kek);
|
||||
zeroize(dek);
|
||||
}
|
||||
}
|
||||
|
||||
// ── derivation ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Bip = 44 | 49 | 84 | 86;
|
||||
|
||||
/** Mainnet is coin type 0; every test network shares coin type 1 (BIP44). */
|
||||
export function coinType(network: BitcoinNetwork): 0 | 1 {
|
||||
return network === 'bitcoin' ? 0 : 1;
|
||||
}
|
||||
|
||||
export function accountPath(bip: Bip, network: BitcoinNetwork, account = 0): string {
|
||||
return `m/${bip}'/${coinType(network)}'/${account}'`;
|
||||
}
|
||||
|
||||
function rootFromSeed(opened: OpenedSeed): HDKey {
|
||||
const seed = Buffer.from(mnemonicToSeedSync(opened.mnemonic, opened.bip39Passphrase || undefined));
|
||||
try {
|
||||
return HDKey.fromMasterSeed(seed);
|
||||
} finally {
|
||||
zeroize(seed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the public account descriptors WITHOUT retaining any private material. Called once at import
|
||||
* time; the returned xpubs are stored in the clear and are what makes watch-only-while-locked work.
|
||||
*/
|
||||
export async function deriveAccountXpubs(
|
||||
env: SeedEnvelope,
|
||||
ownerPassphrase: string,
|
||||
network: BitcoinNetwork,
|
||||
): Promise<{ fingerprint: string; xpubs: Record<Bip, string> }> {
|
||||
const opened = await openEnvelope(env, ownerPassphrase);
|
||||
const root = rootFromSeed(opened);
|
||||
try {
|
||||
const fingerprint = Buffer.from(new Uint8Array(new Uint32Array([root.fingerprint]).buffer))
|
||||
.reverse()
|
||||
.toString('hex');
|
||||
const xpubs = {} as Record<Bip, string>;
|
||||
for (const bip of [44, 49, 84, 86] as const) {
|
||||
const node = root.derive(accountPath(bip, network));
|
||||
xpubs[bip] = node.publicExtendedKey;
|
||||
}
|
||||
return { fingerprint, xpubs };
|
||||
} finally {
|
||||
root.wipePrivateData();
|
||||
}
|
||||
}
|
||||
|
||||
// ── the unlock session ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_TTL_SEC = Number(process.env.WALLET_UNLOCK_TTL_SEC ?? 900);
|
||||
|
||||
// Brute-force resistance. scrypt already makes each guess cost ~1s and ~128 MiB, but an attacker with
|
||||
// the DB and .env can grind offline anyway — this only protects the live endpoint. Backoff is per
|
||||
// wallet id and resets on success.
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCKOUT_MS = 60_000;
|
||||
|
||||
type Attempts = { count: number; lockedUntil: number };
|
||||
const attempts = new Map<number, Attempts>();
|
||||
|
||||
function checkLockout(walletId: number): void {
|
||||
const a = attempts.get(walletId);
|
||||
if (a && a.lockedUntil > Date.now()) {
|
||||
const secs = Math.ceil((a.lockedUntil - Date.now()) / 1000);
|
||||
throw new BackendError(`too many failed attempts, retry in ${secs}s`, 429, 'LOCKED_OUT');
|
||||
}
|
||||
}
|
||||
|
||||
function recordFailure(walletId: number): void {
|
||||
const a = attempts.get(walletId) ?? { count: 0, lockedUntil: 0 };
|
||||
a.count += 1;
|
||||
if (a.count >= MAX_ATTEMPTS) {
|
||||
a.lockedUntil = Date.now() + LOCKOUT_MS;
|
||||
a.count = 0;
|
||||
}
|
||||
attempts.set(walletId, a);
|
||||
}
|
||||
|
||||
/**
|
||||
* A live, unlocked wallet. Holds the derived root key in memory and nothing else — the mnemonic itself
|
||||
* is decrypted, converted to a root key, and dropped inside `unlock()`; it is never retained.
|
||||
*
|
||||
* Structurally satisfies the `Signer` interface that backends/onchain.ts consumes.
|
||||
*/
|
||||
export class UnlockSession {
|
||||
private root: HDKey | null = null;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private expiresAt = 0;
|
||||
|
||||
constructor(readonly walletId: number) {}
|
||||
|
||||
isUnlocked(): boolean {
|
||||
return this.root !== null && Date.now() < this.expiresAt;
|
||||
}
|
||||
|
||||
/** Seconds until auto-lock, or 0 when locked. For the UI's countdown. */
|
||||
secondsRemaining(): number {
|
||||
if (!this.isUnlocked()) return 0;
|
||||
return Math.max(0, Math.ceil((this.expiresAt - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
async unlock(env: SeedEnvelope, ownerPassphrase: string, ttlSec = DEFAULT_TTL_SEC): Promise<void> {
|
||||
checkLockout(this.walletId);
|
||||
let opened: OpenedSeed;
|
||||
try {
|
||||
opened = await openEnvelope(env, ownerPassphrase);
|
||||
} catch (err) {
|
||||
recordFailure(this.walletId);
|
||||
throw err;
|
||||
}
|
||||
attempts.delete(this.walletId);
|
||||
|
||||
this.lock(); // replace any existing session rather than leaking the old root
|
||||
this.root = rootFromSeed(opened);
|
||||
// Drop the words immediately — the root key is all any signing operation needs.
|
||||
opened.mnemonic = '';
|
||||
opened.bip39Passphrase = '';
|
||||
this.arm(ttlSec);
|
||||
}
|
||||
|
||||
private arm(ttlSec: number): void {
|
||||
this.expiresAt = Date.now() + ttlSec * 1000;
|
||||
this.timer = setTimeout(() => this.lock(), ttlSec * 1000);
|
||||
// Don't hold the event loop open just to auto-lock; shutdown wipes memory anyway.
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
lock(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.root?.wipePrivateData();
|
||||
this.root = null;
|
||||
this.expiresAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` with the root key. The ONLY way key material leaves this class, and it never escapes as a
|
||||
* return value by construction — callers get a derived signature, not the key.
|
||||
*
|
||||
* Deliberately does NOT slide the TTL. An unlock is a bounded window the owner opened on purpose;
|
||||
* refreshing it on use would let a compromised session stay open indefinitely by signing.
|
||||
*/
|
||||
withRoot<T>(fn: (root: HDKey) => T): T {
|
||||
if (!this.isUnlocked() || !this.root) {
|
||||
this.lock();
|
||||
throw new WalletLockedError();
|
||||
}
|
||||
return fn(this.root);
|
||||
}
|
||||
}
|
||||
|
||||
// One session per wallet id, process-wide. The sidecar is a single process, so this map IS the unlock
|
||||
// state — there is no cross-process sharing and deliberately no persistence: a sidecar restart relocks
|
||||
// every wallet, which is the correct default.
|
||||
const sessions = new Map<number, UnlockSession>();
|
||||
|
||||
export function sessionFor(walletId: number): UnlockSession {
|
||||
let s = sessions.get(walletId);
|
||||
if (!s) {
|
||||
s = new UnlockSession(walletId);
|
||||
sessions.set(walletId, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function lockAll(): void {
|
||||
for (const s of sessions.values()) s.lock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a passphrase without opening a session — used before destructive operations (seed export,
|
||||
* wallet deletion) so they need a fresh confirmation even when the wallet is already unlocked.
|
||||
*/
|
||||
export async function verifyPassphrase(env: SeedEnvelope, ownerPassphrase: string): Promise<boolean> {
|
||||
try {
|
||||
await openEnvelope(env, ownerPassphrase);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal the mnemonic. The only function that returns raw seed words, and it exists solely so the owner
|
||||
* can back up or migrate. Always requires the passphrase even if a session is open, and callers must
|
||||
* gate it behind a fresh confirmation.
|
||||
*/
|
||||
export async function exportMnemonic(env: SeedEnvelope, ownerPassphrase: string): Promise<string> {
|
||||
const opened = await openEnvelope(env, ownerPassphrase);
|
||||
return opened.mnemonic;
|
||||
}
|
||||
|
||||
/** Re-wrap an existing seed under a new passphrase. Requires the old one; never touches the DEK. */
|
||||
export async function changePassphrase(
|
||||
env: SeedEnvelope,
|
||||
oldPassphrase: string,
|
||||
newPassphrase: string,
|
||||
): Promise<SeedEnvelope> {
|
||||
const opened = await openEnvelope(env, oldPassphrase);
|
||||
return sealSeed(opened.mnemonic, newPassphrase, opened.bip39Passphrase || undefined);
|
||||
}
|
||||
|
||||
/** Constant-time compare for any confirmation token we hand out and take back. */
|
||||
export function safeEqual(a: string, b: string): boolean {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
// Coin selection and PSBT construction/signing for the native on-chain wallet backend.
|
||||
//
|
||||
// This is the raw-transaction half of the wallet: given a set of UTXOs discovered from the chain (see
|
||||
// chain.ts) and a BIP32 root supplied by the caller, it picks inputs, builds a bitcoinjs-lib Psbt,
|
||||
// signs it, and hands back a finalised transaction ready for `EsploraClient.broadcast`.
|
||||
//
|
||||
// It is modelled on Zeus's SweepStore (stores/SweepStore.ts) — the same p2pkh / p2sh-p2wpkh / p2wpkh /
|
||||
// p2tr input construction, the same `toXOnly` from bitcoinjs-lib/src/psbt/bip371 — with three
|
||||
// differences that matter:
|
||||
//
|
||||
// 1. Zeus sweeps a single WIF key and estimates the fee by signing a throwaway PSBT. Here the fee is
|
||||
// estimated analytically from per-script-type weights (see WEIGHTS below), because selection has
|
||||
// to know the fee before it knows the input set, and signing to find out costs a key.
|
||||
// 2. Zeus refuses p2tr sweeps (ZEUS-3276). Taproot key-path spends work here: the private key is
|
||||
// BIP341-tweaked before signing and the signer exposes `signSchnorr`.
|
||||
// 3. SECURITY: no function in this file reads a seed from disk, env or module state. `signAndFinalize`
|
||||
// takes the root HDKey as a parameter and the caller — keys.ts, via the backend's signer interface
|
||||
// — decides whether it is willing to hand one over. Broadcast deliberately lives in chain.ts so
|
||||
// the root is never held across an `await`.
|
||||
|
||||
import type { HDKey } from '@scure/bip32';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371';
|
||||
import * as ecc from '@bitcoinerlab/secp256k1';
|
||||
import { BackendError, type AddressType, type BitcoinNetwork } from './types';
|
||||
|
||||
// bitcoinjs-lib needs an ECC backend for anything taproot (x-only point tweaking). Module-level and
|
||||
// idempotent — `initEcc()` is exported so a consumer that only touches address derivation can force it
|
||||
// without depending on import order.
|
||||
let eccReady = false;
|
||||
|
||||
export function initEcc(): void {
|
||||
if (eccReady) return;
|
||||
bitcoin.initEccLib(ecc);
|
||||
eccReady = true;
|
||||
}
|
||||
|
||||
initEcc();
|
||||
|
||||
// ── networks ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* bitcoinjs-lib ships bitcoin/testnet/regtest only. Signet shares testnet's address parameters exactly
|
||||
* (`tb` bech32 HRP, 0x6f p2pkh version, 0xc4 p2sh version) — only the genesis block and message magic
|
||||
* differ, and neither is used for address encoding or signing — so signet maps onto testnet.
|
||||
*/
|
||||
export function networkFor(network: BitcoinNetwork): bitcoin.Network {
|
||||
switch (network) {
|
||||
case 'bitcoin':
|
||||
return bitcoin.networks.bitcoin;
|
||||
case 'regtest':
|
||||
return bitcoin.networks.regtest;
|
||||
case 'testnet':
|
||||
case 'signet':
|
||||
return bitcoin.networks.testnet;
|
||||
}
|
||||
}
|
||||
|
||||
/** BIP44 coin type: 0 for mainnet, 1 for every test chain (SLIP-44 "Testnet (all coins)"). */
|
||||
export function coinTypeFor(network: BitcoinNetwork): 0 | 1 {
|
||||
return network === 'bitcoin' ? 0 : 1;
|
||||
}
|
||||
|
||||
// ── script classification ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Classify a scriptPubKey. Returns null for anything the wallet cannot own or size precisely (p2wsh,
|
||||
* bare multisig, op_return, future witness versions).
|
||||
*
|
||||
* Note that a bare p2sh script is reported as 'p2sh-p2wpkh'. On the *output* side that is exact — every
|
||||
* p2sh output is 23 bytes regardless of what redeems it — and on the input side the wallet only ever
|
||||
* owns the wrapped-segwit form, so the conflation is safe in both directions.
|
||||
*/
|
||||
export function scriptType(script: Uint8Array): AddressType | null {
|
||||
const b = script;
|
||||
if (b.length === 25 && b[0] === 0x76 && b[1] === 0xa9 && b[2] === 0x14 && b[23] === 0x88 && b[24] === 0xac) {
|
||||
return 'p2pkh';
|
||||
}
|
||||
if (b.length === 23 && b[0] === 0xa9 && b[1] === 0x14 && b[22] === 0x87) return 'p2sh-p2wpkh';
|
||||
if (b.length === 22 && b[0] === 0x00 && b[1] === 0x14) return 'p2wpkh';
|
||||
if (b.length === 34 && b[0] === 0x51 && b[1] === 0x20) return 'p2tr';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Same, from hex. */
|
||||
export function scriptTypeFromHex(hex: string): AddressType | null {
|
||||
return scriptType(Buffer.from(hex, 'hex'));
|
||||
}
|
||||
|
||||
/** Classify a destination address by the script it encodes. Throws when the address is not valid here. */
|
||||
export function addressScriptType(address: string, network: bitcoin.Network): AddressType | null {
|
||||
return scriptType(outputScriptFor(address, network));
|
||||
}
|
||||
|
||||
/** `bitcoin.address.toOutputScript` with the failure turned into a 400 instead of a bare Error. */
|
||||
export function outputScriptFor(address: string, network: bitcoin.Network): Buffer {
|
||||
try {
|
||||
return bitcoin.address.toOutputScript(address, network);
|
||||
} catch {
|
||||
throw new BackendError(`invalid address for this network: ${address}`, 400, 'INVALID_ADDRESS');
|
||||
}
|
||||
}
|
||||
|
||||
// ── size and dust constants ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Everything here is in *weight units* (4 wu = 1 vbyte) so segwit's quarter-vbyte discount survives the
|
||||
// arithmetic instead of being rounded away per input. A flat 148-in/34-out estimate — the usual
|
||||
// shortcut — overpays a p2wpkh spend by ~2.2x and a p2tr spend by ~2.6x, which at any real fee rate is
|
||||
// money handed to a miner for nothing.
|
||||
//
|
||||
// Input weights, assuming a 72-byte low-R DER signature (71 is common, 72 is the worst case) and a
|
||||
// 33-byte compressed pubkey:
|
||||
//
|
||||
// p2pkh 32 txid + 4 vout + 1 len + 107 scriptSig + 4 seq = 148 vB → 592 wu
|
||||
// p2sh-p2wpkh 64 base vB (scriptSig = 23-byte redeemScript push) + 108 wu witness → 364 wu
|
||||
// p2wpkh 41 base vB + 108 wu witness (2 items: 72-byte sig, 33-byte key) → 272 wu
|
||||
// p2tr key-path 41 base vB + 66 wu witness (1 item: 64-byte schnorr sig) → 230 wu
|
||||
//
|
||||
// which come out at 148 / 91 / 68 / 57.5 vbytes — the same numbers Bitcoin Core assumes.
|
||||
const INPUT_WEIGHT: Record<AddressType, number> = {
|
||||
p2pkh: 592,
|
||||
'p2sh-p2wpkh': 364,
|
||||
p2wpkh: 272,
|
||||
p2tr: 230,
|
||||
};
|
||||
|
||||
// Output weights: 8-byte value + 1-byte script length + the script itself, all ×4.
|
||||
// p2pkh 25B script → 34 vB, p2sh 23B → 32 vB, p2wpkh 22B → 31 vB, p2tr 34B → 43 vB.
|
||||
const OUTPUT_WEIGHT: Record<AddressType, number> = {
|
||||
p2pkh: 136,
|
||||
'p2sh-p2wpkh': 128,
|
||||
p2wpkh: 124,
|
||||
p2tr: 172,
|
||||
};
|
||||
|
||||
/** Unclassifiable output (p2wsh, future witness versions): charge the 43-vbyte p2tr/p2wsh size. */
|
||||
const OUTPUT_WEIGHT_UNKNOWN = 172;
|
||||
|
||||
/** version(4) + locktime(4), ×4. The input/output count varints are added separately. */
|
||||
const TX_OVERHEAD_WEIGHT = 32;
|
||||
|
||||
/** Segwit marker + flag: 1 byte each, but they live in the witness so they weigh 1 wu each. */
|
||||
const SEGWIT_MARKER_WEIGHT = 2;
|
||||
|
||||
/**
|
||||
* Bitcoin Core's dust threshold: 3 sat/vB (the default dustRelayFee) times the size of the output plus
|
||||
* the size of the input that would eventually spend it — witness inputs are counted as a flat 67 vB.
|
||||
* p2pkh (34+148)*3 = 546 · p2sh (32+148)*3 = 540 · p2wpkh (31+67)*3 = 294 · p2tr (43+67)*3 = 330
|
||||
* A change output below its threshold is not created; the remainder is donated to the fee instead.
|
||||
*/
|
||||
export const DUST_THRESHOLD: Record<AddressType, number> = {
|
||||
p2pkh: 546,
|
||||
'p2sh-p2wpkh': 540,
|
||||
p2wpkh: 294,
|
||||
p2tr: 330,
|
||||
};
|
||||
|
||||
/** Dust floor for an output we could not classify — use the most demanding value we know. */
|
||||
const DUST_UNKNOWN = 546;
|
||||
|
||||
/** The network will not relay below this, so a caller asking for less gets bumped rather than stuck. */
|
||||
const MIN_SAT_VB = 1;
|
||||
|
||||
function varintWeight(n: number): number {
|
||||
if (n < 0xfd) return 4;
|
||||
if (n <= 0xffff) return 12;
|
||||
return 20;
|
||||
}
|
||||
|
||||
function isSegwit(type: AddressType): boolean {
|
||||
return type !== 'p2pkh';
|
||||
}
|
||||
|
||||
export type VsizeParams = {
|
||||
inputs: AddressType[];
|
||||
/** Output script types, in order. `null` means "unclassifiable" and is charged 43 vbytes. */
|
||||
outputs: (AddressType | null)[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Virtual size of the transaction these inputs and outputs would produce, rounded up. Accurate to
|
||||
* within one vbyte per input (the DER signature is occasionally 71 bytes rather than 72), always in the
|
||||
* conservative direction, so the realised fee rate lands at or just above what was asked for.
|
||||
*/
|
||||
export function estimateVsize({ inputs, outputs }: VsizeParams): number {
|
||||
let weight = TX_OVERHEAD_WEIGHT + varintWeight(inputs.length) + varintWeight(outputs.length);
|
||||
if (inputs.some(isSegwit)) weight += SEGWIT_MARKER_WEIGHT;
|
||||
for (const type of inputs) weight += INPUT_WEIGHT[type];
|
||||
for (const type of outputs) weight += type === null ? OUTPUT_WEIGHT_UNKNOWN : OUTPUT_WEIGHT[type];
|
||||
return Math.ceil(weight / 4);
|
||||
}
|
||||
|
||||
// ── coin selection ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A UTXO the selector may spend. `derivationPath` is the FULL path from the wallet root
|
||||
* (`m/84'/0'/0'/0/7`), not the account-relative form the public Utxo type carries, because it is used
|
||||
* verbatim to derive the signing key.
|
||||
*/
|
||||
export type SpendableUtxo = {
|
||||
txid: string;
|
||||
vout: number;
|
||||
amountSats: number;
|
||||
address: string;
|
||||
addressType: AddressType;
|
||||
confirmations: number;
|
||||
derivationPath: string;
|
||||
frozen: boolean;
|
||||
/** scriptPubKey of the output being spent, hex. */
|
||||
scriptPubKeyHex: string;
|
||||
/** Compressed 33-byte pubkey of the owning address, hex. */
|
||||
pubkeyHex: string;
|
||||
};
|
||||
|
||||
export type CoinSelectionParams = {
|
||||
utxos: SpendableUtxo[];
|
||||
/** Sats to pay the recipient. Ignored when `sendAll` is set. */
|
||||
targetSats: number;
|
||||
sendAll?: boolean;
|
||||
satPerVbyte: number;
|
||||
/** Script type of the recipient output; null when it is a script we cannot size exactly. */
|
||||
recipientType: AddressType | null;
|
||||
/** Script type the change output would use. */
|
||||
changeType: AddressType;
|
||||
/** Coin control: restrict the input set to these `txid:vout` outpoints. */
|
||||
outpoints?: string[];
|
||||
spendUnconfirmed?: boolean;
|
||||
};
|
||||
|
||||
export type CoinSelection = {
|
||||
inputs: SpendableUtxo[];
|
||||
/** Sats actually paid to the recipient. Equals `targetSats` unless `sendAll`. */
|
||||
outputSats: number;
|
||||
/** Sats returned to the wallet, or null when no change output is created. */
|
||||
changeSats: number | null;
|
||||
feeSats: number;
|
||||
/** The vsize the fee was computed from. */
|
||||
vsize: number;
|
||||
};
|
||||
|
||||
const sumSats = (utxos: SpendableUtxo[]): number => utxos.reduce((acc, u) => acc + u.amountSats, 0);
|
||||
|
||||
const outpointOf = (u: SpendableUtxo): string => `${u.txid}:${u.vout}`;
|
||||
|
||||
function dustFor(type: AddressType | null): number {
|
||||
return type === null ? DUST_UNKNOWN : DUST_THRESHOLD[type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Coin selection: a two-phase accumulative selector. Deliberately not branch-and-bound — BnB's payoff
|
||||
* is finding changeless solutions in a large UTXO set, and it needs a waste metric plus a fallback
|
||||
* anyway. Instead:
|
||||
*
|
||||
* Phase 1 — smallest sufficient single input. Walk the eligible UTXOs smallest-first and take the
|
||||
* first one that can cover the payment on its own. One input is the cheapest possible
|
||||
* transaction, and going smallest-first quietly consolidates the wallet's dust over time.
|
||||
* Phase 2 — largest-first accumulation. Nothing single-handedly covers it, so add UTXOs
|
||||
* largest-first (confirmed before unconfirmed) until the total covers payment + fee. Going
|
||||
* largest-first minimises the input count, and each input costs real money.
|
||||
*
|
||||
* Both phases decide change the same way: prefer a change output, but if what is left after the
|
||||
* with-change fee falls under the dust threshold, drop the change output and donate the remainder to
|
||||
* the fee. The donation is bounded by roughly (one output's fee + one dust threshold), so it can never
|
||||
* quietly become a large overpayment.
|
||||
*/
|
||||
export function selectCoins(params: CoinSelectionParams): CoinSelection {
|
||||
const rate = Math.max(params.satPerVbyte, MIN_SAT_VB);
|
||||
const allow = params.outpoints && params.outpoints.length > 0 ? new Set(params.outpoints) : null;
|
||||
|
||||
const eligible = params.utxos.filter((u) => {
|
||||
// An explicit coin-control pick is authoritative: it overrides both the frozen flag and the
|
||||
// confirmed-only default, because the user named this exact outpoint.
|
||||
if (allow) return allow.has(outpointOf(u));
|
||||
if (u.frozen) return false;
|
||||
if (!params.spendUnconfirmed && u.confirmations < 1) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (eligible.length === 0) {
|
||||
const why = allow ? 'none of the selected outpoints are spendable' : 'no spendable UTXOs';
|
||||
throw new BackendError(why, 400, 'INSUFFICIENT_FUNDS');
|
||||
}
|
||||
|
||||
if (params.sendAll) return sweepAll(eligible, rate, params.recipientType);
|
||||
|
||||
if (!Number.isInteger(params.targetSats) || params.targetSats <= 0) {
|
||||
throw new BackendError('amountSats must be a positive integer', 400, 'INVALID_AMOUNT');
|
||||
}
|
||||
|
||||
const attempt = (inputs: SpendableUtxo[]): CoinSelection | null => {
|
||||
const total = sumSats(inputs);
|
||||
const types = inputs.map((u) => u.addressType);
|
||||
const withChange = estimateVsize({ inputs: types, outputs: [params.recipientType, params.changeType] });
|
||||
const noChange = estimateVsize({ inputs: types, outputs: [params.recipientType] });
|
||||
const feeWithChange = Math.ceil(withChange * rate);
|
||||
const feeNoChange = Math.ceil(noChange * rate);
|
||||
|
||||
const change = total - params.targetSats - feeWithChange;
|
||||
if (change >= DUST_THRESHOLD[params.changeType]) {
|
||||
return {
|
||||
inputs: [...inputs],
|
||||
outputSats: params.targetSats,
|
||||
changeSats: change,
|
||||
feeSats: feeWithChange,
|
||||
vsize: withChange,
|
||||
};
|
||||
}
|
||||
if (total >= params.targetSats + feeNoChange) {
|
||||
// Changeless: everything above the payment is fee. Smaller transaction, no dust output created.
|
||||
return {
|
||||
inputs: [...inputs],
|
||||
outputSats: params.targetSats,
|
||||
changeSats: null,
|
||||
feeSats: total - params.targetSats,
|
||||
vsize: noChange,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Phase 1 — smallest sufficient single input.
|
||||
const ascending = [...eligible].sort((a, b) => a.amountSats - b.amountSats);
|
||||
for (const utxo of ascending) {
|
||||
const single = attempt([utxo]);
|
||||
if (single) return single;
|
||||
}
|
||||
|
||||
// Phase 2 — largest-first accumulation, confirmed coins ahead of unconfirmed ones.
|
||||
const ordered = [...eligible].sort((a, b) => {
|
||||
const aPending = a.confirmations > 0 ? 0 : 1;
|
||||
const bPending = b.confirmations > 0 ? 0 : 1;
|
||||
if (aPending !== bPending) return aPending - bPending;
|
||||
return b.amountSats - a.amountSats;
|
||||
});
|
||||
|
||||
const chosen: SpendableUtxo[] = [];
|
||||
for (const utxo of ordered) {
|
||||
chosen.push(utxo);
|
||||
const selection = attempt(chosen);
|
||||
if (selection) return selection;
|
||||
}
|
||||
|
||||
const available = sumSats(eligible);
|
||||
throw new BackendError(
|
||||
`insufficient funds: ${available} sat available, ${params.targetSats} sat requested plus fees`,
|
||||
400,
|
||||
'INSUFFICIENT_FUNDS',
|
||||
);
|
||||
}
|
||||
|
||||
/** sendAll: every eligible coin in, one output out, the fee taken off that output. */
|
||||
function sweepAll(inputs: SpendableUtxo[], rate: number, recipientType: AddressType | null): CoinSelection {
|
||||
const total = sumSats(inputs);
|
||||
const vsize = estimateVsize({ inputs: inputs.map((u) => u.addressType), outputs: [recipientType] });
|
||||
const feeSats = Math.ceil(vsize * rate);
|
||||
const outputSats = total - feeSats;
|
||||
if (outputSats < dustFor(recipientType)) {
|
||||
throw new BackendError(
|
||||
`sweep leaves ${outputSats} sat after a ${feeSats} sat fee, below the dust threshold`,
|
||||
400,
|
||||
'INSUFFICIENT_FUNDS',
|
||||
);
|
||||
}
|
||||
return { inputs: [...inputs], outputSats, changeSats: null, feeSats, vsize };
|
||||
}
|
||||
|
||||
// ── PSBT construction ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** RBF-signalling sequence (BIP125): any input below 0xfffffffe marks the whole transaction replaceable. */
|
||||
const SEQUENCE_RBF = 0xfffffffd;
|
||||
const SEQUENCE_FINAL = 0xffffffff;
|
||||
|
||||
export type PsbtInputSource = {
|
||||
txid: string;
|
||||
vout: number;
|
||||
amountSats: number;
|
||||
addressType: AddressType;
|
||||
/** scriptPubKey being spent, hex. */
|
||||
scriptPubKeyHex: string;
|
||||
/** Compressed 33-byte pubkey of the owning address, hex. */
|
||||
pubkeyHex: string;
|
||||
/** Full BIP32 path from the wallet root — returned in `inputPaths` for `signAndFinalize`. */
|
||||
derivationPath: string;
|
||||
/** Whole previous transaction, hex. Required for p2pkh inputs, unused otherwise. */
|
||||
prevTxHex?: string;
|
||||
};
|
||||
|
||||
export type PsbtOutputSpec = { address: string; amountSats: number };
|
||||
|
||||
export type BuildPsbtParams = {
|
||||
network: bitcoin.Network;
|
||||
inputs: PsbtInputSource[];
|
||||
outputs: PsbtOutputSpec[];
|
||||
/** Signal RBF. Default true. */
|
||||
rbf?: boolean;
|
||||
locktime?: number;
|
||||
};
|
||||
|
||||
export type BuiltPsbt = {
|
||||
psbt: bitcoin.Psbt;
|
||||
/** Derivation paths in input order — pass straight to `signAndFinalize`. */
|
||||
inputPaths: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an unsigned PSBT. Input construction is per script type, exactly as Zeus's SweepStore does it:
|
||||
* legacy p2pkh needs the whole previous transaction (`nonWitnessUtxo`), segwit needs only the output
|
||||
* being spent (`witnessUtxo`), wrapped segwit additionally needs the p2wpkh `redeemScript`, and taproot
|
||||
* needs the x-only internal key so the signer can be matched against the output key.
|
||||
*/
|
||||
export function buildPsbt(params: BuildPsbtParams): BuiltPsbt {
|
||||
initEcc();
|
||||
const { network } = params;
|
||||
if (params.inputs.length === 0) throw new BackendError('cannot build a PSBT with no inputs', 400);
|
||||
if (params.outputs.length === 0) throw new BackendError('cannot build a PSBT with no outputs', 400);
|
||||
|
||||
const psbt = new bitcoin.Psbt({ network });
|
||||
psbt.setVersion(2);
|
||||
psbt.setLocktime(params.locktime ?? 0);
|
||||
|
||||
const sequence = params.rbf === false ? SEQUENCE_FINAL : SEQUENCE_RBF;
|
||||
const inputPaths: string[] = [];
|
||||
|
||||
for (const source of params.inputs) {
|
||||
const pubkey = Buffer.from(source.pubkeyHex, 'hex');
|
||||
const script = Buffer.from(source.scriptPubKeyHex, 'hex');
|
||||
const base = { hash: source.txid, index: source.vout, sequence };
|
||||
|
||||
switch (source.addressType) {
|
||||
case 'p2pkh': {
|
||||
if (!source.prevTxHex) {
|
||||
throw new BackendError(`p2pkh input ${source.txid}:${source.vout} needs the previous tx hex`, 500);
|
||||
}
|
||||
psbt.addInput({ ...base, nonWitnessUtxo: Buffer.from(source.prevTxHex, 'hex') });
|
||||
break;
|
||||
}
|
||||
case 'p2wpkh': {
|
||||
psbt.addInput({ ...base, witnessUtxo: { script, value: source.amountSats } });
|
||||
break;
|
||||
}
|
||||
case 'p2sh-p2wpkh': {
|
||||
const redeem = bitcoin.payments.p2wpkh({ pubkey, network });
|
||||
if (!redeem.output) throw new BackendError('failed to build the p2wpkh redeemScript', 500);
|
||||
psbt.addInput({
|
||||
...base,
|
||||
witnessUtxo: { script, value: source.amountSats },
|
||||
redeemScript: redeem.output,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'p2tr': {
|
||||
psbt.addInput({
|
||||
...base,
|
||||
witnessUtxo: { script, value: source.amountSats },
|
||||
tapInternalKey: toXOnly(pubkey),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
inputPaths.push(source.derivationPath);
|
||||
}
|
||||
|
||||
for (const output of params.outputs) {
|
||||
// Validate against the configured network before adding — bitcoinjs would accept a foreign-network
|
||||
// address encoded for a chain with the same prefixes and silently burn the funds.
|
||||
outputScriptFor(output.address, network);
|
||||
psbt.addOutput({ address: output.address, value: output.amountSats });
|
||||
}
|
||||
|
||||
return { psbt, inputPaths };
|
||||
}
|
||||
|
||||
// ── signing ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SignedTx = {
|
||||
txid: string;
|
||||
rawHex: string;
|
||||
/** Realised vsize of the signed transaction — compare against the estimate to audit fee accuracy. */
|
||||
vsize: number;
|
||||
feeSats: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* BIP341 key-path tweak. The output key is `P + H_TapTweak(P) * G` where P is the x-only internal key,
|
||||
* so the private key has to be tweaked the same way before it can produce a matching schnorr signature.
|
||||
* If the internal point has odd Y the scalar is negated first, since x-only keys are always even-Y.
|
||||
*/
|
||||
function tweakPrivateKey(priv: Uint8Array, pubkey: Buffer): Uint8Array {
|
||||
const even = pubkey[0] === 0x02 ? priv : ecc.privateNegate(priv);
|
||||
const tweak = bitcoin.crypto.taggedHash('TapTweak', toXOnly(pubkey));
|
||||
const tweaked = ecc.privateAdd(even, tweak);
|
||||
if (!tweaked) throw new BackendError('taproot tweak produced an invalid key', 500);
|
||||
return tweaked;
|
||||
}
|
||||
|
||||
function keyMaterial(node: HDKey): { priv: Uint8Array; pubkey: Buffer } {
|
||||
const priv = node.privateKey;
|
||||
const pub = node.publicKey;
|
||||
if (!priv || !pub) throw new BackendError('derived node has no private key — cannot sign', 500);
|
||||
return { priv, pubkey: Buffer.from(pub) };
|
||||
}
|
||||
|
||||
/** ECDSA signer for p2pkh / p2sh-p2wpkh / p2wpkh inputs. */
|
||||
function ecdsaSigner(node: HDKey): bitcoin.Signer {
|
||||
const { priv, pubkey } = keyMaterial(node);
|
||||
return {
|
||||
publicKey: pubkey,
|
||||
sign: (hash: Buffer) => Buffer.from(ecc.sign(hash, priv)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Schnorr signer for a taproot key-path spend. `publicKey` must be the TWEAKED key: bitcoinjs matches
|
||||
* `toXOnly(signer.publicKey)` against the output key in the prevout (psbt.js getTaprootHashesForSig),
|
||||
* and the untweaked internal key would simply not match, failing with "Can not sign for input".
|
||||
*/
|
||||
function taprootSigner(node: HDKey): bitcoin.Signer {
|
||||
const { priv, pubkey } = keyMaterial(node);
|
||||
const tweakedPriv = tweakPrivateKey(priv, pubkey);
|
||||
const tweakedPub = ecc.pointFromScalar(tweakedPriv, true);
|
||||
if (!tweakedPub) throw new BackendError('taproot tweak produced an invalid point', 500);
|
||||
return {
|
||||
publicKey: Buffer.from(tweakedPub),
|
||||
sign: (hash: Buffer) => Buffer.from(ecc.sign(hash, tweakedPriv)),
|
||||
signSchnorr: (hash: Buffer) => Buffer.from(ecc.signSchnorr(hash, tweakedPriv)),
|
||||
};
|
||||
}
|
||||
|
||||
/** @scure/bip32 only accepts the apostrophe form of a hardened index. */
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/[hH]/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign every input from keys derived off `root`, finalise, and extract the transaction.
|
||||
*
|
||||
* `root` is a PARAMETER and is never cached, stored or closed over past this call: the caller holds the
|
||||
* key material and this function borrows it for the duration of a synchronous signing pass. Nothing
|
||||
* here awaits, so the root cannot be pinned alive by a pending network call.
|
||||
*/
|
||||
export function signAndFinalize(psbt: bitcoin.Psbt, root: HDKey, inputPaths: string[]): SignedTx {
|
||||
initEcc();
|
||||
const count = psbt.data.inputs.length;
|
||||
if (inputPaths.length !== count) {
|
||||
throw new BackendError(`expected ${count} derivation paths, got ${inputPaths.length}`, 500);
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const path = inputPaths[i];
|
||||
const input = psbt.data.inputs[i];
|
||||
if (path === undefined || input === undefined) {
|
||||
throw new BackendError(`missing derivation path for input #${i}`, 500);
|
||||
}
|
||||
const node = root.derive(normalizePath(path));
|
||||
// `tapInternalKey` is set only by the p2tr branch of buildPsbt, so it is the authoritative marker
|
||||
// for "this input needs a schnorr signature over a tweaked key".
|
||||
psbt.signInput(i, input.tapInternalKey ? taprootSigner(node) : ecdsaSigner(node));
|
||||
}
|
||||
|
||||
psbt.finalizeAllInputs();
|
||||
const feeSats = psbt.getFee();
|
||||
const tx = psbt.extractTransaction();
|
||||
return { txid: tx.getId(), rawHex: tx.toHex(), vsize: tx.virtualSize(), feeSats };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { getWallet, getWalletSecrets, type WalletSummary } from 'officerdb';
|
||||
import { EsploraChain } from './chain';
|
||||
import { LndBackend } from './backends/lnd';
|
||||
import { ClnRestBackend } from './backends/clnrest';
|
||||
import { LndHubBackend } from './backends/lndhub';
|
||||
import { NwcBackend } from './backends/nwc';
|
||||
import { OnchainBackend } from './backends/onchain';
|
||||
import { sessionFor } from './keys';
|
||||
import { getConfig } from './upstream';
|
||||
import { BackendError, BIP_ADDRESS_TYPE, type AddressType, type BitcoinNetwork, type WalletBackend } from './types';
|
||||
|
||||
// Turns a stored wallet row into a live backend instance. This is the one place that knows the mapping
|
||||
// from `kind` to a class, and the one place node credentials are decrypted — getWalletSecrets() is
|
||||
// called here and the plaintext never travels further than the constructor it is handed to.
|
||||
//
|
||||
// Instances are cached per wallet id. Backends hold connection state worth reusing (LNDHub's access
|
||||
// token, NWC's relay socket, the on-chain gap-limit scan), and rebuilding one per request would both
|
||||
// re-authenticate constantly and defeat the address-scan cache. The cache is invalidated whenever the
|
||||
// wallet's config changes — see `invalidate()`, called from the update/delete routes.
|
||||
|
||||
type Cached = { backend: WalletBackend; configVersion: string };
|
||||
const cache = new Map<number, Cached>();
|
||||
|
||||
export function invalidate(walletId: number): void {
|
||||
cache.delete(walletId);
|
||||
}
|
||||
|
||||
export function invalidateAll(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/** A stable fingerprint of the inputs a backend was built from, so a stale instance is detected. */
|
||||
function versionOf(wallet: WalletSummary, config: Record<string, unknown> | null): string {
|
||||
return JSON.stringify([wallet.kind, wallet.network, wallet.defaultBip, wallet.xpubs, config]);
|
||||
}
|
||||
|
||||
export type Resolved = { wallet: WalletSummary; backend: WalletBackend };
|
||||
|
||||
export async function resolveBackend(userId: number, walletId: number): Promise<Resolved> {
|
||||
const wallet = await getWallet(userId, walletId);
|
||||
if (!wallet) throw new BackendError('wallet not found', 404, 'NOT_FOUND');
|
||||
|
||||
const secrets = await getWalletSecrets(userId, walletId);
|
||||
const config = secrets?.config ?? null;
|
||||
const version = versionOf(wallet, config);
|
||||
|
||||
const hit = cache.get(walletId);
|
||||
if (hit && hit.configVersion === version) return { wallet, backend: hit.backend };
|
||||
|
||||
const backend = build(wallet, config);
|
||||
cache.set(walletId, { backend, configVersion: version });
|
||||
return { wallet, backend };
|
||||
}
|
||||
|
||||
function required(config: Record<string, unknown> | null, key: string, kind: string): string {
|
||||
const v = config?.[key];
|
||||
if (typeof v !== 'string' || !v) {
|
||||
throw new BackendError(`${kind} wallet is missing required config "${key}"`, 400, 'BAD_CONFIG');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function build(wallet: WalletSummary, config: Record<string, unknown> | null): WalletBackend {
|
||||
const network = wallet.network as BitcoinNetwork;
|
||||
|
||||
switch (wallet.kind) {
|
||||
case 'lnd':
|
||||
return new LndBackend({
|
||||
url: required(config, 'url', 'lnd'),
|
||||
macaroonHex: required(config, 'macaroonHex', 'lnd'),
|
||||
allowSelfSigned: config?.allowSelfSigned === true,
|
||||
});
|
||||
|
||||
case 'cln-rest':
|
||||
return new ClnRestBackend({
|
||||
url: required(config, 'url', 'cln-rest'),
|
||||
rune: required(config, 'rune', 'cln-rest'),
|
||||
allowSelfSigned: config?.allowSelfSigned === true,
|
||||
});
|
||||
|
||||
case 'lndhub':
|
||||
return new LndHubBackend({
|
||||
url: required(config, 'url', 'lndhub'),
|
||||
login: required(config, 'login', 'lndhub'),
|
||||
password: required(config, 'password', 'lndhub'),
|
||||
});
|
||||
|
||||
case 'nwc':
|
||||
return new NwcBackend({ connectionUri: required(config, 'connectionUri', 'nwc') });
|
||||
|
||||
case 'onchain': {
|
||||
// Every xpub the wallet holds is handed over, not just the default BIP's. A seed derives all four
|
||||
// accounts (keys.ts::deriveAccountXpubs), and coins can legitimately sit on any of them — a
|
||||
// recovered seed may have been used with a p2tr wallet before, or received to a legacy address.
|
||||
// Scanning only the default account would silently under-report the balance and leave those UTXOs
|
||||
// unspendable. `defaultBip` then means only "which script type new receive addresses use".
|
||||
const accountXpub: Partial<Record<AddressType, string>> = {};
|
||||
for (const [bip, type] of Object.entries(BIP_ADDRESS_TYPE)) {
|
||||
const xpub = wallet.xpubs?.[bip];
|
||||
if (xpub) accountXpub[type] = xpub;
|
||||
}
|
||||
if (Object.keys(accountXpub).length === 0) {
|
||||
throw new BackendError('wallet has no account xpubs', 500, 'BAD_CONFIG');
|
||||
}
|
||||
const { esploraUrl } = getConfig();
|
||||
return new OnchainBackend({
|
||||
chain: new EsploraChain({ baseUrl: esploraUrl, network }),
|
||||
network,
|
||||
accountXpub,
|
||||
// The session is the signer. While locked it holds no key material, so watch-only reads below
|
||||
// still work and only sendCoins/signMessage will throw WalletLockedError.
|
||||
signer: sessionFor(wallet.id),
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
throw new BackendError(`unknown wallet kind "${wallet.kind}"`, 400, 'BAD_CONFIG');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import {
|
||||
listWallets,
|
||||
getWallet,
|
||||
getSealedSeed,
|
||||
createWallet,
|
||||
updateWallet,
|
||||
setActiveWallet,
|
||||
deleteWallet,
|
||||
getActiveWallet,
|
||||
getWalletLabels,
|
||||
setWalletLabel,
|
||||
getFrozenOutpoints,
|
||||
setUtxoFrozen,
|
||||
type WalletKind,
|
||||
} from 'officerdb';
|
||||
import { resolveBackend, invalidate } from './resolve';
|
||||
import {
|
||||
changePassphrase,
|
||||
deriveAccountXpubs,
|
||||
exportMnemonic,
|
||||
generateSeed,
|
||||
sealSeed,
|
||||
sessionFor,
|
||||
verifyPassphrase,
|
||||
type SeedEnvelope,
|
||||
} from './keys';
|
||||
import { getConfig, hasStoreKey } from './upstream';
|
||||
import {
|
||||
asAddressType,
|
||||
BackendError,
|
||||
WalletLockedError,
|
||||
BIP_ADDRESS_TYPE,
|
||||
type BitcoinNetwork,
|
||||
type Capability,
|
||||
type Utxo,
|
||||
type WalletBackend,
|
||||
} from './types';
|
||||
|
||||
// The wallet sidecar's route surface. Every route is scoped to the authenticated owner via X-Officer-User,
|
||||
// which the platform proxy injects (src/servers/api/wallet/router.ts) and which is trustworthy because the
|
||||
// sidecar binds loopback only.
|
||||
//
|
||||
// Route ordering matters here: the seed/lock routes are matched BEFORE the generic wallet operations, so a
|
||||
// wallet named "unlock" can never shadow the unlock endpoint.
|
||||
//
|
||||
// SECURITY NOTES that apply to this whole file:
|
||||
// - No route ever returns a mnemonic, a sealed envelope, a node macaroon, or an unlock passphrase,
|
||||
// except /export-seed, which exists for backup and demands the passphrase every single time.
|
||||
// - Passphrases arrive in request bodies and are never logged. The catch-all handler at the bottom logs
|
||||
// the method and path only, deliberately not the body.
|
||||
// - Capability checks happen before dispatch so an unsupported operation is a clean 501.
|
||||
|
||||
export type OfficerContext = { req: Request; url: URL; userId: number };
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
return Response.json(data as Record<string, unknown>, { status });
|
||||
}
|
||||
|
||||
function badRequest(message: string): Response {
|
||||
return json({ error: message }, 400);
|
||||
}
|
||||
|
||||
async function body<T>(req: Request): Promise<T> {
|
||||
try {
|
||||
return (await req.json()) as T;
|
||||
} catch {
|
||||
throw new BackendError('expected a JSON body', 400, 'BAD_BODY');
|
||||
}
|
||||
}
|
||||
|
||||
/** Guard a capability before dispatching, so callers get 501 rather than a confusing upstream error. */
|
||||
function requireCap(backend: WalletBackend, cap: Capability, op: string): void {
|
||||
if (!backend.supports(cap)) {
|
||||
throw new BackendError(`this wallet does not support ${op}`, 501, 'NOT_SUPPORTED');
|
||||
}
|
||||
}
|
||||
|
||||
// ── entry point ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleOfficerRoute(req: Request, url: URL): Promise<Response | null> {
|
||||
const officerUser = req.headers.get('X-Officer-User');
|
||||
if (!officerUser) return json({ error: 'missing X-Officer-User' }, 401);
|
||||
const userId = Number(officerUser);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User');
|
||||
|
||||
const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean);
|
||||
if (segments.length === 0) return null;
|
||||
const ctx: OfficerContext = { req, url, userId };
|
||||
|
||||
try {
|
||||
switch (segments[0]) {
|
||||
case 'config':
|
||||
return handleConfig();
|
||||
case 'wallets':
|
||||
return await handleWallets(ctx, segments.slice(1));
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof BackendError) {
|
||||
return json({ error: err.message, code: err.code }, err.status);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-secret deployment facts the UI needs before any wallet exists. */
|
||||
function handleConfig(): Response {
|
||||
const cfg = getConfig();
|
||||
return json({
|
||||
network: cfg.network,
|
||||
esploraUrl: cfg.esploraUrl,
|
||||
unlockTtlSec: cfg.unlockTtlSec,
|
||||
// The UI blocks wallet creation on this rather than letting the first write fail on a crypto error.
|
||||
storeKeyConfigured: hasStoreKey(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── /wallets ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const KINDS: readonly WalletKind[] = ['onchain', 'lnd', 'cln-rest', 'lndhub', 'nwc'];
|
||||
|
||||
async function handleWallets(ctx: OfficerContext, seg: string[]): Promise<Response | null> {
|
||||
const { req, userId } = ctx;
|
||||
|
||||
// /_officer/wallets
|
||||
if (seg.length === 0) {
|
||||
if (req.method === 'GET') return json({ wallets: await listWallets(userId) });
|
||||
if (req.method === 'POST') return await createWalletRoute(ctx);
|
||||
return json({ error: 'method not allowed' }, 405);
|
||||
}
|
||||
|
||||
// /_officer/wallets/active — resolves to whichever wallet is currently selected
|
||||
if (seg[0] === 'active' && seg.length === 1 && req.method === 'GET') {
|
||||
const active = await getActiveWallet(userId);
|
||||
return json({ wallet: active });
|
||||
}
|
||||
|
||||
const walletId = Number(seg[0]);
|
||||
if (!Number.isInteger(walletId) || walletId <= 0) return badRequest('invalid wallet id');
|
||||
const rest = seg.slice(1);
|
||||
|
||||
// /_officer/wallets/:id
|
||||
if (rest.length === 0) {
|
||||
if (req.method === 'GET') {
|
||||
const wallet = await getWallet(userId, walletId);
|
||||
return wallet ? json({ wallet }) : json({ error: 'wallet not found' }, 404);
|
||||
}
|
||||
if (req.method === 'PATCH') {
|
||||
const patch = await body<{ name?: string; defaultBip?: number; config?: Record<string, unknown> }>(req);
|
||||
const updated = await updateWallet(userId, walletId, patch);
|
||||
invalidate(walletId);
|
||||
return updated ? json({ wallet: updated }) : json({ error: 'wallet not found' }, 404);
|
||||
}
|
||||
if (req.method === 'DELETE') return await deleteWalletRoute(ctx, walletId);
|
||||
return json({ error: 'method not allowed' }, 405);
|
||||
}
|
||||
|
||||
// Seed / lock lifecycle — matched before the generic operations below.
|
||||
switch (rest[0]) {
|
||||
case 'activate':
|
||||
if (req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
await setActiveWallet(userId, walletId);
|
||||
return json({ ok: true });
|
||||
|
||||
case 'lock-state': {
|
||||
const wallet = await getWallet(userId, walletId);
|
||||
if (!wallet) return json({ error: 'wallet not found' }, 404);
|
||||
const session = sessionFor(walletId);
|
||||
return json({
|
||||
hasSeed: wallet.hasSeed,
|
||||
unlocked: session.isUnlocked(),
|
||||
secondsRemaining: session.secondsRemaining(),
|
||||
});
|
||||
}
|
||||
|
||||
case 'unlock':
|
||||
return await unlockRoute(ctx, walletId);
|
||||
|
||||
case 'lock':
|
||||
if (req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
sessionFor(walletId).lock();
|
||||
return json({ ok: true, unlocked: false });
|
||||
|
||||
case 'passphrase':
|
||||
return await changePassphraseRoute(ctx, walletId);
|
||||
|
||||
case 'export-seed':
|
||||
return await exportSeedRoute(ctx, walletId);
|
||||
}
|
||||
|
||||
// Everything else needs a live backend.
|
||||
const { wallet, backend } = await resolveBackend(userId, walletId);
|
||||
|
||||
switch (rest[0]) {
|
||||
case 'capabilities': {
|
||||
const all: Capability[] = [
|
||||
'onchainReceive',
|
||||
'onchainSend',
|
||||
'coinControl',
|
||||
'psbt',
|
||||
'bumpFee',
|
||||
'sweep',
|
||||
'accounts',
|
||||
'lightningReceive',
|
||||
'lightningSend',
|
||||
'keysend',
|
||||
'customPreimages',
|
||||
'offers',
|
||||
'channels',
|
||||
'peers',
|
||||
'routing',
|
||||
'signMessage',
|
||||
];
|
||||
return json({ kind: wallet.kind, capabilities: all.filter((c) => backend.supports(c)) });
|
||||
}
|
||||
|
||||
case 'info':
|
||||
return json({ info: await backend.getInfo() });
|
||||
|
||||
case 'balances':
|
||||
return json({ balances: await backend.getBalances() });
|
||||
|
||||
case 'transactions': {
|
||||
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
|
||||
const txs = await backend.getTransactions({ limit });
|
||||
// Overlay owner labels, which live in Officer's DB rather than any backend.
|
||||
const labels = await getWalletLabels(walletId);
|
||||
const byRef = new Map(labels.filter((l) => l.kind === 'tx').map((l) => [l.ref, l.label]));
|
||||
return json({ transactions: txs.map((t) => ({ ...t, label: byRef.get(t.txid) ?? t.label })) });
|
||||
}
|
||||
|
||||
case 'address': {
|
||||
requireCap(backend, 'onchainReceive', 'receiving on-chain');
|
||||
const peek = ctx.url.searchParams.get('peek') === 'true';
|
||||
// An explicit ?type= wins; otherwise the wallet's defaultBip decides. Without this the on-chain
|
||||
// backend would always fall back to its own preference order (native segwit), silently ignoring an
|
||||
// owner who set the wallet to taproot or legacy.
|
||||
const asked = ctx.url.searchParams.get('type');
|
||||
if (asked && !asAddressType(asked)) return badRequest(`unknown address type "${asked}"`);
|
||||
const type = asked ? asAddressType(asked) : BIP_ADDRESS_TYPE[String(wallet.defaultBip)];
|
||||
return json(await backend.getNewAddress({ peek, type }));
|
||||
}
|
||||
|
||||
case 'utxos':
|
||||
return await utxosRoute(ctx, walletId, backend, rest.slice(1));
|
||||
|
||||
case 'fees':
|
||||
return json({ fees: await backend.estimateFees() });
|
||||
|
||||
case 'send': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
requireCap(backend, 'onchainSend', 'sending on-chain');
|
||||
// The lock gate comes before body validation, not after. The backend checks it too, but only once
|
||||
// the request has already passed every field check here — so a locked wallet was answering "your
|
||||
// fee rate is wrong" instead of "unlock me first", which sends the UI down the wrong path.
|
||||
if (wallet.hasSeed && !sessionFor(walletId).isUnlocked()) throw new WalletLockedError();
|
||||
const req2 = await body<Parameters<WalletBackend['sendCoins']>[0]>(ctx.req);
|
||||
if (!req2.address) return badRequest('address is required');
|
||||
if (!req2.sendAll && !req2.amountSats) return badRequest('amountSats or sendAll is required');
|
||||
if (!req2.satPerVbyte || req2.satPerVbyte < 1) return badRequest('satPerVbyte must be at least 1');
|
||||
// Never spend a frozen coin, even if the caller passed no explicit outpoint list.
|
||||
const frozen = new Set(await getFrozenOutpoints(walletId));
|
||||
if (req2.outpoints?.some((o) => frozen.has(o))) return badRequest('refusing to spend a frozen UTXO');
|
||||
const result = await backend.sendCoins(req2);
|
||||
if (req2.label) await setWalletLabel(walletId, 'tx', result.txid, req2.label);
|
||||
return json(result);
|
||||
}
|
||||
|
||||
case 'invoices':
|
||||
return await invoicesRoute(ctx, backend, rest.slice(1));
|
||||
|
||||
case 'decode': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { bolt11 } = await body<{ bolt11?: string }>(ctx.req);
|
||||
if (!bolt11) return badRequest('bolt11 is required');
|
||||
return json({ decoded: await backend.decodeInvoice(bolt11) });
|
||||
}
|
||||
|
||||
case 'payments': {
|
||||
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
|
||||
return json({ payments: await backend.getPayments({ limit }) });
|
||||
}
|
||||
|
||||
case 'pay': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
requireCap(backend, 'lightningSend', 'paying lightning invoices');
|
||||
const payReq = await body<Parameters<WalletBackend['payInvoice']>[0]>(ctx.req);
|
||||
if (!payReq.bolt11) return badRequest('bolt11 is required');
|
||||
if (payReq.feeLimitMsat && payReq.feeLimitPercent) {
|
||||
return badRequest('feeLimitMsat and feeLimitPercent are mutually exclusive');
|
||||
}
|
||||
return json({ payment: await backend.payInvoice(payReq) });
|
||||
}
|
||||
|
||||
case 'keysend': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
requireCap(backend, 'keysend', 'keysend');
|
||||
const ks = await body<Parameters<WalletBackend['sendKeysend']>[0]>(ctx.req);
|
||||
if (!ks.destination || !ks.amountMsat) return badRequest('destination and amountMsat are required');
|
||||
return json({ payment: await backend.sendKeysend(ks) });
|
||||
}
|
||||
|
||||
case 'channels':
|
||||
requireCap(backend, 'channels', 'channels');
|
||||
return json({ channels: await backend.getChannels() });
|
||||
|
||||
case 'peers':
|
||||
requireCap(backend, 'peers', 'peers');
|
||||
return json({ peers: await backend.getPeers() });
|
||||
|
||||
case 'sign': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
requireCap(backend, 'signMessage', 'message signing');
|
||||
const { message } = await body<{ message?: string }>(ctx.req);
|
||||
if (!message) return badRequest('message is required');
|
||||
return json(await backend.signMessage(message));
|
||||
}
|
||||
|
||||
case 'verify': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
requireCap(backend, 'signMessage', 'message verification');
|
||||
const { message, signature } = await body<{ message?: string; signature?: string }>(ctx.req);
|
||||
if (!message || !signature) return badRequest('message and signature are required');
|
||||
return json(await backend.verifyMessage(message, signature));
|
||||
}
|
||||
|
||||
case 'labels': {
|
||||
if (ctx.req.method === 'GET') return json({ labels: await getWalletLabels(walletId) });
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { kind, ref, label } = await body<{ kind?: string; ref?: string; label?: string }>(ctx.req);
|
||||
if (kind !== 'address' && kind !== 'tx') return badRequest('kind must be "address" or "tx"');
|
||||
if (!ref) return badRequest('ref is required');
|
||||
await setWalletLabel(walletId, kind, ref, label ?? '');
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── wallet lifecycle ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type CreateBody = {
|
||||
name?: string;
|
||||
kind?: string;
|
||||
network?: string;
|
||||
/** onchain only: omit `mnemonic` to generate a fresh seed. */
|
||||
mnemonic?: string;
|
||||
words?: 12 | 24;
|
||||
passphrase?: string;
|
||||
bip39Passphrase?: string;
|
||||
defaultBip?: number;
|
||||
config?: Record<string, unknown>;
|
||||
makeActive?: boolean;
|
||||
};
|
||||
|
||||
async function createWalletRoute(ctx: OfficerContext): Promise<Response> {
|
||||
if (!hasStoreKey()) {
|
||||
throw new BackendError('VAULT_STORE_KEY is not configured; refusing to store wallet secrets', 503, 'NO_STORE_KEY');
|
||||
}
|
||||
const b = await body<CreateBody>(ctx.req);
|
||||
if (!b.name?.trim()) return badRequest('name is required');
|
||||
if (!b.kind || !(KINDS as readonly string[]).includes(b.kind)) {
|
||||
return badRequest(`kind must be one of ${KINDS.join(', ')}`);
|
||||
}
|
||||
const kind = b.kind as WalletKind;
|
||||
const network = (b.network ?? getConfig().network) as BitcoinNetwork;
|
||||
|
||||
// Remote-node wallets: store the connection config, no seed involved.
|
||||
if (kind !== 'onchain') {
|
||||
if (!b.config) return badRequest(`${kind} wallets require a config object`);
|
||||
const wallet = await createWallet({
|
||||
userId: ctx.userId,
|
||||
name: b.name.trim(),
|
||||
kind,
|
||||
network,
|
||||
config: b.config,
|
||||
makeActive: b.makeActive ?? true,
|
||||
});
|
||||
return json({ wallet }, 201);
|
||||
}
|
||||
|
||||
// Self-custodial on-chain wallet: seal a seed under the owner passphrase.
|
||||
if (!b.passphrase) return badRequest('passphrase is required for a seeded wallet');
|
||||
const mnemonic = b.mnemonic?.trim() || generateSeed(b.words ?? 24);
|
||||
const envelope = await sealSeed(mnemonic, b.passphrase, b.bip39Passphrase);
|
||||
const { fingerprint, xpubs } = await deriveAccountXpubs(envelope, b.passphrase, network);
|
||||
|
||||
const wallet = await createWallet({
|
||||
userId: ctx.userId,
|
||||
name: b.name.trim(),
|
||||
kind,
|
||||
network,
|
||||
sealedSeed: JSON.stringify(envelope),
|
||||
fingerprint,
|
||||
xpubs: Object.fromEntries(Object.entries(xpubs).map(([k, v]) => [k, v])),
|
||||
defaultBip: b.defaultBip ?? 84,
|
||||
makeActive: b.makeActive ?? true,
|
||||
});
|
||||
|
||||
// Return the mnemonic exactly once, and ONLY when we generated it — the owner has to write it down and
|
||||
// has no other chance to see it without re-entering the passphrase. An imported mnemonic is never
|
||||
// echoed back: the caller already has it, and echoing would put it in a response log for no reason.
|
||||
return json({ wallet, mnemonic: b.mnemonic ? undefined : mnemonic }, 201);
|
||||
}
|
||||
|
||||
async function deleteWalletRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
|
||||
const wallet = await getWallet(ctx.userId, walletId);
|
||||
if (!wallet) return json({ error: 'wallet not found' }, 404);
|
||||
|
||||
// Deleting a seeded wallet destroys the only copy of the key material Officer holds. Require the
|
||||
// passphrase, even when the wallet is already unlocked — an open session must not be enough.
|
||||
if (wallet.hasSeed) {
|
||||
const { passphrase } = await body<{ passphrase?: string }>(ctx.req);
|
||||
if (!passphrase) return badRequest('passphrase is required to delete a seeded wallet');
|
||||
const sealed = await getSealedSeed(ctx.userId, walletId);
|
||||
if (!sealed) throw new BackendError('wallet seed is missing', 500, 'NO_SEED');
|
||||
if (!(await verifyPassphrase(JSON.parse(sealed) as SeedEnvelope, passphrase))) {
|
||||
return json({ error: 'incorrect passphrase' }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
sessionFor(walletId).lock();
|
||||
invalidate(walletId);
|
||||
const deleted = await deleteWallet(ctx.userId, walletId);
|
||||
return deleted ? json({ ok: true }) : json({ error: 'wallet not found' }, 404);
|
||||
}
|
||||
|
||||
// ── lock lifecycle ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadEnvelope(userId: number, walletId: number): Promise<SeedEnvelope> {
|
||||
const sealed = await getSealedSeed(userId, walletId);
|
||||
if (!sealed) throw new BackendError('this wallet holds no seed', 400, 'NO_SEED');
|
||||
return JSON.parse(sealed) as SeedEnvelope;
|
||||
}
|
||||
|
||||
async function unlockRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { passphrase, ttlSec } = await body<{ passphrase?: string; ttlSec?: number }>(ctx.req);
|
||||
if (!passphrase) return badRequest('passphrase is required');
|
||||
|
||||
const env = await loadEnvelope(ctx.userId, walletId);
|
||||
const session = sessionFor(walletId);
|
||||
const max = getConfig().unlockTtlSec;
|
||||
// A caller may shorten the window but never extend it past the deployment's configured maximum.
|
||||
await session.unlock(env, passphrase, Math.min(ttlSec ?? max, max));
|
||||
|
||||
return json({ ok: true, unlocked: true, secondsRemaining: session.secondsRemaining() });
|
||||
}
|
||||
|
||||
async function changePassphraseRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { oldPassphrase, newPassphrase } = await body<{ oldPassphrase?: string; newPassphrase?: string }>(ctx.req);
|
||||
if (!oldPassphrase || !newPassphrase) return badRequest('oldPassphrase and newPassphrase are required');
|
||||
|
||||
const env = await loadEnvelope(ctx.userId, walletId);
|
||||
const resealed = await changePassphrase(env, oldPassphrase, newPassphrase);
|
||||
await updateWallet(ctx.userId, walletId, { sealedSeed: JSON.stringify(resealed) });
|
||||
// Force a re-unlock under the new passphrase rather than leaving a session opened by the old one.
|
||||
sessionFor(walletId).lock();
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
async function exportSeedRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { passphrase } = await body<{ passphrase?: string }>(ctx.req);
|
||||
if (!passphrase) return badRequest('passphrase is required');
|
||||
|
||||
const env = await loadEnvelope(ctx.userId, walletId);
|
||||
const mnemonic = await exportMnemonic(env, passphrase);
|
||||
console.warn(`[wallet] seed exported for wallet ${walletId} by user ${ctx.userId}`);
|
||||
return json({ mnemonic, hasBip39Passphrase: env.hasBip39Passphrase });
|
||||
}
|
||||
|
||||
// ── utxos ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function utxosRoute(
|
||||
ctx: OfficerContext,
|
||||
walletId: number,
|
||||
backend: WalletBackend,
|
||||
seg: string[],
|
||||
): Promise<Response> {
|
||||
requireCap(backend, 'coinControl', 'coin control');
|
||||
|
||||
if (seg[0] === 'freeze') {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { outpoint, frozen, reason } = await body<{ outpoint?: string; frozen?: boolean; reason?: string }>(ctx.req);
|
||||
if (!outpoint || !/^[0-9a-f]{64}:\d+$/i.test(outpoint)) return badRequest('outpoint must be "txid:vout"');
|
||||
await setUtxoFrozen(walletId, outpoint, frozen !== false, reason);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
const [utxos, frozenList, labels] = await Promise.all([
|
||||
backend.getUtxos(),
|
||||
getFrozenOutpoints(walletId),
|
||||
getWalletLabels(walletId),
|
||||
]);
|
||||
const frozen = new Set(frozenList);
|
||||
const byAddr = new Map(labels.filter((l) => l.kind === 'address').map((l) => [l.ref, l.label]));
|
||||
|
||||
// The freeze flag is Officer's, not the backend's — overlay it here so coin control is consistent
|
||||
// across every backend, including ones with no freeze concept of their own.
|
||||
const merged: (Utxo & { label: string | null })[] = utxos.map((u) => ({
|
||||
...u,
|
||||
frozen: frozen.has(`${u.txid}:${u.vout}`),
|
||||
label: byAddr.get(u.address) ?? null,
|
||||
}));
|
||||
return json({ utxos: merged });
|
||||
}
|
||||
|
||||
// ── invoices ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function invoicesRoute(ctx: OfficerContext, backend: WalletBackend, seg: string[]): Promise<Response> {
|
||||
// /_officer/wallets/:id/invoices/:paymentHash
|
||||
if (seg.length === 1) {
|
||||
const invoice = await backend.lookupInvoice(seg[0]!);
|
||||
return invoice ? json({ invoice }) : json({ error: 'invoice not found' }, 404);
|
||||
}
|
||||
|
||||
if (ctx.req.method === 'GET') {
|
||||
requireCap(backend, 'lightningReceive', 'lightning');
|
||||
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
|
||||
return json({ invoices: await backend.getInvoices({ limit }) });
|
||||
}
|
||||
|
||||
if (ctx.req.method === 'POST') {
|
||||
requireCap(backend, 'lightningReceive', 'creating invoices');
|
||||
const req = await body<Parameters<WalletBackend['createInvoice']>[0]>(ctx.req);
|
||||
if (req.preimage) requireCap(backend, 'customPreimages', 'custom preimages');
|
||||
return json({ invoice: await backend.createInvoice(req) }, 201);
|
||||
}
|
||||
|
||||
return json({ error: 'method not allowed' }, 405);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// The wallet sidecar's backend contract — a typed re-statement of what Zeus's utils/BackendUtils.ts
|
||||
// dispatches to duck-typed across its seven backends.
|
||||
//
|
||||
// Two things are deliberately NOT copied from Zeus:
|
||||
//
|
||||
// 1. Zeus's `call(funcName, args)` returns `false` when a backend lacks a method, so an unsupported
|
||||
// feature and a typo'd method name are indistinguishable at the call site. Here the surface is a
|
||||
// TypeScript interface and capability negotiation is explicit via `supports()`.
|
||||
// 2. Zeus threads URL path segments through the interface — `decodePaymentRequest(urlParams[0])`,
|
||||
// `closeChannel(urlParams)`. Every operation below takes a named request object.
|
||||
//
|
||||
// UNITS. Amounts are satoshis as `number` (max 2.1e15, safely inside 2^53) and millisatoshis as
|
||||
// decimal `string` (2.1e18 overflows a double). Never widen a msat to a number.
|
||||
|
||||
export type BackendKind = 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc';
|
||||
|
||||
export type BitcoinNetwork = 'bitcoin' | 'testnet' | 'signet' | 'regtest';
|
||||
|
||||
/**
|
||||
* Explicit capability flags, replacing Zeus's ~60 `supportsX()` predicates (BackendUtils.ts:200-270).
|
||||
* A backend declares these once; routes.ts checks before dispatching so an unsupported operation is a
|
||||
* clean 501 rather than a mystery failure deep in an upstream call.
|
||||
*/
|
||||
export type Capability =
|
||||
// on-chain
|
||||
| 'onchainReceive'
|
||||
| 'onchainSend'
|
||||
| 'coinControl'
|
||||
| 'psbt'
|
||||
| 'bumpFee'
|
||||
| 'sweep'
|
||||
| 'accounts'
|
||||
// lightning
|
||||
| 'lightningReceive'
|
||||
| 'lightningSend'
|
||||
| 'keysend'
|
||||
| 'customPreimages'
|
||||
| 'offers'
|
||||
// node operation
|
||||
| 'channels'
|
||||
| 'peers'
|
||||
| 'routing'
|
||||
| 'signMessage';
|
||||
|
||||
// ── node / balances ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type NodeInfo = {
|
||||
kind: BackendKind;
|
||||
/** Node pubkey, or null for backends with no node identity of their own (onchain, lndhub). */
|
||||
pubkey: string | null;
|
||||
alias: string | null;
|
||||
/** Upstream implementation version, verbatim. Null when the backend cannot report one. */
|
||||
version: string | null;
|
||||
network: BitcoinNetwork;
|
||||
blockHeight: number | null;
|
||||
synced: boolean;
|
||||
};
|
||||
|
||||
export type Balances = {
|
||||
/** Spendable on-chain, in sats. */
|
||||
onchainConfirmed: number;
|
||||
onchainUnconfirmed: number;
|
||||
/** Sum of local balance across active channels, in sats. Null when the backend has no channels. */
|
||||
lightningBalance: number | null;
|
||||
/** Sum of remote balance — i.e. inbound liquidity — in sats. */
|
||||
lightningInbound: number | null;
|
||||
};
|
||||
|
||||
// ── on-chain ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AddressType = 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh';
|
||||
|
||||
/**
|
||||
* BIP44 purpose → the script type it derives. Keyed by the string form, because that is how a wallet's
|
||||
* `xpubs` map arrives from the database (JSON object keys are always strings).
|
||||
*/
|
||||
export const BIP_ADDRESS_TYPE: Record<string, AddressType> = {
|
||||
'44': 'p2pkh',
|
||||
'49': 'p2sh-p2wpkh',
|
||||
'84': 'p2wpkh',
|
||||
'86': 'p2tr',
|
||||
};
|
||||
|
||||
/** Narrow caller-supplied text to an AddressType, or undefined if it names none. */
|
||||
export function asAddressType(value: string): AddressType | undefined {
|
||||
return value === 'p2wpkh' || value === 'p2tr' || value === 'p2sh-p2wpkh' || value === 'p2pkh' ? value : undefined;
|
||||
}
|
||||
|
||||
export type OnchainTx = {
|
||||
txid: string;
|
||||
/** Net effect on this wallet in sats — negative for a spend. */
|
||||
amount: number;
|
||||
feeSats: number | null;
|
||||
blockHeight: number | null;
|
||||
/** Unix seconds. Null while unconfirmed on backends that do not timestamp the mempool. */
|
||||
timestamp: number | null;
|
||||
confirmations: number;
|
||||
label: string | null;
|
||||
destAddresses: string[];
|
||||
/** Raw tx hex when the backend supplies it, for RBF/inspection. */
|
||||
rawHex: string | null;
|
||||
};
|
||||
|
||||
export type Utxo = {
|
||||
txid: string;
|
||||
vout: number;
|
||||
amountSats: number;
|
||||
address: string;
|
||||
addressType: AddressType | null;
|
||||
confirmations: number;
|
||||
/** BIP32 path relative to the account xpub, when this UTXO belongs to a derived address. */
|
||||
derivationPath: string | null;
|
||||
/** Frozen UTXOs are excluded from automatic coin selection. */
|
||||
frozen: boolean;
|
||||
};
|
||||
|
||||
export type FeeEstimates = {
|
||||
/** sat/vB, keyed by confirmation target in blocks. */
|
||||
fastestFee: number;
|
||||
halfHourFee: number;
|
||||
hourFee: number;
|
||||
economyFee: number;
|
||||
minimumFee: number;
|
||||
};
|
||||
|
||||
export type NewAddressRequest = {
|
||||
type?: AddressType;
|
||||
/** Return the current unused address instead of advancing the derivation index. */
|
||||
peek?: boolean;
|
||||
};
|
||||
|
||||
export type SendCoinsRequest = {
|
||||
address: string;
|
||||
/** Ignored when `sendAll` is set. */
|
||||
amountSats?: number;
|
||||
sendAll?: boolean;
|
||||
satPerVbyte: number;
|
||||
/** Coin control: restrict inputs to these outpoints (`txid:vout`). Requires the `coinControl` capability. */
|
||||
outpoints?: string[];
|
||||
spendUnconfirmed?: boolean;
|
||||
/** Opt out of RBF signalling. Default is replaceable. */
|
||||
rbf?: boolean;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type SendCoinsResult = {
|
||||
txid: string;
|
||||
feeSats: number;
|
||||
rawHex: string | null;
|
||||
};
|
||||
|
||||
// ── lightning ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type InvoiceState = 'open' | 'settled' | 'canceled' | 'accepted' | 'expired';
|
||||
|
||||
export type Invoice = {
|
||||
/** Payment hash, hex. */
|
||||
paymentHash: string;
|
||||
/** BOLT11 payment request. */
|
||||
bolt11: string;
|
||||
amountMsat: string | null;
|
||||
amountPaidMsat: string | null;
|
||||
memo: string | null;
|
||||
state: InvoiceState;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
settledAt: number | null;
|
||||
/** Only ever populated for invoices this wallet created and only when explicitly requested. */
|
||||
preimage: string | null;
|
||||
isKeysend: boolean;
|
||||
isAmp: boolean;
|
||||
};
|
||||
|
||||
export type CreateInvoiceRequest = {
|
||||
amountMsat?: string;
|
||||
memo?: string;
|
||||
expirySeconds?: number;
|
||||
/** Requires the `customPreimages` capability. */
|
||||
preimage?: string;
|
||||
isAmp?: boolean;
|
||||
private?: boolean;
|
||||
};
|
||||
|
||||
export type DecodedInvoice = {
|
||||
bolt11: string;
|
||||
paymentHash: string;
|
||||
amountMsat: string | null;
|
||||
description: string | null;
|
||||
destination: string;
|
||||
timestamp: number;
|
||||
expiry: number;
|
||||
cltvExpiry: number | null;
|
||||
routeHints: boolean;
|
||||
features: string[];
|
||||
};
|
||||
|
||||
export type PaymentStatus = 'pending' | 'succeeded' | 'failed';
|
||||
|
||||
export type Payment = {
|
||||
paymentHash: string;
|
||||
preimage: string | null;
|
||||
amountMsat: string;
|
||||
feeMsat: string;
|
||||
status: PaymentStatus;
|
||||
createdAt: number;
|
||||
destination: string | null;
|
||||
memo: string | null;
|
||||
/** Upstream failure reason, verbatim, when status is 'failed'. */
|
||||
failureReason: string | null;
|
||||
};
|
||||
|
||||
export type PayInvoiceRequest = {
|
||||
bolt11: string;
|
||||
/** Required when the invoice is zero-amount; rejected otherwise. */
|
||||
amountMsat?: string;
|
||||
/** Absolute cap in msat. Mutually exclusive with feeLimitPercent. */
|
||||
feeLimitMsat?: string;
|
||||
feeLimitPercent?: number;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
|
||||
export type KeysendRequest = {
|
||||
destination: string;
|
||||
amountMsat: string;
|
||||
feeLimitMsat?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
// ── channels / peers ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Channel = {
|
||||
channelId: string;
|
||||
channelPoint: string | null;
|
||||
remotePubkey: string;
|
||||
remoteAlias: string | null;
|
||||
capacitySats: number;
|
||||
localBalanceSats: number;
|
||||
remoteBalanceSats: number;
|
||||
active: boolean;
|
||||
private: boolean;
|
||||
/** 'open' | 'pending-open' | 'pending-close' | 'force-closing' | 'closed' */
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type Peer = {
|
||||
pubkey: string;
|
||||
address: string;
|
||||
alias: string | null;
|
||||
inbound: boolean;
|
||||
};
|
||||
|
||||
// ── signing ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SignMessageResult = { signature: string };
|
||||
export type VerifyMessageResult = { valid: boolean; pubkey: string | null };
|
||||
|
||||
// ── the interface ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every method may throw `BackendError`. Methods guarded by a capability must only be called after
|
||||
* `supports()` returns true — the base class throws `notSupported()` otherwise, so a missed guard is a
|
||||
* loud 501 rather than Zeus's silent `false`.
|
||||
*/
|
||||
export interface WalletBackend {
|
||||
readonly kind: BackendKind;
|
||||
|
||||
supports(cap: Capability): boolean;
|
||||
|
||||
getInfo(): Promise<NodeInfo>;
|
||||
getBalances(): Promise<Balances>;
|
||||
|
||||
// on-chain
|
||||
getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]>;
|
||||
getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }>;
|
||||
getUtxos(): Promise<Utxo[]>;
|
||||
estimateFees(): Promise<FeeEstimates>;
|
||||
sendCoins(req: SendCoinsRequest): Promise<SendCoinsResult>;
|
||||
|
||||
// lightning
|
||||
getInvoices(opts?: { limit?: number }): Promise<Invoice[]>;
|
||||
createInvoice(req: CreateInvoiceRequest): Promise<Invoice>;
|
||||
lookupInvoice(paymentHash: string): Promise<Invoice | null>;
|
||||
decodeInvoice(bolt11: string): Promise<DecodedInvoice>;
|
||||
getPayments(opts?: { limit?: number }): Promise<Payment[]>;
|
||||
payInvoice(req: PayInvoiceRequest): Promise<Payment>;
|
||||
sendKeysend(req: KeysendRequest): Promise<Payment>;
|
||||
|
||||
// node operation
|
||||
getChannels(): Promise<Channel[]>;
|
||||
getPeers(): Promise<Peer[]>;
|
||||
|
||||
signMessage(message: string): Promise<SignMessageResult>;
|
||||
verifyMessage(message: string, signature: string): Promise<VerifyMessageResult>;
|
||||
}
|
||||
|
||||
// ── errors ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class BackendError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number = 502,
|
||||
readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BackendError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The wallet holds a seed but is currently locked — signing is impossible until /unlock. */
|
||||
export class WalletLockedError extends BackendError {
|
||||
constructor() {
|
||||
super('wallet is locked', 423, 'WALLET_LOCKED');
|
||||
this.name = 'WalletLockedError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { BitcoinNetwork } from './types';
|
||||
|
||||
// The ONLY reader of WALLET_* env in the tree. Everything else — node URLs, macaroons, runes, LNDHub
|
||||
// credentials, NWC URIs — is per-wallet configuration the owner enters at runtime and lives encrypted in
|
||||
// Postgres (databases/officer_db/src/schema/wallet.ts), not here. Env holds only what is genuinely
|
||||
// deployment-wide: which chain we're on and where to get chain data.
|
||||
|
||||
const NETWORKS: readonly BitcoinNetwork[] = ['bitcoin', 'testnet', 'signet', 'regtest'];
|
||||
|
||||
// mempool.space's public API. Fine to start on; swap it for your own electrs/esplora when the node is up
|
||||
// — an Esplora endpoint sees every address in the wallet, so the public one is a privacy leak, not a
|
||||
// custody one. It never sees a private key and cannot authorize anything.
|
||||
const DEFAULT_ESPLORA: Record<BitcoinNetwork, string> = {
|
||||
bitcoin: 'https://mempool.space/api',
|
||||
testnet: 'https://mempool.space/testnet/api',
|
||||
signet: 'https://mempool.space/signet/api',
|
||||
regtest: 'http://127.0.0.1:3002',
|
||||
};
|
||||
|
||||
export type WalletConfig = {
|
||||
network: BitcoinNetwork;
|
||||
esploraUrl: string;
|
||||
unlockTtlSec: number;
|
||||
};
|
||||
|
||||
let warned = false;
|
||||
|
||||
export function getConfig(): WalletConfig {
|
||||
const raw = process.env.WALLET_NETWORK?.trim() ?? 'bitcoin';
|
||||
const network = (NETWORKS as readonly string[]).includes(raw) ? (raw as BitcoinNetwork) : 'bitcoin';
|
||||
if (raw && network !== raw && !warned) {
|
||||
console.warn(`[wallet] WALLET_NETWORK="${raw}" is not a known network, falling back to bitcoin`);
|
||||
warned = true;
|
||||
}
|
||||
|
||||
const esploraUrl = process.env.WALLET_ESPLORA_URL?.trim().replace(/\/+$/, '') || DEFAULT_ESPLORA[network];
|
||||
const ttl = Number(process.env.WALLET_UNLOCK_TTL_SEC ?? 900);
|
||||
|
||||
return {
|
||||
network,
|
||||
esploraUrl,
|
||||
// Clamp: a zero TTL makes the wallet unusable, and an unbounded one defeats auto-lock entirely.
|
||||
unlockTtlSec: Number.isFinite(ttl) ? Math.min(Math.max(ttl, 30), 86_400) : 900,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether VAULT_STORE_KEY is present. The sidecar can serve a locked, watch-only view without it, but
|
||||
* every write path that touches an encrypted column will throw, so /_health reports it explicitly rather
|
||||
* than letting the first wallet creation fail with a confusing crypto error.
|
||||
*/
|
||||
export function hasStoreKey(): boolean {
|
||||
const k = process.env.VAULT_STORE_KEY;
|
||||
return Boolean(k && k.length >= 16);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
|
||||
import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
|
||||
import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
|
||||
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
|
||||
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
|
||||
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||
import { useAppRegistry } from './useAppRegistry';
|
||||
import { useUserApps } from 'state/useUserApps';
|
||||
@@ -37,6 +38,7 @@ const apps = [
|
||||
...headscaleMetas,
|
||||
...transmissionMetas,
|
||||
...invoicesMetas,
|
||||
...walletMetas,
|
||||
...monitorMetas,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { formatAmount, formatMsat } from './format';
|
||||
import { useAmountUnit } from './useAmountUnit';
|
||||
|
||||
// One place amounts are rendered, so the sats/BTC toggle flips every number on screen at once.
|
||||
//
|
||||
// `sats` is a number; `msat` is a decimal STRING and stays one all the way through formatMsat. Passing a
|
||||
// msat through the `sats` prop would parse it into a double — which is exactly the bug the string type
|
||||
// exists to prevent, so the two props are deliberately not interchangeable.
|
||||
|
||||
type AmountProps = {
|
||||
sats?: number | null;
|
||||
msat?: string | null;
|
||||
className?: string;
|
||||
/** Colour a negative amount as a spend and a positive one as a receipt. */
|
||||
signed?: boolean;
|
||||
};
|
||||
|
||||
export const Amount = ({ sats, msat, className = '', signed = false }: AmountProps) => {
|
||||
const { unit } = useAmountUnit();
|
||||
const text = msat !== undefined ? formatMsat(msat, unit) : formatAmount(sats, unit);
|
||||
|
||||
const tone = !signed || sats == null ? '' : sats < 0 ? 'text-destructive' : sats > 0 ? 'text-emerald-500' : '';
|
||||
|
||||
return (
|
||||
<span className={`tabular-nums ${tone} ${className}`}>
|
||||
{signed && sats != null && sats > 0 ? '+' : ''}
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/** The toggle itself — a button, because it mutates a preference rather than navigating. */
|
||||
export const UnitToggle = ({ className = '' }: { className?: string }) => {
|
||||
const { unit, toggle } = useAmountUnit();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
title="Switch between satoshis and BTC"
|
||||
className={`rounded-md border border-border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted hover:text-foreground ${className}`}
|
||||
>
|
||||
{unit === 'sats' ? 'sats' : 'BTC'}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Utxo } from './shared';
|
||||
import { Link } from 'react-router';
|
||||
import { Loader2, Snowflake, Sun } from 'lucide-react';
|
||||
import { formatConfirmations, truncateMiddle } from './format';
|
||||
import { walletSectionPath } from './shared';
|
||||
import { Amount } from './Amount';
|
||||
import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useCoinSelection } from './useCoinSelection';
|
||||
import { useCapabilities, useUtxos, useWalletOperations } from './useWalletData';
|
||||
|
||||
// Coin control. Works while locked: which coins exist and which are frozen is watch-only information, and
|
||||
// freezing is Officer's own flag rather than anything signed.
|
||||
//
|
||||
// The selection lives in `?coins=` so the Send section reads the same set straight from the URL.
|
||||
|
||||
export const CoinsView = () => {
|
||||
const { walletId, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = useCapabilities(walletId);
|
||||
const supported = capabilities.includes('coinControl');
|
||||
|
||||
const { utxos, isLoading: utxosLoading } = useUtxos(walletId, supported);
|
||||
const { selected, toggle, clear } = useCoinSelection();
|
||||
|
||||
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
|
||||
if (!supported) return <UnsupportedSection what="coin control" />;
|
||||
|
||||
const selectedTotal = utxos
|
||||
.filter((u) => selected.includes(`${u.txid}:${u.vout}`))
|
||||
.reduce((sum, u) => sum + u.amountSats, 0);
|
||||
const spendableTotal = utxos.filter((u) => !u.frozen).reduce((sum, u) => sum + u.amountSats, 0);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{utxos.length} coin{utxos.length === 1 ? '' : 's'} · <Amount sats={spendableTotal} /> spendable
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{selected.length > 0 && (
|
||||
<>
|
||||
<span>
|
||||
{selected.length} selected · <Amount sats={selectedTotal} className="font-semibold" />
|
||||
</span>
|
||||
<button type="button" onClick={clear} className="text-primary hover:underline">
|
||||
clear
|
||||
</button>
|
||||
<Link
|
||||
to={walletSectionPath('send', walletId)}
|
||||
className="rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Spend these
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{utxosLoading && utxos.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Listing coins…
|
||||
</div>
|
||||
) : utxos.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-8 text-center">
|
||||
<p className="text-sm font-medium">No coins</p>
|
||||
<p className="text-xs text-muted-foreground">Receive something and it will show up here as a UTXO.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{utxos.map((utxo) => (
|
||||
<CoinRow
|
||||
key={`${utxo.txid}:${utxo.vout}`}
|
||||
utxo={utxo}
|
||||
walletId={walletId}
|
||||
selected={selected.includes(`${utxo.txid}:${utxo.vout}`)}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type CoinRowProps = { utxo: Utxo; walletId: number; selected: boolean; onToggle: (outpoint: string) => void };
|
||||
|
||||
const CoinRow = ({ utxo, walletId, selected, onToggle }: CoinRowProps) => {
|
||||
const { freeze } = useWalletOperations(walletId);
|
||||
const outpoint = `${utxo.txid}:${utxo.vout}`;
|
||||
|
||||
return (
|
||||
<li className={`flex items-center gap-3 px-4 py-2.5 ${utxo.frozen ? 'opacity-60' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
disabled={utxo.frozen}
|
||||
onChange={() => onToggle(outpoint)}
|
||||
aria-label={`Select ${outpoint}`}
|
||||
className="h-3.5 w-3.5 shrink-0 accent-primary"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium">{utxo.label || truncateMiddle(utxo.address, 16, 10)}</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 font-mono text-[10px] text-muted-foreground">
|
||||
<span>
|
||||
{truncateMiddle(utxo.txid, 10, 6)}:{utxo.vout}
|
||||
</span>
|
||||
<span className="font-sans">{formatConfirmations(utxo.confirmations)}</span>
|
||||
{utxo.addressType && <span className="font-sans">{utxo.addressType}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Amount sats={utxo.amountSats} className="shrink-0 text-xs font-semibold" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => freeze.mutate({ outpoint, frozen: !utxo.frozen })}
|
||||
disabled={freeze.isPending}
|
||||
title={utxo.frozen ? 'Make this coin spendable again' : 'Keep this coin out of automatic selection'}
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{utxo.frozen ? <Sun className="h-3.5 w-3.5" /> : <Snowflake className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { copyToClipboard } from './format';
|
||||
|
||||
// A read-only value with a copy button — addresses, invoices, txids, xpubs.
|
||||
//
|
||||
// Never used for anything secret. A mnemonic gets its own deliberately awkward treatment in
|
||||
// SeedBackupDialog rather than a one-tap copy, because "copied to clipboard" is where seeds go to die.
|
||||
|
||||
type CopyFieldProps = {
|
||||
value: string;
|
||||
label?: string;
|
||||
/** Wrap rather than truncate — right for a bech32 address, wrong for a table cell. */
|
||||
wrap?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const CopyField = ({ value, label, wrap = false, className = '' }: CopyFieldProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = async () => {
|
||||
if (!(await copyToClipboard(value))) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1_500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||
)}
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2">
|
||||
<code className={`min-w-0 flex-1 font-mono text-xs ${wrap ? 'break-all' : 'truncate'}`}>{value}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
aria-label={`Copy ${label ?? 'value'}`}
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Bitcoin, Loader2 } from 'lucide-react';
|
||||
|
||||
// The "no wallet selected" and "this backend cannot do that" placeholders.
|
||||
//
|
||||
// Deliberately not an error state: with no wallets registered there is nothing wrong, and a section a
|
||||
// backend does not implement is a fact about the backend, not a failure. The nav hides those sections, so
|
||||
// UnsupportedSection is only reached by a deep link — which should explain itself rather than 404.
|
||||
|
||||
export const EmptyWallet = ({ isLoading = false }: { isLoading?: boolean }) => (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Loading wallets…</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bitcoin className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm font-medium">No wallet yet</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
Add one from the panel on the left — a self-custodial seed sealed under a passphrase, or a connection to a
|
||||
node you already run.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const UnsupportedSection = ({ what }: { what: string }) => (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
|
||||
<p className="text-sm font-medium">Not available for this wallet</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
This backend does not support {what}. Pick a different wallet from the panel on the left.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { Channel } from './shared';
|
||||
import { Users, Zap } from 'lucide-react';
|
||||
import { formatSats, formatTimestamp, truncateMiddle, PAYMENT_TONES } from './format';
|
||||
import { Amount } from './Amount';
|
||||
import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useCapabilities, useChannels, usePayments, usePeers } from './useWalletData';
|
||||
|
||||
// The node's own view of itself: payments it has made, channels it holds, peers it is connected to.
|
||||
//
|
||||
// lndhub and nwc have no channels or peers of their own — they are accounts on someone else's node — so
|
||||
// those blocks are absent from their capability set and never rendered. Nothing here shows a control that
|
||||
// would come back 501.
|
||||
|
||||
export const LightningView = () => {
|
||||
const { walletId, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = useCapabilities(walletId);
|
||||
const hasChannels = capabilities.includes('channels');
|
||||
const hasPeers = capabilities.includes('peers');
|
||||
const hasPayments = capabilities.includes('lightningSend');
|
||||
const anyLightning = hasChannels || hasPeers || hasPayments || capabilities.includes('lightningReceive');
|
||||
|
||||
const { channels } = useChannels(walletId, hasChannels);
|
||||
const { peers } = usePeers(walletId, hasPeers);
|
||||
const { payments } = usePayments(walletId, hasPayments, 25);
|
||||
|
||||
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
|
||||
if (!anyLightning) return <UnsupportedSection what="lightning" />;
|
||||
|
||||
return (
|
||||
<div className="h-full space-y-4 overflow-y-auto p-4">
|
||||
{hasChannels && (
|
||||
<Section title="Channels" count={channels.length}>
|
||||
{channels.length === 0 ? (
|
||||
<Blank>No channels open.</Blank>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{channels.map((channel) => (
|
||||
<ChannelRow key={channel.channelId} channel={channel} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasPeers && (
|
||||
<Section title="Peers" count={peers.length}>
|
||||
{peers.length === 0 ? (
|
||||
<Blank>Not connected to anyone.</Blank>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{peers.map((peer) => (
|
||||
<li key={peer.pubkey} className="flex items-center gap-2 py-2 text-xs">
|
||||
<Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{peer.alias || truncateMiddle(peer.pubkey, 14, 8)}</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{peer.address}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{peer.inbound ? 'inbound' : 'outbound'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasPayments && (
|
||||
<Section title="Payments" count={payments.length}>
|
||||
{payments.length === 0 ? (
|
||||
<Blank>Nothing sent yet.</Blank>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{payments.map((payment) => (
|
||||
<li key={payment.paymentHash} className="py-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Zap className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{payment.memo || truncateMiddle(payment.destination ?? payment.paymentHash, 14, 8)}
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 rounded px-1.5 py-px text-[10px] font-medium ${PAYMENT_TONES[payment.status]}`}
|
||||
>
|
||||
{payment.status}
|
||||
</span>
|
||||
{/* msat stays a string all the way to the DOM — see format.ts. */}
|
||||
<Amount msat={payment.amountMsat} className="shrink-0 font-semibold" />
|
||||
</div>
|
||||
<div className="mt-0.5 pl-5 text-[11px] text-muted-foreground">
|
||||
{formatTimestamp(payment.createdAt)} · fee <Amount msat={payment.feeMsat} />
|
||||
{payment.failureReason && <span className="text-destructive"> · {payment.failureReason}</span>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChannelRow = ({ channel }: { channel: Channel }) => {
|
||||
const capacity = channel.capacitySats || 1;
|
||||
const localPct = Math.max(0, Math.min(100, (channel.localBalanceSats / capacity) * 100));
|
||||
|
||||
return (
|
||||
<li className="py-2.5">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${channel.active ? 'bg-emerald-500' : 'bg-muted-foreground'}`}
|
||||
title={channel.active ? 'active' : channel.status}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{channel.remoteAlias || truncateMiddle(channel.remotePubkey, 14, 8)}
|
||||
</span>
|
||||
{channel.private && <span className="shrink-0 text-[10px] text-muted-foreground">private</span>}
|
||||
<Amount sats={channel.capacitySats} className="shrink-0 text-[11px] text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Local/remote split — the number that decides whether you can send or only receive. */}
|
||||
<div className="mt-1.5 flex h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className="bg-primary" style={{ width: `${localPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-[10px] tabular-nums text-muted-foreground">
|
||||
<span>out {formatSats(channel.localBalanceSats)}</span>
|
||||
<span>in {formatSats(channel.remoteBalanceSats)}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps = { title: string; count: number; children: React.ReactNode };
|
||||
|
||||
const Section = ({ title, count, children }: SectionProps) => (
|
||||
<section className="rounded-xl border border-border p-4">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{title}
|
||||
{count > 0 && <span className="ml-1.5 font-normal">({count})</span>}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
|
||||
const Blank = ({ children }: { children: React.ReactNode }) => (
|
||||
<p className="text-xs text-muted-foreground">{children}</p>
|
||||
);
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import { Lock, LockOpen, ShieldCheck } from 'lucide-react';
|
||||
import { formatCountdown } from './format';
|
||||
import { useLockCountdown } from './useLockCountdown';
|
||||
import { useLockActions, useWalletConfig } from './useWalletData';
|
||||
import { UnlockDialog } from './dialogs/UnlockDialog';
|
||||
|
||||
// The lock indicator. Locked is the resting state and reads as reassurance, not as an error — a watch-only
|
||||
// wallet with no key material in memory is the whole point of the design, so it is never styled as a
|
||||
// warning and never blocks the screen behind it.
|
||||
//
|
||||
// The countdown ticks locally (useLockCountdown) and flips to locked at zero without waiting for the poll.
|
||||
|
||||
type LockBadgeProps = {
|
||||
walletId: number | null;
|
||||
walletName: string;
|
||||
/** Compact drops the action button — for the panel header, where there is no room. */
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export const LockBadge = ({ walletId, walletName, compact = false }: LockBadgeProps) => {
|
||||
const { hasSeed, unlocked, secondsRemaining, isLoading } = useLockCountdown(walletId);
|
||||
const { config } = useWalletConfig();
|
||||
const { lock } = useLockActions(walletId);
|
||||
const [unlockOpen, setUnlockOpen] = useState(false);
|
||||
|
||||
// A wallet with no seed — a remote node, a custodial account — has nothing to lock. Showing it a
|
||||
// padlock would imply a protection it does not have.
|
||||
if (walletId == null || hasSeed === false) {
|
||||
if (isLoading || hasSeed == null) return null;
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
no seed held
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasSeed == null) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium tabular-nums ${
|
||||
unlocked ? 'bg-emerald-500/10 text-emerald-600' : 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
title={
|
||||
unlocked
|
||||
? 'The seed is decrypted in the sidecar until this countdown ends'
|
||||
: 'Locked — balances and history still work; only signing needs the passphrase'
|
||||
}
|
||||
>
|
||||
{unlocked ? <LockOpen className="h-3 w-3" /> : <Lock className="h-3 w-3" />}
|
||||
{unlocked ? formatCountdown(secondsRemaining) : 'locked'}
|
||||
</span>
|
||||
|
||||
{!compact &&
|
||||
(unlocked ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => lock.mutate()}
|
||||
disabled={lock.isPending}
|
||||
className="text-[10px] font-medium text-primary hover:underline"
|
||||
>
|
||||
lock now
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUnlockOpen(true)}
|
||||
className="text-[10px] font-medium text-primary hover:underline"
|
||||
>
|
||||
unlock
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{unlockOpen && (
|
||||
<UnlockDialog
|
||||
open={unlockOpen}
|
||||
onOpenChange={setUnlockOpen}
|
||||
walletId={walletId}
|
||||
walletName={walletName}
|
||||
maxTtlSec={config?.unlockTtlSec ?? 300}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The inline "you need to unlock for this" prompt, used by the signing surfaces only. Renders its children
|
||||
* disabled-in-place rather than replacing the screen, so you can still read the form you are about to
|
||||
* submit while the wallet is locked.
|
||||
*/
|
||||
type UnlockPromptProps = { walletId: number; walletName: string; className?: string };
|
||||
|
||||
export const UnlockPrompt = ({ walletId, walletName, className = '' }: UnlockPromptProps) => {
|
||||
const { config } = useWalletConfig();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs ${className}`}
|
||||
>
|
||||
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-muted-foreground">
|
||||
This wallet is locked. Signing needs the passphrase — everything else on this screen already works.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Unlock
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<UnlockDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
walletId={walletId}
|
||||
walletName={walletName}
|
||||
maxTtlSec={config?.unlockTtlSec ?? 300}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ArrowDownLeft, ArrowUpRight, Bitcoin, Clock, Loader2, Zap } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { KIND_LABELS, walletSectionPath } from './shared';
|
||||
import { formatSats, formatTimestamp, truncateMiddle } from './format';
|
||||
import { Amount, UnitToggle } from './Amount';
|
||||
import { EmptyWallet } from './EmptyWallet';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useBalances, useCapabilities, useTransactions, useWalletInfo } from './useWalletData';
|
||||
|
||||
// The at-a-glance section: what you hold, whether the node agrees with the chain, and the last few moves.
|
||||
//
|
||||
// Every number here is a read, so the whole screen works against a locked wallet — which is its resting
|
||||
// state. Nothing on this page prompts for a passphrase.
|
||||
|
||||
export const OverviewView = () => {
|
||||
const { wallet, walletId, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = useCapabilities(walletId);
|
||||
const { balances, isLoading: balancesLoading } = useBalances(walletId);
|
||||
const { info } = useWalletInfo(walletId);
|
||||
const { transactions } = useTransactions(walletId, 5);
|
||||
|
||||
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
|
||||
|
||||
const hasLightning = balances?.lightningBalance != null;
|
||||
const canSend = capabilities.includes('onchainSend') || capabilities.includes('lightningSend');
|
||||
const canReceive = capabilities.includes('onchainReceive') || capabilities.includes('lightningReceive');
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold">{wallet?.name}</h2>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{wallet ? KIND_LABELS[wallet.kind] : '—'}
|
||||
{info?.alias && ` · ${info.alias}`}
|
||||
{info?.version && ` · ${info.version}`}
|
||||
</p>
|
||||
</div>
|
||||
<UnitToggle />
|
||||
</div>
|
||||
|
||||
{balancesLoading && !balances ? (
|
||||
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Reading balances…
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Tile
|
||||
icon={<Bitcoin className="h-4 w-4 text-orange-500" />}
|
||||
label="On-chain"
|
||||
value={<Amount sats={balances?.onchainConfirmed ?? null} className="text-xl font-semibold" />}
|
||||
hint={
|
||||
balances && balances.onchainUnconfirmed !== 0
|
||||
? `${balances.onchainUnconfirmed > 0 ? '+' : ''}${formatSats(balances.onchainUnconfirmed)} sats unconfirmed`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{hasLightning && (
|
||||
<Tile
|
||||
icon={<Zap className="h-4 w-4 text-amber-500" />}
|
||||
label="Lightning"
|
||||
value={<Amount sats={balances?.lightningBalance ?? null} className="text-xl font-semibold" />}
|
||||
hint={
|
||||
balances?.lightningInbound != null ? `${formatSats(balances.lightningInbound)} sats inbound` : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Tile
|
||||
label="Block height"
|
||||
value={
|
||||
<span className="text-xl font-semibold tabular-nums">{info?.blockHeight?.toLocaleString() ?? '—'}</span>
|
||||
}
|
||||
hint={info ? (info.synced ? 'synced' : 'syncing…') : undefined}
|
||||
/>
|
||||
<Tile
|
||||
label="Network"
|
||||
value={<span className="text-xl font-semibold">{info?.network ?? wallet?.network ?? '—'}</span>}
|
||||
hint={info?.pubkey ? truncateMiddle(info.pubkey, 8, 6) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(canReceive || canSend) && (
|
||||
<div className="mt-4 flex gap-2">
|
||||
{canReceive && (
|
||||
<Link
|
||||
to={walletSectionPath('receive', walletId)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
|
||||
>
|
||||
<ArrowDownLeft className="h-3.5 w-3.5 text-emerald-500" />
|
||||
Receive
|
||||
</Link>
|
||||
)}
|
||||
{canSend && (
|
||||
<Link
|
||||
to={walletSectionPath('send', walletId)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
|
||||
>
|
||||
<ArrowUpRight className="h-3.5 w-3.5 text-destructive" />
|
||||
Send
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="mt-4 rounded-xl border border-border p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Recent activity</h3>
|
||||
<Link to={walletSectionPath('transactions', walletId)} className="text-[11px] text-primary hover:underline">
|
||||
all transactions
|
||||
</Link>
|
||||
</div>
|
||||
{transactions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Nothing yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{transactions.map((tx) => (
|
||||
<li key={tx.txid} className="flex items-center gap-3 py-2">
|
||||
<span
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full ${
|
||||
tx.amount < 0 ? 'bg-destructive/10' : 'bg-emerald-500/10'
|
||||
}`}
|
||||
>
|
||||
{tx.amount < 0 ? (
|
||||
<ArrowUpRight className="h-3 w-3 text-destructive" />
|
||||
) : (
|
||||
<ArrowDownLeft className="h-3 w-3 text-emerald-500" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium">{tx.label ?? truncateMiddle(tx.txid)}</div>
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
{tx.confirmations <= 0 && <Clock className="h-3 w-3" />}
|
||||
{tx.confirmations <= 0 ? 'pending' : formatTimestamp(tx.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
<Amount sats={tx.amount} signed className="shrink-0 text-xs" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type TileProps = { label: string; value: ReactNode; hint?: string; icon?: ReactNode };
|
||||
|
||||
const Tile = ({ label, value, hint, icon }: TileProps) => (
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1">{value}</div>
|
||||
{hint && (
|
||||
<div className="mt-0.5 truncate text-xs text-muted-foreground" title={hint}>
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Plus, RefreshCw, Zap } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatTimestamp, truncateMiddle } from './format';
|
||||
import { INVOICE_TONES } from './format';
|
||||
import { Amount } from './Amount';
|
||||
import { CopyField } from './CopyField';
|
||||
import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useCapabilities, useInvoices, useReceiveAddress } from './useWalletData';
|
||||
import { CreateInvoiceDialog } from './dialogs/CreateInvoiceDialog';
|
||||
|
||||
// Receiving. Works while locked — deriving an address needs the account xpub, not the seed, which is why
|
||||
// this whole screen is usable in the wallet's resting state.
|
||||
//
|
||||
// The address is fetched with `?peek=true`: it returns the current unused address without advancing the
|
||||
// derivation index. Rendering must never burn an address, so advancing is an explicit button.
|
||||
|
||||
export const ReceiveView = () => {
|
||||
const { walletId, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = useCapabilities(walletId);
|
||||
const canOnchain = capabilities.includes('onchainReceive');
|
||||
const canLightning = capabilities.includes('lightningReceive');
|
||||
|
||||
const { address, addressType, isLoading: addressLoading, refetch } = useReceiveAddress(walletId, canOnchain);
|
||||
const { invoices } = useInvoices(walletId, canLightning, 10);
|
||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||
|
||||
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
|
||||
if (!canOnchain && !canLightning) return <UnsupportedSection what="receiving" />;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
{canOnchain && (
|
||||
<section className="max-w-2xl rounded-xl border border-border p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
On-chain address
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="flex items-center gap-1 text-[11px] text-primary hover:underline"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
new address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{addressLoading && !address ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Deriving an address…
|
||||
</div>
|
||||
) : address ? (
|
||||
<>
|
||||
<CopyField value={address} wrap />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{addressType ? `${addressType} · ` : ''}Reuse costs you privacy, not money — take a fresh one for each
|
||||
payer.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No address available.</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{canLightning && (
|
||||
<section className="mt-4 max-w-2xl rounded-xl border border-border p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Lightning invoices
|
||||
</h3>
|
||||
<Button size="sm" variant="outline" onClick={() => setInvoiceOpen(true)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New invoice
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{invoices.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No invoices yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{invoices.map((inv) => (
|
||||
<li key={inv.paymentHash} className="py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs">
|
||||
{inv.memo || truncateMiddle(inv.paymentHash, 12, 8)}
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 rounded px-1.5 py-px text-[10px] font-medium ${INVOICE_TONES[inv.state]}`}
|
||||
>
|
||||
{inv.state}
|
||||
</span>
|
||||
{/* msat stays a string all the way to the DOM — see format.ts. */}
|
||||
<Amount msat={inv.amountMsat} className="shrink-0 text-xs" />
|
||||
</div>
|
||||
<div className="mt-0.5 pl-5 text-[11px] text-muted-foreground">
|
||||
created {formatTimestamp(inv.createdAt)}
|
||||
{inv.settledAt ? ` · settled ${formatTimestamp(inv.settledAt)}` : ''}
|
||||
</div>
|
||||
{inv.state === 'open' && <CopyField className="mt-1.5 pl-5" value={inv.bolt11} wrap />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{invoiceOpen && <CreateInvoiceDialog open={invoiceOpen} onOpenChange={setInvoiceOpen} walletId={walletId} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,294 @@
|
||||
import type { FeeEstimates } from './shared';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Bitcoin, Coins, Loader2, Zap } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { walletSectionPath } from './shared';
|
||||
import { formatSats } from './format';
|
||||
import { Amount } from './Amount';
|
||||
import { CopyField } from './CopyField';
|
||||
import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
|
||||
import { UnlockPrompt } from './LockBadge';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useCoinSelection } from './useCoinSelection';
|
||||
import { useLockCountdown } from './useLockCountdown';
|
||||
import { useBalances, useCapabilities, useFees, useUtxos, useWalletOperations } from './useWalletData';
|
||||
import { PayInvoiceDialog } from './dialogs/PayInvoiceDialog';
|
||||
|
||||
// Sending — the only part of the app that genuinely needs an unlocked wallet.
|
||||
//
|
||||
// The form is fully usable while locked: you can compose the whole spend, see the fee, review the coins.
|
||||
// Only the submit is blocked, with an inline unlock prompt above it rather than a modal wall across the
|
||||
// screen. That ordering is deliberate — you should be able to read what you are about to sign before you
|
||||
// are asked for the passphrase.
|
||||
//
|
||||
// The selected coins come from `?coins=` (useCoinSelection), so the Coins section and this one agree
|
||||
// through the URL rather than a shared store.
|
||||
|
||||
const FEE_PRESETS: { key: keyof FeeEstimates; label: string; hint: string }[] = [
|
||||
{ key: 'fastestFee', label: 'Fastest', hint: 'next block' },
|
||||
{ key: 'halfHourFee', label: 'Fast', hint: '~30 min' },
|
||||
{ key: 'hourFee', label: 'Normal', hint: '~1 hour' },
|
||||
{ key: 'economyFee', label: 'Economy', hint: 'hours' },
|
||||
{ key: 'minimumFee', label: 'Minimum', hint: 'whenever' },
|
||||
];
|
||||
|
||||
export const SendView = () => {
|
||||
const { wallet, walletId, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = useCapabilities(walletId);
|
||||
const canOnchain = capabilities.includes('onchainSend');
|
||||
const canLightning = capabilities.includes('lightningSend');
|
||||
|
||||
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
|
||||
if (!canOnchain && !canLightning) return <UnsupportedSection what="sending" />;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
<Tabs defaultValue={canOnchain ? 'onchain' : 'lightning'} className="max-w-2xl">
|
||||
{canOnchain && canLightning && (
|
||||
<TabsList className="mb-4 w-fit">
|
||||
<TabsTrigger value="onchain">
|
||||
<Bitcoin className="mr-1.5 h-3.5 w-3.5" />
|
||||
On-chain
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="lightning">
|
||||
<Zap className="mr-1.5 h-3.5 w-3.5" />
|
||||
Lightning
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
)}
|
||||
|
||||
{canOnchain && (
|
||||
<TabsContent value="onchain" className="mt-0">
|
||||
<OnchainSendForm walletId={walletId} walletName={wallet?.name ?? 'this wallet'} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{canLightning && (
|
||||
<TabsContent value="lightning" className="mt-0">
|
||||
<LightningSendPanel walletId={walletId} walletName={wallet?.name ?? 'this wallet'} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type FormProps = { walletId: number; walletName: string };
|
||||
|
||||
const OnchainSendForm = ({ walletId, walletName }: FormProps) => {
|
||||
const { balances } = useBalances(walletId);
|
||||
const { fees } = useFees(walletId);
|
||||
const { capabilities } = useCapabilities(walletId);
|
||||
const { selected, clear } = useCoinSelection();
|
||||
const { utxos } = useUtxos(walletId, capabilities.includes('coinControl'));
|
||||
const { send } = useWalletOperations(walletId);
|
||||
const { hasSeed, unlocked } = useLockCountdown(walletId);
|
||||
|
||||
const [address, setAddress] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [sendAll, setSendAll] = useState(false);
|
||||
const [satPerVbyte, setSatPerVbyte] = useState('');
|
||||
const [label, setLabel] = useState('');
|
||||
const [broadcast, setBroadcast] = useState<{ txid: string; feeSats: number } | null>(null);
|
||||
|
||||
// A seeded wallet must be unlocked to sign. A wallet with no seed of its own (a remote node) signs
|
||||
// upstream, so there is nothing to unlock and the form is always live.
|
||||
const needsUnlock = hasSeed === true && !unlocked;
|
||||
const selectedUtxos = utxos.filter((u) => selected.includes(`${u.txid}:${u.vout}`));
|
||||
const selectedTotal = selectedUtxos.reduce((sum, u) => sum + u.amountSats, 0);
|
||||
|
||||
const feeRate = Number(satPerVbyte);
|
||||
const amountSats = Number(amount);
|
||||
const canSubmit =
|
||||
!!address.trim() &&
|
||||
(sendAll || (Number.isFinite(amountSats) && amountSats > 0)) &&
|
||||
Number.isFinite(feeRate) &&
|
||||
feeRate >= 1 &&
|
||||
!needsUnlock &&
|
||||
!send.isPending;
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
const result = await send.mutateAsync({
|
||||
address: address.trim(),
|
||||
amountSats: sendAll ? undefined : amountSats,
|
||||
sendAll: sendAll || undefined,
|
||||
satPerVbyte: feeRate,
|
||||
outpoints: selected.length > 0 ? selected : undefined,
|
||||
label: label.trim() || undefined,
|
||||
});
|
||||
setBroadcast({ txid: result.txid, feeSats: result.feeSats });
|
||||
setAddress('');
|
||||
setAmount('');
|
||||
setLabel('');
|
||||
setSendAll(false);
|
||||
clear();
|
||||
};
|
||||
|
||||
if (broadcast) {
|
||||
return (
|
||||
<section className="rounded-xl border border-emerald-500/40 bg-emerald-500/5 p-4">
|
||||
<h3 className="text-sm font-semibold">Broadcast</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Paid a fee of {formatSats(broadcast.feeSats)} sats. It will confirm when a miner includes it.
|
||||
</p>
|
||||
<CopyField className="mt-3" value={broadcast.txid} label="txid" wrap />
|
||||
<Button size="sm" variant="outline" className="mt-3" onClick={() => setBroadcast(null)}>
|
||||
Send another
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Spendable</h3>
|
||||
<Amount sats={balances?.onchainConfirmed ?? null} className="text-sm font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-send-address">To address</Label>
|
||||
<Input
|
||||
id="wallet-send-address"
|
||||
value={address}
|
||||
spellCheck={false}
|
||||
placeholder="bc1q…"
|
||||
onChange={(ev) => setAddress(ev.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-send-amount">Amount (sats)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="wallet-send-amount"
|
||||
type="number"
|
||||
min={0}
|
||||
value={sendAll ? '' : amount}
|
||||
disabled={sendAll}
|
||||
placeholder={sendAll ? 'everything' : '0'}
|
||||
onChange={(ev) => setAmount(ev.target.value)}
|
||||
className="w-48"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSendAll((v) => !v)}
|
||||
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||
sendAll ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
>
|
||||
Send max
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-send-fee">Fee rate (sat/vB)</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{fees &&
|
||||
FEE_PRESETS.map(({ key, label: presetLabel, hint }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setSatPerVbyte(String(fees[key]))}
|
||||
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||
satPerVbyte === String(fees[key])
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
title={hint}
|
||||
>
|
||||
{presetLabel} · {fees[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
id="wallet-send-fee"
|
||||
type="number"
|
||||
min={1}
|
||||
value={satPerVbyte}
|
||||
placeholder="1"
|
||||
onChange={(ev) => setSatPerVbyte(ev.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{capabilities.includes('coinControl') && (
|
||||
<div className="rounded-xl border border-border p-3">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Coins className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{selected.length === 0 ? (
|
||||
<span className="flex-1 text-muted-foreground">
|
||||
Automatic coin selection.{' '}
|
||||
<Link to={walletSectionPath('coins', walletId)} className="text-primary hover:underline">
|
||||
Pick coins
|
||||
</Link>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1">
|
||||
{selected.length} coin{selected.length === 1 ? '' : 's'} selected · <Amount sats={selectedTotal} />
|
||||
</span>
|
||||
<button type="button" onClick={clear} className="text-primary hover:underline">
|
||||
clear
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-send-label">Label (optional)</Label>
|
||||
<Input
|
||||
id="wallet-send-label"
|
||||
value={label}
|
||||
placeholder="what this payment is for"
|
||||
onChange={(ev) => setLabel(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{needsUnlock && <UnlockPrompt walletId={walletId} walletName={walletName} />}
|
||||
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{send.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Sign and broadcast
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const LightningSendPanel = ({ walletId, walletName }: FormProps) => {
|
||||
const { balances } = useBalances(walletId);
|
||||
const { hasSeed, unlocked } = useLockCountdown(walletId);
|
||||
const [payOpen, setPayOpen] = useState(false);
|
||||
const needsUnlock = hasSeed === true && !unlocked;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Lightning balance</h3>
|
||||
<Amount sats={balances?.lightningBalance ?? null} className="text-sm font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needsUnlock && <UnlockPrompt walletId={walletId} walletName={walletName} />}
|
||||
|
||||
<Button onClick={() => setPayOpen(true)} disabled={needsUnlock}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Pay an invoice
|
||||
</Button>
|
||||
|
||||
{payOpen && <PayInvoiceDialog open={payOpen} onOpenChange={setPayOpen} walletId={walletId} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { OnchainTx } from './shared';
|
||||
import { ArrowDownLeft, ArrowUpRight, Loader2 } from 'lucide-react';
|
||||
import { formatConfirmations, formatSats, formatTimestamp, truncateMiddle } from './format';
|
||||
import { Amount } from './Amount';
|
||||
import { EmptyWallet } from './EmptyWallet';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useTransactions } from './useWalletData';
|
||||
|
||||
// On-chain history. Works while locked — the transaction list comes from the account xpub, not the seed.
|
||||
|
||||
export const TransactionsView = () => {
|
||||
const { walletId, isLoading } = useSelectedWallet();
|
||||
const { transactions, isLoading: txLoading } = useTransactions(walletId);
|
||||
|
||||
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
|
||||
|
||||
if (txLoading && transactions.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Reading the chain…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (transactions.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-8 text-center">
|
||||
<p className="text-sm font-medium">Nothing here yet</p>
|
||||
<p className="text-xs text-muted-foreground">Transactions appear as soon as they hit the mempool.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<ul className="divide-y divide-border">
|
||||
{transactions.map((tx) => (
|
||||
<TransactionRow key={tx.txid} tx={tx} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TransactionRow = ({ tx }: { tx: OnchainTx }) => {
|
||||
const incoming = tx.amount >= 0;
|
||||
const pending = tx.confirmations <= 0;
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-3 px-4 py-2.5">
|
||||
<span
|
||||
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full ${
|
||||
incoming ? 'bg-emerald-500/10 text-emerald-500' : 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{incoming ? <ArrowDownLeft className="h-3.5 w-3.5" /> : <ArrowUpRight className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium">
|
||||
{tx.label || truncateMiddle(tx.destAddresses[0] ?? tx.txid, 14, 10)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{formatTimestamp(tx.timestamp)}</span>
|
||||
<span className={pending ? 'text-amber-500' : ''}>{formatConfirmations(tx.confirmations)}</span>
|
||||
{tx.feeSats != null && tx.feeSats > 0 && <span>fee {formatSats(tx.feeSats)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<Amount sats={tx.amount} signed className="text-xs font-semibold" />
|
||||
<div className="mt-0.5 font-mono text-[10px] text-muted-foreground">{truncateMiddle(tx.txid, 8, 6)}</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { WalletSummary } from './shared';
|
||||
import { useState } from 'react';
|
||||
import { Link, NavLink } from 'react-router';
|
||||
import { ArrowDownLeft, ArrowUpRight, Bitcoin, Coins, Gauge, Plus, Receipt, Settings, Star, Zap } from 'lucide-react';
|
||||
import {
|
||||
KIND_LABELS,
|
||||
NETWORK_TONES,
|
||||
WALLET_SECTIONS,
|
||||
sectionAvailable,
|
||||
walletSectionPath,
|
||||
type WalletSectionId,
|
||||
} from './shared';
|
||||
import { Amount, UnitToggle } from './Amount';
|
||||
import { LockBadge } from './LockBadge';
|
||||
import { useWalletSection } from './useWalletSection';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useBalances, useCapabilities, useWalletConfig, useWalletLifecycle } from './useWalletData';
|
||||
import { CreateWalletDialog } from './dialogs/CreateWalletDialog';
|
||||
|
||||
// Left panel of /wallet: the balance, the wallets, the sections, the lock state.
|
||||
//
|
||||
// Wallet rows are real links carrying `?wallet=<id>` — cmd-click, back button and reload all work, and the
|
||||
// id is in the DOM rather than an onClick closure (docs/navigation-audit.md). "Make active" is a sibling
|
||||
// button, not nested inside the anchor, because it mutates rather than navigates.
|
||||
//
|
||||
// Sections are filtered by the backend's declared capabilities: an on-chain wallet has no Lightning tab at
|
||||
// all, an lndhub account has no coin control. Better a shorter nav than a control that answers 501.
|
||||
|
||||
const ICONS: Record<WalletSectionId, LucideIcon> = {
|
||||
overview: Gauge,
|
||||
receive: ArrowDownLeft,
|
||||
send: ArrowUpRight,
|
||||
transactions: Receipt,
|
||||
coins: Coins,
|
||||
lightning: Zap,
|
||||
settings: Settings,
|
||||
};
|
||||
|
||||
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
||||
|
||||
export const WalletNav = () => {
|
||||
const section = useWalletSection();
|
||||
const { config } = useWalletConfig();
|
||||
const { wallet, wallets, walletId, isPinned, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = useCapabilities(walletId);
|
||||
const { balances } = useBalances(walletId);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
// Preserve an explicitly pinned wallet across section changes; leave a bare URL bare so it keeps
|
||||
// meaning "whichever wallet is active".
|
||||
const linkWalletId = isPinned ? walletId : null;
|
||||
const total = balances ? balances.onchainConfirmed + (balances.lightningBalance ?? 0) : null;
|
||||
const network = wallet?.network ?? config?.network ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||
<div className="flex items-center gap-3 px-4 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-orange-500/15 text-orange-500 ring-1 ring-black/5">
|
||||
<Bitcoin className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-semibold leading-tight">Wallet</span>
|
||||
{network && network !== 'bitcoin' && (
|
||||
<span className={`rounded px-1 py-px text-[9px] font-semibold uppercase ${NETWORK_TONES[network] ?? ''}`}>
|
||||
{network}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Amount sats={total} />
|
||||
<UnitToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-5 pb-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Wallets</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="flex items-center gap-0.5 text-[10px] font-medium text-primary hover:underline"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5 px-2 pb-3">
|
||||
{isLoading && wallets.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">Loading wallets…</div>
|
||||
)}
|
||||
{!isLoading && wallets.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
No wallets yet. Add one to get started — a self-custodial seed, or a connection to your node.
|
||||
</div>
|
||||
)}
|
||||
{wallets.map((w) => (
|
||||
<WalletRow key={w.id} wallet={w} section={section} selected={w.id === walletId} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-0.5 border-t border-border px-2 py-3">
|
||||
{WALLET_SECTIONS.filter(({ id }) => sectionAvailable(id, capabilities)).map(({ id, label }) => {
|
||||
const Icon = ICONS[id];
|
||||
return (
|
||||
<NavLink
|
||||
key={id}
|
||||
to={walletSectionPath(id, linkWalletId)}
|
||||
className={({ isActive }) =>
|
||||
`${ROW} ${
|
||||
isActive
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
|
||||
)}
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
/>
|
||||
<span className="flex-1">{label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-2 px-4 py-3">
|
||||
<LockBadge walletId={walletId} walletName={wallet?.name ?? 'this wallet'} />
|
||||
{config && <span className="truncate text-[10px] text-muted-foreground">{config.network}</span>}
|
||||
</div>
|
||||
|
||||
{createOpen && <CreateWalletDialog open={createOpen} onOpenChange={setCreateOpen} section={section} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type WalletRowProps = { wallet: WalletSummary; section: WalletSectionId; selected: boolean };
|
||||
|
||||
const WalletRow = ({ wallet, section, selected }: WalletRowProps) => {
|
||||
const { activate } = useWalletLifecycle();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex items-center gap-1 rounded-lg pr-1.5 transition-colors ${
|
||||
selected ? 'bg-muted' : 'hover:bg-muted/60'
|
||||
}`}
|
||||
>
|
||||
<Link to={walletSectionPath(section, wallet.id)} className="flex min-w-0 flex-1 flex-col px-3 py-1.5">
|
||||
<span className={`truncate text-xs ${selected ? 'font-medium text-foreground' : 'text-muted-foreground'}`}>
|
||||
{wallet.name}
|
||||
</span>
|
||||
<span className="truncate text-[10px] text-muted-foreground">{KIND_LABELS[wallet.kind]}</span>
|
||||
</Link>
|
||||
{/* Sibling of the anchor, never nested inside it — this mutates, it does not navigate. */}
|
||||
{wallet.isActive ? (
|
||||
<Star className="h-3 w-3 shrink-0 fill-amber-400 text-amber-400" aria-label="Active wallet" />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => activate.mutate(wallet.id)}
|
||||
disabled={activate.isPending}
|
||||
title="Make this the active wallet"
|
||||
className="shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-amber-400 group-hover:opacity-100 focus:opacity-100"
|
||||
>
|
||||
<Star className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Eye, KeyRound, Star, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { KIND_LABELS, walletSectionPath } from './shared';
|
||||
import { truncateMiddle } from './format';
|
||||
import { CopyField } from './CopyField';
|
||||
import { EmptyWallet } from './EmptyWallet';
|
||||
import { LockBadge } from './LockBadge';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useLockCountdown } from './useLockCountdown';
|
||||
import { useCapabilities, useWalletConfig, useWalletLifecycle } from './useWalletData';
|
||||
import { ChangePassphraseDialog } from './dialogs/ChangePassphraseDialog';
|
||||
import { ExportSeedDialog } from './dialogs/ExportSeedDialog';
|
||||
import { DeleteWalletDialog } from './dialogs/DeleteWalletDialog';
|
||||
|
||||
// Wallet-level administration: what this wallet is, what its backend can do, and the three passphrase
|
||||
// operations. Nothing here needs the wallet unlocked to *read* — each destructive action asks for the
|
||||
// passphrase itself, because an open unlock window is not consent to change or destroy the key material.
|
||||
|
||||
export const WalletSettingsView = () => {
|
||||
const navigate = useNavigate();
|
||||
const { wallet, walletId, isLoading } = useSelectedWallet();
|
||||
const { config } = useWalletConfig();
|
||||
const { capabilities, kind } = useCapabilities(walletId);
|
||||
const { hasSeed } = useLockCountdown(walletId);
|
||||
const { activate } = useWalletLifecycle();
|
||||
|
||||
const [passphraseOpen, setPassphraseOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
if (!wallet || walletId == null) return <EmptyWallet isLoading={isLoading} />;
|
||||
|
||||
const xpubs = Object.entries(wallet.xpubs ?? {});
|
||||
|
||||
return (
|
||||
<div className="h-full space-y-4 overflow-y-auto p-4">
|
||||
<section className="max-w-2xl rounded-xl border border-border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-sm font-semibold">{wallet.name}</h3>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{KIND_LABELS[wallet.kind]} · {wallet.network} · added {wallet.createdAt.slice(0, 10)}
|
||||
</p>
|
||||
</div>
|
||||
<LockBadge walletId={walletId} walletName={wallet.name} />
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 space-y-1.5 text-xs">
|
||||
<Row label="Backend">{kind ?? wallet.kind}</Row>
|
||||
<Row label="Default derivation">BIP{wallet.defaultBip}</Row>
|
||||
{wallet.fingerprint && (
|
||||
<Row label="Fingerprint">
|
||||
<code className="font-mono">{wallet.fingerprint}</code>
|
||||
</Row>
|
||||
)}
|
||||
<Row label="Active">{wallet.isActive ? 'yes' : 'no'}</Row>
|
||||
<Row label="Holds a seed">{hasSeed === true ? 'yes' : hasSeed === false ? 'no' : '—'}</Row>
|
||||
</dl>
|
||||
|
||||
{!wallet.isActive && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-3"
|
||||
onClick={() => activate.mutate(wallet.id)}
|
||||
disabled={activate.isPending}
|
||||
>
|
||||
<Star className="mr-1.5 h-3.5 w-3.5" />
|
||||
Make this the active wallet
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{xpubs.length > 0 && (
|
||||
<section className="max-w-2xl rounded-xl border border-border p-4">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Account keys</h3>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Extended public keys. They can watch this wallet but never spend it — safe to hand to a block explorer or an
|
||||
accounting tool.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{xpubs.map(([path, xpub]) => (
|
||||
<CopyField key={path} value={xpub} label={path} wrap />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="max-w-2xl rounded-xl border border-border p-4">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
What this backend can do
|
||||
</h3>
|
||||
{capabilities.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No capabilities reported.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{capabilities.map((cap) => (
|
||||
<span key={cap} className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{cap}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{hasSeed === true && (
|
||||
<section className="max-w-2xl rounded-xl border border-border p-4">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Key material</h3>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Both of these ask for the passphrase on their own, whether or not the wallet is currently unlocked.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setPassphraseOpen(true)}>
|
||||
<KeyRound className="mr-1.5 h-3.5 w-3.5" />
|
||||
Change passphrase
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setExportOpen(true)}>
|
||||
<Eye className="mr-1.5 h-3.5 w-3.5" />
|
||||
Show recovery phrase
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{config && (
|
||||
<section className="max-w-2xl rounded-xl border border-border p-4">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Deployment</h3>
|
||||
<dl className="space-y-1.5 text-xs">
|
||||
<Row label="Network">{config.network}</Row>
|
||||
<Row label="Esplora">
|
||||
<span className="font-mono text-[11px]">{truncateMiddle(config.esploraUrl, 28, 12)}</span>
|
||||
</Row>
|
||||
<Row label="Max unlock window">{formatMinutes(config.unlockTtlSec)}</Row>
|
||||
<Row label="Store key">
|
||||
{config.storeKeyConfigured ? 'configured' : <span className="text-destructive">missing</span>}
|
||||
</Row>
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="max-w-2xl rounded-xl border border-destructive/40 p-4">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-destructive">Danger</h3>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
{wallet.hasSeed
|
||||
? 'Deleting erases the encrypted seed. Only your written recovery phrase can bring the coins back.'
|
||||
: 'Deleting removes the connection. The node itself is untouched.'}
|
||||
</p>
|
||||
<Button size="sm" variant="destructive" onClick={() => setDeleteOpen(true)}>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Delete wallet
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
{passphraseOpen && (
|
||||
<ChangePassphraseDialog open={passphraseOpen} onOpenChange={setPassphraseOpen} walletId={walletId} />
|
||||
)}
|
||||
{exportOpen && <ExportSeedDialog open={exportOpen} onOpenChange={setExportOpen} walletId={walletId} />}
|
||||
{deleteOpen && (
|
||||
<DeleteWalletDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
wallet={wallet}
|
||||
// The deleted id must not stay in `?wallet=` — drop back to the bare section, which means
|
||||
// "whatever is active now".
|
||||
onDeleted={() => navigate(walletSectionPath('overview'), { replace: true })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatMinutes = (seconds: number) => (seconds >= 60 ? `${Math.round(seconds / 60)} min` : `${seconds}s`);
|
||||
|
||||
const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="shrink-0 text-muted-foreground">{label}</dt>
|
||||
<dd className="min-w-0 truncate text-right">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useWalletSection } from './useWalletSection';
|
||||
import { OverviewView } from './OverviewView';
|
||||
import { ReceiveView } from './ReceiveView';
|
||||
import { SendView } from './SendView';
|
||||
import { TransactionsView } from './TransactionsView';
|
||||
import { CoinsView } from './CoinsView';
|
||||
import { LightningView } from './LightningView';
|
||||
import { WalletSettingsView } from './WalletSettingsView';
|
||||
|
||||
// Right panel of the /wallet workspace — renders the section named by the URL.
|
||||
//
|
||||
// Each section decides for itself whether the backend supports it, so a deep link to a section this wallet
|
||||
// cannot serve explains itself rather than 404ing. The nav simply hides those entries.
|
||||
|
||||
export const WalletView = () => {
|
||||
const section = useWalletSection();
|
||||
|
||||
switch (section) {
|
||||
case 'receive':
|
||||
return <ReceiveView />;
|
||||
case 'send':
|
||||
return <SendView />;
|
||||
case 'transactions':
|
||||
return <TransactionsView />;
|
||||
case 'coins':
|
||||
return <CoinsView />;
|
||||
case 'lightning':
|
||||
return <LightningView />;
|
||||
case 'settings':
|
||||
return <WalletSettingsView />;
|
||||
default:
|
||||
return <OverviewView />;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Bitcoin } from 'lucide-react';
|
||||
import { WALLET_SECTIONS } from './shared';
|
||||
import { Amount, UnitToggle } from './Amount';
|
||||
import { LockBadge } from './LockBadge';
|
||||
import { useWalletSection } from './useWalletSection';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useBalances } from './useWalletData';
|
||||
|
||||
// Panel header for the right (wallet-view) panel: which section, which wallet, the spendable balance and
|
||||
// the lock state. The lock indicator is compact here — it reports, it does not act, because the actions
|
||||
// live in the nav and on the signing surfaces themselves.
|
||||
|
||||
export const WalletViewHeader = () => {
|
||||
const section = useWalletSection();
|
||||
const { wallet, walletId } = useSelectedWallet();
|
||||
const { balances } = useBalances(walletId);
|
||||
const label = WALLET_SECTIONS.find((s) => s.id === section)?.label ?? 'Wallet';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Bitcoin className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate text-xs font-medium">
|
||||
{label}
|
||||
{wallet && <span className="ml-1.5 font-normal text-black/50">· {wallet.name}</span>}
|
||||
</span>
|
||||
{balances && <Amount sats={balances.onchainConfirmed} className="shrink-0 text-[10px] text-black/60" />}
|
||||
<UnitToggle className="shrink-0" />
|
||||
<LockBadge walletId={walletId} walletName={wallet?.name ?? 'this wallet'} compact />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLockActions } from '../useWalletData';
|
||||
|
||||
// Re-encrypt the seed under a new passphrase. The sidecar decrypts with the old one and re-seals with the
|
||||
// new one, then re-locks — there is no window where the wallet is left open as a side effect.
|
||||
//
|
||||
// Both passphrases live only in this component's state and are cleared as soon as the request settles.
|
||||
|
||||
type ChangePassphraseDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
|
||||
|
||||
export const ChangePassphraseDialog = ({ open, onOpenChange, walletId }: ChangePassphraseDialogProps) => {
|
||||
const { changePassphrase } = useLockActions(walletId);
|
||||
// Never persisted: no localStorage, no sessionStorage, no React Query cache, no URL.
|
||||
const [oldPassphrase, setOldPassphrase] = useState('');
|
||||
const [newPassphrase, setNewPassphrase] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
|
||||
const clearSecrets = () => {
|
||||
setOldPassphrase('');
|
||||
setNewPassphrase('');
|
||||
setConfirm('');
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
clearSecrets();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const mismatch = confirm.length > 0 && confirm !== newPassphrase;
|
||||
const canSubmit =
|
||||
!!oldPassphrase && newPassphrase.length >= 8 && confirm === newPassphrase && !changePassphrase.isPending;
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
try {
|
||||
await changePassphrase.mutateAsync({ oldPassphrase, newPassphrase });
|
||||
close();
|
||||
} finally {
|
||||
// Cleared on failure too — a rejected passphrase is still a passphrase held in memory.
|
||||
clearSecrets();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-md">
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change passphrase</DialogTitle>
|
||||
<DialogDescription>
|
||||
The seed is re-sealed under the new passphrase and the wallet is locked again. Lose it and the coins go
|
||||
with it — there is no reset.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pass-old">Current passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-pass-old"
|
||||
type="password"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
value={oldPassphrase}
|
||||
onChange={(ev) => setOldPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pass-new">New passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-pass-new"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassphrase}
|
||||
onChange={(ev) => setNewPassphrase(ev.target.value)}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">At least 8 characters.</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pass-confirm">Confirm new passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-pass-confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(ev) => setConfirm(ev.target.value)}
|
||||
/>
|
||||
{mismatch && <p className="text-[11px] text-destructive">These do not match.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{changePassphrase.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Change
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { satsToMsat } from '../format';
|
||||
import { CopyField } from '../CopyField';
|
||||
import { useWalletOperations } from '../useWalletData';
|
||||
|
||||
// Create a BOLT11 invoice. Receiving needs no key material, so this works while the wallet is locked.
|
||||
//
|
||||
// The amount is entered in sats and converted with satsToMsat, which appends three zeros to the digit
|
||||
// string rather than multiplying — the request field is a msat STRING and must stay one.
|
||||
|
||||
type CreateInvoiceDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
|
||||
|
||||
export const CreateInvoiceDialog = ({ open, onOpenChange, walletId }: CreateInvoiceDialogProps) => {
|
||||
const { createInvoice } = useWalletOperations(walletId);
|
||||
const [amountSats, setAmountSats] = useState('');
|
||||
const [memo, setMemo] = useState('');
|
||||
const [expiryMinutes, setExpiryMinutes] = useState('60');
|
||||
const [created, setCreated] = useState<string | null>(null);
|
||||
|
||||
const close = () => {
|
||||
setAmountSats('');
|
||||
setMemo('');
|
||||
setExpiryMinutes('60');
|
||||
setCreated(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
const sats = Number(amountSats);
|
||||
const result = await createInvoice.mutateAsync({
|
||||
// Omitted entirely for a zero-amount invoice, which is a legitimate "payer decides" request.
|
||||
amountMsat: amountSats && Number.isFinite(sats) && sats > 0 ? satsToMsat(sats) : undefined,
|
||||
memo: memo.trim() || undefined,
|
||||
expirySeconds: Number(expiryMinutes) > 0 ? Number(expiryMinutes) * 60 : undefined,
|
||||
});
|
||||
setCreated(result.invoice.bolt11);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-md">
|
||||
{created ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invoice created</DialogTitle>
|
||||
<DialogDescription>Send this to whoever is paying you.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CopyField value={created} wrap label="BOLT11" />
|
||||
<DialogFooter>
|
||||
<Button onClick={close}>Done</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New invoice</DialogTitle>
|
||||
<DialogDescription>Leave the amount blank to let the payer choose.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-inv-amount">Amount (sats)</Label>
|
||||
<Input
|
||||
id="wallet-inv-amount"
|
||||
type="number"
|
||||
min={0}
|
||||
value={amountSats}
|
||||
placeholder="any amount"
|
||||
onChange={(ev) => setAmountSats(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-inv-memo">Description</Label>
|
||||
<Input
|
||||
id="wallet-inv-memo"
|
||||
value={memo}
|
||||
placeholder="what this is for"
|
||||
onChange={(ev) => setMemo(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-inv-expiry">Expires in (minutes)</Label>
|
||||
<Input
|
||||
id="wallet-inv-expiry"
|
||||
type="number"
|
||||
min={1}
|
||||
value={expiryMinutes}
|
||||
onChange={(ev) => setExpiryMinutes(ev.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createInvoice.isPending}>
|
||||
{createInvoice.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,353 @@
|
||||
import type { BackendKind } from '../shared';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Loader2, TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { KIND_LABELS, walletSectionPath, type WalletSectionId } from '../shared';
|
||||
import { useWalletConfig, useWalletLifecycle } from '../useWalletData';
|
||||
import { SeedBackupDialog } from './SeedBackupDialog';
|
||||
|
||||
// Add a wallet. Two genuinely different shapes behind one form:
|
||||
//
|
||||
// onchain — self-custodial. A seed is generated (or imported) and sealed under a passphrase that only
|
||||
// ever exists in this component's state and the request body. Generating returns the mnemonic
|
||||
// exactly once, which is why this dialog hands straight over to SeedBackupDialog and does not
|
||||
// close until the owner confirms they wrote it down.
|
||||
// everything else — a connection to somebody's node or account, so it is a config blob, no seed at all.
|
||||
//
|
||||
// Wallet creation is refused outright when VAULT_STORE_KEY is unconfigured. The form says so up front
|
||||
// rather than letting the submit fail with a crypto error.
|
||||
|
||||
type CreateWalletDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Where to land after creating — keeps the new wallet's id in the URL. */
|
||||
section: WalletSectionId;
|
||||
};
|
||||
|
||||
const KINDS: BackendKind[] = ['onchain', 'lnd', 'cln-rest', 'lndhub', 'nwc'];
|
||||
|
||||
/** The connection fields each remote backend needs, spelled once. */
|
||||
const CONFIG_FIELDS: Record<Exclude<BackendKind, 'onchain'>, { key: string; label: string; placeholder: string }[]> = {
|
||||
lnd: [
|
||||
{ key: 'url', label: 'REST URL', placeholder: 'https://node.local:8080' },
|
||||
{ key: 'macaroon', label: 'Admin macaroon (hex)', placeholder: '0201036c6e64…' },
|
||||
],
|
||||
'cln-rest': [
|
||||
{ key: 'url', label: 'CLNRest URL', placeholder: 'https://node.local:3010' },
|
||||
{ key: 'rune', label: 'Rune', placeholder: 'ZW5jcnlwdGVkOg…' },
|
||||
],
|
||||
lndhub: [
|
||||
{ key: 'url', label: 'LNDHub URL', placeholder: 'https://lndhub.io' },
|
||||
{ key: 'login', label: 'Login', placeholder: 'lndhub login' },
|
||||
{ key: 'password', label: 'Password', placeholder: 'lndhub password' },
|
||||
],
|
||||
nwc: [{ key: 'connectionString', label: 'Connection string', placeholder: 'nostr+walletconnect://…' }],
|
||||
};
|
||||
|
||||
export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWalletDialogProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { config } = useWalletConfig();
|
||||
const { create } = useWalletLifecycle();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [kind, setKind] = useState<BackendKind>('onchain');
|
||||
const [words, setWords] = useState<12 | 24>(24);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [mnemonicInput, setMnemonicInput] = useState('');
|
||||
// Both of these are secrets in flight. They exist in this component's state, go into the request body,
|
||||
// and are cleared the moment the request settles — never a store, never a query key, never the URL.
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [confirmPassphrase, setConfirmPassphrase] = useState('');
|
||||
const [bip39Passphrase, setBip39Passphrase] = useState('');
|
||||
const [makeActive, setMakeActive] = useState(true);
|
||||
const [remoteConfig, setRemoteConfig] = useState<Record<string, string>>({});
|
||||
|
||||
// The one mnemonic the sidecar will ever hand back. Held here only until the backup modal is confirmed.
|
||||
const [pendingSeed, setPendingSeed] = useState<{ mnemonic: string; walletName: string; walletId: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const storeKeyMissing = config != null && !config.storeKeyConfigured;
|
||||
const isOnchain = kind === 'onchain';
|
||||
|
||||
const clearSecrets = () => {
|
||||
setPassphrase('');
|
||||
setConfirmPassphrase('');
|
||||
setBip39Passphrase('');
|
||||
setMnemonicInput('');
|
||||
setRemoteConfig({});
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setName('');
|
||||
setKind('onchain');
|
||||
setWords(24);
|
||||
setImporting(false);
|
||||
setMakeActive(true);
|
||||
clearSecrets();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const passphraseValid = !isOnchain || (passphrase.length >= 8 && passphrase === confirmPassphrase);
|
||||
const configValid =
|
||||
isOnchain || CONFIG_FIELDS[kind as Exclude<BackendKind, 'onchain'>].every((f) => remoteConfig[f.key]?.trim());
|
||||
const canSubmit = !!name.trim() && passphraseValid && configValid && !storeKeyMissing && !create.isPending;
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
|
||||
try {
|
||||
const result = await create.mutateAsync(
|
||||
isOnchain
|
||||
? {
|
||||
name: name.trim(),
|
||||
kind,
|
||||
passphrase,
|
||||
words,
|
||||
mnemonic: importing ? mnemonicInput.trim() : undefined,
|
||||
bip39Passphrase: bip39Passphrase || undefined,
|
||||
makeActive,
|
||||
}
|
||||
: { name: name.trim(), kind, config: remoteConfig, makeActive },
|
||||
);
|
||||
|
||||
const created = result.wallet;
|
||||
if (result.mnemonic) {
|
||||
// Generated seed: hold the words for the backup modal and keep this dialog mounted underneath.
|
||||
setPendingSeed({ mnemonic: result.mnemonic, walletName: created.name, walletId: created.id });
|
||||
} else {
|
||||
navigate(walletSectionPath(section, created.id));
|
||||
close();
|
||||
}
|
||||
} finally {
|
||||
// Whatever happened, no secret survives the submit.
|
||||
clearSecrets();
|
||||
}
|
||||
};
|
||||
|
||||
const seedConfirmed = () => {
|
||||
const created = pendingSeed;
|
||||
setPendingSeed(null);
|
||||
close();
|
||||
if (created) navigate(walletSectionPath(section, created.walletId));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open && pendingSeed == null} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a wallet</DialogTitle>
|
||||
<DialogDescription>
|
||||
Self-custodial keys stay in the wallet sidecar; a remote node is only a stored connection.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{storeKeyMissing && (
|
||||
<div className="mt-4 flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-xs">
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
<span>
|
||||
<span className="font-medium">VAULT_STORE_KEY is not configured.</span> The sidecar refuses to store
|
||||
wallet secrets without it, so creation is disabled until it is set.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-new-name">Name</Label>
|
||||
<Input
|
||||
id="wallet-new-name"
|
||||
value={name}
|
||||
autoFocus
|
||||
placeholder="Savings"
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-new-kind">Type</Label>
|
||||
<Select value={kind} onValueChange={(v) => setKind(v as BackendKind)}>
|
||||
<SelectTrigger id="wallet-new-kind">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KINDS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{KIND_LABELS[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{config && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Network: <span className="font-medium">{config.network}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOnchain ? (
|
||||
<>
|
||||
<div className="flex gap-1.5">
|
||||
<SegButton active={!importing} onClick={() => setImporting(false)} label="Generate a new seed" />
|
||||
<SegButton active={importing} onClick={() => setImporting(true)} label="Import a phrase" />
|
||||
</div>
|
||||
|
||||
{importing ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-new-mnemonic">Recovery phrase</Label>
|
||||
{/* Secret in flight: state → request body → cleared. Never stored on this side, and
|
||||
never echoed back by the sidecar either. */}
|
||||
<Textarea
|
||||
id="wallet-new-mnemonic"
|
||||
rows={3}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={mnemonicInput}
|
||||
placeholder="twelve or twenty-four words, separated by spaces"
|
||||
onChange={(ev) => setMnemonicInput(ev.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Phrase length</Label>
|
||||
<div className="flex gap-1.5">
|
||||
<SegButton active={words === 12} onClick={() => setWords(12)} label="12 words" />
|
||||
<SegButton active={words === 24} onClick={() => setWords(24)} label="24 words" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The phrase is shown once, immediately after creation, and never again without this passphrase.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-new-pass">Passphrase</Label>
|
||||
{/* Seals the seed. Straight into the request body, cleared on settle — no store. */}
|
||||
<Input
|
||||
id="wallet-new-pass"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={passphrase}
|
||||
onChange={(ev) => setPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-new-pass2">Confirm</Label>
|
||||
<Input
|
||||
id="wallet-new-pass2"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassphrase}
|
||||
onChange={(ev) => setConfirmPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{passphrase.length > 0 && passphrase.length < 8 && (
|
||||
<p className="text-xs text-destructive">Use at least 8 characters.</p>
|
||||
)}
|
||||
{confirmPassphrase.length > 0 && passphrase !== confirmPassphrase && (
|
||||
<p className="text-xs text-destructive">The two passphrases do not match.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-new-bip39">BIP39 passphrase (optional)</Label>
|
||||
{/* Part of the seed itself — lose it and the coins are gone. Same handling: no store. */}
|
||||
<Input
|
||||
id="wallet-new-bip39"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={bip39Passphrase}
|
||||
onChange={(ev) => setBip39Passphrase(ev.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A 25th word. It is part of the key, not a lock on it — without it the phrase alone recovers a
|
||||
different, empty wallet.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{CONFIG_FIELDS[kind as Exclude<BackendKind, 'onchain'>].map((field) => (
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<Label htmlFor={`wallet-new-${field.key}`}>{field.label}</Label>
|
||||
{/* Node credentials are stored server-side, encrypted, and never returned to the
|
||||
browser — so this input is write-only and holds nothing after submit. */}
|
||||
<Input
|
||||
id={`wallet-new-${field.key}`}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={remoteConfig[field.key] ?? ''}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(ev) => setRemoteConfig((prev) => ({ ...prev, [field.key]: ev.target.value }))}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2.5 text-sm">
|
||||
<Checkbox checked={makeActive} onCheckedChange={(v) => setMakeActive(v === true)} />
|
||||
Make this the active wallet
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{create.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create wallet
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{pendingSeed && (
|
||||
<SeedBackupDialog
|
||||
open
|
||||
mnemonic={pendingSeed.mnemonic}
|
||||
walletName={pendingSeed.walletName}
|
||||
onConfirmed={seedConfirmed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SegButton = ({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||
active ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { WalletSummary } from '../shared';
|
||||
import { useState } from 'react';
|
||||
import { Loader2, TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useWalletLifecycle } from '../useWalletData';
|
||||
|
||||
// Delete a wallet. For a seeded wallet this destroys the only copy of the key material Officer holds, so
|
||||
// the sidecar demands the passphrase even when the wallet is currently unlocked — an open session must not
|
||||
// be enough to erase a seed. Typing the wallet's name is the second, local check against the wrong row.
|
||||
|
||||
type DeleteWalletDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
wallet: WalletSummary;
|
||||
onDeleted?: () => void;
|
||||
};
|
||||
|
||||
export const DeleteWalletDialog = ({ open, onOpenChange, wallet, onDeleted }: DeleteWalletDialogProps) => {
|
||||
const { remove } = useWalletLifecycle();
|
||||
// Never persisted: no localStorage, no sessionStorage, no React Query cache, no URL.
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [confirmName, setConfirmName] = useState('');
|
||||
|
||||
const close = () => {
|
||||
setPassphrase('');
|
||||
setConfirmName('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const nameMatches = confirmName.trim() === wallet.name;
|
||||
const canSubmit = nameMatches && (!wallet.hasSeed || !!passphrase) && !remove.isPending;
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
try {
|
||||
await remove.mutateAsync({ walletId: wallet.id, passphrase: wallet.hasSeed ? passphrase : undefined });
|
||||
close();
|
||||
onDeleted?.();
|
||||
} finally {
|
||||
setPassphrase('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-md">
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TriangleAlert className="h-5 w-5 text-destructive" />
|
||||
Delete {wallet.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{wallet.hasSeed
|
||||
? 'This erases the encrypted seed. Without your written-down recovery phrase the coins are gone for good.'
|
||||
: 'This removes the connection to that node. Nothing on the node itself is touched.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-delete-name">
|
||||
Type <span className="font-mono">{wallet.name}</span> to confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="wallet-delete-name"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
value={confirmName}
|
||||
onChange={(ev) => setConfirmName(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{wallet.hasSeed && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-delete-pass">Passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-delete-pass"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={passphrase}
|
||||
onChange={(ev) => setPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" disabled={!canSubmit}>
|
||||
{remove.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Delete wallet
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from 'react';
|
||||
import { Eye, Loader2, TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLockActions } from '../useWalletData';
|
||||
|
||||
// Show the recovery phrase again — the one path back to it after creation, and the reason the create-time
|
||||
// modal can say "not without your passphrase" rather than "never".
|
||||
//
|
||||
// Two things live only in this component and nowhere else: the passphrase (cleared the moment the request
|
||||
// settles) and the returned mnemonic (held in local state, dropped when the dialog closes). The mutation
|
||||
// deliberately has no onSuccess cache write, so the phrase never enters React Query. No copy button, for
|
||||
// the same reason as SeedBackupDialog.
|
||||
|
||||
type ExportSeedDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
|
||||
|
||||
export const ExportSeedDialog = ({ open, onOpenChange, walletId }: ExportSeedDialogProps) => {
|
||||
const { exportSeed } = useLockActions(walletId);
|
||||
// Never persisted: no localStorage, no sessionStorage, no React Query cache, no URL.
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [revealed, setRevealed] = useState<{ mnemonic: string; hasBip39Passphrase: boolean } | null>(null);
|
||||
|
||||
const close = () => {
|
||||
setPassphrase('');
|
||||
setRevealed(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!passphrase) return;
|
||||
try {
|
||||
setRevealed(await exportSeed.mutateAsync({ passphrase }));
|
||||
} finally {
|
||||
setPassphrase('');
|
||||
}
|
||||
};
|
||||
|
||||
const words = revealed ? revealed.mnemonic.trim().split(/\s+/) : [];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-lg">
|
||||
{revealed ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TriangleAlert className="h-5 w-5 text-amber-500" />
|
||||
Recovery phrase
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Anyone who reads these {words.length} words can spend this wallet. Close this as soon as you are done.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ol className="mt-2 grid grid-cols-3 gap-1.5 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 sm:grid-cols-4">
|
||||
{words.map((word, i) => (
|
||||
<li key={`${i}-${word}`} className="flex items-baseline gap-1.5 font-mono text-xs">
|
||||
<span className="w-5 shrink-0 text-right tabular-nums text-muted-foreground">{i + 1}</span>
|
||||
<span className="font-medium">{word}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{revealed.hasBip39Passphrase && (
|
||||
<p className="text-xs text-amber-600">
|
||||
This seed also has a BIP39 passphrase. The words alone will not restore the wallet — you need both.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={close}>Done</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Show recovery phrase</DialogTitle>
|
||||
<DialogDescription>
|
||||
The phrase is decrypted for display only. It is not stored by the browser and nothing is written
|
||||
anywhere on this side.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<Label htmlFor="wallet-export-pass">Passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-export-pass"
|
||||
type="password"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
value={passphrase}
|
||||
onChange={(ev) => setPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!passphrase || exportSeed.isPending}>
|
||||
{exportSeed.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Show
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { DecodedInvoice } from '../shared';
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Zap } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { formatTimestamp, satsToMsat, truncateMiddle } from '../format';
|
||||
import { Amount } from '../Amount';
|
||||
import { useWalletOperations } from '../useWalletData';
|
||||
|
||||
// Pay a BOLT11 invoice: decode first, confirm what you are about to pay, then pay.
|
||||
//
|
||||
// The two steps are deliberate. A BOLT11 string is opaque, so paying one straight from the paste box is
|
||||
// signing something you cannot read. Decoding is a free read — it does not need the wallet unlocked — and
|
||||
// it is the only chance to see the amount and the destination before the money moves.
|
||||
//
|
||||
// Amounts stay msat STRINGS end to end (see format.ts): the decoded amount is rendered as a string and a
|
||||
// zero-amount invoice's manual amount is converted with satsToMsat rather than a multiplication.
|
||||
|
||||
type PayInvoiceDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
|
||||
|
||||
export const PayInvoiceDialog = ({ open, onOpenChange, walletId }: PayInvoiceDialogProps) => {
|
||||
const { decode, pay } = useWalletOperations(walletId);
|
||||
const [bolt11, setBolt11] = useState('');
|
||||
const [decoded, setDecoded] = useState<DecodedInvoice | null>(null);
|
||||
const [amountSats, setAmountSats] = useState('');
|
||||
const [feeLimitSats, setFeeLimitSats] = useState('');
|
||||
|
||||
const close = () => {
|
||||
setBolt11('');
|
||||
setDecoded(null);
|
||||
setAmountSats('');
|
||||
setFeeLimitSats('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const runDecode = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
const trimmed = bolt11.trim();
|
||||
if (!trimmed) return;
|
||||
const result = await decode.mutateAsync(trimmed);
|
||||
setDecoded(result.decoded);
|
||||
};
|
||||
|
||||
// A zero-amount invoice carries no amountMsat, so the payer names the figure.
|
||||
const needsAmount = decoded != null && decoded.amountMsat == null;
|
||||
const manualSats = Number(amountSats);
|
||||
const feeLimit = Number(feeLimitSats);
|
||||
const canPay = decoded != null && (!needsAmount || (Number.isFinite(manualSats) && manualSats > 0)) && !pay.isPending;
|
||||
|
||||
const runPay = async () => {
|
||||
if (!decoded || !canPay) return;
|
||||
await pay.mutateAsync({
|
||||
bolt11: decoded.bolt11,
|
||||
amountMsat: needsAmount ? satsToMsat(manualSats) : undefined,
|
||||
feeLimitMsat: Number.isFinite(feeLimit) && feeLimit > 0 ? satsToMsat(feeLimit) : undefined,
|
||||
});
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pay an invoice</DialogTitle>
|
||||
<DialogDescription>
|
||||
{decoded
|
||||
? 'Check this before you pay — a lightning payment cannot be reversed.'
|
||||
: 'Paste a BOLT11 invoice.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{decoded ? (
|
||||
<div className="mt-4 space-y-4">
|
||||
<dl className="space-y-2 rounded-lg border border-border bg-muted/40 p-3 text-xs">
|
||||
<Row label="Amount">
|
||||
{decoded.amountMsat == null ? (
|
||||
<span className="text-muted-foreground">payer chooses</span>
|
||||
) : (
|
||||
<Amount msat={decoded.amountMsat} className="font-semibold" />
|
||||
)}
|
||||
</Row>
|
||||
<Row label="Description">{decoded.description || <span className="text-muted-foreground">—</span>}</Row>
|
||||
<Row label="Destination">
|
||||
<code className="font-mono text-[11px]">{truncateMiddle(decoded.destination, 12, 8)}</code>
|
||||
</Row>
|
||||
<Row label="Expires">{formatTimestamp(decoded.timestamp + decoded.expiry)}</Row>
|
||||
</dl>
|
||||
|
||||
{needsAmount && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pay-amount">Amount to pay (sats)</Label>
|
||||
<Input
|
||||
id="wallet-pay-amount"
|
||||
type="number"
|
||||
min={1}
|
||||
value={amountSats}
|
||||
onChange={(ev) => setAmountSats(ev.target.value)}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pay-fee">Max routing fee (sats, optional)</Label>
|
||||
<Input
|
||||
id="wallet-pay-fee"
|
||||
type="number"
|
||||
min={0}
|
||||
value={feeLimitSats}
|
||||
placeholder="node default"
|
||||
onChange={(ev) => setFeeLimitSats(ev.target.value)}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setDecoded(null)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button type="button" onClick={runPay} disabled={!canPay}>
|
||||
{pay.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Zap className="mr-2 h-4 w-4" />}
|
||||
Pay
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={runDecode} className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pay-bolt11">Invoice</Label>
|
||||
<Input
|
||||
id="wallet-pay-bolt11"
|
||||
value={bolt11}
|
||||
spellCheck={false}
|
||||
placeholder="lnbc…"
|
||||
onChange={(ev) => setBolt11(ev.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!bolt11.trim() || decode.isPending}>
|
||||
{decode.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Decode
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="shrink-0 text-muted-foreground">{label}</dt>
|
||||
<dd className="min-w-0 truncate text-right">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState } from 'react';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
// The recovery phrase, shown exactly once.
|
||||
//
|
||||
// The sidecar returns `mnemonic` on the create response and only when IT generated the seed. There is no
|
||||
// second chance without the passphrase (settings → export seed), so this modal cannot be dismissed by
|
||||
// clicking away, pressing Escape, or anything short of ticking the box and pressing the button.
|
||||
//
|
||||
// The words arrive as a prop, live in this component's render only, and are never written to React Query,
|
||||
// localStorage, sessionStorage, the URL, or a clipboard helper. Closing the modal is the only exit and it
|
||||
// drops the string on the floor. There is deliberately no "copy" button: a seed on the clipboard is a seed
|
||||
// in every clipboard manager on the machine.
|
||||
|
||||
type SeedBackupDialogProps = {
|
||||
open: boolean;
|
||||
mnemonic: string;
|
||||
walletName: string;
|
||||
onConfirmed: () => void;
|
||||
};
|
||||
|
||||
export const SeedBackupDialog = ({ open, mnemonic, walletName, onConfirmed }: SeedBackupDialogProps) => {
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
const words = mnemonic.trim().split(/\s+/);
|
||||
|
||||
const confirm = () => {
|
||||
setAcknowledged(false);
|
||||
onConfirmed();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
{/* `[&>button]:hidden` drops the shared X — with no onOpenChange it would be inert anyway, and an
|
||||
inert close button on the one dialog you must not dismiss is worse than no button. */}
|
||||
<DialogContent
|
||||
className="max-w-lg [&>button]:hidden"
|
||||
onEscapeKeyDown={(ev) => ev.preventDefault()}
|
||||
onPointerDownOutside={(ev) => ev.preventDefault()}
|
||||
onInteractOutside={(ev) => ev.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TriangleAlert className="h-5 w-5 text-amber-500" />
|
||||
Write down your recovery phrase
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
These {words.length} words are the only way to recover <span className="font-medium">{walletName}</span> if
|
||||
this server is lost. Officer cannot show them again without your passphrase.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ol className="mt-2 grid grid-cols-3 gap-1.5 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 sm:grid-cols-4">
|
||||
{words.map((word, i) => (
|
||||
<li key={`${i}-${word}`} className="flex items-baseline gap-1.5 font-mono text-xs">
|
||||
<span className="w-5 shrink-0 text-right tabular-nums text-muted-foreground">{i + 1}</span>
|
||||
<span className="font-medium">{word}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Write them on paper, in order. Anyone who reads them can spend these coins, so do not photograph them, type
|
||||
them into another device, or paste them anywhere.
|
||||
</p>
|
||||
|
||||
<label className="flex items-start gap-2.5 rounded-lg border border-border p-3 text-sm">
|
||||
<Checkbox className="mt-0.5" checked={acknowledged} onCheckedChange={(v) => setAcknowledged(v === true)} />
|
||||
<span>
|
||||
I have written this phrase down and stored it somewhere safe.
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
It will not be shown again after this dialog closes.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={confirm} disabled={!acknowledged}>
|
||||
I have written it down
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, LockOpen } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLockActions } from '../useWalletData';
|
||||
|
||||
// Unlock, and nothing else. The wallet spends most of its life locked and that is the intended state —
|
||||
// this dialog exists for the moment before a signature, not as a gate on the app.
|
||||
//
|
||||
// The unlock window is capped by the deployment's unlockTtlSec; a caller may ask for less but never more,
|
||||
// so the shorter options here are real and the longest is simply "whatever the sidecar allows".
|
||||
|
||||
type UnlockDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
walletId: number;
|
||||
walletName: string;
|
||||
maxTtlSec: number;
|
||||
};
|
||||
|
||||
/** Offered windows, filtered to those the deployment actually permits. */
|
||||
const TTL_CHOICES = [
|
||||
{ sec: 60, label: '1 minute' },
|
||||
{ sec: 300, label: '5 minutes' },
|
||||
{ sec: 900, label: '15 minutes' },
|
||||
{ sec: 3600, label: '1 hour' },
|
||||
];
|
||||
|
||||
export const UnlockDialog = ({ open, onOpenChange, walletId, walletName, maxTtlSec }: UnlockDialogProps) => {
|
||||
const { unlock } = useLockActions(walletId);
|
||||
// The passphrase lives HERE and nowhere else: no localStorage, no sessionStorage, no React Query
|
||||
// cache, no URL. It goes straight into the POST body and this state is cleared the moment the request
|
||||
// settles, on success and on failure alike.
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [ttlSec, setTtlSec] = useState<number>(() => Math.min(300, maxTtlSec));
|
||||
|
||||
const choices = TTL_CHOICES.filter((c) => c.sec <= maxTtlSec);
|
||||
|
||||
const close = () => {
|
||||
setPassphrase('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!passphrase) return;
|
||||
try {
|
||||
await unlock.mutateAsync({ passphrase, ttlSec });
|
||||
close();
|
||||
} finally {
|
||||
// Cleared even when the unlock failed — a wrong passphrase left sitting in an input is still a
|
||||
// passphrase sitting in memory attached to a mounted component.
|
||||
setPassphrase('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-md">
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unlock {walletName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
The seed is decrypted in the wallet sidecar for the window you choose, then wiped. Balances and history do
|
||||
not need this — only signing does.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-unlock-pass">Passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-unlock-pass"
|
||||
type="password"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
value={passphrase}
|
||||
onChange={(ev) => setPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{choices.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Keep unlocked for</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{choices.map(({ sec, label }) => (
|
||||
<button
|
||||
key={sec}
|
||||
type="button"
|
||||
onClick={() => setTtlSec(sec)}
|
||||
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||
ttlSec === sec
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!passphrase || unlock.isPending}>
|
||||
{unlock.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LockOpen className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Unlock
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { InvoiceState, PaymentStatus } from './shared';
|
||||
|
||||
// Display formatting for the Wallet panels.
|
||||
//
|
||||
// THE MILLISATOSHI RULE. A msat value arrives as a decimal string because 2.1e18 does not fit in a
|
||||
// double. Every msat formatter below works on the digit string — no Number(), no parseInt, no
|
||||
// BigInt round-trip through a float. Widening one to a number would silently corrupt large amounts,
|
||||
// and the corruption would only show up on the one payment that mattered.
|
||||
|
||||
export type AmountUnit = 'sats' | 'btc';
|
||||
|
||||
const SATS_PER_BTC = 100_000_000;
|
||||
|
||||
/** Thousands separators on a bare digit string, left to right in groups of three from the end. */
|
||||
function groupDigits(digits: string): string {
|
||||
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a non-negative digit string by 10^places without ever building a number.
|
||||
* `'1234', 3` → `{ whole: '1', frac: '234' }`.
|
||||
*/
|
||||
function splitByPowerOfTen(digits: string, places: number): { whole: string; frac: string } {
|
||||
const clean = digits.replace(/^0+(?=\d)/, '');
|
||||
if (places === 0) return { whole: clean, frac: '' };
|
||||
const padded = clean.padStart(places + 1, '0');
|
||||
return { whole: padded.slice(0, -places), frac: padded.slice(-places) };
|
||||
}
|
||||
|
||||
const trimZeros = (frac: string) => frac.replace(/0+$/, '');
|
||||
|
||||
// ── satoshis (numbers) ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Sats with thousands separators. Negative amounts keep their sign — a spend should read as one. */
|
||||
export function formatSats(sats: number | null | undefined): string {
|
||||
if (sats == null || !Number.isFinite(sats)) return '—';
|
||||
const neg = sats < 0;
|
||||
return `${neg ? '-' : ''}${groupDigits(String(Math.abs(Math.trunc(sats))))}`;
|
||||
}
|
||||
|
||||
/** BTC from sats, trailing zeros trimmed but never below two decimals, so amounts stay column-aligned. */
|
||||
export function formatBtc(sats: number | null | undefined): string {
|
||||
if (sats == null || !Number.isFinite(sats)) return '—';
|
||||
const neg = sats < 0;
|
||||
const abs = Math.abs(Math.trunc(sats));
|
||||
const whole = Math.floor(abs / SATS_PER_BTC);
|
||||
const frac = String(abs % SATS_PER_BTC).padStart(8, '0');
|
||||
const shown = trimZeros(frac).padEnd(2, '0');
|
||||
return `${neg ? '-' : ''}${groupDigits(String(whole))}.${shown}`;
|
||||
}
|
||||
|
||||
/** The one entry point the views call, so a unit toggle flips every amount on screen at once. */
|
||||
export function formatAmount(sats: number | null | undefined, unit: AmountUnit): string {
|
||||
if (sats == null || !Number.isFinite(sats)) return '—';
|
||||
return unit === 'btc' ? `${formatBtc(sats)} BTC` : `${formatSats(sats)} sats`;
|
||||
}
|
||||
|
||||
// ── millisatoshis (strings) ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** True when the string is a decimal integer we can format; anything else is passed through verbatim. */
|
||||
const isDecimal = (value: string) => /^\d+$/.test(value);
|
||||
|
||||
/**
|
||||
* A msat string as sats, keeping the sub-sat remainder when there is one. String arithmetic throughout —
|
||||
* see the rule at the top of this file.
|
||||
*/
|
||||
export function formatMsatAsSats(msat: string | null | undefined): string {
|
||||
if (msat == null) return '—';
|
||||
const neg = msat.startsWith('-');
|
||||
const digits = neg ? msat.slice(1) : msat;
|
||||
if (!isDecimal(digits)) return msat;
|
||||
const { whole, frac } = splitByPowerOfTen(digits, 3);
|
||||
const remainder = trimZeros(frac);
|
||||
return `${neg ? '-' : ''}${groupDigits(whole)}${remainder ? `.${remainder}` : ''}`;
|
||||
}
|
||||
|
||||
/** A msat string as BTC — 10^11 msat to the bitcoin. */
|
||||
export function formatMsatAsBtc(msat: string | null | undefined): string {
|
||||
if (msat == null) return '—';
|
||||
const neg = msat.startsWith('-');
|
||||
const digits = neg ? msat.slice(1) : msat;
|
||||
if (!isDecimal(digits)) return msat;
|
||||
const { whole, frac } = splitByPowerOfTen(digits, 11);
|
||||
const shown = trimZeros(frac).padEnd(2, '0');
|
||||
return `${neg ? '-' : ''}${groupDigits(whole)}.${shown}`;
|
||||
}
|
||||
|
||||
export function formatMsat(msat: string | null | undefined, unit: AmountUnit): string {
|
||||
if (msat == null) return '—';
|
||||
return unit === 'btc' ? `${formatMsatAsBtc(msat)} BTC` : `${formatMsatAsSats(msat)} sats`;
|
||||
}
|
||||
|
||||
/** Sats → msat, for request bodies. Multiplying a string by 1000 is three appended zeros. */
|
||||
export function satsToMsat(sats: number): string {
|
||||
return `${Math.trunc(Math.abs(sats))}000`;
|
||||
}
|
||||
|
||||
// ── everything else ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Unix seconds; 0 and null both mean "never" rather than 1970. */
|
||||
export function formatTimestamp(unixSeconds: number | null | undefined): string {
|
||||
if (!unixSeconds || unixSeconds <= 0) return '—';
|
||||
const d = new Date(unixSeconds * 1000);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** A countdown as mm:ss — the unlock window, where the seconds genuinely matter. */
|
||||
export function formatCountdown(seconds: number | null | undefined): string {
|
||||
if (seconds == null || seconds <= 0) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** txids, addresses and pubkeys are unreadable in full and unmistakable at both ends. */
|
||||
export function truncateMiddle(value: string | null | undefined, head = 10, tail = 8): string {
|
||||
if (!value) return '—';
|
||||
if (value.length <= head + tail + 1) return value;
|
||||
return `${value.slice(0, head)}…${value.slice(-tail)}`;
|
||||
}
|
||||
|
||||
export function formatConfirmations(confirmations: number): string {
|
||||
if (confirmations <= 0) return 'unconfirmed';
|
||||
if (confirmations === 1) return '1 conf';
|
||||
if (confirmations >= 6) return '6+ confs';
|
||||
return `${confirmations} confs`;
|
||||
}
|
||||
|
||||
export const INVOICE_TONES: Record<InvoiceState, string> = {
|
||||
open: 'bg-blue-500/10 text-blue-500',
|
||||
settled: 'bg-emerald-500/10 text-emerald-500',
|
||||
accepted: 'bg-amber-500/10 text-amber-500',
|
||||
canceled: 'bg-muted text-muted-foreground',
|
||||
expired: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
export const PAYMENT_TONES: Record<PaymentStatus, string> = {
|
||||
pending: 'bg-amber-500/10 text-amber-500',
|
||||
succeeded: 'bg-emerald-500/10 text-emerald-500',
|
||||
failed: 'bg-destructive/10 text-destructive',
|
||||
};
|
||||
|
||||
/** Clipboard with a graceful failure — an insecure origin has no navigator.clipboard at all. */
|
||||
export async function copyToClipboard(value: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { PanelLeft, LayoutGrid } from 'lucide-react';
|
||||
import { WalletNav } from './WalletNav';
|
||||
import { WalletView } from './WalletView';
|
||||
import { WalletViewHeader } from './WalletViewHeader';
|
||||
|
||||
export { WalletNav, WalletView };
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'wallet-nav',
|
||||
name: 'Wallet',
|
||||
icon: PanelLeft,
|
||||
component: WalletNav,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'wallet-view',
|
||||
name: 'Wallet',
|
||||
icon: LayoutGrid,
|
||||
component: WalletView,
|
||||
header: WalletViewHeader,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,251 @@
|
||||
// Shared types/constants for the /wallet workspace panels.
|
||||
//
|
||||
// The wire shapes mirror src/servers/sidecar/wallet/types.ts and the WalletSummary projection in
|
||||
// src/databases/officer_db/src/queries/wallet.ts. They are restated here rather than imported because the
|
||||
// officerdev workspace has no path into src/servers — pulling the sidecar's module graph into the browser
|
||||
// bundle would drag bitcoinjs-lib and the key handling along with it, which is exactly what must never
|
||||
// reach the client. Keep this file in step with those two by hand; the field names are identical on
|
||||
// purpose so a diff is a grep.
|
||||
//
|
||||
// UNITS — the one rule that matters. Satoshis are `number` (2.1e15, comfortably inside 2^53).
|
||||
// Millisatoshis are decimal STRINGS (2.1e18 overflows a double) and must never be parsed into a JS
|
||||
// number. format.ts does msat arithmetic on the digit string; use it rather than reaching for Number().
|
||||
|
||||
export const WALLET_SECTIONS = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'receive', label: 'Receive' },
|
||||
{ id: 'send', label: 'Send' },
|
||||
{ id: 'transactions', label: 'Transactions' },
|
||||
{ id: 'coins', label: 'Coins' },
|
||||
{ id: 'lightning', label: 'Lightning' },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
] as const;
|
||||
|
||||
export type WalletSectionId = (typeof WALLET_SECTIONS)[number]['id'];
|
||||
|
||||
/** Where /wallet lands, and where an unrecognised section redirects to. */
|
||||
export const DEFAULT_WALLET_SECTION: WalletSectionId = 'overview';
|
||||
|
||||
export const isWalletSection = (value: string | undefined): value is WalletSectionId =>
|
||||
WALLET_SECTIONS.some((s) => s.id === value);
|
||||
|
||||
/**
|
||||
* The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart.
|
||||
* Which wallet is open rides along as `?wallet=<id>` — a query param rather than a second path segment,
|
||||
* because it is orthogonal to the section and every panel reads it independently.
|
||||
*/
|
||||
export const walletSectionPath = (id: WalletSectionId, walletId?: number | null) =>
|
||||
walletId == null ? `/wallet/${id}` : `/wallet/${id}?wallet=${walletId}`;
|
||||
|
||||
/** The search-param name holding the open wallet. Never holds anything secret — just a row id. */
|
||||
export const WALLET_PARAM = 'wallet';
|
||||
|
||||
// ── Domain objects ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type BackendKind = 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc';
|
||||
|
||||
export type BitcoinNetwork = 'bitcoin' | 'testnet' | 'signet' | 'regtest';
|
||||
|
||||
/** Mirrors `Capability` in the sidecar. The UI shows a control only when its capability is present. */
|
||||
export type Capability =
|
||||
| 'onchainReceive'
|
||||
| 'onchainSend'
|
||||
| 'coinControl'
|
||||
| 'psbt'
|
||||
| 'bumpFee'
|
||||
| 'sweep'
|
||||
| 'accounts'
|
||||
| 'lightningReceive'
|
||||
| 'lightningSend'
|
||||
| 'keysend'
|
||||
| 'customPreimages'
|
||||
| 'offers'
|
||||
| 'channels'
|
||||
| 'peers'
|
||||
| 'routing'
|
||||
| 'signMessage';
|
||||
|
||||
/** The browser-safe wallet projection — no config, no seed envelope, ever. */
|
||||
export type WalletSummary = {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: BackendKind;
|
||||
network: string;
|
||||
fingerprint: string | null;
|
||||
xpubs: Record<string, string> | null;
|
||||
defaultBip: number;
|
||||
isActive: boolean;
|
||||
/** Whether this wallet holds a seed at all — i.e. whether unlock/lock apply to it. */
|
||||
hasSeed: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type WalletConfig = {
|
||||
network: BitcoinNetwork;
|
||||
esploraUrl: string;
|
||||
unlockTtlSec: number;
|
||||
/** Wallet creation is refused without it, so the UI blocks the form rather than failing on submit. */
|
||||
storeKeyConfigured: boolean;
|
||||
};
|
||||
|
||||
export type LockState = {
|
||||
hasSeed: boolean;
|
||||
unlocked: boolean;
|
||||
secondsRemaining: number;
|
||||
};
|
||||
|
||||
export type NodeInfo = {
|
||||
kind: BackendKind;
|
||||
pubkey: string | null;
|
||||
alias: string | null;
|
||||
version: string | null;
|
||||
network: BitcoinNetwork;
|
||||
blockHeight: number | null;
|
||||
synced: boolean;
|
||||
};
|
||||
|
||||
export type Balances = {
|
||||
onchainConfirmed: number;
|
||||
onchainUnconfirmed: number;
|
||||
/** Null when the backend has no channels of its own. */
|
||||
lightningBalance: number | null;
|
||||
lightningInbound: number | null;
|
||||
};
|
||||
|
||||
export type AddressType = 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh';
|
||||
|
||||
export type OnchainTx = {
|
||||
txid: string;
|
||||
/** Net effect on this wallet in sats — negative for a spend. */
|
||||
amount: number;
|
||||
feeSats: number | null;
|
||||
blockHeight: number | null;
|
||||
timestamp: number | null;
|
||||
confirmations: number;
|
||||
label: string | null;
|
||||
destAddresses: string[];
|
||||
rawHex: string | null;
|
||||
};
|
||||
|
||||
/** The sidecar overlays Officer's own `frozen` flag and address label onto the backend's UTXO. */
|
||||
export type Utxo = {
|
||||
txid: string;
|
||||
vout: number;
|
||||
amountSats: number;
|
||||
address: string;
|
||||
addressType: AddressType | null;
|
||||
confirmations: number;
|
||||
derivationPath: string | null;
|
||||
frozen: boolean;
|
||||
label: string | null;
|
||||
};
|
||||
|
||||
export type FeeEstimates = {
|
||||
fastestFee: number;
|
||||
halfHourFee: number;
|
||||
hourFee: number;
|
||||
economyFee: number;
|
||||
minimumFee: number;
|
||||
};
|
||||
|
||||
export type SendCoinsResult = { txid: string; feeSats: number; rawHex: string | null };
|
||||
|
||||
export type InvoiceState = 'open' | 'settled' | 'canceled' | 'accepted' | 'expired';
|
||||
|
||||
export type Invoice = {
|
||||
paymentHash: string;
|
||||
bolt11: string;
|
||||
amountMsat: string | null;
|
||||
amountPaidMsat: string | null;
|
||||
memo: string | null;
|
||||
state: InvoiceState;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
settledAt: number | null;
|
||||
preimage: string | null;
|
||||
isKeysend: boolean;
|
||||
isAmp: boolean;
|
||||
};
|
||||
|
||||
export type DecodedInvoice = {
|
||||
bolt11: string;
|
||||
paymentHash: string;
|
||||
amountMsat: string | null;
|
||||
description: string | null;
|
||||
destination: string;
|
||||
timestamp: number;
|
||||
expiry: number;
|
||||
cltvExpiry: number | null;
|
||||
routeHints: boolean;
|
||||
features: string[];
|
||||
};
|
||||
|
||||
export type PaymentStatus = 'pending' | 'succeeded' | 'failed';
|
||||
|
||||
export type Payment = {
|
||||
paymentHash: string;
|
||||
preimage: string | null;
|
||||
amountMsat: string;
|
||||
feeMsat: string;
|
||||
status: PaymentStatus;
|
||||
createdAt: number;
|
||||
destination: string | null;
|
||||
memo: string | null;
|
||||
failureReason: string | null;
|
||||
};
|
||||
|
||||
export type Channel = {
|
||||
channelId: string;
|
||||
channelPoint: string | null;
|
||||
remotePubkey: string;
|
||||
remoteAlias: string | null;
|
||||
capacitySats: number;
|
||||
localBalanceSats: number;
|
||||
remoteBalanceSats: number;
|
||||
active: boolean;
|
||||
private: boolean;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type Peer = { pubkey: string; address: string; alias: string | null; inbound: boolean };
|
||||
|
||||
// ── Capability-driven section visibility ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Which sections a backend can actually serve. Anything not listed here is hidden from the nav rather
|
||||
* than rendered as a control that returns 501 — see the sidecar's `requireCap`.
|
||||
*
|
||||
* `overview`, `transactions` and `settings` are unconditional: getInfo/getBalances/getTransactions are
|
||||
* on the base interface with no capability guard, and settings is wallet lifecycle, not a backend call.
|
||||
*/
|
||||
export function sectionAvailable(section: WalletSectionId, caps: readonly Capability[]): boolean {
|
||||
const has = (c: Capability) => caps.includes(c);
|
||||
switch (section) {
|
||||
case 'receive':
|
||||
return has('onchainReceive') || has('lightningReceive');
|
||||
case 'send':
|
||||
return has('onchainSend') || has('lightningSend');
|
||||
case 'coins':
|
||||
return has('coinControl');
|
||||
case 'lightning':
|
||||
return has('lightningReceive') || has('lightningSend') || has('channels') || has('peers');
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const KIND_LABELS: Record<BackendKind, string> = {
|
||||
onchain: 'On-chain (self-custodial)',
|
||||
lnd: 'LND',
|
||||
'cln-rest': 'Core Lightning',
|
||||
lndhub: 'LNDHub',
|
||||
nwc: 'Nostr Wallet Connect',
|
||||
};
|
||||
|
||||
/** Networks other than mainnet get a visible badge — sending testnet coins by mistake is a bad day. */
|
||||
export const NETWORK_TONES: Record<string, string> = {
|
||||
bitcoin: 'bg-amber-500/15 text-amber-600',
|
||||
testnet: 'bg-emerald-500/15 text-emerald-600',
|
||||
signet: 'bg-violet-500/15 text-violet-600',
|
||||
regtest: 'bg-slate-500/15 text-slate-600',
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { AmountUnit } from './format';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
|
||||
// sats ⇄ BTC, shared across every wallet panel.
|
||||
//
|
||||
// A display preference, not a selection — it names no entity and belongs in no URL (see the audit's
|
||||
// "view toggles over tiny fixed sets"). It lives in useGlobal so the nav and the view agree instantly,
|
||||
// and is mirrored to localStorage so it survives a reload.
|
||||
|
||||
const STORAGE_KEY = 'wallet:amount-unit';
|
||||
|
||||
const initial = (): AmountUnit => {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'btc' ? 'btc' : 'sats';
|
||||
} catch {
|
||||
return 'sats';
|
||||
}
|
||||
};
|
||||
|
||||
export function useAmountUnit() {
|
||||
const [unit, setUnit] = useGlobal<AmountUnit>('WALLET_AMOUNT_UNIT', initial);
|
||||
|
||||
const toggle = () => {
|
||||
const next: AmountUnit = unit === 'sats' ? 'btc' : 'sats';
|
||||
setUnit(next);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
/* private mode — the preference just doesn't persist */
|
||||
}
|
||||
};
|
||||
|
||||
return { unit, toggle };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
// Coin control selection, held in `?coins=txid:vout,txid:vout`.
|
||||
//
|
||||
// The Coins section picks the inputs and the Send section spends them — two panels that must agree on a
|
||||
// set. The URL is how they agree (docs/navigation-audit.md): no channel, no shared store, and the
|
||||
// selection survives a reload and can be handed to someone as a link.
|
||||
//
|
||||
// Outpoints are public transaction data, so there is nothing sensitive about putting them in an address
|
||||
// bar — unlike anything else this app handles.
|
||||
|
||||
const PARAM = 'coins';
|
||||
const OUTPOINT = /^[0-9a-f]{64}:\d+$/i;
|
||||
|
||||
export function useCoinSelection() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
|
||||
const raw = params.get(PARAM) ?? '';
|
||||
const selected = useMemo(() => raw.split(',').filter((o) => OUTPOINT.test(o)), [raw]);
|
||||
|
||||
const write = useCallback(
|
||||
(next: string[]) => {
|
||||
setParams(
|
||||
(prev) => {
|
||||
const updated = new URLSearchParams(prev);
|
||||
if (next.length === 0) updated.delete(PARAM);
|
||||
else updated.set(PARAM, next.join(','));
|
||||
return updated;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setParams],
|
||||
);
|
||||
|
||||
const toggle = useCallback(
|
||||
(outpoint: string) =>
|
||||
write(selected.includes(outpoint) ? selected.filter((o) => o !== outpoint) : [...selected, outpoint]),
|
||||
[selected, write],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => write([]), [write]);
|
||||
|
||||
return { selected, toggle, clear, set: write };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLockState } from './useWalletData';
|
||||
|
||||
// The unlock window, as a second-by-second countdown.
|
||||
//
|
||||
// The sidecar's `secondsRemaining` is a snapshot taken when the request was served, and re-polling once a
|
||||
// second to animate a clock would be absurd. So the poll re-syncs the truth every ten seconds and this
|
||||
// hook interpolates between syncs from React Query's `dataUpdatedAt`, which is the wall time the snapshot
|
||||
// arrived. Drift is bounded by the poll interval and always errs towards showing LESS time than remains —
|
||||
// a countdown that overstates the window is the one that surprises you mid-signature.
|
||||
//
|
||||
// When it reaches zero the UI flips to locked immediately rather than waiting for the next poll to agree.
|
||||
// Nothing else is invalidated when that happens: every read in this app works fine against a locked
|
||||
// wallet, and blowing the caches away would make an expiring timer look like a connection failure.
|
||||
|
||||
export type LockCountdown = {
|
||||
/** Null while the first lock-state request is in flight, or when no wallet is selected. */
|
||||
hasSeed: boolean | null;
|
||||
unlocked: boolean;
|
||||
secondsRemaining: number;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export function useLockCountdown(walletId: number | null): LockCountdown {
|
||||
const { lock, dataUpdatedAt, isLoading } = useLockState(walletId);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
const ticking = lock?.unlocked === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!ticking) return;
|
||||
const timer = setInterval(() => setNow(Date.now()), 1_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [ticking]);
|
||||
|
||||
if (!lock) return { hasSeed: null, unlocked: false, secondsRemaining: 0, isLoading };
|
||||
|
||||
const elapsed = dataUpdatedAt ? Math.floor((now - dataUpdatedAt) / 1000) : 0;
|
||||
const secondsRemaining = Math.max(0, lock.secondsRemaining - Math.max(0, elapsed));
|
||||
|
||||
return {
|
||||
hasSeed: lock.hasSeed,
|
||||
unlocked: lock.unlocked && secondsRemaining > 0,
|
||||
secondsRemaining,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { WalletSummary } from './shared';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { WALLET_PARAM } from './shared';
|
||||
import { useWallets } from './useWalletData';
|
||||
|
||||
// Which wallet is open lives in `?wallet=<id>`, read independently by every panel — never passed between
|
||||
// them over a channel. See docs/navigation-audit.md.
|
||||
//
|
||||
// With no param, the sidecar's own `isActive` wallet wins, then the first in the list. That fallback is a
|
||||
// display default, not a selection: the URL stays bare so a shared /wallet link means "whatever is active
|
||||
// right now" rather than pinning whichever wallet the sender happened to have open.
|
||||
|
||||
export type SelectedWallet = {
|
||||
walletId: number | null;
|
||||
wallet: WalletSummary | null;
|
||||
wallets: WalletSummary[];
|
||||
/** True when the id came from the URL rather than the active-wallet fallback. */
|
||||
isPinned: boolean;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export function useSelectedWallet(): SelectedWallet {
|
||||
const [params] = useSearchParams();
|
||||
const { wallets, isLoading } = useWallets();
|
||||
|
||||
const raw = params.get(WALLET_PARAM);
|
||||
const pinnedId = raw && /^\d+$/.test(raw) ? Number(raw) : null;
|
||||
const pinned = pinnedId == null ? null : (wallets.find((w) => w.id === pinnedId) ?? null);
|
||||
const fallback = wallets.find((w) => w.isActive) ?? wallets[0] ?? null;
|
||||
const wallet = pinned ?? fallback;
|
||||
|
||||
return {
|
||||
walletId: wallet?.id ?? null,
|
||||
wallet,
|
||||
wallets,
|
||||
isPinned: pinned != null,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
import type {
|
||||
Balances,
|
||||
BackendKind,
|
||||
Capability,
|
||||
Channel,
|
||||
DecodedInvoice,
|
||||
FeeEstimates,
|
||||
Invoice,
|
||||
LockState,
|
||||
NodeInfo,
|
||||
OnchainTx,
|
||||
Payment,
|
||||
Peer,
|
||||
SendCoinsResult,
|
||||
Utxo,
|
||||
WalletConfig,
|
||||
WalletSummary,
|
||||
} from './shared';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
// Data layer for the /wallet panels. Everything talks to the officer-wallet sidecar through the
|
||||
// /api/wallet auth proxy, which holds no key material of its own.
|
||||
//
|
||||
// THE WALLET IS USUALLY LOCKED, AND THAT IS THE NORMAL STATE. Every read below — balances, history,
|
||||
// receive addresses, coin control, invoices — works against a locked wallet, because a locked wallet is
|
||||
// still a watch-only wallet. Only signing needs the seed. So nothing here is gated on the lock, and no
|
||||
// query is invalidated when the unlock window expires: the numbers on screen do not become wrong.
|
||||
//
|
||||
// PASSPHRASES ARE NEVER CACHED. The unlock/export/delete/change mutations take the passphrase as a call
|
||||
// argument and hand it straight to the POST body. React Query stores mutation *variables* on the
|
||||
// mutation object, so these are deliberately fire-and-forget: nothing here keeps a reference after the
|
||||
// request resolves, and no passphrase is ever a query key or a query result.
|
||||
|
||||
const ROOT_KEY = ['wallet'] as const;
|
||||
|
||||
/** Balances move with the chain; a 20s poll is live enough without hammering Esplora. */
|
||||
const BALANCE_POLL_MS = 20_000;
|
||||
/** The lock countdown is rendered locally from `secondsRemaining`; this only re-syncs the truth. */
|
||||
const LOCK_POLL_MS = 10_000;
|
||||
/** History and coins are cheaper to refresh on demand than to poll hard. */
|
||||
const HISTORY_POLL_MS = 60_000;
|
||||
|
||||
const EMPTY_WALLETS: WalletSummary[] = [];
|
||||
const EMPTY_CAPS: Capability[] = [];
|
||||
const EMPTY_TXS: OnchainTx[] = [];
|
||||
const EMPTY_UTXOS: Utxo[] = [];
|
||||
const EMPTY_INVOICES: Invoice[] = [];
|
||||
const EMPTY_PAYMENTS: Payment[] = [];
|
||||
const EMPTY_CHANNELS: Channel[] = [];
|
||||
const EMPTY_PEERS: Peer[] = [];
|
||||
|
||||
const base = (id: number) => `/wallet/_officer/wallets/${id}`;
|
||||
|
||||
// ── deployment + wallet list ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWalletConfig() {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'config'] as const,
|
||||
queryFn: () => get<WalletConfig>('/wallet/_officer/config'),
|
||||
// Network and store-key config change only with a sidecar restart.
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
return { config: query.data ?? null, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
/** The wallet list does not poll — it only changes when the owner creates, renames or deletes one. */
|
||||
export function useWallets() {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'wallets'] as const,
|
||||
queryFn: () => get<{ wallets: WalletSummary[] }>('/wallet/_officer/wallets'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
return {
|
||||
wallets: query.data?.wallets ?? EMPTY_WALLETS,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
|
||||
// ── per-wallet reads ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The lock state, polled. The countdown a user sees is interpolated locally in useLockCountdown — this
|
||||
* query is the periodic re-sync, not the clock.
|
||||
*/
|
||||
export function useLockState(walletId: number | null) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'lock-state', walletId] as const,
|
||||
queryFn: () => get<LockState>(`${base(walletId!)}/lock-state`),
|
||||
enabled: walletId != null,
|
||||
refetchInterval: LOCK_POLL_MS,
|
||||
staleTime: LOCK_POLL_MS - 1_000,
|
||||
});
|
||||
|
||||
return { lock: query.data ?? null, dataUpdatedAt: query.dataUpdatedAt, isLoading: query.isLoading };
|
||||
}
|
||||
|
||||
export function useCapabilities(walletId: number | null) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'capabilities', walletId] as const,
|
||||
queryFn: () => get<{ kind: BackendKind; capabilities: Capability[] }>(`${base(walletId!)}/capabilities`),
|
||||
enabled: walletId != null,
|
||||
// A backend's capability set is fixed for the life of the wallet.
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
|
||||
return {
|
||||
kind: query.data?.kind ?? null,
|
||||
capabilities: query.data?.capabilities ?? EMPTY_CAPS,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWalletInfo(walletId: number | null) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'info', walletId] as const,
|
||||
queryFn: () => get<{ info: NodeInfo }>(`${base(walletId!)}/info`),
|
||||
enabled: walletId != null,
|
||||
refetchInterval: BALANCE_POLL_MS,
|
||||
staleTime: BALANCE_POLL_MS - 1_000,
|
||||
});
|
||||
|
||||
return { info: query.data?.info ?? null, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function useBalances(walletId: number | null) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'balances', walletId] as const,
|
||||
queryFn: () => get<{ balances: Balances }>(`${base(walletId!)}/balances`),
|
||||
enabled: walletId != null,
|
||||
refetchInterval: BALANCE_POLL_MS,
|
||||
staleTime: BALANCE_POLL_MS - 1_000,
|
||||
});
|
||||
|
||||
return { balances: query.data?.balances ?? null, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function useTransactions(walletId: number | null, limit = 50) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'transactions', walletId, limit] as const,
|
||||
queryFn: () => get<{ transactions: OnchainTx[] }>(`${base(walletId!)}/transactions?limit=${limit}`),
|
||||
enabled: walletId != null,
|
||||
refetchInterval: HISTORY_POLL_MS,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
return { transactions: query.data?.transactions ?? EMPTY_TXS, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function useUtxos(walletId: number | null, enabled = true) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'utxos', walletId] as const,
|
||||
queryFn: () => get<{ utxos: Utxo[] }>(`${base(walletId!)}/utxos`),
|
||||
enabled: walletId != null && enabled,
|
||||
refetchInterval: HISTORY_POLL_MS,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
return { utxos: query.data?.utxos ?? EMPTY_UTXOS, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function useFees(walletId: number | null, enabled = true) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'fees', walletId] as const,
|
||||
queryFn: () => get<{ fees: FeeEstimates }>(`${base(walletId!)}/fees`),
|
||||
enabled: walletId != null && enabled,
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
return { fees: query.data?.fees ?? null, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* A receive address. `peek` returns the current unused address without advancing the derivation index —
|
||||
* which is what a screen that merely *displays* an address must do, or every render burns an address.
|
||||
*/
|
||||
export function useReceiveAddress(walletId: number | null, enabled = true) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'address', walletId] as const,
|
||||
queryFn: () => get<{ address: string; type: string }>(`${base(walletId!)}/address?peek=true`),
|
||||
enabled: walletId != null && enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return {
|
||||
address: query.data?.address ?? null,
|
||||
addressType: query.data?.type ?? null,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export function useInvoices(walletId: number | null, enabled = true, limit = 50) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'invoices', walletId, limit] as const,
|
||||
queryFn: () => get<{ invoices: Invoice[] }>(`${base(walletId!)}/invoices?limit=${limit}`),
|
||||
enabled: walletId != null && enabled,
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
return { invoices: query.data?.invoices ?? EMPTY_INVOICES, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function usePayments(walletId: number | null, enabled = true, limit = 50) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'payments', walletId, limit] as const,
|
||||
queryFn: () => get<{ payments: Payment[] }>(`${base(walletId!)}/payments?limit=${limit}`),
|
||||
enabled: walletId != null && enabled,
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
return { payments: query.data?.payments ?? EMPTY_PAYMENTS, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function useChannels(walletId: number | null, enabled = true) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'channels', walletId] as const,
|
||||
queryFn: () => get<{ channels: Channel[] }>(`${base(walletId!)}/channels`),
|
||||
enabled: walletId != null && enabled,
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
return { channels: query.data?.channels ?? EMPTY_CHANNELS, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function usePeers(walletId: number | null, enabled = true) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'peers', walletId] as const,
|
||||
queryFn: () => get<{ peers: Peer[] }>(`${base(walletId!)}/peers`),
|
||||
enabled: walletId != null && enabled,
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
return { peers: query.data?.peers ?? EMPTY_PEERS, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
// ── wallet lifecycle mutations ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type CreateWalletInput = {
|
||||
name: string;
|
||||
kind: BackendKind;
|
||||
network?: string;
|
||||
/** onchain only. Omit to have the sidecar generate one — that is the only case that returns a mnemonic. */
|
||||
mnemonic?: string;
|
||||
words?: 12 | 24;
|
||||
/** onchain only, and never stored anywhere on this side. */
|
||||
passphrase?: string;
|
||||
bip39Passphrase?: string;
|
||||
defaultBip?: number;
|
||||
config?: Record<string, unknown>;
|
||||
makeActive?: boolean;
|
||||
};
|
||||
|
||||
export type CreateWalletResult = { wallet: WalletSummary; mnemonic?: string };
|
||||
|
||||
export function useWalletLifecycle() {
|
||||
const { post, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
const invalidateList = () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'wallets'] });
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (input: CreateWalletInput) => post<CreateWalletResult>('/wallet/_officer/wallets', input),
|
||||
// Deliberately no onSuccess toast/cache write with the response: it may carry the mnemonic, and the
|
||||
// only place that is allowed to exist is the backup modal's local state. The caller invalidates.
|
||||
onSuccess: invalidateList,
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not create the wallet')),
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (walletId: number) => post<{ ok: true }>(`${base(walletId)}/activate`),
|
||||
onSuccess: () => {
|
||||
invalidateList();
|
||||
toast.success('Active wallet changed');
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not activate the wallet')),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
// The passphrase is required for a seeded wallet even when it is already unlocked — an open session
|
||||
// must not be enough to destroy the only copy of the key material.
|
||||
mutationFn: ({ walletId, passphrase }: { walletId: number; passphrase?: string }) =>
|
||||
del<{ ok: true }>(`/wallet/_officer/wallets/${walletId}`, passphrase ? { passphrase } : {}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ROOT_KEY });
|
||||
toast.success('Wallet deleted');
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not delete the wallet')),
|
||||
});
|
||||
|
||||
return { create, activate, remove };
|
||||
}
|
||||
|
||||
// ── lock lifecycle ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useLockActions(walletId: number | null) {
|
||||
const { post } = useClient();
|
||||
const qc = useQueryClient();
|
||||
const refreshLock = () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'lock-state', walletId] });
|
||||
|
||||
const unlock = useMutation({
|
||||
// `passphrase` goes from a controlled input straight into this body and the caller clears its state
|
||||
// immediately after. It is never persisted, cached or logged on this side.
|
||||
mutationFn: ({ passphrase, ttlSec }: { passphrase: string; ttlSec?: number }) =>
|
||||
post<{ ok: true; unlocked: boolean; secondsRemaining: number }>(`${base(walletId!)}/unlock`, {
|
||||
passphrase,
|
||||
ttlSec,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
refreshLock();
|
||||
toast.success('Wallet unlocked');
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not unlock — check the passphrase')),
|
||||
});
|
||||
|
||||
const lock = useMutation({
|
||||
mutationFn: () => post<{ ok: true; unlocked: false }>(`${base(walletId!)}/lock`),
|
||||
onSuccess: () => {
|
||||
refreshLock();
|
||||
toast.success('Wallet locked');
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not lock the wallet')),
|
||||
});
|
||||
|
||||
const changePassphrase = useMutation({
|
||||
mutationFn: (input: { oldPassphrase: string; newPassphrase: string }) =>
|
||||
post<{ ok: true }>(`${base(walletId!)}/passphrase`, input),
|
||||
onSuccess: () => {
|
||||
refreshLock();
|
||||
toast.success('Passphrase changed — the wallet was re-locked');
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not change the passphrase')),
|
||||
});
|
||||
|
||||
const exportSeed = useMutation({
|
||||
mutationFn: (input: { passphrase: string }) =>
|
||||
post<{ mnemonic: string; hasBip39Passphrase: boolean }>(`${base(walletId!)}/export-seed`, input),
|
||||
// No cache write and no toast carrying the result — the mnemonic lives only in the dialog's state.
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not export the seed')),
|
||||
});
|
||||
|
||||
return { unlock, lock, changePassphrase, exportSeed };
|
||||
}
|
||||
|
||||
// ── operations ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SendInput = {
|
||||
address: string;
|
||||
amountSats?: number;
|
||||
sendAll?: boolean;
|
||||
satPerVbyte: number;
|
||||
outpoints?: string[];
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function useWalletOperations(walletId: number | null) {
|
||||
const { post } = useClient();
|
||||
const qc = useQueryClient();
|
||||
const invalidateAll = () => qc.invalidateQueries({ queryKey: ROOT_KEY });
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: (input: SendInput) => post<SendCoinsResult>(`${base(walletId!)}/send`, input),
|
||||
onSuccess: (result) => {
|
||||
invalidateAll();
|
||||
toast.success(`Broadcast ${result.txid.slice(0, 12)}…`);
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not send')),
|
||||
});
|
||||
|
||||
const freeze = useMutation({
|
||||
mutationFn: (input: { outpoint: string; frozen: boolean; reason?: string }) =>
|
||||
post<{ ok: true }>(`${base(walletId!)}/utxos/freeze`, input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'utxos', walletId] }),
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not change the freeze flag')),
|
||||
});
|
||||
|
||||
const createInvoice = useMutation({
|
||||
mutationFn: (input: { amountMsat?: string; memo?: string; expirySeconds?: number; private?: boolean }) =>
|
||||
post<{ invoice: Invoice }>(`${base(walletId!)}/invoices`, input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'invoices', walletId] }),
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not create the invoice')),
|
||||
});
|
||||
|
||||
const decode = useMutation({
|
||||
mutationFn: (bolt11: string) => post<{ decoded: DecodedInvoice }>(`${base(walletId!)}/decode`, { bolt11 }),
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not decode that invoice')),
|
||||
});
|
||||
|
||||
const pay = useMutation({
|
||||
mutationFn: (input: { bolt11: string; amountMsat?: string; feeLimitMsat?: string }) =>
|
||||
post<{ payment: Payment }>(`${base(walletId!)}/pay`, input),
|
||||
onSuccess: (data) => {
|
||||
invalidateAll();
|
||||
if (data.payment.status === 'failed') toast.error(data.payment.failureReason ?? 'Payment failed');
|
||||
else toast.success(data.payment.status === 'succeeded' ? 'Payment sent' : 'Payment in flight');
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not pay')),
|
||||
});
|
||||
|
||||
const keysend = useMutation({
|
||||
mutationFn: (input: { destination: string; amountMsat: string; message?: string }) =>
|
||||
post<{ payment: Payment }>(`${base(walletId!)}/keysend`, input),
|
||||
onSuccess: () => {
|
||||
invalidateAll();
|
||||
toast.success('Keysend sent');
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Keysend failed')),
|
||||
});
|
||||
|
||||
const sign = useMutation({
|
||||
mutationFn: (message: string) => post<{ signature: string }>(`${base(walletId!)}/sign`, { message }),
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not sign — the wallet may be locked')),
|
||||
});
|
||||
|
||||
const verify = useMutation({
|
||||
mutationFn: (input: { message: string; signature: string }) =>
|
||||
post<{ valid: boolean; pubkey: string | null }>(`${base(walletId!)}/verify`, input),
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not verify')),
|
||||
});
|
||||
|
||||
const setLabel = useMutation({
|
||||
mutationFn: (input: { kind: 'address' | 'tx'; ref: string; label: string }) =>
|
||||
post<{ ok: true }>(`${base(walletId!)}/labels`, input),
|
||||
onSuccess: () => invalidateAll(),
|
||||
onError: (err) => toast.error(errorMessage(err, 'Could not save the label')),
|
||||
});
|
||||
|
||||
return { send, freeze, createInvoice, decode, pay, keysend, sign, verify, setLabel };
|
||||
}
|
||||
|
||||
/**
|
||||
* useClient rejects with `{status, message}` rather than an Error, and the sidecar's message is a JSON
|
||||
* body — unwrap both so a toast reads "this wallet does not support coin control" rather than
|
||||
* "[object Object]". Never called with anything that could contain a passphrase: the sidecar's error
|
||||
* bodies are messages and codes only.
|
||||
*/
|
||||
function errorMessage(err: unknown, fallback: string): string {
|
||||
const raw = typeof err === 'object' && err !== null && 'message' in err ? String(err.message) : '';
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: string };
|
||||
if (parsed.error) return parsed.error;
|
||||
} catch {
|
||||
/* not JSON — use it as-is */
|
||||
}
|
||||
return raw || fallback;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { DEFAULT_WALLET_SECTION, isWalletSection, type WalletSectionId } from './shared';
|
||||
|
||||
// The URL names the open section — not a panel channel. See docs/navigation-audit.md. WalletScreen
|
||||
// redirects anything unrecognised, so the fallback here only covers the instant before that lands.
|
||||
|
||||
export function useWalletSection(): WalletSectionId {
|
||||
const { section } = useParams();
|
||||
return isWalletSection(section) ? section : DEFAULT_WALLET_SECTION;
|
||||
}
|
||||
@@ -43,6 +43,10 @@ export type { TransmissionSectionId } from './apps/Transmission/shared';
|
||||
export { DEFAULT_INVOICES_SECTION, invoicesSectionPath, isInvoicesSection } from './apps/Invoices/shared';
|
||||
export type { InvoicesSectionId } from './apps/Invoices/shared';
|
||||
|
||||
// Same for /wallet. WALLET_PARAM is exported too so anything linking into the screen from outside spells
|
||||
// the wallet query param the same way the panels read it.
|
||||
export { DEFAULT_WALLET_SECTION, walletSectionPath, isWalletSection, WALLET_PARAM } from './apps/Wallet/shared';
|
||||
export type { WalletSectionId } from './apps/Wallet/shared';
|
||||
export {
|
||||
useFilesAPI,
|
||||
useTasks,
|
||||
|
||||
Reference in New Issue
Block a user