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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 →
Expand Down
1 change: 1 addition & 0 deletions docs/INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand Down
5 changes: 5 additions & 0 deletions packages/payments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
non-empty value such as `Password123` works because sandbox settlement does not
Expand Down Expand Up @@ -301,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.
Expand Down
1 change: 1 addition & 0 deletions packages/payments/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type { WebhookVerificationErrorCode } from './errors.js';

export type {
PrepareSendParams,
RetryPaymentOptions,
SendDestination,
SendParams,
SendProgress,
Expand Down
2 changes: 2 additions & 0 deletions packages/payments/src/node/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) => server!.close(() => resolve()));
server = undefined;
lastBody = undefined;
});

async function startNode(lines: object[]): Promise<string> {
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();
Expand Down Expand Up @@ -107,6 +111,26 @@ function fakeClient(
}

describe('Transactions.retryPayment', () => {
it('leaves self-payment off 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 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));
Expand Down
27 changes: 27 additions & 0 deletions packages/payments/src/resources/transactions.send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 10 additions & 3 deletions packages/payments/src/resources/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { selectSendNode } from './sendNode.js';
import type {
PreparedSend,
PrepareSendParams,
RetryPaymentOptions,
SendDestination,
SendParams,
SendResult,
Expand Down Expand Up @@ -183,7 +184,7 @@ export class Transactions {
* password-derived credentials independently.
*/
async send(params: SendParams): Promise<SendResult> {
const { destination, onUpdate, signal } = params;
const { destination, onUpdate, signal, allowSelfPayment } = params;
const timeoutSeconds = params.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;

if (!params.password?.trim()) {
Expand Down Expand Up @@ -236,6 +237,7 @@ export class Transactions {
timeoutSeconds,
onUpdate,
signal,
allowSelfPayment,
});

return { transaction, payment };
Expand All @@ -258,7 +260,7 @@ export class Transactions {
* this fails with a `PaymentSendError` asking for a team password via
* `prepareSend()` first.
*/
async retryPayment(paymentId: string): Promise<SendResult> {
async retryPayment(paymentId: string, options: RetryPaymentOptions = {}): Promise<SendResult> {
const transaction = await this.findOne(paymentId);

if (transaction.status !== 'FAILED') {
Expand All @@ -279,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 };
Expand Down Expand Up @@ -364,9 +367,11 @@ export class Transactions {
timeoutSeconds: number;
onUpdate?: SendParams['onUpdate'];
signal?: AbortSignal;
allowSelfPayment?: boolean;
},
): Promise<NodePaymentResult> {
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;
Expand All @@ -386,6 +391,7 @@ export class Transactions {
payment_request: paymentRequest,
fee_limit_sat: FEE_LIMIT_SATS,
timeout_seconds: timeoutSeconds,
...selfPayment,
},
...(prepared.groupKeyBase64 ? { group_key: prepared.groupKeyBase64 } : {}),
},
Expand All @@ -397,6 +403,7 @@ export class Transactions {
...(amountSats ? { amt: amountSats } : {}),
fee_limit_sat: FEE_LIMIT_SATS,
timeout_seconds: timeoutSeconds,
...selfPayment,
},
});
}
Expand Down
10 changes: 10 additions & 0 deletions packages/payments/src/resources/transactions.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ export interface SendParams extends PrepareSendParams {
onUpdate?: (progress: SendProgress) => void;
/** Aborts the in-flight node payment. */
signal?: AbortSignal;
/**
* 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;
}

export interface RetryPaymentOptions {
/** Same as {@link SendParams.allowSelfPayment}; pass it again to retry a self-payment. */
allowSelfPayment?: boolean;
}

export interface SendResult {
Expand Down
Loading