From 78fa285d5c4fed3cf9061ae6cd65362e0be5a761 Mon Sep 17 00:00:00 2001 From: Bufo Date: Thu, 24 Sep 2026 15:07:10 +0200 Subject: [PATCH 1/3] feat(payments): add optional allowSelfPayment to send [AMB-3241] LND rejects a payment to its own node unless allow_self_payment is set, so a team could not pay its own invoice from another wallet on the same node. The flag is off by default and forwarded to both the LND and litd bodies. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/INTEGRATION.md | 1 + packages/payments/README.md | 2 ++ packages/payments/src/node/types.ts | 2 ++ .../src/resources/transactions.send.test.ts | 27 +++++++++++++++++++ .../payments/src/resources/transactions.ts | 9 +++++-- .../src/resources/transactions.types.ts | 6 +++++ 6 files changed, 45 insertions(+), 2 deletions(-) diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index e5e3302..90a3ec8 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -129,6 +129,7 @@ Notes: 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. +- To pay an invoice issued by the same node (for example, your own Taproot Asset wallet's invoice from your BTC wallet), pass `allowSelfPayment: true`. It is off by default; LND rejects payments to its own node without it. **Sandbox wallets** need no node, but a non-empty `password` remains mandatory so the sandbox request matches production. Any non-empty value such as diff --git a/packages/payments/README.md b/packages/payments/README.md index b77afd5..cf9c82a 100644 --- a/packages/payments/README.md +++ b/packages/payments/README.md @@ -220,6 +220,8 @@ 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. +To pay an invoice issued by the same node — for example, paying your own Taproot Asset wallet's invoice from your BTC wallet — pass `allowSelfPayment: true`. It is off by default, and LND rejects a payment to its own node without it. + **Sandbox wallets** need no node or macaroon, but a non-empty `password` is still mandatory so sandbox and production sends use the same call shape. Any non-empty value such as `Password123` works because sandbox settlement does not diff --git a/packages/payments/src/node/types.ts b/packages/payments/src/node/types.ts index 2786560..a2cccfc 100644 --- a/packages/payments/src/node/types.ts +++ b/packages/payments/src/node/types.ts @@ -7,6 +7,7 @@ export interface SendLndPaymentBody { amt?: string; fee_limit_sat: string; timeout_seconds: number; + allow_self_payment?: boolean; } /** Streamed update from `POST /v2/router/send` (LND). */ @@ -35,6 +36,7 @@ export interface SendAssetPaymentBody { payment_request: string; fee_limit_sat: string; timeout_seconds: number; + allow_self_payment?: boolean; }; /** * Asset group key — identifies which asset to spend on the asset channel. diff --git a/packages/payments/src/resources/transactions.send.test.ts b/packages/payments/src/resources/transactions.send.test.ts index 4bc0aae..19c99be 100644 --- a/packages/payments/src/resources/transactions.send.test.ts +++ b/packages/payments/src/resources/transactions.send.test.ts @@ -179,6 +179,33 @@ describe('Transactions.send', () => { assert.equal((lastBody as { fee_limit_sat: string }).fee_limit_sat, '4294967296'); }); + it('leaves self-payment off by default', async () => { + const host = await startNode([{ result: { status: 'SUCCEEDED', payment_hash: 'ph' } }]); + const transactions = new Transactions(fakeClient(host)); + + await transactions.send({ + walletId: 'w1', + password: PASSWORD, + destination: { bolt11: 'lnbc1xyz' }, + }); + + assert.equal((lastBody as { allow_self_payment?: boolean }).allow_self_payment, undefined); + }); + + it('forwards allowSelfPayment so a team can pay its own invoice', async () => { + const host = await startNode([{ result: { status: 'SUCCEEDED', payment_hash: 'ph' } }]); + const transactions = new Transactions(fakeClient(host)); + + await transactions.send({ + walletId: 'w1', + password: PASSWORD, + destination: { bolt11: 'lnbc1xyz' }, + allowSelfPayment: true, + }); + + assert.equal((lastBody as { allow_self_payment?: boolean }).allow_self_payment, true); + }); + it('accepts any non-empty sandbox password and returns payment: null', async () => { // No node should be contacted for sandbox — point at an unroutable host // so any accidental node call would fail the test. diff --git a/packages/payments/src/resources/transactions.ts b/packages/payments/src/resources/transactions.ts index 64133f6..04cc536 100644 --- a/packages/payments/src/resources/transactions.ts +++ b/packages/payments/src/resources/transactions.ts @@ -183,7 +183,7 @@ export class Transactions { * password-derived credentials independently. */ async send(params: SendParams): Promise { - const { destination, onUpdate, signal } = params; + const { destination, onUpdate, signal, allowSelfPayment } = params; const timeoutSeconds = params.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS; if (!params.password?.trim()) { @@ -236,6 +236,7 @@ export class Transactions { timeoutSeconds, onUpdate, signal, + allowSelfPayment, }); return { transaction, payment }; @@ -364,9 +365,11 @@ export class Transactions { timeoutSeconds: number; onUpdate?: SendParams['onUpdate']; signal?: AbortSignal; + allowSelfPayment?: boolean; }, ): Promise { - const { amountSats, timeoutSeconds, onUpdate, signal } = options; + const { amountSats, timeoutSeconds, onUpdate, signal, allowSelfPayment } = options; + const selfPayment = allowSelfPayment ? { allow_self_payment: true } : {}; const onStatus = onUpdate ? (status: PaymentLifecycleStatus) => onUpdate({ status }) : undefined; @@ -386,6 +389,7 @@ export class Transactions { payment_request: paymentRequest, fee_limit_sat: FEE_LIMIT_SATS, timeout_seconds: timeoutSeconds, + ...selfPayment, }, ...(prepared.groupKeyBase64 ? { group_key: prepared.groupKeyBase64 } : {}), }, @@ -397,6 +401,7 @@ export class Transactions { ...(amountSats ? { amt: amountSats } : {}), fee_limit_sat: FEE_LIMIT_SATS, timeout_seconds: timeoutSeconds, + ...selfPayment, }, }); } diff --git a/packages/payments/src/resources/transactions.types.ts b/packages/payments/src/resources/transactions.types.ts index 20e46b2..9beb683 100644 --- a/packages/payments/src/resources/transactions.types.ts +++ b/packages/payments/src/resources/transactions.types.ts @@ -72,6 +72,12 @@ export interface SendParams extends PrepareSendParams { onUpdate?: (progress: SendProgress) => void; /** Aborts the in-flight node payment. */ signal?: AbortSignal; + /** + * Lets the node pay an invoice it issued itself, e.g. a BTC wallet paying + * a Taproot Asset invoice of a wallet on the same node. Off by default: + * LND rejects a payment to its own node unless this is set. + */ + allowSelfPayment?: boolean; } export interface SendResult { From 48695510a3001d94fe14bbd8a4e6a0f0a984332e Mon Sep 17 00:00:00 2001 From: Bufo Date: Thu, 24 Sep 2026 15:07:28 +0200 Subject: [PATCH 2/3] feat(payments): accept allowSelfPayment in retryPayment [AMB-3241] Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 5 ++-- packages/payments/README.md | 3 +++ packages/payments/src/index.ts | 1 + .../transactions.retryPayment.test.ts | 26 ++++++++++++++++++- .../payments/src/resources/transactions.ts | 4 ++- .../src/resources/transactions.types.ts | 5 ++++ 6 files changed, 40 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0c5cf23..f0f9a00 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)`, `retryPayment(paymentId)`, `prepareSend(params)`, `isSendReady(walletId)`, `forgetSend(walletId)` | +| `.transactions` | `Transactions` | `findOne(id)`, `findMany(params)`, `createReceive(input)`, `send(params)`, `retryPayment(paymentId, options?)`, `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. @@ -98,7 +98,8 @@ Resource getters are lazy and call `requireServiceApiKey`: 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 + 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. - The live `send` path resolves a **prepare** step (wallet send context → diff --git a/packages/payments/README.md b/packages/payments/README.md index cf9c82a..6c675d4 100644 --- a/packages/payments/README.md +++ b/packages/payments/README.md @@ -303,6 +303,9 @@ transaction isn't retryable. const { transaction, payment } = await payments.transactions.retryPayment(paymentId); ``` +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 prepare the wallet first. diff --git a/packages/payments/src/index.ts b/packages/payments/src/index.ts index f2e317b..77c5997 100644 --- a/packages/payments/src/index.ts +++ b/packages/payments/src/index.ts @@ -13,6 +13,7 @@ export type { WebhookVerificationErrorCode } from './errors.js'; export type { PrepareSendParams, + RetryPaymentOptions, SendDestination, SendParams, SendProgress, diff --git a/packages/payments/src/resources/transactions.retryPayment.test.ts b/packages/payments/src/resources/transactions.retryPayment.test.ts index 431882c..67668b6 100644 --- a/packages/payments/src/resources/transactions.retryPayment.test.ts +++ b/packages/payments/src/resources/transactions.retryPayment.test.ts @@ -15,14 +15,18 @@ const MACAROON_HEX = '0201036c6e6402240a'; const SYMMETRIC_KEY = bytesToHex(new Uint8Array(64).map((_, i) => (i * 5 + 1) & 0xff)); let server: Server | undefined; +let lastBody: unknown; afterEach(async () => { if (server) await new Promise((resolve) => server!.close(() => resolve())); server = undefined; + lastBody = undefined; }); async function startNode(lines: object[]): Promise { server = createServer(async (req, res: ServerResponse) => { - for await (const _chunk of req) void _chunk; + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + lastBody = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); res.writeHead(200, { 'content-type': 'application/json' }); for (const line of lines) res.write(`${JSON.stringify(line)}\n`); res.end(); @@ -107,6 +111,26 @@ function fakeClient( } describe('Transactions.retryPayment', () => { + it('leaves self-payment off on the node call by default', async () => { + const host = await startNode([{ result: { status: 'SUCCEEDED', payment_hash: 'ph2' } }]); + const transactions = new Transactions(fakeClient(host)); + + await transactions.prepareSend({ walletId: 'w1', password: PASSWORD }); + await transactions.retryPayment('tx1'); + + assert.equal((lastBody as { allow_self_payment?: boolean }).allow_self_payment, undefined); + }); + + it('forwards allowSelfPayment to the node so a failed self-payment can be retried', async () => { + const host = await startNode([{ result: { status: 'SUCCEEDED', payment_hash: 'ph2' } }]); + const transactions = new Transactions(fakeClient(host)); + + await transactions.prepareSend({ walletId: 'w1', password: PASSWORD }); + await transactions.retryPayment('tx1', { allowSelfPayment: true }); + + assert.equal((lastBody as { allow_self_payment?: boolean }).allow_self_payment, true); + }); + 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 04cc536..3187a43 100644 --- a/packages/payments/src/resources/transactions.ts +++ b/packages/payments/src/resources/transactions.ts @@ -19,6 +19,7 @@ import { selectSendNode } from './sendNode.js'; import type { PreparedSend, PrepareSendParams, + RetryPaymentOptions, SendDestination, SendParams, SendResult, @@ -259,7 +260,7 @@ export class Transactions { * this fails with a `PaymentSendError` asking for a team password via * `prepareSend()` first. */ - async retryPayment(paymentId: string): Promise { + async retryPayment(paymentId: string, options: RetryPaymentOptions = {}): Promise { const transaction = await this.findOne(paymentId); if (transaction.status !== 'FAILED') { @@ -280,6 +281,7 @@ export class Transactions { const payment = await this.#payAtNode(prepared, transaction.payment_request, { amountSats: transaction.amount_sats ?? undefined, timeoutSeconds: DEFAULT_TIMEOUT_SECONDS, + allowSelfPayment: options.allowSelfPayment, }); return { transaction, payment }; diff --git a/packages/payments/src/resources/transactions.types.ts b/packages/payments/src/resources/transactions.types.ts index 9beb683..699a696 100644 --- a/packages/payments/src/resources/transactions.types.ts +++ b/packages/payments/src/resources/transactions.types.ts @@ -80,6 +80,11 @@ export interface SendParams extends PrepareSendParams { allowSelfPayment?: boolean; } +export interface RetryPaymentOptions { + /** Same as {@link SendParams.allowSelfPayment}; pass it again to retry a self-payment. */ + allowSelfPayment?: boolean; +} + export interface SendResult { /** The transaction record created by `create_send`. */ transaction: PaymentsTransactionFieldsFragment; From 2e0dced3a4beee5165717615eab2ee75353eff39 Mon Sep 17 00:00:00 2001 From: Bufo Date: Thu, 24 Sep 2026 15:07:28 +0200 Subject: [PATCH 3/3] docs(payments): describe allowSelfPayment without node terms [AMB-3241] Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/INTEGRATION.md | 2 +- packages/payments/README.md | 2 +- .../payments/src/resources/transactions.retryPayment.test.ts | 4 ++-- packages/payments/src/resources/transactions.types.ts | 5 ++--- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 90a3ec8..a6fd18f 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -129,7 +129,7 @@ Notes: 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. -- To pay an invoice issued by the same node (for example, your own Taproot Asset wallet's invoice from your BTC wallet), pass `allowSelfPayment: true`. It is off by default; LND rejects payments to its own node without 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. **Sandbox wallets** need no node, but a non-empty `password` remains mandatory so the sandbox request matches production. Any non-empty value such as diff --git a/packages/payments/README.md b/packages/payments/README.md index 6c675d4..80a42e6 100644 --- a/packages/payments/README.md +++ b/packages/payments/README.md @@ -220,7 +220,7 @@ 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. -To pay an invoice issued by the same node — for example, paying your own Taproot Asset wallet's invoice from your BTC wallet — pass `allowSelfPayment: true`. It is off by default, and LND rejects a payment to its own node without 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. **Sandbox wallets** need no node or macaroon, but a non-empty `password` is still mandatory so sandbox and production sends use the same call shape. Any diff --git a/packages/payments/src/resources/transactions.retryPayment.test.ts b/packages/payments/src/resources/transactions.retryPayment.test.ts index 67668b6..02626fc 100644 --- a/packages/payments/src/resources/transactions.retryPayment.test.ts +++ b/packages/payments/src/resources/transactions.retryPayment.test.ts @@ -111,7 +111,7 @@ function fakeClient( } describe('Transactions.retryPayment', () => { - it('leaves self-payment off on the node call by default', async () => { + it('leaves self-payment off by default', async () => { const host = await startNode([{ result: { status: 'SUCCEEDED', payment_hash: 'ph2' } }]); const transactions = new Transactions(fakeClient(host)); @@ -121,7 +121,7 @@ describe('Transactions.retryPayment', () => { assert.equal((lastBody as { allow_self_payment?: boolean }).allow_self_payment, undefined); }); - it('forwards allowSelfPayment to the node so a failed self-payment can be retried', async () => { + it('forwards allowSelfPayment so a failed self-payment can be retried', 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.types.ts b/packages/payments/src/resources/transactions.types.ts index 699a696..46312d9 100644 --- a/packages/payments/src/resources/transactions.types.ts +++ b/packages/payments/src/resources/transactions.types.ts @@ -73,9 +73,8 @@ export interface SendParams extends PrepareSendParams { /** Aborts the in-flight node payment. */ signal?: AbortSignal; /** - * Lets the node pay an invoice it issued itself, e.g. a BTC wallet paying - * a Taproot Asset invoice of a wallet on the same node. Off by default: - * LND rejects a payment to its own node unless this is set. + * Allows paying an invoice your own team issued, e.g. your Taproot Asset + * wallet's invoice from your BTC wallet. Off by default. */ allowSelfPayment?: boolean; }