From 6687d4d446eb3ed33eb73673aa89e65548fdb546 Mon Sep 17 00:00:00 2001 From: Bufo Date: Tue, 1 Sep 2026 15:32:00 +0200 Subject: [PATCH 1/3] feat: add retryPayment method [AMB-3091] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retries a FAILED send transaction, reusing its stored payment_request (no new invoice minted), and pays it against the node the same way send() does. Calls the retry_send mutation, hand-authored against amboss-rails-api#577 (unmerged, not yet deployed) the same way mintStreamToken was hand-authored for AMB-3016 — codegen only generates against the live schema. TODO(AMB-3091) left in resources/retrySend.ts and retrySend.types.ts: once #577 merges and deploys, refresh the schema, run codegen, and delete both files in favor of the generated operation. --- AGENTS.md | 9 +- packages/payments/README.md | 16 ++ packages/payments/src/resources/retrySend.ts | 78 ++++++++++ .../payments/src/resources/retrySend.types.ts | 28 ++++ .../transactions.retryPayment.test.ts | 137 ++++++++++++++++++ .../payments/src/resources/transactions.ts | 63 +++++++- 6 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 packages/payments/src/resources/retrySend.ts create mode 100644 packages/payments/src/resources/retrySend.types.ts create mode 100644 packages/payments/src/resources/transactions.retryPayment.test.ts diff --git a/AGENTS.md b/AGENTS.md index 5ba4290..2b64286 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ Resource getters are lazy and call `requireServiceApiKey`: | --------------- | -------------- | ------------------------------------------------------------- | | `.environments` | `Environments` | `list()`, `get(id)`, `create(input)`, `delete(id)` | | `.wallets` | `Wallets` | `list({ environmentId })`, `get(id)`, `create(input)`, `delete(id)` | -| `.transactions` | `Transactions` | `findOne(id)`, `findMany(params)`, `createReceive(input)`, `send(params)`, `prepareSend(params)`, `isSendReady(walletId)`, `forgetSend(walletId)` | +| `.transactions` | `Transactions` | `findOne(id)`, `findMany(params)`, `createReceive(input)`, `send(params)`, `retryPayment(paymentId)`, `prepareSend(params)`, `isSendReady(walletId)`, `forgetSend(walletId)` | | `.webhooks` | `Webhooks` | `verify(input)` — does NOT require any API key | `Payments.webhooks` is also a static reference to `Webhooks` for stateless use. @@ -88,6 +88,13 @@ Resource getters are lazy and call `requireServiceApiKey`: driven by `metadata.amb_sandbox_behavior` (`complete` / `fail` / `expire`). - Send errors: wrong password → `DecryptionError`; node-side failure → `PaymentSendError`. +- `retryPayment(paymentId)` retries a `FAILED` send, reusing its stored + `payment_request` (no new invoice minted). Server-validated precondition: + a SEND transaction in `FAILED` status with an unexpired invoice. 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. Hand-authored against amboss-rails-api#577 (unmerged) — see + `resources/retrySend.ts`. - `send` is split into a **prepare** step (wallet send context → `GetWalletSendContext`; node permissions → `GetWalletNodePermissions`; two Argon2id passes; nip44 decrypt) and the payment itself (`CreateSendTransaction` diff --git a/packages/payments/README.md b/packages/payments/README.md index 60223a0..1ecda5f 100644 --- a/packages/payments/README.md +++ b/packages/payments/README.md @@ -295,6 +295,22 @@ Notes: - Sandbox wallets prepare too (no password, nothing to decrypt) — it just caches the fact that no node payment is needed. +#### Retrying a failed send + +`transactions.retryPayment(paymentId)` retries a `FAILED` send using its +stored `payment_request` — no new invoice is minted. It only takes the +transaction id: the API validates that the transaction is a `FAILED` send with +an unexpired invoice, and rejects otherwise. + +```ts +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. + ## Examples Runnable scripts live in [`examples/`](./examples). They run against a live API diff --git a/packages/payments/src/resources/retrySend.ts b/packages/payments/src/resources/retrySend.ts new file mode 100644 index 0000000..7c31a6a --- /dev/null +++ b/packages/payments/src/resources/retrySend.ts @@ -0,0 +1,78 @@ +import type { GraphQLClient } from 'graphql-request'; + +import { AmbossClient } from '@ambosstech/core'; + +import type { PaymentsTransactionFieldsFragment } from '../generated/sdk.js'; +import type { + RetrySendTransactionMutation, + RetrySendTransactionMutationVariables, +} from './retrySend.types.js'; + +/** + * Hand-authored — `payment.transaction.retry_send` lands in + * amboss-rails-api#577 (unmerged, not yet deployed), so this SDK's schema + * snapshot (`packages/core/schema/rails.graphql`) doesn't have it yet and + * `pnpm --filter @ambosstech/payments run codegen` cannot generate a typed + * document for it. Written by hand against PR #577's schema in the meantime. + * Field selection mirrors the `PaymentsTransactionFields` fragment in + * `transactions.graphql` so the return type lines up with + * `PaymentsTransactionFieldsFragment`. + * + * TODO(AMB-3091): once #577 merges and deploys, run + * `pnpm --filter @ambosstech/core run refresh-schema && pnpm --filter @ambosstech/payments run codegen` + * and delete this file (and `retrySend.types.ts`) in favor of the generated + * `RetrySendTransaction` operation in `../generated/sdk.js`. + */ +const RetrySendTransactionDocument = ` + mutation RetrySendTransaction($input: RetrySendTransactionInput!) { + payment { + transaction { + retry_send(input: $input) { + id + wallet_id + node_id + idempotency_key + direction + status + amount { + id + display_amount + full_amount + } + amount_sats + asset { + id + symbol + type + precision + } + fee + payment_hash + payment_request + description + error + expires_at + settled_at + created_at + updated_at + } + } + } + } +`; + +/** Retries a FAILED send transaction, reusing its stored payment_request — no new invoice is minted. */ +export async function retrySendTransaction( + graphqlClient: GraphQLClient, + paymentId: string, +): Promise { + try { + const res = await graphqlClient.request< + RetrySendTransactionMutation, + RetrySendTransactionMutationVariables + >(RetrySendTransactionDocument, { input: { id: paymentId } }); + return res.payment.transaction.retry_send; + } catch (err) { + throw AmbossClient.translateError(err); + } +} diff --git a/packages/payments/src/resources/retrySend.types.ts b/packages/payments/src/resources/retrySend.types.ts new file mode 100644 index 0000000..7e7b16e --- /dev/null +++ b/packages/payments/src/resources/retrySend.types.ts @@ -0,0 +1,28 @@ +import type { PaymentsTransactionFieldsFragment } from '../generated/sdk.js'; + +/** + * Hand-written GraphQL types for `payment.transaction.retry_send`. + * + * TODO(AMB-3091): amboss-rails-api#577 (unmerged) adds this mutation to the + * live schema. Once it merges and deploys to production, run + * `pnpm --filter @ambosstech/core run refresh-schema && pnpm --filter @ambosstech/payments run codegen` + * to generate the real `RetrySendTransaction*` types/document in + * `../generated/sdk.js`, then delete this file and `retrySend.ts` in favor of + * the generated versions. Names here match PR #577's schema exactly so the + * swap is a rename, not a rewrite. + */ +export interface RetrySendTransactionInput { + id: string; +} + +export interface RetrySendTransactionMutationVariables { + input: RetrySendTransactionInput; +} + +export interface RetrySendTransactionMutation { + payment: { + transaction: { + retry_send: PaymentsTransactionFieldsFragment; + }; + }; +} diff --git a/packages/payments/src/resources/transactions.retryPayment.test.ts b/packages/payments/src/resources/transactions.retryPayment.test.ts new file mode 100644 index 0000000..c7c8d99 --- /dev/null +++ b/packages/payments/src/resources/transactions.retryPayment.test.ts @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { createServer, type Server, type ServerResponse } from 'node:http'; +import { afterEach, describe, it } from 'node:test'; + +import { argon2id } from '@noble/hashes/argon2'; +import { bytesToHex } from '@noble/hashes/utils'; +import type { GraphQLClient } from 'graphql-request'; + +import { nip44Encrypt } from '../crypto/nip44.js'; +import { Transactions } from './transactions.js'; + +const PASSWORD = 'hunter2-pw'; // >= 8 chars: Argon2 salts (the password, in the 2nd hash) must be >= 8 bytes +const TEAM_ID = '11111111-1111-1111-1111-111111111111'; +const MACAROON_HEX = '0201036c6e6402240a'; +const SYMMETRIC_KEY = bytesToHex(new Uint8Array(64).map((_, i) => (i * 5 + 1) & 0xff)); + +let server: Server | undefined; +afterEach(async () => { + if (server) await new Promise((resolve) => server!.close(() => resolve())); + server = undefined; +}); + +async function startNode(lines: object[]): Promise { + server = createServer(async (req, res: ServerResponse) => { + for await (const _chunk of req) void _chunk; + res.writeHead(200, { 'content-type': 'application/json' }); + for (const line of lines) res.write(`${JSON.stringify(line)}\n`); + res.end(); + }); + await new Promise((resolve) => server!.listen(0, '127.0.0.1', () => resolve())); + const addr = server.address(); + if (!addr || typeof addr === 'string') throw new Error('no address'); + return `http://127.0.0.1:${addr.port}`; +} + +/** Fake GraphQLClient that answers the operations prepareSend()/retryPayment() issue. */ +function fakeClient( + restHost: string, + environmentType: 'LIVE' | 'SANDBOX' = 'LIVE', + retrySendTransaction: object = { + id: 'tx1', + wallet_id: 'w1', + status: 'PENDING', + payment_request: 'lnbc1xyz', + }, +): GraphQLClient { + const masterKey = bytesToHex(argon2id(PASSWORD, TEAM_ID, { dkLen: 32, t: 3, m: 64000, p: 4 })); + const encrypted_symmetric_key = nip44Encrypt(SYMMETRIC_KEY, masterKey); + const encrypted_macaroon = nip44Encrypt(MACAROON_HEX, SYMMETRIC_KEY); + + const request = async (arg: { document: string } | string): Promise => { + const document = typeof arg === 'string' ? arg : arg.document; + if (document.includes('GetWalletSendContext')) { + return { + payment: { + wallet: { + find_one: { + id: 'w1', + team_id: TEAM_ID, + environment: { id: 'e1', type: environmentType }, + }, + }, + }, + }; + } + if (document.includes('GetWalletNodePermissions')) { + return { + payment: { + wallet: { + find_one: { + id: 'w1', + asset: { id: 'a1', type: 'BASE_ASSET' }, + node_permissions: { + id: 'np1', + encrypted_symmetric_key, + nodes: [ + { + id: 'n1', + node_id: 'node-1', + network: 'regtest', + encrypted_macaroon, + tls_cert: null, + sockets: { id: 's1', lnd: { id: 'l1', rest: restHost }, litd: null }, + }, + ], + }, + }, + }, + }, + }; + } + if (document.includes('RetrySendTransaction')) { + return { + payment: { + transaction: { + retry_send: retrySendTransaction, + }, + }, + }; + } + throw new Error(`unexpected document: ${document.slice(0, 40)}`); + }; + + return { request } as unknown as GraphQLClient; +} + +describe('Transactions.retryPayment', () => { + it('retries via retry_send and pays via the node, using a prepared macaroon', async () => { + const host = await startNode([{ result: { status: 'SUCCEEDED', payment_hash: 'ph2' } }]); + const transactions = new Transactions(fakeClient(host)); + + await transactions.prepareSend({ walletId: 'w1', password: PASSWORD }); + const result = await transactions.retryPayment('tx1'); + + assert.ok(result.payment); + assert.equal(result.payment.status, 'SUCCEEDED'); + assert.equal(result.payment.paymentHash, 'ph2'); + assert.equal(result.transaction.payment_request, 'lnbc1xyz'); + }); + + it('throws PaymentSendError for a live wallet with no prepared macaroon', async () => { + const host = await startNode([]); + const transactions = new Transactions(fakeClient(host)); + + await assert.rejects(transactions.retryPayment('tx1'), /password/); + }); + + it('returns payment: null for a sandbox wallet without pre-paring anything', async () => { + const host = await startNode([]); + const transactions = new Transactions(fakeClient(host, 'SANDBOX')); + + const result = await transactions.retryPayment('tx1'); + + assert.equal(result.payment, null); + assert.equal(result.transaction.id, 'tx1'); + }); +}); diff --git a/packages/payments/src/resources/transactions.ts b/packages/payments/src/resources/transactions.ts index 170c75b..c7665ba 100644 --- a/packages/payments/src/resources/transactions.ts +++ b/packages/payments/src/resources/transactions.ts @@ -14,6 +14,7 @@ import { import { sendAssetPayment } from '../node/lit.js'; import { sendLndPayment } from '../node/lnd.js'; import type { PaymentLifecycleStatus } from '../node/types.js'; +import { retrySendTransaction } from './retrySend.js'; import { translateSdkErrors } from './sdkErrors.js'; import { selectSendNode } from './sendNode.js'; import type { @@ -70,6 +71,7 @@ function lndAmountSats(destination: SendDestination): string | undefined { export class Transactions { readonly #sdk: ReturnType; + readonly #graphqlClient: GraphQLClient; /** * Macaroons prepared by {@link prepareSend}, keyed by wallet id. Only a * *successful* preparation lands here, and only `prepareSend` ever writes: @@ -83,6 +85,7 @@ export class Transactions { constructor(graphqlClient: GraphQLClient) { this.#sdk = getSdk(graphqlClient, translateSdkErrors); + this.#graphqlClient = graphqlClient; } /** @@ -263,6 +266,64 @@ export class Transactions { return { transaction, payment }; } + /** + * Retries a `FAILED` send, reusing its stored `payment_request` — no new + * invoice is minted. **Precondition** (enforced server-side, not + * re-checked here): `paymentId` must identify a SEND transaction in + * `FAILED` status whose invoice has not expired; violations surface as an + * `ApiError` from the `retry_send` mutation. + * + * Takes no password: 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. + * + * TODO(AMB-3091): `retry_send` is hand-authored against amboss-rails-api#577 + * (unmerged) via `./retrySend.js` — see that file's header. Once #577 + * deploys, refresh the schema, run codegen, and delete `retrySend.ts` / + * `retrySend.types.ts` in favor of the generated operation. + */ + async retryPayment(paymentId: string): Promise { + const transaction = await retrySendTransaction(this.#graphqlClient, paymentId); + + const prepared = await this.#sendContext({ walletId: transaction.wallet_id }); + if (prepared.kind === 'sandbox') return { transaction, payment: null }; + + if (!transaction.payment_request) { + throw new PaymentSendError('Backend did not return a payment request.'); + } + + const common = { + restHost: prepared.restHost, + macaroon: prepared.macaroon, + tlsCert: prepared.tlsCert, + }; + + const payment = prepared.isAsset + ? await sendAssetPayment({ + ...common, + body: { + payment_request: { + payment_request: transaction.payment_request, + fee_limit_sat: FEE_LIMIT_SATS, + timeout_seconds: DEFAULT_TIMEOUT_SECONDS, + }, + ...(prepared.groupKeyBase64 ? { group_key: prepared.groupKeyBase64 } : {}), + }, + }) + : await sendLndPayment({ + ...common, + body: { + payment_request: transaction.payment_request, + fee_limit_sat: FEE_LIMIT_SATS, + timeout_seconds: DEFAULT_TIMEOUT_SECONDS, + }, + }); + + 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 @@ -270,7 +331,7 @@ export class Transactions { * sandbox wallets (no password, nothing to decrypt) still work unprepared, * and how an unprepared live wallet gets its "password required" error. */ - #sendContext(params: SendParams): Promise { + #sendContext(params: PrepareSendParams): Promise { if (params.password !== undefined) return this.#resolveSendContext(params); const prepared = this.#prepared.get(params.walletId); From 7a930866834f50241717762234bdf63bb7a5bb2e Mon Sep 17 00:00:00 2001 From: Bufo Date: Tue, 1 Sep 2026 15:32:00 +0200 Subject: [PATCH 2/3] refactor: make retryPayment client-side (resend via create_send) [AMB-3091] --- AGENTS.md | 11 +-- packages/payments/README.md | 9 +- packages/payments/src/resources/retrySend.ts | 78 ------------------ .../payments/src/resources/retrySend.types.ts | 28 ------- .../transactions.retryPayment.test.ts | 57 ++++++++++--- .../payments/src/resources/transactions.ts | 82 ++++++++----------- 6 files changed, 90 insertions(+), 175 deletions(-) delete mode 100644 packages/payments/src/resources/retrySend.ts delete mode 100644 packages/payments/src/resources/retrySend.types.ts diff --git a/AGENTS.md b/AGENTS.md index 2b64286..871633a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,13 +88,14 @@ Resource getters are lazy and call `requireServiceApiKey`: driven by `metadata.amb_sandbox_behavior` (`complete` / `fail` / `expire`). - Send errors: wrong password → `DecryptionError`; node-side failure → `PaymentSendError`. -- `retryPayment(paymentId)` retries a `FAILED` send, reusing its stored - `payment_request` (no new invoice minted). Server-validated precondition: - a SEND transaction in `FAILED` status with an unexpired invoice. Takes no +- `retryPayment(paymentId)` retries a `FAILED` send **client-side**: looks + up the transaction, validates it is retryable (status `FAILED`, invoice not + expired), then resends its own `payment_request` through `send` with a + fresh idempotency key — no dedicated server-side retry mutation involved. + 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. Hand-authored against amboss-rails-api#577 (unmerged) — see - `resources/retrySend.ts`. + is cached. - `send` is split into a **prepare** step (wallet send context → `GetWalletSendContext`; node permissions → `GetWalletNodePermissions`; two Argon2id passes; nip44 decrypt) and the payment itself (`CreateSendTransaction` diff --git a/packages/payments/README.md b/packages/payments/README.md index 1ecda5f..d2d3000 100644 --- a/packages/payments/README.md +++ b/packages/payments/README.md @@ -297,10 +297,11 @@ Notes: #### Retrying a failed send -`transactions.retryPayment(paymentId)` retries a `FAILED` send using its -stored `payment_request` — no new invoice is minted. It only takes the -transaction id: the API validates that the transaction is a `FAILED` send with -an unexpired invoice, and rejects otherwise. +`transactions.retryPayment(paymentId)` retries a `FAILED` send client-side: it +looks up the transaction, checks it is retryable (status `FAILED`, invoice not +expired), then resends its own `payment_request` through `send` with a fresh +idempotency key. It throws a `PaymentSendError` if the transaction isn't +retryable. ```ts const { transaction, payment } = await payments.transactions.retryPayment(paymentId); diff --git a/packages/payments/src/resources/retrySend.ts b/packages/payments/src/resources/retrySend.ts deleted file mode 100644 index 7c31a6a..0000000 --- a/packages/payments/src/resources/retrySend.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { GraphQLClient } from 'graphql-request'; - -import { AmbossClient } from '@ambosstech/core'; - -import type { PaymentsTransactionFieldsFragment } from '../generated/sdk.js'; -import type { - RetrySendTransactionMutation, - RetrySendTransactionMutationVariables, -} from './retrySend.types.js'; - -/** - * Hand-authored — `payment.transaction.retry_send` lands in - * amboss-rails-api#577 (unmerged, not yet deployed), so this SDK's schema - * snapshot (`packages/core/schema/rails.graphql`) doesn't have it yet and - * `pnpm --filter @ambosstech/payments run codegen` cannot generate a typed - * document for it. Written by hand against PR #577's schema in the meantime. - * Field selection mirrors the `PaymentsTransactionFields` fragment in - * `transactions.graphql` so the return type lines up with - * `PaymentsTransactionFieldsFragment`. - * - * TODO(AMB-3091): once #577 merges and deploys, run - * `pnpm --filter @ambosstech/core run refresh-schema && pnpm --filter @ambosstech/payments run codegen` - * and delete this file (and `retrySend.types.ts`) in favor of the generated - * `RetrySendTransaction` operation in `../generated/sdk.js`. - */ -const RetrySendTransactionDocument = ` - mutation RetrySendTransaction($input: RetrySendTransactionInput!) { - payment { - transaction { - retry_send(input: $input) { - id - wallet_id - node_id - idempotency_key - direction - status - amount { - id - display_amount - full_amount - } - amount_sats - asset { - id - symbol - type - precision - } - fee - payment_hash - payment_request - description - error - expires_at - settled_at - created_at - updated_at - } - } - } - } -`; - -/** Retries a FAILED send transaction, reusing its stored payment_request — no new invoice is minted. */ -export async function retrySendTransaction( - graphqlClient: GraphQLClient, - paymentId: string, -): Promise { - try { - const res = await graphqlClient.request< - RetrySendTransactionMutation, - RetrySendTransactionMutationVariables - >(RetrySendTransactionDocument, { input: { id: paymentId } }); - return res.payment.transaction.retry_send; - } catch (err) { - throw AmbossClient.translateError(err); - } -} diff --git a/packages/payments/src/resources/retrySend.types.ts b/packages/payments/src/resources/retrySend.types.ts deleted file mode 100644 index 7e7b16e..0000000 --- a/packages/payments/src/resources/retrySend.types.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { PaymentsTransactionFieldsFragment } from '../generated/sdk.js'; - -/** - * Hand-written GraphQL types for `payment.transaction.retry_send`. - * - * TODO(AMB-3091): amboss-rails-api#577 (unmerged) adds this mutation to the - * live schema. Once it merges and deploys to production, run - * `pnpm --filter @ambosstech/core run refresh-schema && pnpm --filter @ambosstech/payments run codegen` - * to generate the real `RetrySendTransaction*` types/document in - * `../generated/sdk.js`, then delete this file and `retrySend.ts` in favor of - * the generated versions. Names here match PR #577's schema exactly so the - * swap is a rename, not a rewrite. - */ -export interface RetrySendTransactionInput { - id: string; -} - -export interface RetrySendTransactionMutationVariables { - input: RetrySendTransactionInput; -} - -export interface RetrySendTransactionMutation { - payment: { - transaction: { - retry_send: PaymentsTransactionFieldsFragment; - }; - }; -} diff --git a/packages/payments/src/resources/transactions.retryPayment.test.ts b/packages/payments/src/resources/transactions.retryPayment.test.ts index c7c8d99..51974c1 100644 --- a/packages/payments/src/resources/transactions.retryPayment.test.ts +++ b/packages/payments/src/resources/transactions.retryPayment.test.ts @@ -37,10 +37,10 @@ async function startNode(lines: object[]): Promise { function fakeClient( restHost: string, environmentType: 'LIVE' | 'SANDBOX' = 'LIVE', - retrySendTransaction: object = { + findOneTransaction: object = { id: 'tx1', wallet_id: 'w1', - status: 'PENDING', + status: 'FAILED', payment_request: 'lnbc1xyz', }, ): GraphQLClient { @@ -50,6 +50,9 @@ function fakeClient( const request = async (arg: { document: string } | string): Promise => { const document = typeof arg === 'string' ? arg : arg.document; + if (document.includes('GetTransaction')) { + return { payment: { transaction: { find_one: findOneTransaction } } }; + } if (document.includes('GetWalletSendContext')) { return { payment: { @@ -89,13 +92,9 @@ function fakeClient( }, }; } - if (document.includes('RetrySendTransaction')) { + if (document.includes('CreateSendTransaction')) { return { - payment: { - transaction: { - retry_send: retrySendTransaction, - }, - }, + payment: { transaction: { create_send: findOneTransaction } }, }; } throw new Error(`unexpected document: ${document.slice(0, 40)}`); @@ -105,7 +104,7 @@ function fakeClient( } describe('Transactions.retryPayment', () => { - it('retries via retry_send and pays via the node, using a prepared macaroon', async () => { + it("resends the FAILED transaction's own payment_request via create_send, using a prepared macaroon", async () => { const host = await startNode([{ result: { status: 'SUCCEEDED', payment_hash: 'ph2' } }]); const transactions = new Transactions(fakeClient(host)); @@ -125,7 +124,7 @@ describe('Transactions.retryPayment', () => { await assert.rejects(transactions.retryPayment('tx1'), /password/); }); - it('returns payment: null for a sandbox wallet without pre-paring anything', async () => { + it('returns payment: null for a sandbox wallet without preparing anything', async () => { const host = await startNode([]); const transactions = new Transactions(fakeClient(host, 'SANDBOX')); @@ -134,4 +133,42 @@ describe('Transactions.retryPayment', () => { assert.equal(result.payment, null); assert.equal(result.transaction.id, 'tx1'); }); + + it('throws PaymentSendError when the transaction is not FAILED', async () => { + const host = await startNode([]); + const transactions = new Transactions( + fakeClient(host, 'LIVE', { + id: 'tx1', + wallet_id: 'w1', + status: 'PENDING', + payment_request: 'lnbc1xyz', + }), + ); + + await assert.rejects(transactions.retryPayment('tx1'), /not retryable/); + }); + + it('throws PaymentSendError when the invoice has expired', async () => { + const host = await startNode([]); + const transactions = new Transactions( + fakeClient(host, 'LIVE', { + id: 'tx1', + wallet_id: 'w1', + status: 'FAILED', + payment_request: 'lnbc1xyz', + expires_at: new Date(Date.now() - 60_000).toISOString(), + }), + ); + + await assert.rejects(transactions.retryPayment('tx1'), /expired/); + }); + + it('throws PaymentSendError when the transaction has no payment_request', async () => { + const host = await startNode([]); + const transactions = new Transactions( + fakeClient(host, 'LIVE', { id: 'tx1', wallet_id: 'w1', status: 'FAILED' }), + ); + + await assert.rejects(transactions.retryPayment('tx1'), /payment_request/); + }); }); diff --git a/packages/payments/src/resources/transactions.ts b/packages/payments/src/resources/transactions.ts index c7665ba..6851746 100644 --- a/packages/payments/src/resources/transactions.ts +++ b/packages/payments/src/resources/transactions.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto'; + import type { GraphQLClient } from 'graphql-request'; import { createMasterPasswordHash } from '../crypto/argon2.js'; @@ -14,7 +16,6 @@ import { import { sendAssetPayment } from '../node/lit.js'; import { sendLndPayment } from '../node/lnd.js'; import type { PaymentLifecycleStatus } from '../node/types.js'; -import { retrySendTransaction } from './retrySend.js'; import { translateSdkErrors } from './sdkErrors.js'; import { selectSendNode } from './sendNode.js'; import type { @@ -71,7 +72,6 @@ function lndAmountSats(destination: SendDestination): string | undefined { export class Transactions { readonly #sdk: ReturnType; - readonly #graphqlClient: GraphQLClient; /** * Macaroons prepared by {@link prepareSend}, keyed by wallet id. Only a * *successful* preparation lands here, and only `prepareSend` ever writes: @@ -85,7 +85,6 @@ export class Transactions { constructor(graphqlClient: GraphQLClient) { this.#sdk = getSdk(graphqlClient, translateSdkErrors); - this.#graphqlClient = graphqlClient; } /** @@ -267,61 +266,44 @@ export class Transactions { } /** - * Retries a `FAILED` send, reusing its stored `payment_request` — no new - * invoice is minted. **Precondition** (enforced server-side, not - * re-checked here): `paymentId` must identify a SEND transaction in - * `FAILED` status whose invoice has not expired; violations surface as an - * `ApiError` from the `retry_send` mutation. + * Retries a `FAILED` send **client-side**: looks up the transaction, checks + * it is actually retryable, then resends its own `payment_request` through + * {@link send} with a fresh idempotency key — no dedicated server-side + * retry mutation involved, so this works against any deployed schema. * - * Takes no password: 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. + * Throws {@link PaymentSendError} if the transaction is not in `FAILED` + * status, its invoice has expired, or it has no `payment_request` to retry. * - * TODO(AMB-3091): `retry_send` is hand-authored against amboss-rails-api#577 - * (unmerged) via `./retrySend.js` — see that file's header. Once #577 - * deploys, refresh the schema, run codegen, and delete `retrySend.ts` / - * `retrySend.types.ts` in favor of the generated operation. + * 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. */ async retryPayment(paymentId: string): Promise { - const transaction = await retrySendTransaction(this.#graphqlClient, paymentId); - - const prepared = await this.#sendContext({ walletId: transaction.wallet_id }); - if (prepared.kind === 'sandbox') return { transaction, payment: null }; + const transaction = await this.findOne(paymentId); + if (transaction.status !== 'FAILED') { + throw new PaymentSendError( + `Transaction ${paymentId} is not retryable: status is ${transaction.status}, expected FAILED.`, + ); + } + if (transaction.expires_at && new Date(transaction.expires_at).getTime() <= Date.now()) { + throw new PaymentSendError(`Transaction ${paymentId}'s invoice has expired.`); + } if (!transaction.payment_request) { - throw new PaymentSendError('Backend did not return a payment request.'); + throw new PaymentSendError(`Transaction ${paymentId} has no payment_request to retry.`); } - const common = { - restHost: prepared.restHost, - macaroon: prepared.macaroon, - tlsCert: prepared.tlsCert, - }; - - const payment = prepared.isAsset - ? await sendAssetPayment({ - ...common, - body: { - payment_request: { - payment_request: transaction.payment_request, - fee_limit_sat: FEE_LIMIT_SATS, - timeout_seconds: DEFAULT_TIMEOUT_SECONDS, - }, - ...(prepared.groupKeyBase64 ? { group_key: prepared.groupKeyBase64 } : {}), - }, - }) - : await sendLndPayment({ - ...common, - body: { - payment_request: transaction.payment_request, - fee_limit_sat: FEE_LIMIT_SATS, - timeout_seconds: DEFAULT_TIMEOUT_SECONDS, - }, - }); - - return { transaction, payment }; + return this.send({ + walletId: transaction.wallet_id, + destination: { + bolt11: transaction.payment_request, + ...(transaction.amount_sats ? { amountSats: transaction.amount_sats } : {}), + }, + idempotencyKey: randomUUID(), + }); } /** From a8857b31e074c1f2c69a01ea1a13effacccc6721 Mon Sep 17 00:00:00 2001 From: Bufo Date: Tue, 1 Sep 2026 15:32:00 +0200 Subject: [PATCH 3/3] refactor: retry pays invoice directly, skip create_send [AMB-3091] --- AGENTS.md | 11 +- packages/payments/README.md | 12 +- .../transactions.retryPayment.test.ts | 13 +- .../payments/src/resources/transactions.ts | 118 +++++++++++------- 4 files changed, 95 insertions(+), 59 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 871633a..d16f4af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,10 +88,13 @@ Resource getters are lazy and call `requireServiceApiKey`: driven by `metadata.amb_sandbox_behavior` (`complete` / `fail` / `expire`). - Send errors: wrong password → `DecryptionError`; node-side failure → `PaymentSendError`. -- `retryPayment(paymentId)` retries a `FAILED` send **client-side**: looks - up the transaction, validates it is retryable (status `FAILED`, invoice not - expired), then resends its own `payment_request` through `send` with a - fresh idempotency key — no dedicated server-side retry mutation involved. +- `retryPayment(paymentId)` retries a `FAILED` send **without calling + `create_send`**: looks up the transaction, validates it is retryable + (status `FAILED`, invoice not expired), then pays its existing + `payment_request` directly against the node — reusing `send()`'s + node-execution step, not its `create_send` step. Calling `create_send` + 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 diff --git a/packages/payments/README.md b/packages/payments/README.md index d2d3000..95d4ecc 100644 --- a/packages/payments/README.md +++ b/packages/payments/README.md @@ -297,11 +297,13 @@ Notes: #### Retrying a failed send -`transactions.retryPayment(paymentId)` retries a `FAILED` send client-side: it -looks up the transaction, checks it is retryable (status `FAILED`, invoice not -expired), then resends its own `payment_request` through `send` with a fresh -idempotency key. It throws a `PaymentSendError` if the transaction isn't -retryable. +`transactions.retryPayment(paymentId)` retries a `FAILED` send without minting +a new invoice: it looks up the transaction, checks it is retryable (status +`FAILED`, invoice not expired), then pays its existing `payment_request` +directly against the node. It never calls `create_send` — doing so would +persist a second transaction row for the same invoice instead of letting the +existing failed row's status update. It throws a `PaymentSendError` if the +transaction isn't retryable. ```ts const { transaction, payment } = await payments.transactions.retryPayment(paymentId); diff --git a/packages/payments/src/resources/transactions.retryPayment.test.ts b/packages/payments/src/resources/transactions.retryPayment.test.ts index 51974c1..431882c 100644 --- a/packages/payments/src/resources/transactions.retryPayment.test.ts +++ b/packages/payments/src/resources/transactions.retryPayment.test.ts @@ -33,7 +33,12 @@ async function startNode(lines: object[]): Promise { return `http://127.0.0.1:${addr.port}`; } -/** Fake GraphQLClient that answers the operations prepareSend()/retryPayment() issue. */ +/** + * Fake GraphQLClient that answers the operations `prepareSend()`/`retryPayment()` + * issue. `retryPayment()` must never call `CreateSendTransaction` (`create_send`) + * — retrying re-pays the existing invoice instead of minting/persisting a new + * transaction row, so any request for that document is a bug. + */ function fakeClient( restHost: string, environmentType: 'LIVE' | 'SANDBOX' = 'LIVE', @@ -93,9 +98,7 @@ function fakeClient( }; } if (document.includes('CreateSendTransaction')) { - return { - payment: { transaction: { create_send: findOneTransaction } }, - }; + throw new Error('retryPayment must not call create_send'); } throw new Error(`unexpected document: ${document.slice(0, 40)}`); }; @@ -104,7 +107,7 @@ function fakeClient( } describe('Transactions.retryPayment', () => { - it("resends the FAILED transaction's own payment_request via create_send, using a prepared macaroon", async () => { + it("pays the FAILED transaction's own payment_request directly at the node, without calling create_send", async () => { const host = await startNode([{ result: { status: 'SUCCEEDED', payment_hash: 'ph2' } }]); const transactions = new Transactions(fakeClient(host)); diff --git a/packages/payments/src/resources/transactions.ts b/packages/payments/src/resources/transactions.ts index 6851746..a21eb73 100644 --- a/packages/payments/src/resources/transactions.ts +++ b/packages/payments/src/resources/transactions.ts @@ -1,5 +1,3 @@ -import { randomUUID } from 'node:crypto'; - import type { GraphQLClient } from 'graphql-request'; import { createMasterPasswordHash } from '../crypto/argon2.js'; @@ -15,7 +13,7 @@ import { } from '../generated/sdk.js'; import { sendAssetPayment } from '../node/lit.js'; import { sendLndPayment } from '../node/lnd.js'; -import type { PaymentLifecycleStatus } from '../node/types.js'; +import type { NodePaymentResult, PaymentLifecycleStatus } from '../node/types.js'; import { translateSdkErrors } from './sdkErrors.js'; import { selectSendNode } from './sendNode.js'; import type { @@ -229,47 +227,24 @@ export class Transactions { } // 4. Execute the payment against the node. - const onStatus = onUpdate - ? (status: PaymentLifecycleStatus) => onUpdate({ status }) - : undefined; - const common = { - restHost: prepared.restHost, - macaroon: prepared.macaroon, - tlsCert: prepared.tlsCert, - onUpdate: onStatus, + const payment = await this.#payAtNode(prepared, transaction.payment_request, { + amountSats: lndAmountSats(destination), + timeoutSeconds, + onUpdate, signal, - }; - - const payment = prepared.isAsset - ? await sendAssetPayment({ - ...common, - body: { - payment_request: { - payment_request: transaction.payment_request, - fee_limit_sat: FEE_LIMIT_SATS, - timeout_seconds: timeoutSeconds, - }, - ...(prepared.groupKeyBase64 ? { group_key: prepared.groupKeyBase64 } : {}), - }, - }) - : await sendLndPayment({ - ...common, - body: { - payment_request: transaction.payment_request, - ...(lndAmountSats(destination) ? { amt: lndAmountSats(destination) } : {}), - fee_limit_sat: FEE_LIMIT_SATS, - timeout_seconds: timeoutSeconds, - }, - }); + }); return { transaction, payment }; } /** - * Retries a `FAILED` send **client-side**: looks up the transaction, checks - * it is actually retryable, then resends its own `payment_request` through - * {@link send} with a fresh idempotency key — no dedicated server-side - * retry mutation involved, so this works against any deployed schema. + * Retries a `FAILED` send **without minting a new invoice**: looks up the + * transaction, checks it is actually retryable, then pays its own + * `payment_request` directly against the node — the same node-execution + * logic {@link send}'s step 4 uses, but skipping `send()`'s `create_send` + * step entirely. Calling `create_send` again would persist a second + * `payments_transaction` row for the same invoice instead of letting the + * existing failed row's status update, so this never touches it. * * Throws {@link PaymentSendError} if the transaction is not in `FAILED` * status, its invoice has expired, or it has no `payment_request` to retry. @@ -296,14 +271,15 @@ export class Transactions { throw new PaymentSendError(`Transaction ${paymentId} has no payment_request to retry.`); } - return this.send({ - walletId: transaction.wallet_id, - destination: { - bolt11: transaction.payment_request, - ...(transaction.amount_sats ? { amountSats: transaction.amount_sats } : {}), - }, - idempotencyKey: randomUUID(), + const prepared = await this.#sendContext({ walletId: transaction.wallet_id }); + if (prepared.kind === 'sandbox') return { transaction, payment: null }; + + const payment = await this.#payAtNode(prepared, transaction.payment_request, { + amountSats: transaction.amount_sats ?? undefined, + timeoutSeconds: DEFAULT_TIMEOUT_SECONDS, }); + + return { transaction, payment }; } /** @@ -376,4 +352,56 @@ export class Transactions { ...(groupKeyHex ? { groupKeyBase64: hexGroupKeyToBase64(groupKeyHex) } : {}), }; } + + /** + * Pays an already-minted `payment_request` against the node — LND directly + * for base-asset wallets, litd for Taproot Asset wallets. Shared by + * `send()`'s step 4 (paying the invoice `create_send` just minted) and + * `retryPayment()` (paying a `FAILED` transaction's existing invoice again), + * so the macaroon/credential handling and node-call shape live in one place. + */ + async #payAtNode( + prepared: Extract, + paymentRequest: string, + options: { + amountSats?: string; + timeoutSeconds: number; + onUpdate?: SendParams['onUpdate']; + signal?: AbortSignal; + }, + ): Promise { + const { amountSats, timeoutSeconds, onUpdate, signal } = options; + const onStatus = onUpdate + ? (status: PaymentLifecycleStatus) => onUpdate({ status }) + : undefined; + const common = { + restHost: prepared.restHost, + macaroon: prepared.macaroon, + tlsCert: prepared.tlsCert, + onUpdate: onStatus, + signal, + }; + + return prepared.isAsset + ? sendAssetPayment({ + ...common, + body: { + payment_request: { + payment_request: paymentRequest, + fee_limit_sat: FEE_LIMIT_SATS, + timeout_seconds: timeoutSeconds, + }, + ...(prepared.groupKeyBase64 ? { group_key: prepared.groupKeyBase64 } : {}), + }, + }) + : sendLndPayment({ + ...common, + body: { + payment_request: paymentRequest, + ...(amountSats ? { amt: amountSats } : {}), + fee_limit_sat: FEE_LIMIT_SATS, + timeout_seconds: timeoutSeconds, + }, + }); + } }