Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/payments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
53 changes: 51 additions & 2 deletions packages/payments/src/resources/transactions.send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ async function startNode(lines: object[]): Promise<string> {
}

/** 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);
Expand Down Expand Up @@ -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,
},
},
};
Expand Down Expand Up @@ -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');
});
});
23 changes: 23 additions & 0 deletions packages/payments/src/resources/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}
Expand Down
Loading