From f8826e4c24a3c89254cd2b89d63f4701c5c8c251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 31 Jul 2026 06:48:06 +0000 Subject: [PATCH] add the bitcoin wallet sidecar and ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 14 + bun.lock | 89 ++ ecosystem.config.cjs | 8 + package.json | 9 + src/apps/officer-web/App.tsx | 2 + .../Screens/Dashboard/Layout/Dock.tsx | 2 + .../Screens/Dashboard/Wallet/WalletScreen.tsx | 62 + .../Screens/Dashboard/Wallet/defaultLayout.ts | 11 + .../Screens/Dashboard/Wallet/index.tsx | 1 + .../officer-web/Screens/Dashboard/index.tsx | 1 + src/apps/officer-web/state/usePageTitle.ts | 1 + src/databases/officer_db/src/index.ts | 16 + .../officer_db/src/queries/wallet.ts | 266 +++++ src/databases/officer_db/src/schema/index.ts | 1 + src/databases/officer_db/src/schema/wallet.ts | 102 ++ src/servers/api/wallet/router.ts | 54 + src/servers/api/wallet/sidecar-server.ts | 20 + src/servers/hono.ts | 3 + src/servers/sidecar/protocol.ts | 2 + src/servers/sidecar/wallet/backends/base.ts | 194 ++++ .../sidecar/wallet/backends/clnrest.ts | 1011 +++++++++++++++++ src/servers/sidecar/wallet/backends/lnd.ts | 859 ++++++++++++++ src/servers/sidecar/wallet/backends/lndhub.ts | 601 ++++++++++ src/servers/sidecar/wallet/backends/nwc.ts | 407 +++++++ .../sidecar/wallet/backends/onchain.ts | 732 ++++++++++++ src/servers/sidecar/wallet/bolt11.test.ts | 301 +++++ src/servers/sidecar/wallet/bolt11.ts | 516 +++++++++ src/servers/sidecar/wallet/chain.ts | 281 +++++ src/servers/sidecar/wallet/index.ts | 200 ++++ src/servers/sidecar/wallet/keys.test.ts | 196 ++++ src/servers/sidecar/wallet/keys.ts | 384 +++++++ src/servers/sidecar/wallet/psbt.ts | 565 +++++++++ src/servers/sidecar/wallet/resolve.ts | 119 ++ src/servers/sidecar/wallet/routes.ts | 536 +++++++++ src/servers/sidecar/wallet/types.ts | 315 +++++ src/servers/sidecar/wallet/upstream.ts | 55 + .../src/AppRegistry/AppRegistry.tsx | 2 + .../officerdev/src/apps/Wallet/Amount.tsx | 46 + .../officerdev/src/apps/Wallet/CoinsView.tsx | 128 +++ .../officerdev/src/apps/Wallet/CopyField.tsx | 45 + .../src/apps/Wallet/EmptyWallet.tsx | 36 + .../src/apps/Wallet/LightningView.tsx | 146 +++ .../officerdev/src/apps/Wallet/LockBadge.tsx | 132 +++ .../src/apps/Wallet/OverviewView.tsx | 164 +++ .../src/apps/Wallet/ReceiveView.tsx | 115 ++ .../officerdev/src/apps/Wallet/SendView.tsx | 294 +++++ .../src/apps/Wallet/TransactionsView.tsx | 77 ++ .../officerdev/src/apps/Wallet/WalletNav.tsx | 178 +++ .../src/apps/Wallet/WalletSettingsView.tsx | 181 +++ .../officerdev/src/apps/Wallet/WalletView.tsx | 34 + .../src/apps/Wallet/WalletViewHeader.tsx | 31 + .../Wallet/dialogs/ChangePassphraseDialog.tsx | 121 ++ .../Wallet/dialogs/CreateInvoiceDialog.tsx | 121 ++ .../Wallet/dialogs/CreateWalletDialog.tsx | 353 ++++++ .../Wallet/dialogs/DeleteWalletDialog.tsx | 113 ++ .../apps/Wallet/dialogs/ExportSeedDialog.tsx | 125 ++ .../apps/Wallet/dialogs/PayInvoiceDialog.tsx | 170 +++ .../apps/Wallet/dialogs/SeedBackupDialog.tsx | 94 ++ .../src/apps/Wallet/dialogs/UnlockDialog.tsx | 132 +++ .../officerdev/src/apps/Wallet/format.ts | 152 +++ .../officerdev/src/apps/Wallet/index.ts | 25 + .../officerdev/src/apps/Wallet/shared.ts | 251 ++++ .../src/apps/Wallet/useAmountUnit.ts | 34 + .../src/apps/Wallet/useCoinSelection.ts | 46 + .../src/apps/Wallet/useLockCountdown.ts | 47 + .../src/apps/Wallet/useSelectedWallet.ts | 39 + .../src/apps/Wallet/useWalletData.ts | 485 ++++++++ .../src/apps/Wallet/useWalletSection.ts | 10 + src/workspaces/officerdev/src/index.ts | 4 + 69 files changed, 11867 insertions(+) create mode 100644 src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Wallet/defaultLayout.ts create mode 100644 src/apps/officer-web/Screens/Dashboard/Wallet/index.tsx create mode 100644 src/databases/officer_db/src/queries/wallet.ts create mode 100644 src/databases/officer_db/src/schema/wallet.ts create mode 100644 src/servers/api/wallet/router.ts create mode 100644 src/servers/api/wallet/sidecar-server.ts create mode 100644 src/servers/sidecar/wallet/backends/base.ts create mode 100644 src/servers/sidecar/wallet/backends/clnrest.ts create mode 100644 src/servers/sidecar/wallet/backends/lnd.ts create mode 100644 src/servers/sidecar/wallet/backends/lndhub.ts create mode 100644 src/servers/sidecar/wallet/backends/nwc.ts create mode 100644 src/servers/sidecar/wallet/backends/onchain.ts create mode 100644 src/servers/sidecar/wallet/bolt11.test.ts create mode 100644 src/servers/sidecar/wallet/bolt11.ts create mode 100644 src/servers/sidecar/wallet/chain.ts create mode 100644 src/servers/sidecar/wallet/index.ts create mode 100644 src/servers/sidecar/wallet/keys.test.ts create mode 100644 src/servers/sidecar/wallet/keys.ts create mode 100644 src/servers/sidecar/wallet/psbt.ts create mode 100644 src/servers/sidecar/wallet/resolve.ts create mode 100644 src/servers/sidecar/wallet/routes.ts create mode 100644 src/servers/sidecar/wallet/types.ts create mode 100644 src/servers/sidecar/wallet/upstream.ts create mode 100644 src/workspaces/officerdev/src/apps/Wallet/Amount.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/CoinsView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/CopyField.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/EmptyWallet.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/LightningView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/LockBadge.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/OverviewView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/ReceiveView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/SendView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/TransactionsView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/WalletNav.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/WalletView.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/WalletViewHeader.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/dialogs/ChangePassphraseDialog.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateInvoiceDialog.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateWalletDialog.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/dialogs/DeleteWalletDialog.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/dialogs/ExportSeedDialog.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/dialogs/PayInvoiceDialog.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/dialogs/SeedBackupDialog.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/dialogs/UnlockDialog.tsx create mode 100644 src/workspaces/officerdev/src/apps/Wallet/format.ts create mode 100644 src/workspaces/officerdev/src/apps/Wallet/index.ts create mode 100644 src/workspaces/officerdev/src/apps/Wallet/shared.ts create mode 100644 src/workspaces/officerdev/src/apps/Wallet/useAmountUnit.ts create mode 100644 src/workspaces/officerdev/src/apps/Wallet/useCoinSelection.ts create mode 100644 src/workspaces/officerdev/src/apps/Wallet/useLockCountdown.ts create mode 100644 src/workspaces/officerdev/src/apps/Wallet/useSelectedWallet.ts create mode 100644 src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts create mode 100644 src/workspaces/officerdev/src/apps/Wallet/useWalletSection.ts diff --git a/.env.example b/.env.example index d76d3dee..67f3b800 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,17 @@ VAULT_STORE_KEY="" # HEADSCALE_URL=https://headscale.example.com # HEADSCALE_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. diff --git a/bun.lock b/bun.lock index f01b42d6..bc4e828f 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index e3affe5b..ddaa5ee8 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -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, + }, ], }; diff --git a/package.json b/package.json index 042a4213..a7c5d968 100644 --- a/package.json +++ b/package.json @@ -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:*", diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index a7ea42db..d1ad4668 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -48,6 +48,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index f93b3c43..8cb99f95 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -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' }, diff --git a/src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx new file mode 100644 index 00000000..2ebb9338 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx @@ -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(['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('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 ; + } + + return ( +
+ +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Wallet/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Wallet/defaultLayout.ts new file mode 100644 index 00000000..ebdad736 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Wallet/defaultLayout.ts @@ -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 }, + ], +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Wallet/index.tsx b/src/apps/officer-web/Screens/Dashboard/Wallet/index.tsx new file mode 100644 index 00000000..b39d800e --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Wallet/index.tsx @@ -0,0 +1 @@ +export * from './WalletScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 85473265..bf39201a 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -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'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 3cc30b3f..3d2e56a2 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -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' }, diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 71bb94c6..035f4276 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -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'; diff --git a/src/databases/officer_db/src/queries/wallet.ts b/src/databases/officer_db/src/queries/wallet.ts new file mode 100644 index 00000000..9f3e1956 --- /dev/null +++ b/src/databases/officer_db/src/queries/wallet.ts @@ -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 | 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 | 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 | 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 { + 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 { + 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 { + 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 { + 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) : 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 { + 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 | null; + /** The already-sealed envelope as JSON; encrypted again here. */ + sealedSeed?: string | null; + fingerprint?: string | null; + xpubs?: Record | null; + defaultBip?: number; + makeActive?: boolean; +}; + +export async function createWallet(params: CreateWalletParams): Promise { + 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; defaultBip?: number; sealedSeed?: string }, +): Promise { + const set: Record = { 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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(); +} diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts index 9fe2ebd4..12be57da 100644 --- a/src/databases/officer_db/src/schema/index.ts +++ b/src/databases/officer_db/src/schema/index.ts @@ -10,3 +10,4 @@ export * from './music'; export * from './soulseek'; export * from './headscale'; export * from './vault'; +export * from './wallet'; diff --git a/src/databases/officer_db/src/schema/wallet.ts b/src/databases/officer_db/src/schema/wallet.ts new file mode 100644 index 00000000..a756fac8 --- /dev/null +++ b/src/databases/officer_db/src/schema/wallet.ts @@ -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>(), + // 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)], +); diff --git a/src/servers/api/wallet/router.ts b/src/servers/api/wallet/router.ts new file mode 100644 index 00000000..fec03aee --- /dev/null +++ b/src/servers/api/wallet/router.ts @@ -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 = {}; + 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) }); +}); diff --git a/src/servers/api/wallet/sidecar-server.ts b/src/servers/api/wallet/sidecar-server.ts new file mode 100644 index 00000000..d8b9e2ea --- /dev/null +++ b/src/servers/api/wallet/sidecar-server.ts @@ -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; +} diff --git a/src/servers/hono.ts b/src/servers/hono.ts index e80c9382..db08c10b 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -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); diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index a3705e18..cf7f08ec 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -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 }; diff --git a/src/servers/sidecar/wallet/backends/base.ts b/src/servers/sidecar/wallet/backends/base.ts new file mode 100644 index 00000000..1f5bd79f --- /dev/null +++ b/src/servers/sidecar/wallet/backends/base.ts @@ -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; + + 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; + abstract getBalances(): Promise; + + getTransactions(_opts?: { limit?: number }): Promise { + return this.notSupported('on-chain transaction history'); + } + getNewAddress(_req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> { + return this.notSupported('on-chain receive'); + } + getUtxos(): Promise { + return this.notSupported('coin control'); + } + estimateFees(): Promise { + return this.notSupported('fee estimation'); + } + sendCoins(_req: SendCoinsRequest): Promise { + return this.notSupported('on-chain send'); + } + + getInvoices(_opts?: { limit?: number }): Promise { + return this.notSupported('invoice listing'); + } + createInvoice(_req: CreateInvoiceRequest): Promise { + return this.notSupported('lightning receive'); + } + lookupInvoice(_paymentHash: string): Promise { + return this.notSupported('invoice lookup'); + } + decodeInvoice(_bolt11: string): Promise { + return this.notSupported('invoice decoding'); + } + getPayments(_opts?: { limit?: number }): Promise { + return this.notSupported('payment history'); + } + payInvoice(_req: PayInvoiceRequest): Promise { + return this.notSupported('lightning send'); + } + sendKeysend(_req: KeysendRequest): Promise { + return this.notSupported('keysend'); + } + + getChannels(): Promise { + return this.notSupported('channels'); + } + getPeers(): Promise { + return this.notSupported('peers'); + } + + signMessage(_message: string): Promise { + return this.notSupported('message signing'); + } + verifyMessage(_message: string, _signature: string): Promise { + 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; + body?: unknown; + query?: Record; + 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(opts: HttpOptions): Promise { + 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 = { 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'); +} diff --git a/src/servers/sidecar/wallet/backends/clnrest.ts b/src/servers/sidecar/wallet/backends/clnrest.ts new file mode 100644 index 00000000..fb52ea91 --- /dev/null +++ b/src/servers/sidecar/wallet/backends/clnrest.ts @@ -0,0 +1,1011 @@ +// Core Lightning REST backend — ported from Zeus's `backends/CLNRest.ts` together with +// `backends/CoreLightningRequestHandler.ts`, which holds all of the actual shaping logic (balances, +// channels, peers, chain transactions) that CLNRest.ts delegates to. +// +// CLNRest is POST-only: every command is `POST /v1/` with a JSON body (`{}` when the command +// takes no arguments) and a `Rune: ` header. There are no GET endpoints and no path parameters. +// +// UNITS. CLN reports millisatoshis two ways depending on the endpoint and on the node's deprecated-API +// setting: a bare JSON number (`1234`) or a suffixed string (`"1234msat"`). Zeus assumes the number form +// everywhere — `opArray[i].amount_msat / 1000` in CoreLightningRequestHandler — which yields NaN against a +// node still emitting the string form. `parseMsat` below accepts both. +// +// INTENTIONAL BEHAVIOURAL DIFFERENCES FROM ZEUS +// +// 1. No Tor, no in-flight call de-duplication, no `trusty:`-style blanket TLS bypass — see the same three +// notes in `lnd.ts`. Self-signed certs are accepted only when the owner opts in per node. +// 2. `payLightningInvoice` no longer sends `amount_msat: Number(data.amt && data.amt * 1000)`. When `amt` +// is undefined that expression is `0`, and CLN rejects an explicit zero amount on an invoice that +// already carries one. We omit the field unless an amount was actually requested. +// 3. `feeLimitMsat` is honoured via `pay`'s `maxfee`. Zeus declares `supportsCustomFeeLimit = () => false` +// for CLN and only ever sends `maxfeepercent`; both forms are supported here. +// 4. `signMessage` returns the **zbase** signature. CLN's signmessage returns `signature` (hex), +// `recid` and `zbase`, but its own `checkmessage` only accepts zbase — returning the hex form would +// produce a signature this backend could not verify. +// 5. Channel states are reported rather than dropped. Zeus's `listPeerChannels` filters ONCHAIN, CLOSED +// and CHANNELD_AWAITING_LOCKIN out of the list entirely because its flat model has nowhere to put +// them; `Channel.status` does, so awaiting-lockin surfaces as 'pending-open'. +// 6. Chain-transaction confirmations are `tip - height + 1`. Zeus computes `tip - height`, which reports +// a transaction in the tip block as having zero confirmations. +// 7. `getInvoices` recovers each invoice's creation time by reading the 35-bit timestamp straight out of +// the BOLT11 data part. Zeus does the same thing via `Bolt11Utils.decode`; listinvoices genuinely has +// no creation-time column, only `paid_at` and `expires_at`. +// 8. `customPreimages` is NOT declared, matching Zeus's `supportsCustomPreimages = () => false`, even +// though CLN's `invoice` command does accept a `preimage` argument. +// 9. `accounts` is NOT declared — `supportsAccounts = () => false` in Zeus. CLN has no account concept. +// 10. `NewAddressRequest.peek` is ignored: `newaddr` always derives a fresh address. +// 11. `SendCoinsRequest.rbf` and `.label` are ignored — `withdraw` exposes neither. +// 12. BOLT12 offer management (`listoffers`, `offer`, `disableoffer`, `fetchinvoice`, `invoicerequest`, +// `sendinvoice`) and the askrene/getroutes/sendpay routing surface are not ported: `WalletBackend` +// has no methods for them. The `offers` capability is still declared, since the node supports them. +// +// DEPENDENCIES ON CLN PLUGINS. `getInvoices`, `getPayments` and `getTransactions` go through the **sql** +// plugin, exactly as Zeus does; `getTransactions` additionally reads the **bookkeeper**'s +// `bkpr_accountevents` table. Both plugins ship with CLN and are enabled by default, but a node that has +// disabled either will fail those three calls and no others. + +import * as bitcoin from 'bitcoinjs-lib'; +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, httpJson } from './base'; + +// ── upstream wire types ────────────────────────────────────────────────────────────────────────── +// +// Field names are lightningd's own, verbatim. `Msat` marks every field that may arrive as either a number +// or a `"…msat"` string — see the UNITS note above. + +type Msat = number | string; + +type ClnGetInfo = { + id?: string; + alias?: string; + version?: string; + blockheight?: number; + network?: string; + fees_collected_msat?: Msat; + warning_bitcoind_sync?: string; + warning_lightningd_sync?: string; +}; + +type ClnFundsOutput = { + txid?: string; + output?: number; + amount_msat?: Msat; + scriptpubkey?: string; + address?: string; + status?: string; + blockheight?: number; + reserved?: boolean; +}; + +type ClnFundsChannel = { + peer_id?: string; + our_amount_msat?: Msat; + amount_msat?: Msat; + connected?: boolean; + state?: string; +}; + +type ClnListFunds = { outputs?: ClnFundsOutput[]; channels?: ClnFundsChannel[] }; + +type ClnPeerChannel = { + peer_id?: string; + peer_connected?: boolean; + state?: string; + channel_id?: string; + short_channel_id?: string; + funding_txid?: string; + funding_outnum?: number; + total_msat?: Msat; + to_us_msat?: Msat; + private?: boolean; +}; + +type ClnPeer = { id?: string; connected?: boolean; num_channels?: number; netaddr?: string[] }; + +type ClnNode = { nodeid?: string; alias?: string }; + +type ClnTxOutput = { index?: number; amount_msat?: Msat; scriptPubKey?: string }; + +type ClnTransaction = { + hash?: string; + rawtx?: string; + blockheight?: number; + txindex?: number; + outputs?: ClnTxOutput[]; +}; + +/** The sql plugin answers with positional rows in the order the SELECT names its columns. */ +type ClnSqlResult = { rows?: SqlCell[][] }; + +type ClnFeerateEstimate = { blockcount?: number; feerate?: number; smoothed_feerate?: number }; + +type ClnFeerates = { + perkb?: { + min_acceptable?: number; + max_acceptable?: number; + floor?: number; + opening?: number; + estimates?: ClnFeerateEstimate[]; + }; +}; + +type ClnNewAddr = { bech32?: string; p2tr?: string }; + +type ClnWithdraw = { tx?: string; txid?: string; psbt?: string }; + +type ClnInvoiceResponse = { + bolt11?: string; + payment_hash?: string; + payment_secret?: string; + expires_at?: number; + created_index?: number; +}; + +type ClnListInvoice = { + label?: string; + bolt11?: string; + bolt12?: string; + payment_hash?: string; + amount_msat?: Msat; + status?: string; + amount_received_msat?: Msat; + paid_at?: number; + payment_preimage?: string; + description?: string; + expires_at?: number; +}; + +type ClnDecoded = { + type?: string; + valid?: boolean; + created_at?: number; + expiry?: number; + payee?: string; + amount_msat?: Msat; + payment_hash?: string; + description?: string; + min_final_cltv_expiry?: number; + features?: string; + routes?: unknown[]; +}; + +type ClnPayResult = { + payment_preimage?: string; + payment_hash?: string; + created_at?: number; + parts?: number; + amount_msat?: Msat; + amount_sent_msat?: Msat; + status?: string; + destination?: string; +}; + +type ClnSignMessage = { signature?: string; recid?: number; zbase?: string }; + +type ClnCheckMessage = { verified?: boolean; pubkey?: string }; + +// ── helpers ────────────────────────────────────────────────────────────────────────────────────── + +/** Accepts `1234`, `"1234"` and `"1234msat"`; returns a decimal msat string, never a number. */ +function parseMsat(value: Msat | null | undefined): string | null { + if (value == null) return null; + if (typeof value === 'number') return Number.isFinite(value) ? Math.round(value).toString() : null; + const match = /^(\d+)\s*(msat)?$/i.exec(value.trim()); + return match?.[1] ?? null; +} + +/** msat (either form) → whole satoshis, truncated the way CLN's own sat-denominated views truncate. */ +function msatToSats(value: Msat | null | undefined): number { + const msat = parseMsat(value); + return msat == null ? 0 : Math.floor(Number(msat) / 1000); +} + +function toNumber(value: unknown): number { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +/** + * Caller-supplied msat decimal string → the JSON number CLN's API expects. + * + * CLN takes amounts as JSON numbers, so a msat value above 2^53 would silently lose precision on the + * way out — a request to pay 1 msat more than MAX_SAFE_INTEGER would be rounded rather than rejected. + * The amounts involved are absurd (2^53 msat ≈ 90,000 BTC) but the failure is silent and the input is + * caller-controlled, so it is checked rather than assumed. Mirrors toMsatNumber() in nwc.ts. + */ +function msatToApiNumber(msat: string, field: string): number { + let value: bigint; + try { + value = BigInt(msat); + } catch { + throw new BackendError(`${field} must be a decimal msat string, got "${msat}"`, 400, 'BAD_AMOUNT'); + } + if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new BackendError(`${field}=${msat}msat is out of range`, 400, 'BAD_AMOUNT'); + } + return Number(value); +} + +function toNetwork(network: string | undefined): BitcoinNetwork { + switch (network) { + case 'testnet': + case 'testnet4': + return 'testnet'; + case 'signet': + return 'signet'; + case 'regtest': + return 'regtest'; + default: + return 'bitcoin'; + } +} + +const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + +/** + * BOLT11 puts a 35-bit creation timestamp in the first seven characters of the data part, immediately + * after the separator (the LAST '1' in the string, per the spec — the human-readable part may contain + * one). Reading it directly avoids pulling in a full bech32 decoder just for `listinvoices`, which has no + * creation-time column at all. + */ +function bolt11Timestamp(bolt11: string | null | undefined): number | null { + if (!bolt11) return null; + const invoice = bolt11.toLowerCase(); + const separator = invoice.lastIndexOf('1'); + if (separator < 0) return null; + const data = invoice.slice(separator + 1); + if (data.length < 7) return null; + + let timestamp = 0; + for (let i = 0; i < 7; i++) { + const char = data[i]; + // indexOf('') would answer 0, so the empty case has to be rejected before the lookup. + if (!char) return null; + const value = BECH32_CHARSET.indexOf(char); + if (value < 0) return null; + timestamp = timestamp * 32 + value; + } + return timestamp; +} + +// ── sql plugin cells ───────────────────────────────────────────────────────────────────────────── +// +// The sql plugin returns positional rows, so every read is an index into an array of loosely-typed cells. +// These three readers keep that in one place instead of scattering casts through the mappers. + +type SqlCell = string | number | boolean | null | undefined; + +function cellStr(cell: SqlCell): string | null { + return typeof cell === 'string' && cell.length > 0 ? cell : null; +} + +function cellNum(cell: SqlCell): number | null { + if (cell == null || typeof cell === 'boolean') return null; + const n = Number(cell); + return Number.isFinite(n) ? n : null; +} + +function cellMsat(cell: SqlCell): Msat | undefined { + return typeof cell === 'number' || typeof cell === 'string' ? cell : undefined; +} + +/** + * The sql plugin takes a whole SQL string, not bound parameters, so `limit` is the one caller-supplied + * value that reaches it. Force it to a bounded integer before it is ever interpolated. + */ +function clampLimit(limit: number | undefined, fallback: number): number { + const n = Math.floor(Number(limit)); + if (!Number.isFinite(n) || n <= 0) return fallback; + return Math.min(n, 5000); +} + +/** + * Address type by prefix. `listfunds` reports the address but not its kind, and the prefix is an exact + * discriminator across every network CLN runs on, so no lookup is needed. + */ +function addressType(address: string | undefined): AddressType | null { + if (!address) return null; + const addr = address.toLowerCase(); + const bech32Prefix = /^(bc1|tb1|bcrt1)/.exec(addr); + if (bech32Prefix) return addr[bech32Prefix[0].length] === 'p' ? 'p2tr' : 'p2wpkh'; + if (addr.startsWith('3') || addr.startsWith('2')) return 'p2sh-p2wpkh'; + if (addr.startsWith('1') || addr.startsWith('m') || addr.startsWith('n')) return 'p2pkh'; + return null; +} + +/** bitcoinjs has no signet parameter set; signet reuses testnet's version bytes and 'tb' hrp. */ +function bitcoinjsNetwork(network: BitcoinNetwork): bitcoin.Network { + switch (network) { + case 'regtest': + return bitcoin.networks.regtest; + case 'testnet': + case 'signet': + return bitcoin.networks.testnet; + default: + return bitcoin.networks.bitcoin; + } +} + +function toPaymentStatus(status: string | undefined): PaymentStatus { + switch (status) { + case 'complete': + return 'succeeded'; + case 'failed': + return 'failed'; + default: + return 'pending'; + } +} + +/** + * CLN's channel state machine → the coarse status in `Channel`. Zeus never models this; it filters the + * terminal states out and treats everything else as open. + */ +function toChannelStatus(state: string | undefined): string { + switch (state) { + case 'CHANNELD_NORMAL': + return 'open'; + case 'OPENINGD': + case 'CHANNELD_AWAITING_LOCKIN': + case 'DUALOPEND_OPEN_INIT': + case 'DUALOPEND_AWAITING_LOCKIN': + case 'DUALOPEND_OPEN_COMMITTED': + case 'DUALOPEND_OPEN_COMMIT_READY': + return 'pending-open'; + case 'CHANNELD_SHUTTING_DOWN': + case 'CLOSINGD_SIGEXCHANGE': + case 'CLOSINGD_COMPLETE': + return 'pending-close'; + case 'AWAITING_UNILATERAL': + case 'FUNDING_SPEND_SEEN': + case 'ONCHAIN': + return 'force-closing'; + case 'CLOSED': + return 'closed'; + default: + return state ?? 'open'; + } +} + +/** Column indices for the two SQL projections below — positional rows are otherwise unreadable. */ +const INVOICE_COL = { + label: 0, + bolt11: 1, + bolt12: 2, + paymentHash: 3, + amountMsat: 4, + status: 5, + amountReceivedMsat: 6, + paidAt: 7, + preimage: 8, + description: 9, + expiresAt: 10, +} as const; + +const PAYMENT_COL = { + paymentHash: 0, + groupId: 1, + status: 2, + destination: 3, + createdAt: 4, + description: 5, + bolt11: 6, + bolt12: 7, + amountSentMsat: 8, + amountMsat: 9, + preimage: 10, +} as const; + +const ACCOUNT_EVENT_COL = { + account: 0, + tag: 1, + outpoint: 2, + creditMsat: 3, + debitMsat: 4, + timestamp: 5, + blockheight: 6, +} as const; + +/** TLV record carrying a free-text note alongside a keysend payment. */ +const KEYSEND_MESSAGE_RECORD = '34349334'; + +export type ClnRestConfig = { + /** Full base URL including scheme and port, e.g. `https://192.168.1.5:3010`. */ + url: string; + /** The rune authorising this connection. Sent as the `Rune` header. */ + rune: string; + allowSelfSigned?: boolean; +}; + +// ── the backend ────────────────────────────────────────────────────────────────────────────────── + +export class ClnRestBackend extends BaseBackend { + readonly kind: BackendKind = 'cln-rest'; + + protected readonly capabilities: ReadonlySet = new Set([ + 'onchainReceive', + 'onchainSend', + 'coinControl', // Zeus: supportsCoinControl / supportsChannelCoinControl + 'sweep', // `withdraw … satoshi: "all"` + 'lightningReceive', + 'lightningSend', + 'keysend', + 'offers', // Zeus: supportsOffers / supportsListingOffers / supportsBolt12Address + 'channels', + 'peers', + 'routing', + 'signMessage', + // Deliberately absent: psbt, bumpFee (supportsBumpFee = false), accounts (supportsAccounts = false), + // customPreimages (supportsCustomPreimages = false). + ]); + + constructor(private readonly config: ClnRestConfig) { + super(); + } + + // ── transport ────────────────────────────────────────────────────────────────────────────────── + + /** Every CLNRest command is a POST with a JSON body; commands without arguments still need `{}`. */ + private call(command: string, body: Record = {}, timeoutMs?: number): Promise { + return httpJson({ + base: this.config.url, + path: `/v1/${command}`, + method: 'POST', + body, + headers: { Rune: this.config.rune }, + allowSelfSigned: this.config.allowSelfSigned, + timeoutMs, + }); + } + + private sql(query: string): Promise { + return this.call('sql', { query }); + } + + /** + * Resolve node aliases in one pass. Zeus issues a `listnodes` per channel and per peer inside + * `Promise.all` and lets a single failure reject the whole list; we de-duplicate by pubkey and swallow + * individual lookups, because a missing alias must not cost you the channel list. + */ + private async resolveAliases(pubkeys: string[]): Promise> { + const unique = [...new Set(pubkeys.filter(Boolean))]; + const entries = await Promise.all( + unique.map(async (id): Promise<[string, string] | null> => { + try { + const res = await this.call<{ nodes?: ClnNode[] }>('listnodes', { id }); + const alias = res.nodes?.[0]?.alias; + return alias ? [id, alias] : null; + } catch { + return null; + } + }), + ); + return new Map(entries.filter((entry): entry is [string, string] => entry !== null)); + } + + // ── node / balances ──────────────────────────────────────────────────────────────────────────── + + override async getInfo(): Promise { + const info = await this.call('getinfo'); + return { + kind: this.kind, + pubkey: info.id ?? null, + alias: info.alias ?? null, + version: info.version ?? null, + network: toNetwork(info.network), + blockHeight: info.blockheight ?? null, + // CLN reports lag by ADDING a warning field; their absence is the only "synced" signal there is. + synced: !info.warning_bitcoind_sync && !info.warning_lightningd_sync, + }; + } + + override async getBalances(): Promise { + // One listfunds call answers both halves — Zeus makes two, one via getBalance and one via + // getOffchainBalance, each re-fetching the identical payload. + const funds = await this.call('listfunds'); + + let onchainConfirmed = 0; + let onchainUnconfirmed = 0; + for (const output of funds.outputs ?? []) { + // 'spent' and 'immature' outputs are also listed; neither is spendable balance. + if (output.status === 'confirmed') onchainConfirmed += msatToSats(output.amount_msat); + else if (output.status === 'unconfirmed') onchainUnconfirmed += msatToSats(output.amount_msat); + } + + let lightningBalance = 0; + let lightningInbound = 0; + for (const channel of funds.channels ?? []) { + // Zeus counts only connected CHANNELD_NORMAL channels towards the spendable balance; funds in + // disconnected or still-opening channels are real but not usable right now. + if (channel.state !== 'CHANNELD_NORMAL' || channel.connected !== true) continue; + const ours = Number(parseMsat(channel.our_amount_msat) ?? '0'); + const total = Number(parseMsat(channel.amount_msat) ?? '0'); + lightningBalance += Math.floor(ours / 1000); + lightningInbound += Math.floor((total - ours) / 1000); + } + + return { onchainConfirmed, onchainUnconfirmed, lightningBalance, lightningInbound }; + } + + // ── on-chain ─────────────────────────────────────────────────────────────────────────────────── + + /** + * Port of `CoreLightningRequestHandler.getChainTransactions`. + * + * CLN has no single "wallet transactions" command. `listtransactions` knows every transaction the node + * has seen but not what it MEANT; the bookkeeper's `bkpr_accountevents` knows the meaning (deposit to + * the wallet vs. payment out to an external account) but is keyed by outpoint. So the two are joined on + * txid, and any transaction that is really a channel open or close is excluded — otherwise a channel + * funding would show up as an ordinary withdrawal. + */ + override async getTransactions(opts?: { limit?: number }): Promise { + const limit = clampLimit(opts?.limit, 150); + const query = + 'SELECT account, tag, outpoint, credit_msat, debit_msat, timestamp, blockheight ' + + "FROM bkpr_accountevents WHERE (tag='deposit' OR tag='to_them' OR tag='channel_open' " + + `OR tag='channel_close') ORDER BY timestamp DESC LIMIT ${limit}`; + + // Zeus uses allSettled here so a missing bookkeeper or sql plugin degrades instead of failing; only + // getinfo is load-bearing (it supplies the chain tip and the network for address decoding). + const [eventsResult, txsResult, infoResult] = await Promise.allSettled([ + this.sql(query), + this.call<{ transactions?: ClnTransaction[] }>('listtransactions'), + this.call('getinfo'), + ]); + + if (infoResult.status !== 'fulfilled') return []; + const tip = infoResult.value.blockheight ?? 0; + const network = bitcoinjsNetwork(toNetwork(infoResult.value.network)); + + const rows = eventsResult.status === 'fulfilled' ? (eventsResult.value.rows ?? []) : []; + const transactions = txsResult.status === 'fulfilled' ? (txsResult.value.transactions ?? []) : []; + + const txidOf = (row: SqlCell[]): string | null => { + const outpoint = cellStr(row[ACCOUNT_EVENT_COL.outpoint]); + return outpoint ? (outpoint.split(':')[0] ?? null) : null; + }; + + const deposits = rows.filter((row) => row[ACCOUNT_EVENT_COL.tag] === 'deposit'); + const walletDeposits = deposits.filter((row) => row[ACCOUNT_EVENT_COL.account] === 'wallet'); + const externalDeposits = deposits.filter((row) => row[ACCOUNT_EVENT_COL.account] === 'external'); + + const isChannelChange = (txid: string): boolean => + rows.some( + (row) => + txidOf(row) === txid && + (row[ACCOUNT_EVENT_COL.tag] === 'channel_open' || row[ACCOUNT_EVENT_COL.tag] === 'channel_close'), + ); + + const result: OnchainTx[] = []; + for (const tx of transactions) { + const txid = tx.hash ?? ''; + if (!txid || isChannelChange(txid)) continue; + + // A deposit booked against the 'external' account is money leaving us; against 'wallet', arriving. + const outgoing = externalDeposits.find((row) => txidOf(row) === txid); + const incoming = outgoing ? undefined : walletDeposits.find((row) => txidOf(row) === txid); + const row = outgoing ?? incoming; + if (!row) continue; + + const credit = Number(parseMsat(cellMsat(row[ACCOUNT_EVENT_COL.creditMsat])) ?? '0'); + const amount = Math.floor((outgoing ? -Math.abs(credit) : credit) / 1000); + const height = cellNum(row[ACCOUNT_EVENT_COL.blockheight]) || null; + + const destAddresses: string[] = []; + for (const output of tx.outputs ?? []) { + if (!output.scriptPubKey) continue; + try { + destAddresses.push(bitcoin.address.fromOutputScript(Buffer.from(output.scriptPubKey, 'hex'), network)); + } catch { + // Non-standard or unrecognised script — CLN reports it, we simply have no address for it. + } + } + + result.push({ + txid, + amount, + // bkpr records the fee as its own accountevent tag, not on the deposit row. + feeSats: null, + blockHeight: height, + timestamp: cellNum(row[ACCOUNT_EVENT_COL.timestamp]) || null, + confirmations: height ? Math.max(0, tip - height + 1) : 0, + label: null, + destAddresses, + rawHex: tx.rawtx ?? null, + }); + } + + return result.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); + } + + override async getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> { + const type: AddressType = req?.type ?? 'p2wpkh'; + // CLN dropped p2sh-segwit issuance in v23.05 and has never issued legacy p2pkh + // (Zeus: supportsNestedSegWit = () => false). + const addresstype = type === 'p2wpkh' ? 'bech32' : type === 'p2tr' ? 'p2tr' : null; + if (!addresstype) { + throw new BackendError(`core lightning cannot derive a ${type} address`, 400, 'UNSUPPORTED_ADDRESS_TYPE'); + } + + const res = await this.call('newaddr', { addresstype }); + // newaddr keys the response by the address type it produced, not by a generic `address` field. + const address = addresstype === 'p2tr' ? res.p2tr : res.bech32; + if (!address) throw new BackendError('core lightning returned no address', 502); + return { address, type }; + } + + override async getUtxos(): Promise { + const [funds, info] = await Promise.all([this.call('listfunds'), this.call('getinfo')]); + const tip = info.blockheight ?? 0; + + return (funds.outputs ?? []) + .filter((output) => output.status === 'confirmed' || output.status === 'unconfirmed') + .map((output) => ({ + txid: output.txid ?? '', + vout: output.output ?? 0, + amountSats: msatToSats(output.amount_msat), + address: output.address ?? '', + addressType: addressType(output.address), + confirmations: output.blockheight ? Math.max(0, tip - output.blockheight + 1) : 0, + // CLN's listfunds does not expose the derivation path for its own outputs. + derivationPath: null, + // `reserved` is CLN's exclusion flag: an output held for an in-flight psbt or channel open. + frozen: output.reserved === true, + })); + } + + override async estimateFees(): Promise { + const res = await this.call('feerates', { style: 'perkb' }); + const perkb = res.perkb; + const estimates = perkb?.estimates ?? []; + + // perkb is sat per 1000 vbytes; the interface wants sat/vB, and never below the 1 sat/vB relay floor. + const toVbyte = (perKb: number | undefined): number => Math.max(1, Math.ceil(toNumber(perKb) / 1000)); + + /** Cheapest estimate that still confirms within `blocks`, falling back to the slowest we have. */ + const forTarget = (blocks: number): number | undefined => { + const eligible = estimates.filter((e) => (e.blockcount ?? 0) <= blocks && e.feerate != null); + const best = eligible.length > 0 ? eligible[eligible.length - 1] : estimates[estimates.length - 1]; + return best?.feerate ?? perkb?.opening; + }; + + return { + fastestFee: toVbyte(forTarget(2)), + halfHourFee: toVbyte(forTarget(3)), + hourFee: toVbyte(forTarget(6)), + economyFee: toVbyte(forTarget(144)), + minimumFee: toVbyte(perkb?.floor ?? perkb?.min_acceptable), + }; + } + + override async sendCoins(req: SendCoinsRequest): Promise { + const body: Record = { + destination: req.address, + // CLN takes a feerate with an explicit unit; sat/vB × 1000 is the perkb form. + feerate: `${Math.round(req.satPerVbyte * 1000)}perkb`, + satoshi: req.sendAll ? 'all' : (req.amountSats ?? 0), + }; + // CLN's utxos are plain `txid:vout` strings, unlike LND's structured outpoints. + if (req.outpoints?.length) body.utxos = req.outpoints; + if (req.spendUnconfirmed) body.minconf = 0; + + const res = await this.call('withdraw', body); + if (!res.txid) throw new BackendError('core lightning returned no txid', 502); + // `withdraw` returns the signed transaction but not the fee it chose. + return { txid: res.txid, feeSats: 0, rawHex: res.tx ?? null }; + } + + // ── lightning ────────────────────────────────────────────────────────────────────────────────── + + private toInvoice(inv: ClnListInvoice): Invoice { + const expiresAt = inv.expires_at ?? null; + + let state: InvoiceState; + switch (inv.status) { + case 'paid': + state = 'settled'; + break; + case 'expired': + state = 'expired'; + break; + default: + state = expiresAt != null && expiresAt * 1000 < Date.now() ? 'expired' : 'open'; + } + + const bolt11 = inv.bolt11 ?? ''; + return { + paymentHash: inv.payment_hash ?? '', + // A BOLT12-only invoice has no bolt11; fall back to the offer-issued string so the row is still + // addressable rather than blank. + bolt11: bolt11 || (inv.bolt12 ?? ''), + amountMsat: parseMsat(inv.amount_msat), + amountPaidMsat: parseMsat(inv.amount_received_msat), + memo: inv.description ? inv.description : null, + state, + // See difference 7: listinvoices has no creation-time column. + createdAt: bolt11Timestamp(bolt11) ?? inv.paid_at ?? 0, + expiresAt, + settledAt: inv.paid_at ?? null, + preimage: inv.payment_preimage ?? null, + // CLN does not flag keysend-created invoices, and has no AMP at all (Zeus: supportsAMP = false). + isKeysend: false, + isAmp: false, + }; + } + + override async getInvoices(opts?: { limit?: number }): Promise { + const limit = clampLimit(opts?.limit, 150); + const res = await this.sql( + 'SELECT label, bolt11, bolt12, payment_hash, amount_msat, status, amount_received_msat, ' + + `paid_at, payment_preimage, description, expires_at FROM invoices ORDER BY created_index DESC LIMIT ${limit};`, + ); + + return (res.rows ?? []).map((row) => + this.toInvoice({ + label: cellStr(row[INVOICE_COL.label]) ?? undefined, + bolt11: cellStr(row[INVOICE_COL.bolt11]) ?? undefined, + bolt12: cellStr(row[INVOICE_COL.bolt12]) ?? undefined, + payment_hash: cellStr(row[INVOICE_COL.paymentHash]) ?? undefined, + amount_msat: cellMsat(row[INVOICE_COL.amountMsat]), + status: cellStr(row[INVOICE_COL.status]) ?? undefined, + amount_received_msat: cellMsat(row[INVOICE_COL.amountReceivedMsat]), + paid_at: cellNum(row[INVOICE_COL.paidAt]) ?? undefined, + payment_preimage: cellStr(row[INVOICE_COL.preimage]) ?? undefined, + description: cellStr(row[INVOICE_COL.description]) ?? undefined, + expires_at: cellNum(row[INVOICE_COL.expiresAt]) ?? undefined, + }), + ); + } + + override async createInvoice(req: CreateInvoiceRequest): Promise { + if (req.preimage) this.notSupported('custom preimages'); + if (req.isAmp) this.notSupported('AMP invoices'); + + const res = await this.call('invoice', { + description: req.memo ?? '', + // `label` is CLN's primary key for an invoice and must be unique per node. + label: `officer.${Date.now()}.${Math.floor(Math.random() * 1_000_000)}`, + // The literal string 'any' is how CLN expresses a zero-amount (donation) invoice. + amount_msat: req.amountMsat ? msatToApiNumber(req.amountMsat, 'amountMsat') : 'any', + expiry: req.expirySeconds, + // Zeus hardcodes this to true so an invoice is payable through unannounced channels — without it a + // node whose inbound liquidity is all private is unreachable. + exposeprivatechannels: req.private ?? true, + }); + + const now = Math.floor(Date.now() / 1000); + return { + paymentHash: res.payment_hash ?? '', + bolt11: res.bolt11 ?? '', + amountMsat: req.amountMsat ?? null, + amountPaidMsat: null, + memo: req.memo ?? null, + state: 'open', + createdAt: bolt11Timestamp(res.bolt11) ?? now, + expiresAt: res.expires_at ?? (req.expirySeconds != null ? now + req.expirySeconds : null), + settledAt: null, + preimage: null, + isKeysend: false, + isAmp: false, + }; + } + + override async lookupInvoice(paymentHash: string): Promise { + const res = await this.call<{ invoices?: ClnListInvoice[] }>('listinvoices', { payment_hash: paymentHash }); + const invoice = res.invoices?.[0]; + return invoice ? this.toInvoice(invoice) : null; + } + + override async decodeInvoice(bolt11: string): Promise { + // `decode` handles bolt11 and bolt12 alike but arrives with the offers plugin; `decodepay` is the + // classic bolt11-only command and is always present. Try the general one, fall back to the old one. + let decoded: ClnDecoded; + try { + decoded = await this.call('decode', { string: bolt11 }); + if (decoded.valid === false) throw new BackendError('invoice failed to decode', 400); + } catch (err) { + if (err instanceof BackendError && err.status === 400) throw err; + decoded = await this.call('decodepay', { bolt11 }); + } + + return { + bolt11, + paymentHash: decoded.payment_hash ?? '', + amountMsat: parseMsat(decoded.amount_msat), + description: decoded.description ? decoded.description : null, + destination: decoded.payee ?? '', + timestamp: decoded.created_at ?? 0, + expiry: decoded.expiry ?? 0, + cltvExpiry: decoded.min_final_cltv_expiry ?? null, + routeHints: (decoded.routes ?? []).length > 0, + // CLN hands back a raw hex feature BITMAP where LND names each feature; expand it to the indices of + // the set bits so the shape still matches and nothing is invented. + features: featureBits(decoded.features), + }; + } + + override async getPayments(opts?: { limit?: number }): Promise { + const limit = clampLimit(opts?.limit, 150); + // Grouping by (payment_hash, groupid) collapses the parts of an MPP payment back into one row; the + // per-part rows in `sendpays` would otherwise each look like a separate payment. + const res = await this.sql( + 'select sp.payment_hash, sp.groupid, min(sp.status) as status, min(sp.destination) as destination, ' + + 'min(sp.created_at) as created_at, min(sp.description) as description, min(sp.bolt11) as bolt11, ' + + "min(sp.bolt12) as bolt12, sum(case when sp.status = 'complete' then sp.amount_sent_msat else null end) " + + "as amount_sent_msat, sum(case when sp.status = 'complete' then sp.amount_msat else 0 end) as amount_msat, " + + `max(sp.payment_preimage) as preimage from sendpays sp group by sp.payment_hash, sp.groupid ` + + `order by created_index desc limit ${limit}`, + ); + + return (res.rows ?? []).map((row) => { + const amountMsat = parseMsat(cellMsat(row[PAYMENT_COL.amountMsat])) ?? '0'; + const sentMsat = parseMsat(cellMsat(row[PAYMENT_COL.amountSentMsat])) ?? amountMsat; + // The routing fee is the difference between what left us and what reached the payee. + const fee = BigInt(sentMsat) - BigInt(amountMsat); + + return { + paymentHash: cellStr(row[PAYMENT_COL.paymentHash]) ?? '', + preimage: cellStr(row[PAYMENT_COL.preimage]), + amountMsat, + feeMsat: (fee > 0n ? fee : 0n).toString(), + status: toPaymentStatus(cellStr(row[PAYMENT_COL.status]) ?? undefined), + createdAt: cellNum(row[PAYMENT_COL.createdAt]) ?? 0, + destination: cellStr(row[PAYMENT_COL.destination]), + memo: cellStr(row[PAYMENT_COL.description]), + // sendpays records no failure text; a failed attempt only carries a status. + failureReason: null, + }; + }); + } + + private toPayment(res: ClnPayResult, fallbackDestination: string | null, memo: string | null): Payment { + const amountMsat = parseMsat(res.amount_msat) ?? '0'; + const sentMsat = parseMsat(res.amount_sent_msat) ?? amountMsat; + const fee = BigInt(sentMsat) - BigInt(amountMsat); + + return { + paymentHash: res.payment_hash ?? '', + preimage: res.payment_preimage ?? null, + amountMsat, + feeMsat: (fee > 0n ? fee : 0n).toString(), + status: toPaymentStatus(res.status), + // created_at is a float in CLN (sub-second resolution); the interface is unix seconds. + createdAt: Math.floor(toNumber(res.created_at)), + destination: res.destination ?? fallbackDestination, + memo, + failureReason: null, + }; + } + + override async payInvoice(req: PayInvoiceRequest): Promise { + if (req.feeLimitMsat && req.feeLimitPercent != null) { + throw new BackendError('feeLimitMsat and feeLimitPercent are mutually exclusive', 400); + } + + const timeoutSeconds = req.timeoutSeconds ?? 60; + const body: Record = { bolt11: req.bolt11, retry_for: timeoutSeconds }; + // Only send an amount when one was actually requested — see difference 2. + if (req.amountMsat) body.amount_msat = msatToApiNumber(req.amountMsat, 'amountMsat'); + if (req.feeLimitMsat) body.maxfee = msatToApiNumber(req.feeLimitMsat, 'feeLimitMsat'); + else if (req.feeLimitPercent != null) body.maxfeepercent = req.feeLimitPercent; + + // `retry_for` bounds CLN's own retrying; give the socket a little more than that. + const res = await this.call('pay', body, (timeoutSeconds + 10) * 1000); + return this.toPayment(res, null, null); + } + + override async sendKeysend(req: KeysendRequest): Promise { + const timeoutSeconds = 60; + const body: Record = { + destination: req.destination, + amount_msat: msatToApiNumber(req.amountMsat, 'amountMsat'), + retry_for: timeoutSeconds, + }; + if (req.feeLimitMsat) body.maxfee = msatToApiNumber(req.feeLimitMsat, 'feeLimitMsat'); + // `extratlvs` values are hex-encoded by CLN's convention, not base64 as in LND's custom records. + if (req.message) { + body.extratlvs = { [KEYSEND_MESSAGE_RECORD]: Buffer.from(req.message, 'utf8').toString('hex') }; + } + + const res = await this.call('keysend', body, (timeoutSeconds + 10) * 1000); + return this.toPayment(res, req.destination, req.message ?? null); + } + + // ── channels / peers ─────────────────────────────────────────────────────────────────────────── + + override async getChannels(): Promise { + const res = await this.call<{ channels?: ClnPeerChannel[] }>('listpeerchannels'); + const channels = res.channels ?? []; + const aliases = await this.resolveAliases(channels.map((chan) => chan.peer_id ?? '')); + + return channels + .filter((chan) => chan.state !== 'CLOSED' && chan.state !== 'ONCHAIN') + .map((chan) => { + const total = Number(parseMsat(chan.total_msat) ?? '0'); + const ours = Number(parseMsat(chan.to_us_msat) ?? '0'); + return { + // short_channel_id is the network-visible id but is absent until the funding confirms. + channelId: chan.short_channel_id ?? chan.channel_id ?? '', + channelPoint: chan.funding_txid ? `${chan.funding_txid}:${chan.funding_outnum ?? 0}` : null, + remotePubkey: chan.peer_id ?? '', + remoteAlias: aliases.get(chan.peer_id ?? '') ?? null, + capacitySats: Math.floor(total / 1000), + localBalanceSats: Math.floor(ours / 1000), + remoteBalanceSats: Math.floor((total - ours) / 1000), + active: chan.peer_connected === true && chan.state === 'CHANNELD_NORMAL', + // CLN's `private` is the inverse of the gossip announcement; unset means announced. + private: chan.private === true, + status: toChannelStatus(chan.state), + }; + }); + } + + override async getPeers(): Promise { + const res = await this.call<{ peers?: ClnPeer[] }>('listpeers'); + const peers = res.peers ?? []; + const aliases = await this.resolveAliases(peers.map((peer) => peer.id ?? '')); + + return peers.map((peer) => ({ + pubkey: peer.id ?? '', + address: peer.netaddr?.[0] ?? '', + alias: aliases.get(peer.id ?? '') ?? null, + // listpeers does not report which side dialled; LND's `inbound` has no CLN equivalent. + inbound: false, + })); + } + + // ── signing ──────────────────────────────────────────────────────────────────────────────────── + + override async signMessage(message: string): Promise { + const res = await this.call('signmessage', { message }); + // zbase, not the hex `signature` — see difference 4. + if (!res.zbase) throw new BackendError('core lightning returned no signature', 502); + return { signature: res.zbase }; + } + + override async verifyMessage(message: string, signature: string): Promise { + const res = await this.call('checkmessage', { message, zbase: signature }); + return { valid: res.verified === true, pubkey: res.pubkey ?? null }; + } +} + +/** Hex feature bitmap → the indices of its set bits, least-significant bit first. */ +function featureBits(hex: string | undefined): string[] { + if (!hex) return []; + const bytes = Buffer.from(hex, 'hex'); + const bits: string[] = []; + // The bitmap is big-endian: the LAST byte holds bits 0-7. + for (let i = 0; i < bytes.length; i++) { + const byte = bytes[bytes.length - 1 - i] ?? 0; + for (let bit = 0; bit < 8; bit++) { + if (byte & (1 << bit)) bits.push(String(i * 8 + bit)); + } + } + return bits; +} diff --git a/src/servers/sidecar/wallet/backends/lnd.ts b/src/servers/sidecar/wallet/backends/lnd.ts new file mode 100644 index 00000000..304b2f0f --- /dev/null +++ b/src/servers/sidecar/wallet/backends/lnd.ts @@ -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; +}; + +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 = { + '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 = { + 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 = { + 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([ + 'onchainReceive', + 'onchainSend', + 'coinControl', + 'psbt', + 'bumpFee', + 'sweep', + 'accounts', + 'lightningReceive', + 'lightningSend', + 'keysend', + 'customPreimages', + 'offers', + 'channels', + 'peers', + 'routing', + 'signMessage', + ]); + protected readonly capabilities: ReadonlySet = this.caps; + + constructor(private readonly config: LndConfig) { + super(); + } + + // ── transport ────────────────────────────────────────────────────────────────────────────────── + + private get(path: string, query?: Record): Promise { + return httpJson({ + base: this.config.url, + path, + query, + method: 'GET', + headers: { 'Grpc-Metadata-macaroon': this.config.macaroonHex }, + allowSelfSigned: this.config.allowSelfSigned, + }); + } + + private post(path: string, body: unknown, timeoutMs?: number): Promise { + return httpJson({ + 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, timeoutMs: number): Promise { + const res = await httpJson({ + 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 { + const info = await this.get('/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 { + const [chain, channels] = await Promise.all([ + this.get('/v1/balance/blockchain'), + this.get('/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 { + // `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 { + // 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 { + // 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(`/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 { + if (req.outpoints?.length && !this.supports('coinControl')) this.notSupported('coin control'); + + const body: Record = { + 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 { + // 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 { + const res = await this.post('/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 { + try { + // The path segment is `r_hash_str` — hex, not the base64 used by the body fields. + const inv = await this.get(`/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 { + const res = await this.get(`/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 { + 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 { + if (req.feeLimitMsat && req.feeLimitPercent != null) { + throw new BackendError('feeLimitMsat and feeLimitPercent are mutually exclusive', 400); + } + + const timeoutSeconds = req.timeoutSeconds ?? 60; + const body: Record = { + 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 { + // 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 = { + [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 = { + 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 { + 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('/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 { + 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 { + // 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 { + 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 }; + } +} diff --git a/src/servers/sidecar/wallet/backends/lndhub.ts b/src/servers/sidecar/wallet/backends/lndhub.ts new file mode 100644 index 00000000..5baf8c3b --- /dev/null +++ b/src/servers/sidecar/wallet/backends/lndhub.ts @@ -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; +}; + +/** `/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; +}; + +// ── 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 = new Set([ + '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 | 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 { + const res = await httpJson({ + 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 { + 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(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(req: LndHubCall): Promise { + const send = async (token: string): Promise => + this.unwrap( + await httpJson({ + 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 { + let info: LndHubGetInfo | null = null; + try { + info = await this.call({ 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 { + const res = await this.call({ 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 { + 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({ 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 { + const res = await this.call({ path: '/getbtc' }); + if (Array.isArray(res)) return res[0]?.address ?? ''; + return res?.address ?? ''; + } + + // ── lightning ────────────────────────────────────────────────────────────────────────────────── + + override async getInvoices(opts?: { limit?: number }): Promise { + const res = await this.call({ + path: '/getuserinvoices', + query: { limit: opts?.limit }, + }); + return (Array.isArray(res) ? res : []).map((inv) => this.toInvoice(inv)); + } + + override async createInvoice(req: CreateInvoiceRequest): Promise { + 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({ + 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 { + const wanted = paymentHash.toLowerCase(); + const res = await this.call({ 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 { + const res = await this.call({ 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 { + 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 { + const amountSats = req.amountMsat ? msatToSats(req.amountMsat, 'amountMsat') : undefined; + const res = await this.call({ + 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 { + const res = await this.call({ 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, + }; + } +} diff --git a/src/servers/sidecar/wallet/backends/nwc.ts b/src/servers/sidecar/wallet/backends/nwc.ts new file mode 100644 index 00000000..f262d385 --- /dev/null +++ b/src/servers/sidecar/wallet/backends/nwc.ts @@ -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://?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 & { state?: Nip47Transaction['state'] }; + +/** NIP-47 error codes, mapped onto HTTP so routes.ts can answer honestly. */ +const ERROR_STATUS: Record = { + 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(['lightningReceive', 'lightningSend']); + protected readonly capabilities: ReadonlySet = this.caps; + + private client: NWCClient | null = null; + private connecting: Promise | null = null; + private methods: ReadonlySet = new Set(); + + 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 { + if (this.client) return this.client; + this.connecting ??= this.open().finally(() => { + this.connecting = null; + }); + return this.connecting; + } + + private async open(): Promise { + 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(op: string, fn: (client: NWCClient) => Promise): Promise { + 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(); + } + + // ── node / balances ──────────────────────────────────────────────────────────────────────────── + + override async getInfo(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + return decodeBolt11(bolt11); + } + + override async getPayments(opts?: { limit?: number }): Promise { + 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 { + 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 { + 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 { + 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 { + if (this.methods.size && !this.methods.has('lookup_invoice')) return null; + try { + return await this.lookup(request); + } catch { + return null; + } + } +} diff --git a/src/servers/sidecar/wallet/backends/onchain.ts b/src/servers/sidecar/wallet/backends/onchain.ts new file mode 100644 index 00000000..eaa6b5bf --- /dev/null +++ b/src/servers/sidecar/wallet/backends/onchain.ts @@ -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(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 = { + 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 = { + 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; +}; + +// ── 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>; + signer: WalletSigner; +}; + +export class OnchainBackend extends BaseBackend { + readonly kind: BackendKind = 'onchain'; + + protected readonly capabilities: ReadonlySet = new Set([ + '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; + 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(); + + /** 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(); + + private scanCache: WalletScan | null = null; + private scanInflight: Promise | 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 { + 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 { + 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(); + 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 { + 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 { + 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 { + // 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 { + 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 { + return this.chain.getFeeEstimates(); + } + + override async getUtxos(): Promise { + 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 { + 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(); + 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 { + // 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(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 { + 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 { + 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 { + // 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((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 { + const scan = await this.scan(); + const key = `${type}:${chain}`; + const hint = this.issued.get(key) ?? 0; + + const used = new Set(); + 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(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const out = new Array(items.length); + let cursor = 0; + const worker = async (): Promise => { + 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; +} diff --git a/src/servers/sidecar/wallet/bolt11.test.ts b/src/servers/sidecar/wallet/bolt11.test.ts new file mode 100644 index 00000000..d6219013 --- /dev/null +++ b/src/servers/sidecar/wallet/bolt11.test.ts @@ -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); + }); +}); diff --git a/src/servers/sidecar/wallet/bolt11.ts b/src/servers/sidecar/wallet/bolt11.ts new file mode 100644 index 00000000..43f4cc8b --- /dev/null +++ b/src/servers/sidecar/wallet/bolt11.ts @@ -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(); + +const MSAT_PER_BTC = 100_000_000_000n; +const MAX_MSAT = 2_100_000_000_000_000_000n; +const DIVISORS: Readonly> = { + 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; + +/** 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> = { 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: ``, 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(); +} diff --git a/src/servers/sidecar/wallet/chain.ts b/src/servers/sidecar/wallet/chain.ts new file mode 100644 index 00000000..50df0177 --- /dev/null +++ b/src/servers/sidecar/wallet/chain.ts @@ -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; + +// ── 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; + +/** 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 { + 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(path: string): Promise { + 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 { + return this.json(`/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 { + const addr = encodeURIComponent(address); + const path = afterTxid ? `/address/${addr}/txs/chain/${encodeURIComponent(afterTxid)}` : `/address/${addr}/txs`; + return this.json(path); + } + + /** GET /address/{addr}/utxo — unspent outputs, confirmed and unconfirmed. */ + getAddressUtxos(address: string): Promise { + return this.json(`/address/${encodeURIComponent(address)}/utxo`); + } + + /** GET /tx/{txid}. */ + getTx(txid: string): Promise { + return this.json(`/tx/${encodeURIComponent(txid)}`); + } + + /** GET /tx/{txid}/hex — plain text, not JSON. Needed as `nonWitnessUtxo` for legacy p2pkh inputs. */ + async getTxHex(txid: string): Promise { + 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 { + 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 { + return mapFeeEstimates(await this.json('/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 { + 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 }; diff --git a/src/servers/sidecar/wallet/index.ts b/src/servers/sidecar/wallet/index.ts new file mode 100644 index 00000000..471afa36 --- /dev/null +++ b/src/servers/sidecar/wallet/index.ts @@ -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).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')); diff --git a/src/servers/sidecar/wallet/keys.test.ts b/src/servers/sidecar/wallet/keys.test.ts new file mode 100644 index 00000000..a39a2014 --- /dev/null +++ b/src/servers/sidecar/wallet/keys.test.ts @@ -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, + ); +}); diff --git a/src/servers/sidecar/wallet/keys.ts b/src/servers/sidecar/wallet/keys.ts new file mode 100644 index 00000000..ef6856a2 --- /dev/null +++ b/src/servers/sidecar/wallet/keys.ts @@ -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; + +// 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 { + 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 { + 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 { + 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 }> { + 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; + 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(); + +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 | 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 { + 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(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(); + +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 { + 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 { + 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 { + 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); +} diff --git a/src/servers/sidecar/wallet/psbt.ts b/src/servers/sidecar/wallet/psbt.ts new file mode 100644 index 00000000..17304a34 --- /dev/null +++ b/src/servers/sidecar/wallet/psbt.ts @@ -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 = { + 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 = { + 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 = { + 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 }; +} diff --git a/src/servers/sidecar/wallet/resolve.ts b/src/servers/sidecar/wallet/resolve.ts new file mode 100644 index 00000000..be7752d5 --- /dev/null +++ b/src/servers/sidecar/wallet/resolve.ts @@ -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(); + +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 | 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 { + 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 | 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 | 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> = {}; + 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'); + } +} diff --git a/src/servers/sidecar/wallet/routes.ts b/src/servers/sidecar/wallet/routes.ts new file mode 100644 index 00000000..1e0396f8 --- /dev/null +++ b/src/servers/sidecar/wallet/routes.ts @@ -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, { status }); +} + +function badRequest(message: string): Response { + return json({ error: message }, 400); +} + +async function body(req: Request): Promise { + 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 { + 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 { + 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 }>(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[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[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[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; + makeActive?: boolean; +}; + +async function createWalletRoute(ctx: OfficerContext): Promise { + if (!hasStoreKey()) { + throw new BackendError('VAULT_STORE_KEY is not configured; refusing to store wallet secrets', 503, 'NO_STORE_KEY'); + } + const b = await body(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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // /_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[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); +} diff --git a/src/servers/sidecar/wallet/types.ts b/src/servers/sidecar/wallet/types.ts new file mode 100644 index 00000000..053aa396 --- /dev/null +++ b/src/servers/sidecar/wallet/types.ts @@ -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 = { + '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; + getBalances(): Promise; + + // on-chain + getTransactions(opts?: { limit?: number }): Promise; + getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }>; + getUtxos(): Promise; + estimateFees(): Promise; + sendCoins(req: SendCoinsRequest): Promise; + + // lightning + getInvoices(opts?: { limit?: number }): Promise; + createInvoice(req: CreateInvoiceRequest): Promise; + lookupInvoice(paymentHash: string): Promise; + decodeInvoice(bolt11: string): Promise; + getPayments(opts?: { limit?: number }): Promise; + payInvoice(req: PayInvoiceRequest): Promise; + sendKeysend(req: KeysendRequest): Promise; + + // node operation + getChannels(): Promise; + getPeers(): Promise; + + signMessage(message: string): Promise; + verifyMessage(message: string, signature: string): Promise; +} + +// ── 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'; + } +} diff --git a/src/servers/sidecar/wallet/upstream.ts b/src/servers/sidecar/wallet/upstream.ts new file mode 100644 index 00000000..4680a9b4 --- /dev/null +++ b/src/servers/sidecar/wallet/upstream.ts @@ -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 = { + 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); +} diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 9ee80212..f9c391cf 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -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, ]; diff --git a/src/workspaces/officerdev/src/apps/Wallet/Amount.tsx b/src/workspaces/officerdev/src/apps/Wallet/Amount.tsx new file mode 100644 index 00000000..0119f4ff --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/Amount.tsx @@ -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 ( + + {signed && sats != null && sats > 0 ? '+' : ''} + {text} + + ); +}; + +/** The toggle itself — a button, because it mutates a preference rather than navigating. */ +export const UnitToggle = ({ className = '' }: { className?: string }) => { + const { unit, toggle } = useAmountUnit(); + + return ( + + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/CoinsView.tsx b/src/workspaces/officerdev/src/apps/Wallet/CoinsView.tsx new file mode 100644 index 00000000..0136b568 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/CoinsView.tsx @@ -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 ; + if (!supported) return ; + + 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 ( +
+
+ + {utxos.length} coin{utxos.length === 1 ? '' : 's'} · spendable + + + {selected.length > 0 && ( + <> + + {selected.length} selected · + + + + Spend these + + + )} +
+ +
+ {utxosLoading && utxos.length === 0 ? ( +
+ + Listing coins… +
+ ) : utxos.length === 0 ? ( +
+

No coins

+

Receive something and it will show up here as a UTXO.

+
+ ) : ( +
    + {utxos.map((utxo) => ( + + ))} +
+ )} +
+
+ ); +}; + +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 ( +
  • + onToggle(outpoint)} + aria-label={`Select ${outpoint}`} + className="h-3.5 w-3.5 shrink-0 accent-primary" + /> + +
    +
    {utxo.label || truncateMiddle(utxo.address, 16, 10)}
    +
    + + {truncateMiddle(utxo.txid, 10, 6)}:{utxo.vout} + + {formatConfirmations(utxo.confirmations)} + {utxo.addressType && {utxo.addressType}} +
    +
    + + + + +
  • + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/CopyField.tsx b/src/workspaces/officerdev/src/apps/Wallet/CopyField.tsx new file mode 100644 index 00000000..41ed52e0 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/CopyField.tsx @@ -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 ( +
    + {label && ( +
    {label}
    + )} +
    + {value} + +
    +
    + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/EmptyWallet.tsx b/src/workspaces/officerdev/src/apps/Wallet/EmptyWallet.tsx new file mode 100644 index 00000000..15880280 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/EmptyWallet.tsx @@ -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 }) => ( +
    + {isLoading ? ( + <> + +

    Loading wallets…

    + + ) : ( + <> + +

    No wallet yet

    +

    + 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. +

    + + )} +
    +); + +export const UnsupportedSection = ({ what }: { what: string }) => ( +
    +

    Not available for this wallet

    +

    + This backend does not support {what}. Pick a different wallet from the panel on the left. +

    +
    +); diff --git a/src/workspaces/officerdev/src/apps/Wallet/LightningView.tsx b/src/workspaces/officerdev/src/apps/Wallet/LightningView.tsx new file mode 100644 index 00000000..c4f58d88 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/LightningView.tsx @@ -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 ; + if (!anyLightning) return ; + + return ( +
    + {hasChannels && ( +
    + {channels.length === 0 ? ( + No channels open. + ) : ( +
      + {channels.map((channel) => ( + + ))} +
    + )} +
    + )} + + {hasPeers && ( +
    + {peers.length === 0 ? ( + Not connected to anyone. + ) : ( +
      + {peers.map((peer) => ( +
    • + + {peer.alias || truncateMiddle(peer.pubkey, 14, 8)} + {peer.address} + + {peer.inbound ? 'inbound' : 'outbound'} + +
    • + ))} +
    + )} +
    + )} + + {hasPayments && ( +
    + {payments.length === 0 ? ( + Nothing sent yet. + ) : ( +
      + {payments.map((payment) => ( +
    • +
      + + + {payment.memo || truncateMiddle(payment.destination ?? payment.paymentHash, 14, 8)} + + + {payment.status} + + {/* msat stays a string all the way to the DOM — see format.ts. */} + +
      +
      + {formatTimestamp(payment.createdAt)} · fee + {payment.failureReason && · {payment.failureReason}} +
      +
    • + ))} +
    + )} +
    + )} +
    + ); +}; + +const ChannelRow = ({ channel }: { channel: Channel }) => { + const capacity = channel.capacitySats || 1; + const localPct = Math.max(0, Math.min(100, (channel.localBalanceSats / capacity) * 100)); + + return ( +
  • +
    + + + {channel.remoteAlias || truncateMiddle(channel.remotePubkey, 14, 8)} + + {channel.private && private} + +
    + + {/* Local/remote split — the number that decides whether you can send or only receive. */} +
    +
    +
    +
    + out {formatSats(channel.localBalanceSats)} + in {formatSats(channel.remoteBalanceSats)} +
    +
  • + ); +}; + +type SectionProps = { title: string; count: number; children: React.ReactNode }; + +const Section = ({ title, count, children }: SectionProps) => ( +
    +

    + {title} + {count > 0 && ({count})} +

    + {children} +
    +); + +const Blank = ({ children }: { children: React.ReactNode }) => ( +

    {children}

    +); diff --git a/src/workspaces/officerdev/src/apps/Wallet/LockBadge.tsx b/src/workspaces/officerdev/src/apps/Wallet/LockBadge.tsx new file mode 100644 index 00000000..25f69c80 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/LockBadge.tsx @@ -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 ( + + + no seed held + + ); + } + + if (hasSeed == null) return null; + + return ( + <> +
    + + {unlocked ? : } + {unlocked ? formatCountdown(secondsRemaining) : 'locked'} + + + {!compact && + (unlocked ? ( + + ) : ( + + ))} +
    + + {unlockOpen && ( + + )} + + ); +}; + +/** + * 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 ( + <> +
    + + + This wallet is locked. Signing needs the passphrase — everything else on this screen already works. + + +
    + + {open && ( + + )} + + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/OverviewView.tsx b/src/workspaces/officerdev/src/apps/Wallet/OverviewView.tsx new file mode 100644 index 00000000..03584577 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/OverviewView.tsx @@ -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 ; + + const hasLightning = balances?.lightningBalance != null; + const canSend = capabilities.includes('onchainSend') || capabilities.includes('lightningSend'); + const canReceive = capabilities.includes('onchainReceive') || capabilities.includes('lightningReceive'); + + return ( +
    +
    +
    +

    {wallet?.name}

    +

    + {wallet ? KIND_LABELS[wallet.kind] : '—'} + {info?.alias && ` · ${info.alias}`} + {info?.version && ` · ${info.version}`} +

    +
    + +
    + + {balancesLoading && !balances ? ( +
    + + Reading balances… +
    + ) : ( +
    + } + label="On-chain" + value={} + hint={ + balances && balances.onchainUnconfirmed !== 0 + ? `${balances.onchainUnconfirmed > 0 ? '+' : ''}${formatSats(balances.onchainUnconfirmed)} sats unconfirmed` + : undefined + } + /> + {hasLightning && ( + } + label="Lightning" + value={} + hint={ + balances?.lightningInbound != null ? `${formatSats(balances.lightningInbound)} sats inbound` : undefined + } + /> + )} + {info?.blockHeight?.toLocaleString() ?? '—'} + } + hint={info ? (info.synced ? 'synced' : 'syncing…') : undefined} + /> + {info?.network ?? wallet?.network ?? '—'}} + hint={info?.pubkey ? truncateMiddle(info.pubkey, 8, 6) : undefined} + /> +
    + )} + + {(canReceive || canSend) && ( +
    + {canReceive && ( + + + Receive + + )} + {canSend && ( + + + Send + + )} +
    + )} + +
    +
    +

    Recent activity

    + + all transactions + +
    + {transactions.length === 0 ? ( +

    Nothing yet.

    + ) : ( +
      + {transactions.map((tx) => ( +
    • + + {tx.amount < 0 ? ( + + ) : ( + + )} + +
      +
      {tx.label ?? truncateMiddle(tx.txid)}
      +
      + {tx.confirmations <= 0 && } + {tx.confirmations <= 0 ? 'pending' : formatTimestamp(tx.timestamp)} +
      +
      + +
    • + ))} +
    + )} +
    +
    + ); +}; + +type TileProps = { label: string; value: ReactNode; hint?: string; icon?: ReactNode }; + +const Tile = ({ label, value, hint, icon }: TileProps) => ( +
    +
    + {icon} + {label} +
    +
    {value}
    + {hint && ( +
    + {hint} +
    + )} +
    +); diff --git a/src/workspaces/officerdev/src/apps/Wallet/ReceiveView.tsx b/src/workspaces/officerdev/src/apps/Wallet/ReceiveView.tsx new file mode 100644 index 00000000..2d58c554 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/ReceiveView.tsx @@ -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 ; + if (!canOnchain && !canLightning) return ; + + return ( +
    + {canOnchain && ( +
    +
    +

    + On-chain address +

    + +
    + + {addressLoading && !address ? ( +
    + + Deriving an address… +
    + ) : address ? ( + <> + +

    + {addressType ? `${addressType} · ` : ''}Reuse costs you privacy, not money — take a fresh one for each + payer. +

    + + ) : ( +

    No address available.

    + )} +
    + )} + + {canLightning && ( +
    +
    +

    + Lightning invoices +

    + +
    + + {invoices.length === 0 ? ( +

    No invoices yet.

    + ) : ( +
      + {invoices.map((inv) => ( +
    • +
      + + + {inv.memo || truncateMiddle(inv.paymentHash, 12, 8)} + + + {inv.state} + + {/* msat stays a string all the way to the DOM — see format.ts. */} + +
      +
      + created {formatTimestamp(inv.createdAt)} + {inv.settledAt ? ` · settled ${formatTimestamp(inv.settledAt)}` : ''} +
      + {inv.state === 'open' && } +
    • + ))} +
    + )} +
    + )} + + {invoiceOpen && } +
    + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/SendView.tsx b/src/workspaces/officerdev/src/apps/Wallet/SendView.tsx new file mode 100644 index 00000000..ff6dff1b --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/SendView.tsx @@ -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 ; + if (!canOnchain && !canLightning) return ; + + return ( +
    + + {canOnchain && canLightning && ( + + + + On-chain + + + + Lightning + + + )} + + {canOnchain && ( + + + + )} + {canLightning && ( + + + + )} + +
    + ); +}; + +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 ( +
    +

    Broadcast

    +

    + Paid a fee of {formatSats(broadcast.feeSats)} sats. It will confirm when a miner includes it. +

    + + +
    + ); + } + + return ( +
    +
    +
    +

    Spendable

    + +
    +
    + +
    + + setAddress(ev.target.value)} + className="font-mono text-xs" + /> +
    + +
    + +
    + setAmount(ev.target.value)} + className="w-48" + /> + +
    +
    + +
    + +
    + {fees && + FEE_PRESETS.map(({ key, label: presetLabel, hint }) => ( + + ))} +
    + setSatPerVbyte(ev.target.value)} + className="w-32" + /> +
    + + {capabilities.includes('coinControl') && ( +
    +
    + + {selected.length === 0 ? ( + + Automatic coin selection.{' '} + + Pick coins + + + ) : ( + <> + + {selected.length} coin{selected.length === 1 ? '' : 's'} selected · + + + + )} +
    +
    + )} + +
    + + setLabel(ev.target.value)} + /> +
    + + {needsUnlock && } + + + + ); +}; + +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 ( +
    +
    +
    +

    Lightning balance

    + +
    +
    + + {needsUnlock && } + + + + {payOpen && } +
    + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/TransactionsView.tsx b/src/workspaces/officerdev/src/apps/Wallet/TransactionsView.tsx new file mode 100644 index 00000000..bd1422bd --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/TransactionsView.tsx @@ -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 ; + + if (txLoading && transactions.length === 0) { + return ( +
    + + Reading the chain… +
    + ); + } + + if (transactions.length === 0) { + return ( +
    +

    Nothing here yet

    +

    Transactions appear as soon as they hit the mempool.

    +
    + ); + } + + return ( +
    +
      + {transactions.map((tx) => ( + + ))} +
    +
    + ); +}; + +const TransactionRow = ({ tx }: { tx: OnchainTx }) => { + const incoming = tx.amount >= 0; + const pending = tx.confirmations <= 0; + + return ( +
  • + + {incoming ? : } + + +
    +
    + {tx.label || truncateMiddle(tx.destAddresses[0] ?? tx.txid, 14, 10)} +
    +
    + {formatTimestamp(tx.timestamp)} + {formatConfirmations(tx.confirmations)} + {tx.feeSats != null && tx.feeSats > 0 && fee {formatSats(tx.feeSats)}} +
    +
    + +
    + +
    {truncateMiddle(tx.txid, 8, 6)}
    +
    +
  • + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/WalletNav.tsx b/src/workspaces/officerdev/src/apps/Wallet/WalletNav.tsx new file mode 100644 index 00000000..33a0f7c4 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/WalletNav.tsx @@ -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=` — 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 = { + 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 ( +
    +
    +
    + +
    +
    +
    + Wallet + {network && network !== 'bitcoin' && ( + + {network} + + )} +
    +
    + + +
    +
    +
    + +
    + Wallets + +
    + +
    + {isLoading && wallets.length === 0 && ( +
    Loading wallets…
    + )} + {!isLoading && wallets.length === 0 && ( +
    + No wallets yet. Add one to get started — a self-custodial seed, or a connection to your node. +
    + )} + {wallets.map((w) => ( + + ))} +
    + + + +
    + + {config && {config.network}} +
    + + {createOpen && } +
    + ); +}; + +type WalletRowProps = { wallet: WalletSummary; section: WalletSectionId; selected: boolean }; + +const WalletRow = ({ wallet, section, selected }: WalletRowProps) => { + const { activate } = useWalletLifecycle(); + + return ( +
    + + + {wallet.name} + + {KIND_LABELS[wallet.kind]} + + {/* Sibling of the anchor, never nested inside it — this mutates, it does not navigate. */} + {wallet.isActive ? ( + + ) : ( + + )} +
    + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx b/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx new file mode 100644 index 00000000..095d288b --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx @@ -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 ; + + const xpubs = Object.entries(wallet.xpubs ?? {}); + + return ( +
    +
    +
    +
    +

    {wallet.name}

    +

    + {KIND_LABELS[wallet.kind]} · {wallet.network} · added {wallet.createdAt.slice(0, 10)} +

    +
    + +
    + +
    + {kind ?? wallet.kind} + BIP{wallet.defaultBip} + {wallet.fingerprint && ( + + {wallet.fingerprint} + + )} + {wallet.isActive ? 'yes' : 'no'} + {hasSeed === true ? 'yes' : hasSeed === false ? 'no' : '—'} +
    + + {!wallet.isActive && ( + + )} +
    + + {xpubs.length > 0 && ( +
    +

    Account keys

    +

    + Extended public keys. They can watch this wallet but never spend it — safe to hand to a block explorer or an + accounting tool. +

    +
    + {xpubs.map(([path, xpub]) => ( + + ))} +
    +
    + )} + +
    +

    + What this backend can do +

    + {capabilities.length === 0 ? ( +

    No capabilities reported.

    + ) : ( +
    + {capabilities.map((cap) => ( + + {cap} + + ))} +
    + )} +
    + + {hasSeed === true && ( +
    +

    Key material

    +

    + Both of these ask for the passphrase on their own, whether or not the wallet is currently unlocked. +

    +
    + + +
    +
    + )} + + {config && ( +
    +

    Deployment

    +
    + {config.network} + + {truncateMiddle(config.esploraUrl, 28, 12)} + + {formatMinutes(config.unlockTtlSec)} + + {config.storeKeyConfigured ? 'configured' : missing} + +
    +
    + )} + +
    +

    Danger

    +

    + {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.'} +

    + +
    + + {passphraseOpen && ( + + )} + {exportOpen && } + {deleteOpen && ( + navigate(walletSectionPath('overview'), { replace: true })} + /> + )} +
    + ); +}; + +const formatMinutes = (seconds: number) => (seconds >= 60 ? `${Math.round(seconds / 60)} min` : `${seconds}s`); + +const Row = ({ label, children }: { label: string; children: React.ReactNode }) => ( +
    +
    {label}
    +
    {children}
    +
    +); diff --git a/src/workspaces/officerdev/src/apps/Wallet/WalletView.tsx b/src/workspaces/officerdev/src/apps/Wallet/WalletView.tsx new file mode 100644 index 00000000..acf37fcd --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/WalletView.tsx @@ -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 ; + case 'send': + return ; + case 'transactions': + return ; + case 'coins': + return ; + case 'lightning': + return ; + case 'settings': + return ; + default: + return ; + } +}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/WalletViewHeader.tsx b/src/workspaces/officerdev/src/apps/Wallet/WalletViewHeader.tsx new file mode 100644 index 00000000..982e29f8 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/WalletViewHeader.tsx @@ -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 ( + <> + + + {label} + {wallet && · {wallet.name}} + + {balances && } + + + + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/dialogs/ChangePassphraseDialog.tsx b/src/workspaces/officerdev/src/apps/Wallet/dialogs/ChangePassphraseDialog.tsx new file mode 100644 index 00000000..1f0b1ddf --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/dialogs/ChangePassphraseDialog.tsx @@ -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 ( + (next ? onOpenChange(true) : close())}> + +
    + + Change passphrase + + 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. + + + +
    +
    + + {/* Controlled input → request body → cleared. Never written to any store. */} + setOldPassphrase(ev.target.value)} + /> +
    +
    + + {/* Controlled input → request body → cleared. Never written to any store. */} + setNewPassphrase(ev.target.value)} + /> +

    At least 8 characters.

    +
    +
    + + {/* Controlled input → request body → cleared. Never written to any store. */} + setConfirm(ev.target.value)} + /> + {mismatch &&

    These do not match.

    } +
    +
    + + + + + +
    +
    +
    + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateInvoiceDialog.tsx b/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateInvoiceDialog.tsx new file mode 100644 index 00000000..e3fd1ffa --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateInvoiceDialog.tsx @@ -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(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 ( + (next ? onOpenChange(true) : close())}> + + {created ? ( + <> + + Invoice created + Send this to whoever is paying you. + + + + + + + ) : ( +
    + + New invoice + Leave the amount blank to let the payer choose. + + +
    +
    + + setAmountSats(ev.target.value)} + /> +
    +
    + + setMemo(ev.target.value)} + /> +
    +
    + + setExpiryMinutes(ev.target.value)} + className="w-32" + /> +
    +
    + + + + + +
    + )} +
    +
    + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateWalletDialog.tsx b/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateWalletDialog.tsx new file mode 100644 index 00000000..86a9fedd --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateWalletDialog.tsx @@ -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, { 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('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>({}); + + // 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].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 ( + <> + (next ? onOpenChange(true) : close())}> + +
    + + Add a wallet + + Self-custodial keys stay in the wallet sidecar; a remote node is only a stored connection. + + + + {storeKeyMissing && ( +
    + + + VAULT_STORE_KEY is not configured. The sidecar refuses to store + wallet secrets without it, so creation is disabled until it is set. + +
    + )} + +
    +
    + + setName(ev.target.value)} + /> +
    + +
    + + + {config && ( +

    + Network: {config.network} +

    + )} +
    + + {isOnchain ? ( + <> +
    + setImporting(false)} label="Generate a new seed" /> + setImporting(true)} label="Import a phrase" /> +
    + + {importing ? ( +
    + + {/* Secret in flight: state → request body → cleared. Never stored on this side, and + never echoed back by the sidecar either. */} +