diff --git a/AGENTS.md b/AGENTS.md index 67b5bff..0c5cf23 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,12 @@ 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. 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`. - `retryPayment(paymentId)` retries a `FAILED` send **without calling @@ -96,26 +99,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..e5e3302 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, // 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,26 +130,25 @@ 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 +**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: 'Password123', // any non-empty value works in sandbox destination: { bolt11: 'lnbc1...' }, metadata: { amb_sandbox_behavior: 'complete' }, // 'complete' | 'fail' | 'expire' (default) }); ``` -### Making sends fast +### Preparing credentials for retries -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. - -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 +166,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 +349,10 @@ 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 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 (`amb_sandbox_behavior: 'complete' | 'fail' | 'expire'` covers all outcomes). diff --git a/packages/payments/README.md b/packages/payments/README.md index ee70351..b77afd5 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,18 @@ 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 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`): ```ts const { transaction, payment } = await payments.transactions.send({ - walletId, // a sandbox wallet — no password required + walletId, // a sandbox wallet + password: 'Password123', // any non-empty value works in sandbox destination: { bolt11: 'lnbc1...' }, metadata: { amb_sandbox_behavior: 'complete' }, // force success in sandbox }); @@ -236,24 +239,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 +266,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 +301,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..5739f90 100644 --- a/packages/payments/examples/README.md +++ b/packages/payments/examples/README.md @@ -52,8 +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 used only to decrypt the node admin macaroon locally; it is -never sent to the API. 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 3333037..784cd1f 100644 --- a/packages/payments/examples/send.ts +++ b/packages/payments/examples/send.ts @@ -73,20 +73,20 @@ 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. 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 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..4bc0aae 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('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: 'Password123', 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..64133f6 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,25 @@ 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 non-empty password is still required so the call matches + * production, but sandbox does not use its value. * - * 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 non-empty 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 +253,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 +284,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..20e46b2 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 { + /** + * 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; /** Idempotency key forwarded to `create_send`. */ idempotencyKey?: string;