diff --git a/AGENTS.md b/AGENTS.md index 5ba4290..d16f4af 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,17 @@ 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 **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 + 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 60223a0..95d4ecc 100644 --- a/packages/payments/README.md +++ b/packages/payments/README.md @@ -295,6 +295,25 @@ 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 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); +``` + +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/transactions.retryPayment.test.ts b/packages/payments/src/resources/transactions.retryPayment.test.ts new file mode 100644 index 0000000..431882c --- /dev/null +++ b/packages/payments/src/resources/transactions.retryPayment.test.ts @@ -0,0 +1,177 @@ +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. `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', + findOneTransaction: object = { + id: 'tx1', + wallet_id: 'w1', + status: 'FAILED', + 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('GetTransaction')) { + return { payment: { transaction: { find_one: findOneTransaction } } }; + } + 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('CreateSendTransaction')) { + throw new Error('retryPayment must not call create_send'); + } + throw new Error(`unexpected document: ${document.slice(0, 40)}`); + }; + + return { request } as unknown as GraphQLClient; +} + +describe('Transactions.retryPayment', () => { + 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)); + + 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 preparing 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'); + }); + + 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 170c75b..a21eb73 100644 --- a/packages/payments/src/resources/transactions.ts +++ b/packages/payments/src/resources/transactions.ts @@ -13,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 { @@ -227,38 +227,57 @@ 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 **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. + * + * 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 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(`Transaction ${paymentId} has no payment_request to retry.`); + } + + 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 }; } @@ -270,7 +289,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); @@ -333,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, + }, + }); + } }