diff --git a/AGENTS.md b/AGENTS.md index f0f9a00..5eed42a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,30 +100,30 @@ Resource getters are lazy and call `requireServiceApiKey`: invoice instead of letting the existing failed row's status update. Throws `PaymentSendError` if the transaction isn't retryable. `options.allowSelfPayment` works like the `send` parameter. Takes no - password — it reads the wallet's macaroon from the `prepareSend` cache and - fails when nothing is cached. + password — it reads the wallet's macaroon from the send cache (filled by + `prepareSend` or `send`) 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 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 + (`CreateSendTransaction` + node REST call). `send` and `prepareSend` both go + through one cached derivation per wallet; `isSendReady(walletId)` reports + whether a macaroon is resident and `forgetSend(walletId)` drops it. + `PaymentsConfig.send` prepares 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 sends hit the cache without reintroducing all of it. +- **Cache rule:** `send`, `prepareSend` and `retryPayment` share one per-wallet + cache. A cache hit wins even when `send` is given a `password`, so a wrong + password does not fail a send while credentials are cached. The first `send` + fills an empty cache. The cache is not keyed by credentials and has no + expiry: `forgetSend` is the only way to drop it or to pick up rotated node + credentials. - Remaining invariants, each with a regression test in `transactions.send.test.ts`: - Only the **macaroon** is retained, never `masterKey` / `masterPasswordHash`. - - A failing `send` cannot disturb a prepared wallet, because it never touches - the map. + - A failed derivation is never cached, and concurrent sends share one + derivation. A sender that joined a derivation which then failed derives + again with its own credentials, so one caller's bad password cannot fail + another's send. - `forgetSend` mid-preparation wins: a result landing afterwards is discarded rather than resurrecting the macaroon. - Argon2id runs on a shared worker thread, so it does not block the event loop. diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 8dcd6c1..a0aa534 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -150,10 +150,13 @@ await payments.transactions.send({ }); ``` -### Preparing credentials for retries +### Preparing credentials for sends and retries -`retryPayment` takes no password argument, so prepare and cache its node -credentials before retrying. You can do this at startup: +The first `send` for a wallet derives its node credentials (7 to 15 s) and +caches them in memory. Later sends reuse them and skip that cost, even when you +pass a different `password`. `retryPayment` takes no password argument and uses +the same cache. To pay the derivation cost before the first payment, prepare the +wallet at startup: ```ts const payments = new Payments({ @@ -171,8 +174,8 @@ await payments.transactions.prepareSend({ }); ``` -Either way the wallet's macaroon ends up decrypted in memory for -`retryPayment`. New sends always require a password and derive afresh: +Either way the wallet's macaroon ends up decrypted in memory for `send` and +`retryPayment`: ```ts payments.transactions.isSendReady(walletId); // true once prepared @@ -185,13 +188,15 @@ await payments.transactions.send({ Three things to plan around: -- **Always pass `password` to `send`.** Prepared credentials are reserved for - `retryPayment`; they do not bypass the send-time password requirement. +- **Always pass `password` to `send`.** It is required on every call, but it is + not checked while credentials for the wallet are cached. A wrong password + fails only the send that has to derive. Concurrent sends for one wallet share + one derivation. - **Argon2id runs on a shared worker thread.** Its CPU work does not block the 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 +- **You are holding node admin credentials in memory** from the first `send` or + `prepareSend` until you drop them, which is what makes sends fast. Call `payments.transactions.forgetSend(walletId)` to release them, and to pick up rotated node credentials — there is no expiry. The Argon2 master key is never cached; only the one wallet's macaroon is. @@ -356,8 +361,8 @@ Send-specific: `DecryptionError` (wrong team password) and `PaymentSendError` - [ ] Sends handle `DecryptionError` / `PaymentSendError` distinctly. - [ ] 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. +- [ ] `forgetSend` runs when node credentials rotate; `retryPayment` needs a + prior `send` or `prepareSend` for its wallet. - [ ] 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 01f2b36..75d5e62 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-cache retry credentials + send?: Array<{ walletId: string, password?: string, teamId?: string }>, // pre-cache send credentials }); ``` @@ -224,6 +224,10 @@ Base-asset wallets pay over LND; Taproot Asset wallets pay over litd — the SDK selects the endpoint automatically from the wallet's asset. A wrong password throws `DecryptionError`; a node-side failure throws `PaymentSendError`. +The first `send` for a wallet derives its node credentials (a few seconds of +Argon2id) and caches them in memory. Later sends reuse the cache, even when you +pass a different `password`; see [Preparing credentials](#preparing-credentials-for-sends-and-retries). + 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. To pay an invoice your own team issued — for example, your Taproot Asset wallet's invoice from your BTC wallet — pass `allowSelfPayment: true`. It is off by default. @@ -247,12 +251,13 @@ const { transaction, payment } = await payments.transactions.send({ payment; // null — settlement happens server-side ``` -#### Preparing credentials for retries +#### Preparing credentials for sends and retries `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. +two Argon2id passes, and caches only the decrypted macaroon. `send` and +`retryPayment` both use this cache. `send` fills it too when it is empty, so +`prepareSend` only moves the cost ahead of the first payment. `send` still +requires a non-empty `password`; `retryPayment` has no password parameter. ```ts await payments.transactions.prepareSend({ walletId, password }); @@ -286,11 +291,14 @@ Notes: overlap anything). - `isSendReady` is `false` while a preparation is still running, `true` only once the macaroon is resident. -- `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 +- While credentials are cached, `send` uses them and does not check the + `password` you pass. A wrong password fails only the send that has to derive. + A failed derivation is never cached. +- Concurrent sends and `prepareSend` calls for one wallet share a single + derivation. +- The cache has no expiry, and the first `send` fills it. 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 can prepare too; this only caches the fact that no node payment is needed. @@ -313,7 +321,7 @@ To retry a self-payment, pass the flag again: `retryPayment(paymentId, { allowSelfPayment: true })`. It relies on a cached macaroon: call `prepareSend({ walletId, password })` -before retrying. Without one, it fails with a `PaymentSendError` asking you to +or `send` before retrying. Without one, it fails with a `PaymentSendError` asking you to prepare the wallet first. ## Examples diff --git a/packages/payments/src/client.ts b/packages/payments/src/client.ts index 4e01f44..af7877d 100644 --- a/packages/payments/src/client.ts +++ b/packages/payments/src/client.ts @@ -9,12 +9,12 @@ import { Webhooks } from './resources/webhooks.js'; export type PaymentsConfig = ClientConfig & { webhookSecret?: string; /** - * Wallets whose retry credentials should be prepared in the background. Each + * Wallets whose send credentials should be prepared in the background. Each * entry's node endpoint is fetched and its admin macaroon decrypted for use - * by `retryPayment()`. + * by `send()` and `retryPayment()`. * - * Per-wallet failures are ignored here. A new `send()` always performs its - * own derivation and surfaces the real error. Requires + * Per-wallet failures are ignored here. A later `send()` derives the + * credentials itself and surfaces the real error. Requires * `serviceApiKey`: passing this without one throws `ConfigError` from the * constructor rather than preparing nothing in silence. */ diff --git a/packages/payments/src/resources/transactions.send.test.ts b/packages/payments/src/resources/transactions.send.test.ts index c51e876..71a3d2e 100644 --- a/packages/payments/src/resources/transactions.send.test.ts +++ b/packages/payments/src/resources/transactions.send.test.ts @@ -146,37 +146,6 @@ function withCallLog(inner: GraphQLClient): { client: GraphQLClient; ops: string const countOf = (ops: readonly string[], operation: string): number => ops.filter((document) => document.includes(operation)).length; -/** - * Prepares `w1`, then fails a send on it with the wrong password — the shared - * arrangement for the cases asserting that a bad password disturbs nothing. - * `ops` is cleared right before the failing send, so what it holds on return is - * that send's own traffic. - */ -async function prepareThenFailWrongPassword(): Promise<{ - transactions: Transactions; - ops: string[]; -}> { - 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 }); - ops.length = 0; - - await assert.rejects( - transactions.send({ - walletId: 'w1', - password: 'a-different-password', - destination: { bolt11: FIXED_AMOUNT_BOLT11 }, - }), - /admin macaroon/, - ); - - return { transactions, ops }; -} - describe('Transactions.send', () => { it('decrypts the macaroon, creates the send, and pays via the LND node', async () => { const host = await startNode([ @@ -398,16 +367,6 @@ describe('Transactions.prepareSend', () => { assert.equal(transactions.isSendReady('w1'), false); }); - it('does not reuse a prepared macaroon for a send() with a different password', async () => { - const { ops } = await prepareThenFailWrongPassword(); - - assert.equal( - countOf(ops, 'GetWalletNodePermissions'), - 1, - 'different credentials must re-derive rather than hit the cache', - ); - }); - it('marks a sandbox wallet ready without a password', async () => { const transactions = new Transactions(fakeClient('http://127.0.0.1:1', 'SANDBOX')); @@ -415,62 +374,170 @@ describe('Transactions.prepareSend', () => { assert.equal(transactions.isSendReady('w1'), true); }); +}); - it('keeps an already-prepared wallet after a send() with the wrong password fails', async () => { - const { transactions, ops } = await prepareThenFailWrongPassword(); +describe('Transactions.send credential cache', () => { + const sendOnW1 = (transactions: Transactions, password = PASSWORD) => + transactions.send({ + walletId: 'w1', + password, + destination: { bolt11: FIXED_AMOUNT_BOLT11 }, + }); + + const paidNode = (): Promise => + startNode([{ result: { status: 'SUCCEEDED', payment_hash: 'ph', fee_sat: '1' } }]); + + it('uses prepared credentials without any derivation when a password is passed', async () => { + const { client, ops } = withCallLog(fakeClient(await paidNode())); + const transactions = new Transactions(client); + await transactions.prepareSend({ walletId: 'w1', password: PASSWORD }); + ops.length = 0; + + await sendOnW1(transactions, 'a-different-password'); assert.equal( - transactions.isSendReady('w1'), - true, - "one caller's bad password must not evict a working prepared wallet", + countOf(ops, 'GetWalletNodePermissions') + countOf(ops, 'GetWalletSendContext'), + 0, ); + }); + + it('pays with the cached credentials when a password is passed', async () => { + const transactions = new Transactions(fakeClient(await paidNode())); + await transactions.prepareSend({ walletId: 'w1', password: PASSWORD }); + + const result = await sendOnW1(transactions, 'a-different-password'); + + assert.equal(result.payment?.status, 'SUCCEEDED'); + }); + + it('warms the cache on the first send', async () => { + const transactions = new Transactions(fakeClient(await paidNode())); + + await sendOnW1(transactions); + + assert.equal(transactions.isSendReady('w1'), true); + }); + + it('derives only once across consecutive sends', async () => { + const { client, ops } = withCallLog(fakeClient(await paidNode())); + const transactions = new Transactions(client); + + await sendOnW1(transactions); + await sendOnW1(transactions); assert.equal(countOf(ops, 'GetWalletNodePermissions'), 1); }); - it('always re-derives for a send() that passes a password, even the prepared one', async () => { - const host = await startNode([ - { result: { status: 'SUCCEEDED', payment_hash: 'ph', fee_sat: '1' } }, - ]); - const { client, ops } = withCallLog(fakeClient(host)); + it('shares one derivation between concurrent sends', async () => { + const { client, ops } = withCallLog(fakeClient(await paidNode())); const transactions = new Transactions(client); - await transactions.prepareSend({ walletId: 'w1', password: PASSWORD }); - ops.length = 0; + await Promise.all([sendOnW1(transactions), sendOnW1(transactions)]); - // Passing a password means "use these credentials", so the cache is not - // consulted — that is what frees it from ever comparing credentials. - const result = await transactions.send({ - walletId: 'w1', - password: PASSWORD, - destination: { bolt11: FIXED_AMOUNT_BOLT11 }, - }); + assert.equal(countOf(ops, 'GetWalletNodePermissions'), 1); + }); - assert.ok(result.payment); - assert.equal(countOf(ops, 'GetWalletNodePermissions'), 1, 'a password send derives afresh'); - assert.equal(transactions.isSendReady('w1'), true, 'and leaves the prepared wallet untouched'); + it('shares an in-flight prepareSend derivation with a concurrent send', async () => { + const { client, ops } = withCallLog(fakeClient(await paidNode())); + const transactions = new Transactions(client); + + await Promise.all([ + transactions.prepareSend({ walletId: 'w1', password: PASSWORD }), + sendOnW1(transactions), + ]); + + assert.equal(countOf(ops, 'GetWalletNodePermissions'), 1); }); - it('keeps a concurrent successful preparation when another send has bad credentials', async () => { - const host = await startNode([{ result: { status: 'SUCCEEDED' } }]); - const transactions = new Transactions(fakeClient(host)); + it('does not cache a failed derivation', async () => { + const transactions = new Transactions(fakeClient(await paidNode())); - // Both derivations are in flight at once: the good one is started first, - // then the bad one, which must not displace it. - const prepared = transactions.prepareSend({ walletId: 'w1', password: PASSWORD }); - const failed = transactions.send({ - walletId: 'w1', - password: 'a-different-password', - destination: { bolt11: FIXED_AMOUNT_BOLT11 }, - }); + await assert.rejects(sendOnW1(transactions, 'wrong-password'), /admin macaroon/); + + assert.equal(transactions.isSendReady('w1'), false); + }); + + it('derives again with the right password after a failed one', async () => { + const transactions = new Transactions(fakeClient(await paidNode())); + await assert.rejects(sendOnW1(transactions, 'wrong-password'), /admin macaroon/); - await prepared; - await assert.rejects(failed, /admin macaroon/); + const result = await sendOnW1(transactions); + + assert.equal(result.payment?.status, 'SUCCEEDED'); + }); + + it('does not store credentials when forgetSend runs during the derivation', async () => { + const transactions = new Transactions(fakeClient(await paidNode())); + + const sending = sendOnW1(transactions); + transactions.forgetSend('w1'); + await sending; + + assert.equal(transactions.isSendReady('w1'), false); + }); + + it('still pays with the credentials it derived when forgetSend ran mid-derivation', async () => { + const transactions = new Transactions(fakeClient(await paidNode())); + + const sending = sendOnW1(transactions); + transactions.forgetSend('w1'); + + assert.equal((await sending).payment?.status, 'SUCCEEDED'); + }); + it('derives again after forgetSend', async () => { + const { client, ops } = withCallLog(fakeClient(await paidNode())); + const transactions = new Transactions(client); + await sendOnW1(transactions); + + transactions.forgetSend('w1'); + await sendOnW1(transactions); + + assert.equal(countOf(ops, 'GetWalletNodePermissions'), 2); + }); + + it('skips the wallet lookup on a second sandbox send', async () => { + const { client, ops } = withCallLog(fakeClient('http://127.0.0.1:1', 'SANDBOX')); + const transactions = new Transactions(client); + + await sendOnW1(transactions); + await sendOnW1(transactions); + + assert.equal(countOf(ops, 'GetWalletSendContext'), 1); + }); + + it('pays with the right password when a concurrent send with a wrong password fails first', async () => { + const transactions = new Transactions(fakeClient(await paidNode())); + + const [wrongPasswordSend, rightPasswordSend] = await Promise.allSettled([ + sendOnW1(transactions, 'wrong-password'), + sendOnW1(transactions), + ]); + + assert.equal(wrongPasswordSend.status, 'rejected'); assert.equal( - transactions.isSendReady('w1'), - true, - 'a resolved prepareSend must survive a concurrent bad-credential send', + rightPasswordSend.status === 'fulfilled' && rightPasswordSend.value.payment?.status, + 'SUCCEEDED', ); }); + + it('pays with the right password when a concurrent passwordless prepare fails first', async () => { + const transactions = new Transactions(fakeClient(await paidNode())); + + const [passwordlessPrepare, send] = await Promise.allSettled([ + transactions.prepareSend({ walletId: 'w1' }), + sendOnW1(transactions), + ]); + + assert.equal(passwordlessPrepare.status, 'rejected'); + assert.equal(send.status === 'fulfilled' && send.value.payment?.status, 'SUCCEEDED'); + }); + + it('caches the credentials derived by the fallback send', async () => { + const transactions = new Transactions(fakeClient(await paidNode())); + + await Promise.allSettled([sendOnW1(transactions, 'wrong-password'), sendOnW1(transactions)]); + + assert.equal(transactions.isSendReady('w1'), true); + }); }); diff --git a/packages/payments/src/resources/transactions.ts b/packages/payments/src/resources/transactions.ts index 4065fc1..c755038 100644 --- a/packages/payments/src/resources/transactions.ts +++ b/packages/payments/src/resources/transactions.ts @@ -74,14 +74,12 @@ function bolt11AmountSats(destination: SendDestination): string | undefined { export class Transactions { readonly #sdk: ReturnType; /** - * Macaroons prepared by {@link prepareSend}, keyed by wallet id. Only a - * *successful* preparation lands here, and only `prepareSend` ever writes: - * `send()` never adds to or evicts from this map, so no failing send can - * disturb a prepared wallet. ponytail: no TTL — call `forgetSend()` to - * refresh rotated credentials. + * Send contexts keyed by wallet id, filled by {@link prepareSend} and by the + * first `send()` for a wallet. Only a *successful* derivation lands here. + * ponytail: no TTL — call `forgetSend()` to refresh rotated credentials. */ readonly #prepared = new Map(); - /** Preparations still running, so concurrent `prepareSend` calls share one Argon2 pass. */ + /** Derivations still running, so concurrent sends and `prepareSend` calls share one Argon2 pass. */ readonly #pending = new Map>(); constructor(graphqlClient: GraphQLClient) { @@ -91,60 +89,38 @@ 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. This prepares the credentials used by - * `retryPayment()` without putting a password on that method. - * - * `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. + * decrypted admin macaroon. `send()` warms the same cache on its first call, + * so this is only needed to pay the derivation cost ahead of time, or to + * prepare credentials for `retryPayment()` without putting a password on it. * * 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 avoids - * that work when `retryPayment()` needs the cached context. + * thread so it does not block the event loop. */ async prepareSend(params: PrepareSendParams): Promise { - const { walletId } = params; - if (this.#prepared.has(walletId)) return; - - const inFlight = this.#pending.get(walletId); - if (inFlight) { - await inFlight; - return; - } - - const promise = this.#resolveSendContext(params); - this.#pending.set(walletId, promise); - try { - const prepared = await promise; - // Skip the write if `forgetSend()` ran mid-derivation — the caller asked - // for this macaroon *not* to be held. - if (this.#pending.get(walletId) === promise) this.#prepared.set(walletId, prepared); - } finally { - if (this.#pending.get(walletId) === promise) this.#pending.delete(walletId); - } + await this.#prepare(params); } /** * Whether `walletId`'s macaroon and node endpoint are decrypted and resident - * in memory for `retryPayment()`. `false` while a `prepareSend()` for that - * wallet is still running. + * in memory for `send()` and `retryPayment()`. `false` while the derivation + * for that wallet is still running. */ isSendReady(walletId: string): boolean { return this.#prepared.has(walletId); } /** - * Drops a wallet's prepared context, releasing the decrypted macaroon from + * Drops a wallet's cached context, releasing the decrypted macaroon from * memory. Use it to pick up rotated node credentials, or to stop holding node * admin access once a run of sends is done. */ forgetSend(walletId: string): void { this.#prepared.delete(walletId); - // Dropping the in-flight preparation too, so its result cannot land in + // Dropping the in-flight derivation too, so its result cannot land in // #prepared after the caller asked for the macaroon to be released. this.#pending.delete(walletId); } @@ -182,8 +158,10 @@ export class Transactions { * `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. + * Credentials cached for the wallet are used even when a `password` is + * passed, so a wrong password does not fail a send until the cache is empty. + * The first send for a wallet derives the credentials and caches them; call + * {@link forgetSend} to drop them or to pick up rotated node credentials. */ async send(params: SendParams): Promise { const { destination, onUpdate, signal, allowSelfPayment } = params; @@ -193,11 +171,9 @@ export class Transactions { 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. 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); + // 1–2. Resolve the node endpoint + decrypted macaroon. A wrong password + // fails here, before any transaction is created. + const prepared = await this.#prepare(params); // 3. Create the send transaction → backend returns the bolt11 to pay. const createRes = await this.#sdk.CreateSendTransaction({ @@ -257,10 +233,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: 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. + * Takes no password: it relies on {@link prepareSend} or an earlier + * {@link send} having 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, options: RetryPaymentOptions = {}): Promise { const transaction = await this.findOne(paymentId); @@ -277,7 +253,7 @@ export class Transactions { throw new PaymentSendError(`Transaction ${paymentId} has no payment_request to retry.`); } - const prepared = await this.#sendContext({ walletId: transaction.wallet_id }); + const prepared = await this.#prepare({ walletId: transaction.wallet_id }); if (prepared.kind === 'sandbox') return { transaction, payment: null }; const payment = await this.#payAtNode(prepared, transaction.payment_request, { @@ -289,12 +265,34 @@ export class Transactions { return { transaction, payment }; } - /** Resolve the cached context used by `retryPayment()`, which takes no password. */ - #sendContext(params: PrepareSendParams): Promise { - if (params.password !== undefined) return this.#resolveSendContext(params); + /** Cached context for the wallet, or one shared derivation that fills the cache. */ + async #prepare(params: PrepareSendParams): Promise { + const { walletId } = params; + const cached = this.#prepared.get(walletId); + if (cached) return cached; + + const inFlight = this.#pending.get(walletId); + if (inFlight) { + try { + return await inFlight; + } catch { + // The starter's own credentials failed, which says nothing about ours. + if (this.#pending.get(walletId) === inFlight) this.#pending.delete(walletId); + return this.#prepare(params); + } + } - const prepared = this.#prepared.get(params.walletId); - return prepared ? Promise.resolve(prepared) : this.#resolveSendContext(params); + const promise = this.#resolveSendContext(params); + this.#pending.set(walletId, promise); + try { + const prepared = await promise; + // Skip the write if `forgetSend()` ran mid-derivation — the caller asked + // for this macaroon *not* to be held. + if (this.#pending.get(walletId) === promise) this.#prepared.set(walletId, prepared); + return prepared; + } finally { + if (this.#pending.get(walletId) === promise) this.#pending.delete(walletId); + } } async #resolveSendContext(params: PrepareSendParams): Promise { diff --git a/packages/payments/src/resources/transactions.types.ts b/packages/payments/src/resources/transactions.types.ts index 0f4f9bc..0b83ff7 100644 --- a/packages/payments/src/resources/transactions.types.ts +++ b/packages/payments/src/resources/transactions.types.ts @@ -59,7 +59,8 @@ 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. + * non-empty value because settlement does not use it. Not checked while + * credentials for the wallet are cached. */ password: string; destination: SendDestination;