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
13 changes: 12 additions & 1 deletion 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)`, `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.
Expand All @@ -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`
Expand Down
19 changes: 19 additions & 0 deletions packages/payments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
177 changes: 177 additions & 0 deletions packages/payments/src/resources/transactions.retryPayment.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((resolve) => server!.close(() => resolve()));
server = undefined;
});

async function startNode(lines: object[]): Promise<string> {
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<void>((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<unknown> => {
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/);
});
});
135 changes: 103 additions & 32 deletions packages/payments/src/resources/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<SendResult> {
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 };
}
Expand All @@ -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<PreparedSend> {
#sendContext(params: PrepareSendParams): Promise<PreparedSend> {
if (params.password !== undefined) return this.#resolveSendContext(params);

const prepared = this.#prepared.get(params.walletId);
Expand Down Expand Up @@ -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<PreparedSend, { kind: 'node' }>,
paymentRequest: string,
options: {
amountSats?: string;
timeoutSeconds: number;
onUpdate?: SendParams['onUpdate'];
signal?: AbortSignal;
},
): Promise<NodePaymentResult> {
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,
},
});
}
}
Loading