From 049c01ebed0525ecaab0658b2be4e38b57f76485 Mon Sep 17 00:00:00 2001 From: Bufo Date: Thu, 24 Sep 2026 13:14:54 +0200 Subject: [PATCH 1/2] fix(payments): require password for all sends [AMB-3265] --- AGENTS.md | 38 ++++----- docs/INTEGRATION.md | 47 +++++------ packages/payments/README.md | 58 ++++++-------- packages/payments/examples/README.md | 6 +- packages/payments/examples/send.ts | 13 ++- packages/payments/src/client.ts | 20 ++--- .../src/resources/transactions.send.test.ts | 80 +++++-------------- .../payments/src/resources/transactions.ts | 55 ++++++------- .../src/resources/transactions.types.ts | 6 ++ 9 files changed, 136 insertions(+), 187 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 67b5bff..72f1449 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ new Payments({ baseUrl?: string, fetch?: typeof fetch, timeoutMs?: number, - send?: readonly PrepareSendParams[], // wallets to pre-warm for sending, in the background + send?: readonly PrepareSendParams[], // retry credentials to prepare in the background }); ``` @@ -83,9 +83,10 @@ Resource getters are lazy and call `requireServiceApiKey`: - `send` creates the send transaction, decrypts the node admin macaroon **in-process** using the team password (never sent to the API), then pays directly against the node's REST endpoint. Base-asset wallets pay over LND; - Taproot Asset wallets over litd. Sandbox wallets need no password — the - backend settles asynchronously (`payment` resolves `null`); behavior is - driven by `metadata.amb_sandbox_behavior` (`complete` / `fail` / `expire`). + Taproot Asset wallets over litd. `password` is mandatory for sandbox and live + sends so their call shapes match. Sandbox does not use it: the backend settles + asynchronously (`payment` resolves `null`); behavior is driven by + `metadata.amb_sandbox_behavior` (`complete` / `fail` / `expire`). - Send errors: wrong password → `DecryptionError`; node-side failure → `PaymentSendError`. - `retryPayment(paymentId)` retries a `FAILED` send **without calling @@ -96,26 +97,25 @@ Resource getters are lazy and call `requireServiceApiKey`: again would persist a second `payments_transaction` row for the same invoice instead of letting the existing failed row's status update. Throws `PaymentSendError` if the transaction isn't retryable. Takes no - password — it reads the wallet's macaroon from the `prepareSend` cache the - same way a password-less `send` does, and fails the same way when nothing - is cached. -- `send` is split into a **prepare** step (wallet send context → + password — it reads the wallet's macaroon from the `prepareSend` cache and + fails when nothing is cached. +- The live `send` path resolves a **prepare** step (wallet send context → `GetWalletSendContext`; node permissions → `GetWalletNodePermissions`; two - Argon2id passes; nip44 decrypt) and the payment itself (`CreateSendTransaction` - + node REST call). `prepareSend` runs that step ahead of time and caches the - macaroon per wallet in `Transactions.#prepared`; `isSendReady(walletId)` - reports whether one is resident; `forgetSend(walletId)` drops it. - `PaymentsConfig.send` pre-warms an array of wallets from the constructor, - sequentially and fire-and-forget (per-wallet errors swallowed there; a missing - `serviceApiKey` still throws from the constructor). -- **The one rule the cache runs on:** only a `send` that omits `password` reads - it, and only `prepareSend` writes it. A `send` carrying a password always - derives afresh. That is deliberate, and it is what keeps the cache from ever + Argon2id passes; nip44 decrypt) and then runs the payment itself + (`CreateSendTransaction` + node REST call). Every new `send` derives afresh. + `prepareSend` caches the macaroon for `retryPayment`; `isSendReady(walletId)` + reports whether one is resident and `forgetSend(walletId)` drops it. + `PaymentsConfig.send` prepares retry credentials for an array of wallets from + the constructor, sequentially and fire-and-forget (per-wallet errors swallowed + there; a missing `serviceApiKey` still throws from the constructor). +- **The one rule the cache runs on:** only `retryPayment` reads it, and only + `prepareSend` writes it. Every `send` requires a password and derives afresh. + That is deliberate, and it is what keeps the cache from ever having to decide whether two sets of credentials are equivalent — the question that produced three rounds of bugs when the cache was credential-keyed (wrong-password eviction, a concurrent attempt displacing a good one, and an omitted `teamId` being answered from an overridden slot). Do not "optimize" by - letting password-bearing sends hit the cache without reintroducing all of it. + letting sends hit the cache without reintroducing all of it. - Remaining invariants, each with a regression test in `transactions.send.test.ts`: - Only the **macaroon** is retained, never `masterKey` / `masterPasswordHash`. diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 9edc433..bd3ec6b 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -51,7 +51,7 @@ new Payments({ baseUrl?: string, // default: https://app.amboss.tech/graphql fetch?: typeof fetch, // override for tests / non-Node runtimes timeoutMs?: number, // default: 30000 - send?: Array<{ walletId, password?, teamId? }>, // pre-warm sending — see Step 4 + send?: Array<{ walletId, password?, teamId? }>, // pre-cache retry credentials — see Step 4 }); ``` @@ -110,7 +110,7 @@ node and resolves with the terminal result. ```ts const { transaction, payment } = await payments.transactions.send({ walletId, - password: process.env.TEAM_PASSWORD, // live wallets only + password: process.env.TEAM_PASSWORD, // required for sandbox and live wallets teamId, // optional — resolved from the wallet unless you override it destination: { bolt11: 'lnbc1...' }, // or: destination: { lightningAddress: 'user@domain.com', amountSats: '1000' } @@ -130,26 +130,24 @@ Notes: - Wrong password → `DecryptionError`. Node-side failure → `PaymentSendError`. - If the invoice was already paid (duplicate, or a replayed `idempotencyKey`), the SDK detects the returned `COMPLETED` transaction and resolves with `payment.status === 'SUCCEEDED'` without re-paying on the node. `payment.paymentPreimage` is `undefined` in this case. -**Sandbox wallets** need no password and no node — the backend settles the -transaction asynchronously and `payment` resolves `null`. Control the outcome -with metadata and observe it via webhooks: +**Sandbox wallets** need no node, but `password` remains mandatory so the +sandbox request matches production. It is not used for sandbox settlement. The +backend settles the transaction asynchronously and `payment` resolves `null`. +Control the outcome with metadata and observe it via webhooks: ```ts await payments.transactions.send({ walletId: sandboxWalletId, + password: process.env.TEAM_PASSWORD, destination: { bolt11: 'lnbc1...' }, metadata: { amb_sandbox_behavior: 'complete' }, // 'complete' | 'fail' | 'expire' (default) }); ``` -### Making sends fast - -A cold `send` is expensive, and almost none of that cost is the payment. Before -it can pay it must fetch the wallet's send context, fetch its node permissions, -and run two Argon2id passes (m=64 MiB, t=3, p=4) to derive the key that decrypts -your macaroon. Seconds of work — all of it independent of the invoice. +### Preparing credentials for retries -Do it once, at startup: +`retryPayment` takes no password argument, so prepare and cache its node +credentials before retrying. You can do this at startup: ```ts const payments = new Payments({ @@ -167,22 +165,25 @@ await payments.transactions.prepareSend({ }); ``` -Either way the wallet's macaroon ends up decrypted in memory, and every later -`send` for it is one API call plus the payment — no password argument needed: +Either way the wallet's macaroon ends up decrypted in memory for +`retryPayment`. New sends always require a password and derive afresh: ```ts payments.transactions.isSendReady(walletId); // true once prepared -await payments.transactions.send({ walletId, destination: { bolt11: 'lnbc1...' } }); +await payments.transactions.send({ + walletId, + password: process.env.TEAM_PASSWORD, + destination: { bolt11: 'lnbc1...' }, +}); ``` Three things to plan around: -- **Drop the `password` from prepared sends.** It is what makes them fast: a - `send` that carries a password derives from scratch every time, prepared or - not. Keep passing it only where you have not prepared the wallet. +- **Always pass `password` to `send`.** Prepared credentials are reserved for + `retryPayment`; they do not bypass the send-time password requirement. - **Argon2id runs on a shared worker thread.** Its CPU work does not block the - event loop. Prepare during startup or a warm-up hook to avoid that latency on - the first payment request. The constructor `send` option starts it in the background. + event loop. The constructor `send` option prepares retry credentials in the + background. - **You are holding node admin credentials in memory** for as long as the wallet stays prepared, which is what makes sends fast. Call `payments.transactions.forgetSend(walletId)` to release them, and to pick up @@ -347,9 +348,9 @@ Send-specific: `DecryptionError` (wrong team password) and `PaymentSendError` - [ ] `idempotency_key` / `idempotencyKey` set on receives and sends so your retries are safe. - [ ] Sends handle `DecryptionError` / `PaymentSendError` distinctly. -- [ ] Sending wallets are prepared at startup (constructor `send` or - `prepareSend`) so no request pays the Argon2id cost, and - `forgetSend` runs when node credentials rotate. +- [ ] Every sandbox and production send includes the team password. +- [ ] Wallets used by `retryPayment` are prepared first, and `forgetSend` runs + when node credentials rotate. - [ ] The full flow was exercised against a `SANDBOX` environment first (`amb_sandbox_behavior: 'complete' | 'fail' | 'expire'` covers all outcomes). diff --git a/packages/payments/README.md b/packages/payments/README.md index ee70351..f44be3e 100644 --- a/packages/payments/README.md +++ b/packages/payments/README.md @@ -43,7 +43,7 @@ new Payments({ baseUrl?: string, // default: https://app.amboss.tech/graphql fetch?: typeof fetch, // override for tests / non-Node runtimes timeoutMs?: number, // default: 30000 - send?: Array<{ walletId: string, password?: string, teamId?: string }>, // pre-warm — see Sending + send?: Array<{ walletId: string, password?: string, teamId?: string }>, // pre-cache retry credentials }); ``` @@ -220,15 +220,17 @@ throws `DecryptionError`; a node-side failure throws `PaymentSendError`. If the invoice was already paid (a genuine duplicate, or a replayed `idempotencyKey`), the backend returns the existing `COMPLETED` transaction instead of creating a new one; the SDK detects this and resolves immediately with `payment.status === 'SUCCEEDED'` without re-paying on the node. `payment.paymentPreimage` is `undefined` in this case — the transaction record doesn't store it. -**Sandbox wallets** need no node, no macaroon, and no password — just call -`send` and the backend settles the transaction for you. `payment` comes back -`null`; observe the outcome via webhooks or by polling the transaction status. -The backend settles asynchronously per the `amb_sandbox_behavior` metadata -(`complete` / `fail` / `expire`; default `expire`): +**Sandbox wallets** need no node or macaroon, but `password` is still mandatory +so sandbox and production sends use the same call shape. The password is not +used for sandbox settlement. `payment` comes back `null`; observe the outcome +via webhooks or by polling the transaction status. The backend settles +asynchronously per the `amb_sandbox_behavior` metadata (`complete` / `fail` / +`expire`; default `expire`): ```ts const { transaction, payment } = await payments.transactions.send({ - walletId, // a sandbox wallet — no password required + walletId, // a sandbox wallet + password, // still required, matching production destination: { bolt11: 'lnbc1...' }, metadata: { amb_sandbox_behavior: 'complete' }, // force success in sandbox }); @@ -236,24 +238,18 @@ const { transaction, payment } = await payments.transactions.send({ payment; // null — settlement happens server-side ``` -#### Pre-warming a wallet +#### Preparing credentials for retries -Before it can pay, `send` has to fetch the wallet's send context, fetch its node -permissions, and run **two Argon2id passes** (m=64 MiB, t=3, p=4) to derive the -key that decrypts the macaroon. That is seconds of work, and none of it depends -on the invoice. - -`prepareSend` does it up front and caches the result per wallet. Afterwards -`send` issues a single API call — `CreateSendTransaction` — and pays: +`prepareSend` fetches a wallet's send context and node permissions, performs the +two Argon2id passes, and caches only the decrypted macaroon. The cache is used +by `retryPayment`, which intentionally has no password parameter. A new `send` +always requires `password` and derives credentials afresh. ```ts await payments.transactions.prepareSend({ walletId, password }); payments.transactions.isSendReady(walletId); // true — macaroon is in memory -// no password needed now: the macaroon is already decrypted -await payments.transactions.send({ walletId, destination: { bolt11: 'lnbc1...' } }); - payments.transactions.forgetSend(walletId); // drop it again ``` @@ -269,31 +265,26 @@ const payments = new Payments({ payments.transactions.isSendReady(walletId); ``` -The constructor form is fire-and-forget and **ignores failures** — a bad -password surfaces later, from `send`. Use `await prepareSend(...)` when you want -to see the error at startup. +The constructor form is fire-and-forget and **ignores failures**. Use +`await prepareSend(...)` when you need to observe preparation errors. Notes: - **Argon2id runs on a shared worker thread**, so its CPU-bound work does not - block the event loop. Prepare at startup to avoid its latency on the first - payment request. + block the event loop. - Each wallet costs its own derivation, so a long `send` list takes a while. Entries are prepared one at a time (running them concurrently would not overlap anything). - `isSendReady` is `false` while a preparation is still running, `true` only once the macaroon is resident. -- **Only a `send` that omits `password` uses the cache.** Passing a `password` - means "use these credentials", so it always derives afresh — the same cost as - not preparing at all — and never reads or replaces what you prepared. So a - typo'd password fails that one call and nothing else: the prepared wallet stays - prepared and later password-less sends keep working. +- `send` never reads or replaces the cache. A typo'd password fails that call + without disturbing credentials prepared for a later retry. - The cache has no expiry. Call `forgetSend(walletId)` to pick up rotated node credentials — or to stop holding decrypted node admin access in memory once a run of sends is finished. Only the macaroon is retained; the Argon2 master key is discarded after the decrypt. -- Sandbox wallets prepare too (no password, nothing to decrypt) — it just caches - the fact that no node payment is needed. +- Sandbox wallets can prepare too; this only caches the fact that no node + payment is needed. #### Retrying a failed send @@ -309,10 +300,9 @@ transaction isn't retryable. const { transaction, payment } = await payments.transactions.retryPayment(paymentId); ``` -It relies on a cached macaroon the same way a password-less `send` does — call -`prepareSend({ walletId, password })` first (it usually already ran for the -original send). Without one, it fails the same way an unprepared, password-less -`send` would. +It relies on a cached macaroon: call `prepareSend({ walletId, password })` +before retrying. Without one, it fails with a `PaymentSendError` asking you to +prepare the wallet first. ## Examples diff --git a/packages/payments/examples/README.md b/packages/payments/examples/README.md index 6089ec4..92c538b 100644 --- a/packages/payments/examples/README.md +++ b/packages/payments/examples/README.md @@ -52,8 +52,10 @@ Destinations (set one): - `BOLT11` — a BOLT11 invoice (`AMOUNT_SATS` only needed for zero-amount invoices). - `LIGHTNING_ADDRESS` + `AMOUNT_SATS` — pay a Lightning address. -The team password is used only to decrypt the node admin macaroon locally; it is -never sent to the API. See `.env.example` for every supported variable. +The team password is mandatory for both sandbox and live sends. For live +wallets it decrypts the node admin macaroon locally; it is never sent to the +API. Sandbox settlement does not use it, but requiring it keeps the call shape +identical to production. See `.env.example` for every supported variable. ### `verify-webhook.mjs` / `verify-webhook.cjs` — offline webhook verification diff --git a/packages/payments/examples/send.ts b/packages/payments/examples/send.ts index 3333037..743d3bf 100644 --- a/packages/payments/examples/send.ts +++ b/packages/payments/examples/send.ts @@ -73,20 +73,19 @@ async function main(): Promise { const walletId = process.env.WALLET_ID; const password = process.env.TEAM_PASSWORD; - // Live wallets need TEAM_PASSWORD; sandbox wallets do not (the backend - // settles the send itself — set IDEMPOTENCY_KEY/metadata as needed). - if (!walletId || !destination) { + // Every send requires TEAM_PASSWORD so sandbox exercises the production + // call shape, even though its backend-managed settlement does not use it. + if (!walletId || !password || !destination) { console.log( - '\nSkipping send — set WALLET_ID and a destination ' + - '(BOLT11, or LIGHTNING_ADDRESS + AMOUNT_SATS) to send. ' + - 'Live wallets also need TEAM_PASSWORD.', + '\nSkipping send — set WALLET_ID, TEAM_PASSWORD, and a destination ' + + '(BOLT11, or LIGHTNING_ADDRESS + AMOUNT_SATS) to send.', ); return; } const params: SendParams = { walletId, - ...(password ? { password } : {}), + password, destination, ...(process.env.TEAM_ID ? { teamId: process.env.TEAM_ID } : {}), ...(process.env.IDEMPOTENCY_KEY ? { idempotencyKey: process.env.IDEMPOTENCY_KEY } : {}), diff --git a/packages/payments/src/client.ts b/packages/payments/src/client.ts index df88a99..4e01f44 100644 --- a/packages/payments/src/client.ts +++ b/packages/payments/src/client.ts @@ -9,14 +9,14 @@ import { Webhooks } from './resources/webhooks.js'; export type PaymentsConfig = ClientConfig & { webhookSecret?: string; /** - * Wallets to pre-warm for sending. Each entry's node endpoint is fetched and - * its admin macaroon decrypted in the background, so the first `send()` for - * that wallet skips two API round-trips and two Argon2id passes. + * Wallets whose retry credentials should be prepared in the background. Each + * entry's node endpoint is fetched and its admin macaroon decrypted for use + * by `retryPayment()`. * - * Per-wallet failures are ignored here — pre-warming is an optimization, and - * `send()` redoes the work and surfaces the real error. Requires + * Per-wallet failures are ignored here. A new `send()` always performs its + * own derivation and surfaces the real error. Requires * `serviceApiKey`: passing this without one throws `ConfigError` from the - * constructor rather than pre-warming nothing in silence. + * constructor rather than preparing nothing in silence. */ send?: readonly PrepareSendParams[]; }; @@ -34,12 +34,12 @@ export class Payments extends AmbossClient { // Resolving the resource here rather than inside the loop keeps a missing // serviceApiKey a constructor-time ConfigError. Reaching it through the // getter mid-loop would land that throw in the per-wallet catch below and - // pre-warm nothing, silently and forever. + // prepare nothing, silently and forever. if (config.send?.length) void this.#prewarmSend(this.transactions, config.send); } /** - * Fire-and-forget pre-warm of the configured wallets. Sequential on purpose: + * Fire-and-forget preparation of the configured wallets. Sequential on purpose: * each wallet needs two memory-hard Argon2id passes, so limiting work to one * wallet at a time avoids excessive memory pressure during startup. * @@ -55,8 +55,8 @@ export class Payments extends AmbossClient { try { await transactions.prepareSend(wallet); } catch { - // Deliberately swallowed: `send()` re-runs the derivation and throws - // the real DecryptionError / ApiError where the caller can catch it. + // Deliberately swallowed: use explicit prepareSend() when callers need + // to observe a DecryptionError / ApiError. } } } diff --git a/packages/payments/src/resources/transactions.send.test.ts b/packages/payments/src/resources/transactions.send.test.ts index d7e27de..8b62f20 100644 --- a/packages/payments/src/resources/transactions.send.test.ts +++ b/packages/payments/src/resources/transactions.send.test.ts @@ -179,13 +179,14 @@ describe('Transactions.send', () => { assert.equal((lastBody as { fee_limit_sat: string }).fee_limit_sat, '4294967296'); }); - it('creates a sandbox send without a password and returns payment: null', async () => { + it('creates a sandbox send with a password and returns payment: null', async () => { // No node should be contacted for sandbox — point at an unroutable host // so any accidental node call would fail the test. const transactions = new Transactions(fakeClient('http://127.0.0.1:1', 'SANDBOX')); const result = await transactions.send({ walletId: 'w1', + password: PASSWORD, destination: { bolt11: 'lnbc1xyz' }, }); @@ -193,6 +194,21 @@ describe('Transactions.send', () => { assert.equal(result.transaction.payment_request, 'lnbc1xyz'); }); + it('rejects a sandbox send without a password before creating a transaction', async () => { + const { client, ops } = withCallLog(fakeClient('http://127.0.0.1:1', 'SANDBOX')); + const transactions = new Transactions(client); + + await assert.rejects( + transactions.send({ + walletId: 'w1', + password: '', + destination: { bolt11: 'lnbc1xyz' }, + }), + /password is required/, + ); + assert.deepEqual(ops, []); + }); + it('accepts an explicit teamId override', async () => { const host = await startNode([ { result: { status: 'SUCCEEDED', payment_hash: 'ph', fee_sat: '1' } }, @@ -271,32 +287,6 @@ describe('Transactions.send', () => { }); describe('Transactions.prepareSend', () => { - it('lets a later send() skip the context and permissions queries', async () => { - const host = await startNode([ - { result: { status: 'SUCCEEDED', payment_hash: 'ph', fee_sat: '1' } }, - ]); - const { client, ops } = withCallLog(fakeClient(host)); - const transactions = new Transactions(client); - - await transactions.prepareSend({ walletId: 'w1', password: PASSWORD }); - assert.equal(countOf(ops, 'GetWalletSendContext'), 1); - assert.equal(countOf(ops, 'GetWalletNodePermissions'), 1); - - ops.length = 0; - const result = await transactions.send({ - walletId: 'w1', // no password — the macaroon is already in memory - destination: { bolt11: 'lnbc1xyz' }, - }); - - assert.ok(result.payment); - assert.equal(result.payment.status, 'SUCCEEDED'); - assert.equal(ops.length, 1, 'send() should issue exactly one operation'); - assert.equal(countOf(ops, 'CreateSendTransaction'), 1); - // The cache only short-circuits credential derivation — the node call - // itself must still carry the fee limit every send gets. - assert.equal((lastBody as { fee_limit_sat: string }).fee_limit_sat, '4294967296'); - }); - it('reports isSendReady false while preparing and true once resolved', async () => { const transactions = new Transactions(fakeClient('http://127.0.0.1:1')); @@ -337,15 +327,7 @@ describe('Transactions.prepareSend', () => { "one caller's bad password must not evict a working prepared wallet", ); - ops.length = 0; - const result = await transactions.send({ - walletId: 'w1', // still no password — the surviving macaroon is used - destination: { bolt11: 'lnbc1xyz' }, - }); - - assert.ok(result.payment); - assert.equal(countOf(ops, 'GetWalletNodePermissions'), 0, 'the survivor must still be cached'); - assert.equal(ops.length, 1, 'send() should issue exactly one operation'); + assert.equal(countOf(ops, 'GetWalletNodePermissions'), 1); }); it('always re-derives for a send() that passes a password, even the prepared one', async () => { @@ -393,30 +375,4 @@ describe('Transactions.prepareSend', () => { 'a resolved prepareSend must survive a concurrent bad-credential send', ); }); - - it('serves a password-less send from a wallet prepared with a teamId override', async () => { - const WALLET_TEAM_ID = '22222222-2222-2222-2222-222222222222'; - const host = await startNode([ - { result: { status: 'SUCCEEDED', payment_hash: 'ph', fee_sat: '1' } }, - ]); - const { client, ops } = withCallLog(fakeClient(host, 'LIVE', undefined, WALLET_TEAM_ID)); - const transactions = new Transactions(client); - - // Only the override's salt decrypts this wallet, so preparing succeeding - // at all proves the override was used. - await transactions.prepareSend({ walletId: 'w1', password: PASSWORD, teamId: TEAM_ID }); - assert.equal(transactions.isSendReady('w1'), true); - ops.length = 0; - - // The prepared macaroon is served as-is. There is no salt to re-resolve - // and disagree about, because nothing is re-derived. - const result = await transactions.send({ - walletId: 'w1', - destination: { bolt11: 'lnbc1xyz' }, - }); - - assert.ok(result.payment); - assert.equal(ops.length, 1, 'send() should issue exactly one operation'); - assert.equal(countOf(ops, 'CreateSendTransaction'), 1); - }); }); diff --git a/packages/payments/src/resources/transactions.ts b/packages/payments/src/resources/transactions.ts index a21eb73..e6cee30 100644 --- a/packages/payments/src/resources/transactions.ts +++ b/packages/payments/src/resources/transactions.ts @@ -88,20 +88,20 @@ export class Transactions { /** * Resolves and caches everything `send()` needs before it can pay: the * wallet's environment type, its node endpoint, and — for live wallets — the - * decrypted admin macaroon. Two GraphQL round-trips plus two Argon2id passes, - * so calling this ahead of time takes seconds off the first `send()`. + * decrypted admin macaroon. This prepares the credentials used by + * `retryPayment()` without putting a password on that method. * - * A later `send()` for the same wallet that **omits `password`** uses what - * this cached. A `send()` that passes a `password` always derives afresh — - * the cache never has to decide whether two sets of credentials match. + * `retryPayment()` uses what this cached. A new `send()` always requires a + * password and derives afresh, so the cache never has to decide whether two + * sets of credentials match. * * Safe to call repeatedly: an already-prepared wallet resolves immediately, * and concurrent calls for one wallet share a single derivation. Call * {@link forgetSend} first to re-derive after credentials rotate. * * Argon2id is CPU-bound (m=64 MiB, t=3, p=4), but runs on a shared worker - * thread so it does not block the event loop. Preparing ahead of time still - * avoids that work on the first payment request. + * thread so it does not block the event loop. Preparing ahead of time avoids + * that work when `retryPayment()` needs the cached context. */ async prepareSend(params: PrepareSendParams): Promise { const { walletId } = params; @@ -127,9 +127,8 @@ export class Transactions { /** * Whether `walletId`'s macaroon and node endpoint are decrypted and resident - * in memory, so a password-less `send()` would skip straight to creating the - * transaction. `false` while a `prepareSend()` for that wallet is still - * running. + * in memory for `retryPayment()`. `false` while a `prepareSend()` for that + * wallet is still running. */ isSendReady(walletId: string): boolean { return this.#prepared.has(walletId); @@ -177,20 +176,24 @@ export class Transactions { * just creates the send and returns (`payment` is `null`). The backend * settles the transaction asynchronously according to the * `amb_sandbox_behavior` metadata (`complete` / `fail` / `expire`; default - * `expire`). No password is required. + * `expire`). A password is still required so the call matches production. * - * Call {@link prepareSend} beforehand (or pass `send` to the `Payments` - * constructor) to move steps 1–2 off this path entirely. + * Prepared credentials are not used here: every new send validates the + * password-derived credentials independently. */ async send(params: SendParams): Promise { const { destination, onUpdate, signal } = params; const timeoutSeconds = params.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS; + if (!params.password?.trim()) { + throw new PaymentSendError('A team password is required to send from any wallet.'); + } + // 1–2. Resolve the node endpoint + decrypted macaroon. A caller who passes - // a `password` always gets a fresh derivation; only a password-less - // send reads what `prepareSend()` cached. Either way a wrong password - // fails here, before any transaction is created. - const prepared = await this.#sendContext(params); + // a `password` always gets a fresh derivation. A wrong password fails + // here, before any transaction is created. Prepared contexts remain + // reserved for retryPayment(), which has no password parameter. + const prepared = await this.#resolveSendContext(params); // 3. Create the send transaction → backend returns the bolt11 to pay. const createRes = await this.#sdk.CreateSendTransaction({ @@ -249,12 +252,10 @@ export class Transactions { * Throws {@link PaymentSendError} if the transaction is not in `FAILED` * status, its invoice has expired, or it has no `payment_request` to retry. * - * Takes no password: like a password-less {@link send}, it relies on - * {@link prepareSend} having already cached the wallet's macaroon (as it - * would for the original `send()` call that failed). If nothing is cached - * for the transaction's wallet, this fails the same way an unprepared, - * password-less `send()` does — a `PaymentSendError` asking for a team - * password via `prepareSend()` first. + * Takes no password: it relies on {@link prepareSend} having already cached + * the wallet's macaroon. If nothing is cached for the transaction's wallet, + * this fails with a `PaymentSendError` asking for a team password via + * `prepareSend()` first. */ async retryPayment(paymentId: string): Promise { const transaction = await this.findOne(paymentId); @@ -282,13 +283,7 @@ export class Transactions { return { transaction, payment }; } - /** - * The context `send()` will pay with. Passing a `password` means "use these - * credentials", so it always derives; omitting one means "use what was - * prepared", falling through to a derivation when nothing was — which is how - * sandbox wallets (no password, nothing to decrypt) still work unprepared, - * and how an unprepared live wallet gets its "password required" error. - */ + /** Resolve the cached context used by `retryPayment()`, which takes no password. */ #sendContext(params: PrepareSendParams): Promise { if (params.password !== undefined) return this.#resolveSendContext(params); diff --git a/packages/payments/src/resources/transactions.types.ts b/packages/payments/src/resources/transactions.types.ts index 75615dc..d25975f 100644 --- a/packages/payments/src/resources/transactions.types.ts +++ b/packages/payments/src/resources/transactions.types.ts @@ -55,6 +55,12 @@ export type PreparedSend = }; export interface SendParams extends PrepareSendParams { + /** + * Team password. Required for every send, including sandbox sends, so the + * sandbox call shape matches production. Sandbox settlement does not use the + * password, but callers must still provide it. + */ + password: string; destination: SendDestination; /** Idempotency key forwarded to `create_send`. */ idempotencyKey?: string; From d24db528ef2aeaa23530f4eeff4863bc40cc405d Mon Sep 17 00:00:00 2001 From: Bufo Date: Thu, 24 Sep 2026 13:14:54 +0200 Subject: [PATCH 2/2] fix(payments): clarify sandbox password validation [AMB-3265] --- AGENTS.md | 8 +++++--- docs/INTEGRATION.md | 16 +++++++++------- packages/payments/README.md | 9 +++++---- packages/payments/examples/README.md | 10 ++++++---- packages/payments/examples/send.ts | 3 ++- .../src/resources/transactions.send.test.ts | 4 ++-- packages/payments/src/resources/transactions.ts | 5 +++-- .../payments/src/resources/transactions.types.ts | 6 +++--- 8 files changed, 35 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 72f1449..0c5cf23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,9 +83,11 @@ Resource getters are lazy and call `requireServiceApiKey`: - `send` creates the send transaction, decrypts the node admin macaroon **in-process** using the team password (never sent to the API), then pays directly against the node's REST endpoint. Base-asset wallets pay over LND; - Taproot Asset wallets over litd. `password` is mandatory for sandbox and live - sends so their call shapes match. Sandbox does not use it: the backend settles - asynchronously (`payment` resolves `null`); behavior is driven by + Taproot Asset wallets over litd. A non-empty `password` is mandatory for + sandbox and live sends so their call shapes match. Live requires the real + team password; sandbox accepts any non-empty value because it does not use + it. The backend settles sandbox sends asynchronously (`payment` resolves + `null`); behavior is driven by `metadata.amb_sandbox_behavior` (`complete` / `fail` / `expire`). - Send errors: wrong password → `DecryptionError`; node-side failure → `PaymentSendError`. diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index bd3ec6b..e5e3302 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -110,7 +110,7 @@ node and resolves with the terminal result. ```ts const { transaction, payment } = await payments.transactions.send({ walletId, - password: process.env.TEAM_PASSWORD, // required for sandbox and live wallets + password: process.env.TEAM_PASSWORD, // real team password for live wallets teamId, // optional — resolved from the wallet unless you override it destination: { bolt11: 'lnbc1...' }, // or: destination: { lightningAddress: 'user@domain.com', amountSats: '1000' } @@ -130,15 +130,16 @@ Notes: - Wrong password → `DecryptionError`. Node-side failure → `PaymentSendError`. - If the invoice was already paid (duplicate, or a replayed `idempotencyKey`), the SDK detects the returned `COMPLETED` transaction and resolves with `payment.status === 'SUCCEEDED'` without re-paying on the node. `payment.paymentPreimage` is `undefined` in this case. -**Sandbox wallets** need no node, but `password` remains mandatory so the -sandbox request matches production. It is not used for sandbox settlement. The -backend settles the transaction asynchronously and `payment` resolves `null`. -Control the outcome with metadata and observe it via webhooks: +**Sandbox wallets** need no node, but a non-empty `password` remains mandatory +so the sandbox request matches production. Any non-empty value such as +`Password123` works because sandbox does not use it. The backend settles the +transaction asynchronously and `payment` resolves `null`. Control the outcome +with metadata and observe it via webhooks: ```ts await payments.transactions.send({ walletId: sandboxWalletId, - password: process.env.TEAM_PASSWORD, + password: 'Password123', // any non-empty value works in sandbox destination: { bolt11: 'lnbc1...' }, metadata: { amb_sandbox_behavior: 'complete' }, // 'complete' | 'fail' | 'expire' (default) }); @@ -348,7 +349,8 @@ Send-specific: `DecryptionError` (wrong team password) and `PaymentSendError` - [ ] `idempotency_key` / `idempotencyKey` set on receives and sends so your retries are safe. - [ ] Sends handle `DecryptionError` / `PaymentSendError` distinctly. -- [ ] Every sandbox and production send includes the team password. +- [ ] Every send includes a non-empty password; live uses the real team + password, while sandbox may use a placeholder such as `Password123`. - [ ] Wallets used by `retryPayment` are prepared first, and `forgetSend` runs when node credentials rotate. - [ ] The full flow was exercised against a `SANDBOX` environment first diff --git a/packages/payments/README.md b/packages/payments/README.md index f44be3e..b77afd5 100644 --- a/packages/payments/README.md +++ b/packages/payments/README.md @@ -220,9 +220,10 @@ throws `DecryptionError`; a node-side failure throws `PaymentSendError`. If the invoice was already paid (a genuine duplicate, or a replayed `idempotencyKey`), the backend returns the existing `COMPLETED` transaction instead of creating a new one; the SDK detects this and resolves immediately with `payment.status === 'SUCCEEDED'` without re-paying on the node. `payment.paymentPreimage` is `undefined` in this case — the transaction record doesn't store it. -**Sandbox wallets** need no node or macaroon, but `password` is still mandatory -so sandbox and production sends use the same call shape. The password is not -used for sandbox settlement. `payment` comes back `null`; observe the outcome +**Sandbox wallets** need no node or macaroon, but a non-empty `password` is +still mandatory so sandbox and production sends use the same call shape. Any +non-empty value such as `Password123` works because sandbox settlement does not +use it. `payment` comes back `null`; observe the outcome via webhooks or by polling the transaction status. The backend settles asynchronously per the `amb_sandbox_behavior` metadata (`complete` / `fail` / `expire`; default `expire`): @@ -230,7 +231,7 @@ asynchronously per the `amb_sandbox_behavior` metadata (`complete` / `fail` / ```ts const { transaction, payment } = await payments.transactions.send({ walletId, // a sandbox wallet - password, // still required, matching production + password: 'Password123', // any non-empty value works in sandbox destination: { bolt11: 'lnbc1...' }, metadata: { amb_sandbox_behavior: 'complete' }, // force success in sandbox }); diff --git a/packages/payments/examples/README.md b/packages/payments/examples/README.md index 92c538b..5739f90 100644 --- a/packages/payments/examples/README.md +++ b/packages/payments/examples/README.md @@ -52,10 +52,12 @@ Destinations (set one): - `BOLT11` — a BOLT11 invoice (`AMOUNT_SATS` only needed for zero-amount invoices). - `LIGHTNING_ADDRESS` + `AMOUNT_SATS` — pay a Lightning address. -The team password is mandatory for both sandbox and live sends. For live -wallets it decrypts the node admin macaroon locally; it is never sent to the -API. Sandbox settlement does not use it, but requiring it keeps the call shape -identical to production. See `.env.example` for every supported variable. +A non-empty password is mandatory for both sandbox and live sends. Live wallets +require the real team password, which decrypts the node admin macaroon locally +and is never sent to the API. Sandbox accepts any non-empty value such as +`Password123` because settlement does not use it; requiring the field keeps the +call shape identical to production. See `.env.example` for every supported +variable. ### `verify-webhook.mjs` / `verify-webhook.cjs` — offline webhook verification diff --git a/packages/payments/examples/send.ts b/packages/payments/examples/send.ts index 743d3bf..784cd1f 100644 --- a/packages/payments/examples/send.ts +++ b/packages/payments/examples/send.ts @@ -74,7 +74,8 @@ async function main(): Promise { const password = process.env.TEAM_PASSWORD; // Every send requires TEAM_PASSWORD so sandbox exercises the production - // call shape, even though its backend-managed settlement does not use it. + // call shape. Sandbox accepts any non-empty placeholder because settlement + // does not use the value; live sends require the real team password. if (!walletId || !password || !destination) { console.log( '\nSkipping send — set WALLET_ID, TEAM_PASSWORD, and a destination ' + diff --git a/packages/payments/src/resources/transactions.send.test.ts b/packages/payments/src/resources/transactions.send.test.ts index 8b62f20..4bc0aae 100644 --- a/packages/payments/src/resources/transactions.send.test.ts +++ b/packages/payments/src/resources/transactions.send.test.ts @@ -179,14 +179,14 @@ describe('Transactions.send', () => { assert.equal((lastBody as { fee_limit_sat: string }).fee_limit_sat, '4294967296'); }); - it('creates a sandbox send with a password and returns payment: null', async () => { + it('accepts any non-empty sandbox password and returns payment: null', async () => { // No node should be contacted for sandbox — point at an unroutable host // so any accidental node call would fail the test. const transactions = new Transactions(fakeClient('http://127.0.0.1:1', 'SANDBOX')); const result = await transactions.send({ walletId: 'w1', - password: PASSWORD, + password: 'Password123', destination: { bolt11: 'lnbc1xyz' }, }); diff --git a/packages/payments/src/resources/transactions.ts b/packages/payments/src/resources/transactions.ts index e6cee30..64133f6 100644 --- a/packages/payments/src/resources/transactions.ts +++ b/packages/payments/src/resources/transactions.ts @@ -176,7 +176,8 @@ export class Transactions { * just creates the send and returns (`payment` is `null`). The backend * settles the transaction asynchronously according to the * `amb_sandbox_behavior` metadata (`complete` / `fail` / `expire`; default - * `expire`). A password is still required so the call matches production. + * `expire`). A non-empty password is still required so the call matches + * production, but sandbox does not use its value. * * Prepared credentials are not used here: every new send validates the * password-derived credentials independently. @@ -186,7 +187,7 @@ export class Transactions { const timeoutSeconds = params.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS; if (!params.password?.trim()) { - throw new PaymentSendError('A team password is required to send from any wallet.'); + throw new PaymentSendError('A non-empty password is required to send from any wallet.'); } // 1–2. Resolve the node endpoint + decrypted macaroon. A caller who passes diff --git a/packages/payments/src/resources/transactions.types.ts b/packages/payments/src/resources/transactions.types.ts index d25975f..20e46b2 100644 --- a/packages/payments/src/resources/transactions.types.ts +++ b/packages/payments/src/resources/transactions.types.ts @@ -56,9 +56,9 @@ export type PreparedSend = export interface SendParams extends PrepareSendParams { /** - * Team password. Required for every send, including sandbox sends, so the - * sandbox call shape matches production. Sandbox settlement does not use the - * password, but callers must still provide it. + * Password required for every send so sandbox matches the production call + * shape. Live sends require the real team password; sandbox accepts any + * non-empty value because settlement does not use it. */ password: string; destination: SendDestination;