diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index a0aa118..19f94e4 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -127,6 +127,7 @@ Notes: - Base-asset wallets pay over LND; Taproot Asset wallets over litd — the SDK picks the endpoint from the wallet's asset automatically. - Wrong password → `DecryptionError`. Node-side failure → `PaymentSendError`. +- If the invoice was already paid (duplicate, or a replayed `idempotencyKey`), the SDK detects the returned `COMPLETED` transaction and resolves with `payment.status === 'SUCCEEDED'` without re-paying on the node. `payment.paymentPreimage` is `undefined` in this case. **Sandbox wallets** need no password and no node — the backend settles the transaction asynchronously and `payment` resolves `null`. Control the outcome diff --git a/packages/payments/README.md b/packages/payments/README.md index d409ee9..90eb38d 100644 --- a/packages/payments/README.md +++ b/packages/payments/README.md @@ -204,6 +204,8 @@ 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`. +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. diff --git a/packages/payments/src/resources/transactions.send.test.ts b/packages/payments/src/resources/transactions.send.test.ts index 6b26498..6c78f9a 100644 --- a/packages/payments/src/resources/transactions.send.test.ts +++ b/packages/payments/src/resources/transactions.send.test.ts @@ -38,7 +38,11 @@ async function startNode(lines: object[]): Promise { } /** Fake GraphQLClient that answers the operations send() issues. */ -function fakeClient(restHost: string, environmentType: 'LIVE' | 'SANDBOX' = 'LIVE'): GraphQLClient { +function fakeClient( + restHost: string, + environmentType: 'LIVE' | 'SANDBOX' = 'LIVE', + createSendTransaction: object = { id: 'tx1', status: 'PENDING', payment_request: 'lnbc1xyz' }, +): GraphQLClient { const masterKey = deriveMasterKey(PASSWORD, TEAM_ID); const encrypted_symmetric_key = nip44Encrypt(SYMMETRIC_KEY, masterKey); const encrypted_macaroon = nip44Encrypt(MACAROON_HEX, SYMMETRIC_KEY); @@ -87,7 +91,7 @@ function fakeClient(restHost: string, environmentType: 'LIVE' | 'SANDBOX' = 'LIV return { payment: { transaction: { - create_send: { id: 'tx1', status: 'PENDING', payment_request: 'lnbc1xyz' }, + create_send: createSendTransaction, }, }, }; @@ -166,4 +170,49 @@ describe('Transactions.send', () => { /admin macaroon/, ); }); + + it('short-circuits an already-COMPLETED transaction without paying on the node', async () => { + // Point at an unroutable host so any accidental node call fails the test. + const transactions = new Transactions( + fakeClient('http://127.0.0.1:1', 'LIVE', { + id: 'tx1', + status: 'COMPLETED', + payment_hash: 'ph-existing', + fee: '3', + payment_request: 'lnbc1xyz', + }), + ); + + const result = await transactions.send({ + walletId: 'w1', + password: PASSWORD, + destination: { bolt11: 'lnbc1xyz' }, + }); + + assert.ok(result.payment); + assert.equal(result.payment.status, 'SUCCEEDED'); + assert.equal(result.payment.paymentHash, 'ph-existing'); + assert.equal(result.payment.feeSat, '3'); + assert.equal(result.payment.paymentPreimage, undefined); + assert.equal(result.transaction.status, 'COMPLETED'); + }); + + it('still executes the node payment for a non-completed transaction', async () => { + const host = await startNode([ + { result: { status: 'SUCCEEDED', payment_hash: 'ph', fee_sat: '1' } }, + ]); + const transactions = new Transactions( + fakeClient(host, 'LIVE', { id: 'tx1', status: 'PENDING', payment_request: 'lnbc1xyz' }), + ); + + const result = await transactions.send({ + walletId: 'w1', + password: PASSWORD, + destination: { bolt11: 'lnbc1xyz' }, + }); + + assert.ok(result.payment); + assert.equal(result.payment.status, 'SUCCEEDED'); + assert.ok(lastBody, 'node should have been called'); + }); }); diff --git a/packages/payments/src/resources/transactions.ts b/packages/payments/src/resources/transactions.ts index f95400d..7b27068 100644 --- a/packages/payments/src/resources/transactions.ts +++ b/packages/payments/src/resources/transactions.ts @@ -146,6 +146,29 @@ export class Transactions { input: buildCreateSendInput(params), }); const transaction = createRes.payment.transaction.create_send; + + // Already-completed: `create_send` found an existing COMPLETED transaction + // with the same payment hash (a genuine duplicate, or an idempotency-key + // replay) and returned it instead of creating a new one. Paying it again + // on the node would fail (or double-pay), so short-circuit and report it + // as the successful send it already is. + if (transaction.status === 'COMPLETED') { + return { + transaction, + payment: { + status: 'SUCCEEDED', + paymentHash: transaction.payment_hash ?? undefined, + // `fee` is already sats today (backend writes it from LND's + // `safe_fee`) — same unit as `feeSat`, so no numeric conversion. + feeSat: transaction.fee ?? undefined, + // PaymentsTransaction has no preimage field, so it can't be + // recovered for an already-settled transaction — only a payment + // actually executed on the node below returns one. + paymentPreimage: undefined, + }, + }; + } + if (!transaction.payment_request) { throw new PaymentSendError('Backend did not return a payment request.'); }