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:
2026-07-31 06:48:06 +00:00
co-authored by Claude Opus 5
parent 5ee56e736b
commit f8826e4c24
69 changed files with 11867 additions and 0 deletions
+14
View File
@@ -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.
+89
View File
@@ -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=="],
+8
View File
@@ -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,
},
],
};
+9
View File
@@ -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:*",
+2
View File
@@ -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' },
+16
View File
@@ -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)],
);
+54
View File
@@ -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) });
});
+20
View File
@@ -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;
}
+3
View File
@@ -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);
+2
View File
@@ -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 };
+194
View File
@@ -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
+859
View File
@@ -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,
};
}
}
+407
View File
@@ -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;
}
+301
View File
@@ -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);
});
});
+516
View File
@@ -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();
}
+281
View File
@@ -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 };
+200
View File
@@ -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'));
+196
View File
@@ -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,
);
});
+384
View File
@@ -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);
}
+565
View File
@@ -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 };
}
+119
View File
@@ -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');
}
}
+536
View File
@@ -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);
}
+315
View File
@@ -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';
}
}
+55
View File
@@ -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;
}
+4
View File
@@ -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,