diff --git a/AGENTS.md b/AGENTS.md index 1f57c22..c87ca05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,7 @@ new Payments({ baseUrl?: string, fetch?: typeof fetch, timeoutMs?: number, + send?: readonly PrepareSendParams[], // wallets to pre-warm for sending, in the background }); ``` @@ -70,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` | `createReceive(input)`, `send(params)` | +| `.transactions` | `Transactions` | `createReceive(input)`, `send(params)`, `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. @@ -87,6 +88,32 @@ 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`. +- `send` is split into a **prepare** step (wallet send context → + `GetWalletSendContext`; node permissions → `GetWalletNodePermissions`; two + Argon2id passes; nip44 decrypt) and the payment itself (`CreateSendTransaction` + + node REST call). `prepareSend` runs that step ahead of time and caches the + macaroon per wallet in `Transactions.#prepared`; `isSendReady(walletId)` + reports whether one is resident; `forgetSend(walletId)` drops it. + `PaymentsConfig.send` pre-warms an array of wallets from the constructor, + sequentially and fire-and-forget (per-wallet errors swallowed there; a missing + `serviceApiKey` still throws from the constructor). +- **The one rule the cache runs on:** only a `send` that omits `password` reads + it, and only `prepareSend` writes it. A `send` carrying a password always + derives afresh. That is deliberate, and it is what keeps the cache from ever + having to decide whether two sets of credentials are equivalent — the question + that produced three rounds of bugs when the cache was credential-keyed + (wrong-password eviction, a concurrent attempt displacing a good one, and an + omitted `teamId` being answered from an overridden slot). Do not "optimize" by + letting password-bearing sends hit the cache without reintroducing all of it. +- Remaining invariants, each with a regression test in + `transactions.send.test.ts`: + - Only the **macaroon** is retained, never `masterKey` / `masterPasswordHash`. + - A failing `send` cannot disturb a prepared wallet, because it never touches + the map. + - `forgetSend` mid-preparation wins: a result landing afterwards is discarded + rather than resurrecting the macaroon. +- Argon2id is **synchronous** and blocks the event loop for seconds — prepare is + `async` because of the API calls, not because key derivation yields. #### Webhooks diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 19f94e4..60cf777 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -51,6 +51,7 @@ new Payments({ baseUrl?: string, // default: https://rails.amboss.tech/graphql fetch?: typeof fetch, // override for tests / non-Node runtimes timeoutMs?: number, // default: 30000 + send?: Array<{ walletId, password?, teamId? }>, // pre-warm sending — see Step 4 }); ``` @@ -110,7 +111,7 @@ node and resolves with the terminal result. const { transaction, payment } = await payments.transactions.send({ walletId, password: process.env.TEAM_PASSWORD, // live wallets only - teamId, // required with a service API key + teamId, // optional — resolved from the wallet unless you override it destination: { bolt11: 'lnbc1...' }, // or: destination: { lightningAddress: 'user@domain.com', amountSats: '1000' } idempotencyKey: payoutId, // recommended — prevents double-sends on retry @@ -141,6 +142,54 @@ await payments.transactions.send({ }); ``` +### Making sends fast + +A cold `send` is expensive, and almost none of that cost is the payment. Before +it can pay it must fetch the wallet's send context, fetch its node permissions, +and run two Argon2id passes (m=64 MiB, t=3, p=4) to derive the key that decrypts +your macaroon. Seconds of work — all of it independent of the invoice. + +Do it once, at startup: + +```ts +const payments = new Payments({ + serviceApiKey: process.env.AMBOSS_API_KEY, + send: [{ walletId, password: process.env.TEAM_PASSWORD }], +}); +``` + +or explicitly, when you want to await it: + +```ts +await payments.transactions.prepareSend({ + walletId, + password: process.env.TEAM_PASSWORD, +}); +``` + +Either way the wallet's macaroon ends up decrypted in memory, and every later +`send` for it is one API call plus the payment — no password argument needed: + +```ts +payments.transactions.isSendReady(walletId); // true once prepared +await payments.transactions.send({ walletId, destination: { bolt11: 'lnbc1...' } }); +``` + +Three things to plan around: + +- **Drop the `password` from prepared sends.** It is what makes them fast: a + `send` that carries a password derives from scratch every time, prepared or + not. Keep passing it only where you have not prepared the wallet. +- **Argon2id blocks the event loop.** It is synchronous CPU work; `await` does + not make it yield. Prepare during startup or a warm-up hook, never inside a + request handler. The constructor `send` option starts it in the background but + the block still happens — just early, while you have no traffic. +- **You are holding node admin credentials in memory** for as long as the wallet + stays prepared, which is what makes sends fast. Call + `payments.transactions.forgetSend(walletId)` to release them, and to pick up + rotated node credentials — there is no expiry. The Argon2 master key is never + cached; only the one wallet's macaroon is. + ## Step 5 — Consume webhooks Amboss signs every webhook: HMAC-SHA256 over `${timestamp}.${rawBody}`, sent @@ -283,6 +332,9 @@ Send-specific: `DecryptionError` (wrong team password) and `PaymentSendError` - [ ] `idempotency_key` / `idempotencyKey` set on receives and sends so your retries are safe. - [ ] Sends handle `DecryptionError` / `PaymentSendError` distinctly. +- [ ] Sending wallets are prepared at startup (constructor `send` or + `prepareSend`) so no request pays the Argon2id cost, and + `forgetSend` runs when node credentials rotate. - [ ] The full flow was exercised against a `SANDBOX` environment first (`amb_sandbox_behavior: 'complete' | 'fail' | 'expire'` covers all outcomes). diff --git a/packages/core/package.json b/packages/core/package.json index 6f9f8b3..037547b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -37,7 +37,7 @@ "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.build.cjs.json && cp dist-cjs.package.json dist-cjs/package.json", "clean": "rm -rf dist dist-cjs", "typecheck": "tsc --noEmit", - "test": "tsx --test src/**/*.test.ts", + "test": "tsx --test 'src/**/*.test.ts'", "refresh-schema": "tsx scripts/refresh-schema.ts" }, "dependencies": { diff --git a/packages/payments/README.md b/packages/payments/README.md index 90eb38d..3c7a47c 100644 --- a/packages/payments/README.md +++ b/packages/payments/README.md @@ -43,6 +43,7 @@ new Payments({ baseUrl?: string, // default: https://rails.amboss.tech/graphql fetch?: typeof fetch, // override for tests / non-Node runtimes timeoutMs?: number, // default: 30000 + send?: Array<{ walletId: string, password?: string, teamId?: string }>, // pre-warm — see Sending }); ``` @@ -190,7 +191,7 @@ with the terminal result. const { transaction, payment } = await payments.transactions.send({ walletId, password, // team password — used only to decrypt the node macaroon locally - teamId, // required with a serviceApiKey (Argon2 salt); omit and it's resolved from the user + teamId, // optional (Argon2 salt) — resolved from the wallet unless you override it destination: { bolt11: 'lnbc1...' }, // or: destination: { lightningAddress: 'user@domain.com', amountSats: '1000' } onUpdate: ({ status }) => console.log(status), // 'IN_FLIGHT' | ... @@ -222,6 +223,64 @@ const { transaction, payment } = await payments.transactions.send({ payment; // null — settlement happens server-side ``` +#### Pre-warming a wallet + +Before it can pay, `send` has to fetch the wallet's send context, fetch its node +permissions, and run **two Argon2id passes** (m=64 MiB, t=3, p=4) to derive the +key that decrypts the macaroon. That is seconds of work, and none of it depends +on the invoice. + +`prepareSend` does it up front and caches the result per wallet. Afterwards +`send` issues a single API call — `CreateSendTransaction` — and pays: + +```ts +await payments.transactions.prepareSend({ walletId, password }); + +payments.transactions.isSendReady(walletId); // true — macaroon is in memory + +// no password needed now: the macaroon is already decrypted +await payments.transactions.send({ walletId, destination: { bolt11: 'lnbc1...' } }); + +payments.transactions.forgetSend(walletId); // drop it again +``` + +Pass `send` to the constructor to start this during startup instead: + +```ts +const payments = new Payments({ + serviceApiKey, + send: [{ walletId, password }], +}); + +// ...prepared in the background; poll until it lands +payments.transactions.isSendReady(walletId); +``` + +The constructor form is fire-and-forget and **ignores failures** — a bad +password surfaces later, from `send`. Use `await prepareSend(...)` when you want +to see the error at startup. + +Notes: + +- **Argon2id blocks the event loop** while it runs; it is synchronous, CPU-bound + work that no amount of `await` yields on. Prepare at startup, not mid-request. +- Each wallet costs its own derivation, so a long `send` list takes a while. + Entries are prepared one at a time (running them concurrently would not + overlap anything). +- `isSendReady` is `false` while a preparation is still running, `true` only + once the macaroon is resident. +- **Only a `send` that omits `password` uses the cache.** Passing a `password` + means "use these credentials", so it always derives afresh — the same cost as + not preparing at all — and never reads or replaces what you prepared. So a + typo'd password fails that one call and nothing else: the prepared wallet stays + prepared and later password-less sends keep working. +- The cache has no expiry. Call `forgetSend(walletId)` to pick up rotated node + credentials — or to stop holding decrypted node admin access in memory once a + run of sends is finished. Only the macaroon is retained; the Argon2 master key + is discarded after the decrypt. +- Sandbox wallets prepare too (no password, nothing to decrypt) — it just caches + the fact that no node payment is needed. + ## Examples Runnable scripts live in [`examples/`](./examples). They run against a live API diff --git a/packages/payments/package.json b/packages/payments/package.json index 059b1c7..208ef6b 100644 --- a/packages/payments/package.json +++ b/packages/payments/package.json @@ -43,7 +43,7 @@ "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.build.cjs.json && cp dist-cjs.package.json dist-cjs/package.json", "clean": "rm -rf dist dist-cjs", "typecheck": "tsc --noEmit", - "test": "tsx --test src/**/*.test.ts", + "test": "tsx --test 'src/**/*.test.ts'", "test:examples": "node examples/verify-webhook.mjs && node examples/verify-webhook.cjs", "typecheck:examples": "tsc --noEmit -p tsconfig.examples.cjs.json", "codegen": "graphql-codegen --config codegen.ts" diff --git a/packages/payments/src/client.test.ts b/packages/payments/src/client.test.ts index 3946a95..151cae4 100644 --- a/packages/payments/src/client.test.ts +++ b/packages/payments/src/client.test.ts @@ -43,6 +43,15 @@ describe('Payments serviceApiKey gating', () => { ); }); + it('throws ConfigError when send pre-warming is configured without a serviceApiKey', () => { + // Otherwise the missing key is swallowed by the per-wallet catch and the + // wallets are never pre-warmed, with nothing to explain why. + assert.throws( + () => new Payments({ send: [{ walletId: 'w1', password: 'hunter2-pw' }] }), + (err: unknown) => err instanceof ConfigError, + ); + }); + it('does not throw when serviceApiKey is provided', () => { const payments = new Payments({ serviceApiKey: 'amb_live_test', webhookSecret: 'whsec_test' }); assert.ok(payments.environments); diff --git a/packages/payments/src/client.ts b/packages/payments/src/client.ts index ba14b10..335923c 100644 --- a/packages/payments/src/client.ts +++ b/packages/payments/src/client.ts @@ -3,10 +3,22 @@ import { AmbossClient, type ClientConfig } from '@ambosstech/core'; import { Environments } from './resources/environments.js'; import { Transactions } from './resources/transactions.js'; import { Wallets } from './resources/wallets.js'; +import type { PrepareSendParams } from './resources/transactions.types.js'; import { Webhooks } from './resources/webhooks.js'; export type PaymentsConfig = ClientConfig & { webhookSecret?: string; + /** + * Wallets to pre-warm for sending. Each entry's node endpoint is fetched and + * its admin macaroon decrypted in the background, so the first `send()` for + * that wallet skips two API round-trips and two Argon2id passes. + * + * Per-wallet failures are ignored here — pre-warming is an optimization, and + * `send()` redoes the work and surfaces the real error. Requires + * `serviceApiKey`: passing this without one throws `ConfigError` from the + * constructor rather than pre-warming nothing in silence. + */ + send?: readonly PrepareSendParams[]; }; export class Payments extends AmbossClient { @@ -19,6 +31,34 @@ export class Payments extends AmbossClient { constructor(config: PaymentsConfig = {}) { super(config); this.webhooks = new Webhooks(config.webhookSecret); + // Resolving the resource here rather than inside the loop keeps a missing + // serviceApiKey a constructor-time ConfigError. Reaching it through the + // getter mid-loop would land that throw in the per-wallet catch below and + // pre-warm nothing, silently and forever. + if (config.send?.length) void this.#prewarmSend(this.transactions, config.send); + } + + /** + * Fire-and-forget pre-warm of the configured wallets. Sequential on purpose: + * Argon2id is synchronous and CPU-bound, so running the wallets concurrently + * would interleave nothing and only delay the first one becoming ready. + * + * Poll `transactions.isSendReady(walletId)` to see when a wallet is done, or + * `await transactions.prepareSend(...)` instead of using this option when you + * need to observe failures. + */ + async #prewarmSend( + transactions: Transactions, + wallets: readonly PrepareSendParams[], + ): Promise { + for (const wallet of wallets) { + try { + await transactions.prepareSend(wallet); + } catch { + // Deliberately swallowed: `send()` re-runs the derivation and throws + // the real DecryptionError / ApiError where the caller can catch it. + } + } } get environments(): Environments { diff --git a/packages/payments/src/index.ts b/packages/payments/src/index.ts index 3ce5312..f2e317b 100644 --- a/packages/payments/src/index.ts +++ b/packages/payments/src/index.ts @@ -12,6 +12,7 @@ export { WebhookVerificationError, DecryptionError, PaymentSendError } from './e export type { WebhookVerificationErrorCode } from './errors.js'; export type { + PrepareSendParams, SendDestination, SendParams, SendProgress, diff --git a/packages/payments/src/resources/transactions.send.test.ts b/packages/payments/src/resources/transactions.send.test.ts index 6c78f9a..54f6a30 100644 --- a/packages/payments/src/resources/transactions.send.test.ts +++ b/packages/payments/src/resources/transactions.send.test.ts @@ -37,11 +37,18 @@ async function startNode(lines: object[]): Promise { return `http://127.0.0.1:${addr.port}`; } -/** Fake GraphQLClient that answers the operations send() issues. */ +/** + * Fake GraphQLClient that answers the operations send() issues. + * + * The node credentials are always encrypted for `TEAM_ID`. `walletTeamId` is + * the team the *wallet* reports; pointing it elsewhere models a wallet whose + * credentials only an explicit `teamId` override can decrypt. + */ function fakeClient( restHost: string, environmentType: 'LIVE' | 'SANDBOX' = 'LIVE', createSendTransaction: object = { id: 'tx1', status: 'PENDING', payment_request: 'lnbc1xyz' }, + walletTeamId: string = TEAM_ID, ): GraphQLClient { const masterKey = deriveMasterKey(PASSWORD, TEAM_ID); const encrypted_symmetric_key = nip44Encrypt(SYMMETRIC_KEY, masterKey); @@ -54,7 +61,7 @@ function fakeClient( wallet: { find_one: { id: 'w1', - team_id: TEAM_ID, + team_id: walletTeamId, environment: { id: 'e1', type: environmentType }, }, }, @@ -102,6 +109,52 @@ function fakeClient( return { request } as unknown as GraphQLClient; } +/** Wraps a fake client so a test can assert which operations were issued. */ +function withCallLog(inner: GraphQLClient): { client: GraphQLClient; ops: string[] } { + const ops: string[] = []; + const innerRequest = (inner as unknown as { request: (args: unknown) => Promise }) + .request; + const request = async (args: { document: string }): Promise => { + ops.push(args.document); + return innerRequest(args); + }; + return { client: { request } as unknown as GraphQLClient, ops }; +} + +const countOf = (ops: readonly string[], operation: string): number => + ops.filter((document) => document.includes(operation)).length; + +/** + * Prepares `w1`, then fails a send on it with the wrong password — the shared + * arrangement for the cases asserting that a bad password disturbs nothing. + * `ops` is cleared right before the failing send, so what it holds on return is + * that send's own traffic. + */ +async function prepareThenFailWrongPassword(): Promise<{ + transactions: Transactions; + ops: string[]; +}> { + const host = await startNode([ + { result: { status: 'SUCCEEDED', payment_hash: 'ph', fee_sat: '1' } }, + ]); + const { client, ops } = withCallLog(fakeClient(host)); + const transactions = new Transactions(client); + + await transactions.prepareSend({ walletId: 'w1', password: PASSWORD }); + ops.length = 0; + + await assert.rejects( + transactions.send({ + walletId: 'w1', + password: 'a-different-password', + destination: { bolt11: 'lnbc1xyz' }, + }), + /admin macaroon/, + ); + + return { transactions, ops }; +} + describe('Transactions.send', () => { it('decrypts the macaroon, creates the send, and pays via the LND node', async () => { const host = await startNode([ @@ -216,3 +269,154 @@ describe('Transactions.send', () => { assert.ok(lastBody, 'node should have been called'); }); }); + +describe('Transactions.prepareSend', () => { + it('lets a later send() skip the context and permissions queries', async () => { + const host = await startNode([ + { result: { status: 'SUCCEEDED', payment_hash: 'ph', fee_sat: '1' } }, + ]); + const { client, ops } = withCallLog(fakeClient(host)); + const transactions = new Transactions(client); + + await transactions.prepareSend({ walletId: 'w1', password: PASSWORD }); + assert.equal(countOf(ops, 'GetWalletSendContext'), 1); + assert.equal(countOf(ops, 'GetWalletNodePermissions'), 1); + + ops.length = 0; + const result = await transactions.send({ + walletId: 'w1', // no password — the macaroon is already in memory + destination: { bolt11: 'lnbc1xyz' }, + }); + + assert.ok(result.payment); + assert.equal(result.payment.status, 'SUCCEEDED'); + assert.equal(ops.length, 1, 'send() should issue exactly one operation'); + assert.equal(countOf(ops, 'CreateSendTransaction'), 1); + // The cache only short-circuits credential derivation — the node call + // itself must still carry the fee limit every send gets. + assert.equal((lastBody as { fee_limit_sat: string }).fee_limit_sat, '4294967296'); + }); + + it('reports isSendReady false while preparing and true once resolved', async () => { + const transactions = new Transactions(fakeClient('http://127.0.0.1:1')); + + const pending = transactions.prepareSend({ walletId: 'w1', password: PASSWORD }); + assert.equal(transactions.isSendReady('w1'), false, 'not ready while Argon2 is still running'); + + await pending; + assert.equal(transactions.isSendReady('w1'), true); + + transactions.forgetSend('w1'); + assert.equal(transactions.isSendReady('w1'), false); + }); + + it('does not reuse a prepared macaroon for a send() with a different password', async () => { + const { ops } = await prepareThenFailWrongPassword(); + + assert.equal( + countOf(ops, 'GetWalletNodePermissions'), + 1, + 'different credentials must re-derive rather than hit the cache', + ); + }); + + it('marks a sandbox wallet ready without a password', async () => { + const transactions = new Transactions(fakeClient('http://127.0.0.1:1', 'SANDBOX')); + + await transactions.prepareSend({ walletId: 'w1' }); + + assert.equal(transactions.isSendReady('w1'), true); + }); + + it('keeps an already-prepared wallet after a send() with the wrong password fails', async () => { + const { transactions, ops } = await prepareThenFailWrongPassword(); + + assert.equal( + transactions.isSendReady('w1'), + true, + "one caller's bad password must not evict a working prepared wallet", + ); + + ops.length = 0; + const result = await transactions.send({ + walletId: 'w1', // still no password — the surviving macaroon is used + destination: { bolt11: 'lnbc1xyz' }, + }); + + assert.ok(result.payment); + assert.equal(countOf(ops, 'GetWalletNodePermissions'), 0, 'the survivor must still be cached'); + assert.equal(ops.length, 1, 'send() should issue exactly one operation'); + }); + + it('always re-derives for a send() that passes a password, even the prepared one', async () => { + const host = await startNode([ + { result: { status: 'SUCCEEDED', payment_hash: 'ph', fee_sat: '1' } }, + ]); + const { client, ops } = withCallLog(fakeClient(host)); + const transactions = new Transactions(client); + + await transactions.prepareSend({ walletId: 'w1', password: PASSWORD }); + ops.length = 0; + + // Passing a password means "use these credentials", so the cache is not + // consulted — that is what frees it from ever comparing credentials. + const result = await transactions.send({ + walletId: 'w1', + password: PASSWORD, + destination: { bolt11: 'lnbc1xyz' }, + }); + + assert.ok(result.payment); + assert.equal(countOf(ops, 'GetWalletNodePermissions'), 1, 'a password send derives afresh'); + assert.equal(transactions.isSendReady('w1'), true, 'and leaves the prepared wallet untouched'); + }); + + it('keeps a concurrent successful preparation when another send has bad credentials', async () => { + const host = await startNode([{ result: { status: 'SUCCEEDED' } }]); + const transactions = new Transactions(fakeClient(host)); + + // Both derivations are in flight at once: the good one is started first, + // then the bad one, which must not displace it. + const prepared = transactions.prepareSend({ walletId: 'w1', password: PASSWORD }); + const failed = transactions.send({ + walletId: 'w1', + password: 'a-different-password', + destination: { bolt11: 'lnbc1xyz' }, + }); + + await prepared; + await assert.rejects(failed, /admin macaroon/); + + assert.equal( + transactions.isSendReady('w1'), + true, + 'a resolved prepareSend must survive a concurrent bad-credential send', + ); + }); + + it('serves a password-less send from a wallet prepared with a teamId override', async () => { + const WALLET_TEAM_ID = '22222222-2222-2222-2222-222222222222'; + const host = await startNode([ + { result: { status: 'SUCCEEDED', payment_hash: 'ph', fee_sat: '1' } }, + ]); + const { client, ops } = withCallLog(fakeClient(host, 'LIVE', undefined, WALLET_TEAM_ID)); + const transactions = new Transactions(client); + + // Only the override's salt decrypts this wallet, so preparing succeeding + // at all proves the override was used. + await transactions.prepareSend({ walletId: 'w1', password: PASSWORD, teamId: TEAM_ID }); + assert.equal(transactions.isSendReady('w1'), true); + ops.length = 0; + + // The prepared macaroon is served as-is. There is no salt to re-resolve + // and disagree about, because nothing is re-derived. + const result = await transactions.send({ + walletId: 'w1', + destination: { bolt11: 'lnbc1xyz' }, + }); + + assert.ok(result.payment); + assert.equal(ops.length, 1, 'send() should issue exactly one operation'); + assert.equal(countOf(ops, 'CreateSendTransaction'), 1); + }); +}); diff --git a/packages/payments/src/resources/transactions.ts b/packages/payments/src/resources/transactions.ts index 7b27068..6a1d365 100644 --- a/packages/payments/src/resources/transactions.ts +++ b/packages/payments/src/resources/transactions.ts @@ -14,7 +14,13 @@ import { sendLndPayment } from '../node/lnd.js'; import type { PaymentLifecycleStatus } from '../node/types.js'; import { translateSdkErrors } from './sdkErrors.js'; import { selectSendNode } from './sendNode.js'; -import type { SendDestination, SendParams, SendResult } from './transactions.types.js'; +import type { + PreparedSend, + PrepareSendParams, + SendDestination, + SendParams, + SendResult, +} from './transactions.types.js'; const DEFAULT_TIMEOUT_SECONDS = 60; @@ -62,11 +68,84 @@ function lndAmountSats(destination: SendDestination): string | undefined { export class Transactions { readonly #sdk: ReturnType; + /** + * Macaroons prepared by {@link prepareSend}, keyed by wallet id. Only a + * *successful* preparation lands here, and only `prepareSend` ever writes: + * `send()` never adds to or evicts from this map, so no failing send can + * disturb a prepared wallet. ponytail: no TTL — call `forgetSend()` to + * refresh rotated credentials. + */ + readonly #prepared = new Map(); + /** Preparations still running, so concurrent `prepareSend` calls share one Argon2 pass. */ + readonly #pending = new Map>(); constructor(graphqlClient: GraphQLClient) { this.#sdk = getSdk(graphqlClient, translateSdkErrors); } + /** + * Resolves and caches everything `send()` needs before it can pay: the + * wallet's environment type, its node endpoint, and — for live wallets — the + * decrypted admin macaroon. Two GraphQL round-trips plus two Argon2id passes, + * so calling this ahead of time takes seconds off the first `send()`. + * + * A later `send()` for the same wallet that **omits `password`** uses what + * this cached. A `send()` that passes a `password` always derives afresh — + * the cache never has to decide whether two sets of credentials match. + * + * Safe to call repeatedly: an already-prepared wallet resolves immediately, + * and concurrent calls for one wallet share a single derivation. Call + * {@link forgetSend} first to re-derive after credentials rotate. + * + * **Blocks the event loop.** Argon2id is synchronous and CPU-bound + * (m=64 MiB, t=3, p=4); this method is `async` because of the API calls, not + * because the key derivation yields. Prepare during startup, not while + * serving requests. + */ + async prepareSend(params: PrepareSendParams): Promise { + const { walletId } = params; + if (this.#prepared.has(walletId)) return; + + const inFlight = this.#pending.get(walletId); + if (inFlight) { + await inFlight; + return; + } + + const promise = this.#resolveSendContext(params); + this.#pending.set(walletId, promise); + try { + const prepared = await promise; + // Skip the write if `forgetSend()` ran mid-derivation — the caller asked + // for this macaroon *not* to be held. + if (this.#pending.get(walletId) === promise) this.#prepared.set(walletId, prepared); + } finally { + if (this.#pending.get(walletId) === promise) this.#pending.delete(walletId); + } + } + + /** + * Whether `walletId`'s macaroon and node endpoint are decrypted and resident + * in memory, so a password-less `send()` would skip straight to creating the + * transaction. `false` while a `prepareSend()` for that wallet is still + * running. + */ + isSendReady(walletId: string): boolean { + return this.#prepared.has(walletId); + } + + /** + * Drops a wallet's prepared context, releasing the decrypted macaroon from + * memory. Use it to pick up rotated node credentials, or to stop holding node + * admin access once a run of sends is done. + */ + forgetSend(walletId: string): void { + this.#prepared.delete(walletId); + // Dropping the in-flight preparation too, so its result cannot land in + // #prepared after the caller asked for the macaroon to be released. + this.#pending.delete(walletId); + } + async createReceive( input: CreateReceiveTransactionInput, ): Promise { @@ -88,65 +167,28 @@ export class Transactions { * settles the transaction asynchronously according to the * `amb_sandbox_behavior` metadata (`complete` / `fail` / `expire`; default * `expire`). No password is required. + * + * Call {@link prepareSend} beforehand (or pass `send` to the `Payments` + * constructor) to move steps 1–2 off this path entirely. */ async send(params: SendParams): Promise { - const { walletId, password, destination, onUpdate, signal } = params; + const { destination, onUpdate, signal } = params; const timeoutSeconds = params.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS; - // 1. Fetch the wallet's send context up-front: the environment type (to - // detect sandbox) and the team id. Both are readable with only the - // service API key — no team password required yet. Sandbox wallets - // settle server-side, so the SDK only has to create the send. - const ctxRes = await this.#sdk.GetWalletSendContext({ id: walletId }); - const walletCtx = ctxRes.payment.wallet.find_one; - const isSandbox = walletCtx.environment.type === 'SANDBOX'; + // 1–2. Resolve the node endpoint + decrypted macaroon. A caller who passes + // a `password` always gets a fresh derivation; only a password-less + // send reads what `prepareSend()` cached. Either way a wrong password + // fails here, before any transaction is created. + const prepared = await this.#sendContext(params); - if (isSandbox) { - const createRes = await this.#sdk.CreateSendTransaction({ - input: buildCreateSendInput(params), - }); - return { transaction: createRes.payment.transaction.create_send, payment: null }; - } - - // 2. Live wallet — a team password is required to decrypt the node macaroon. - // teamId is the Argon2 salt; it comes back on the wallet above, so no - // separate lookup is needed. Callers may still override it explicitly. - if (!password) { - throw new PaymentSendError('A team password is required to send from a live wallet.'); - } - const teamId = params.teamId ?? walletCtx.team_id; - const { masterKey, masterPasswordHash } = createMasterPasswordHash(password, teamId); - - // 3. Resolve the node + its credentials — node_permissions is gated on the - // password hash, so a wrong password is rejected here before any payment. - const permRes = await this.#sdk.GetWalletNodePermissions({ - id: walletId, - password_hash: masterPasswordHash, - }); - const wallet = permRes.payment.wallet.find_one; - const isAsset = wallet.asset.type !== 'BASE_ASSET'; - const node = selectSendNode(wallet.node_permissions.nodes, isAsset); - if (!node) { - throw new PaymentSendError( - isAsset - ? 'No litd endpoint available for this wallet.' - : 'No LND endpoint available for this wallet.', - ); - } - - // 4. Decrypt the admin macaroon in-process (reusing the master key). - const macaroon = decryptAdminMacaroonWithMasterKey({ - masterKey, - encryptedSymmetricKey: wallet.node_permissions.encrypted_symmetric_key, - encryptedMacaroon: node.encryptedMacaroon, - }); - - // 5. Create the send transaction → backend returns the bolt11 to pay. + // 3. Create the send transaction → backend returns the bolt11 to pay. const createRes = await this.#sdk.CreateSendTransaction({ input: buildCreateSendInput(params), }); const transaction = createRes.payment.transaction.create_send; + if (prepared.kind === 'sandbox') return { transaction, payment: null }; + // 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 @@ -173,19 +215,19 @@ export class Transactions { throw new PaymentSendError('Backend did not return a payment request.'); } - // 6. Execute the payment against the node. + // 4. Execute the payment against the node. const onStatus = onUpdate ? (status: PaymentLifecycleStatus) => onUpdate({ status }) : undefined; const common = { - restHost: node.restHost, - macaroon, - tlsCert: node.tlsCert, + restHost: prepared.restHost, + macaroon: prepared.macaroon, + tlsCert: prepared.tlsCert, onUpdate: onStatus, signal, }; - const payment = isAsset + const payment = prepared.isAsset ? await sendAssetPayment({ ...common, body: { @@ -194,11 +236,7 @@ export class Transactions { fee_limit_sat: FEE_LIMIT_SATS, timeout_seconds: timeoutSeconds, }, - ...(wallet.asset.taproot_asset_details?.group_key - ? { - group_key: hexGroupKeyToBase64(wallet.asset.taproot_asset_details.group_key), - } - : {}), + ...(prepared.groupKeyBase64 ? { group_key: prepared.groupKeyBase64 } : {}), }, }) : await sendLndPayment({ @@ -213,4 +251,75 @@ export class Transactions { return { transaction, payment }; } + + /** + * The context `send()` will pay with. Passing a `password` means "use these + * credentials", so it always derives; omitting one means "use what was + * prepared", falling through to a derivation when nothing was — which is how + * 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 { + if (params.password !== undefined) return this.#resolveSendContext(params); + + const prepared = this.#prepared.get(params.walletId); + return prepared ? Promise.resolve(prepared) : this.#resolveSendContext(params); + } + + async #resolveSendContext(params: PrepareSendParams): Promise { + const { walletId, password } = params; + + // 1. The wallet's environment type (to detect sandbox) and team id. Both are + // readable with only the service API key — no team password required yet. + // Sandbox wallets settle server-side, so there is nothing to decrypt. + const ctxRes = await this.#sdk.GetWalletSendContext({ id: walletId }); + const walletCtx = ctxRes.payment.wallet.find_one; + if (walletCtx.environment.type === 'SANDBOX') return { kind: 'sandbox' }; + + // 2. Live wallet — a team password is required to decrypt the node macaroon. + // teamId is the Argon2 salt; it comes back on the wallet above, so no + // separate lookup is needed. Callers may still override it explicitly. + if (!password) { + throw new PaymentSendError('A team password is required to send from a live wallet.'); + } + const teamId = params.teamId ?? walletCtx.team_id; + const { masterKey, masterPasswordHash } = createMasterPasswordHash(password, teamId); + + // 3. Resolve the node + its credentials — node_permissions is gated on the + // password hash, so a wrong password is rejected here before any payment. + const permRes = await this.#sdk.GetWalletNodePermissions({ + id: walletId, + password_hash: masterPasswordHash, + }); + const wallet = permRes.payment.wallet.find_one; + const isAsset = wallet.asset.type !== 'BASE_ASSET'; + const node = selectSendNode(wallet.node_permissions.nodes, isAsset); + if (!node) { + throw new PaymentSendError( + isAsset + ? 'No litd endpoint available for this wallet.' + : 'No LND endpoint available for this wallet.', + ); + } + + // 4. Decrypt the admin macaroon in-process (reusing the master key). Only + // the macaroon is kept: `masterKey` and `masterPasswordHash` go out of + // scope here rather than being cached, so a prepared wallet holds node + // access for one node instead of the key to every wallet in the team. + const macaroon = decryptAdminMacaroonWithMasterKey({ + masterKey, + encryptedSymmetricKey: wallet.node_permissions.encrypted_symmetric_key, + encryptedMacaroon: node.encryptedMacaroon, + }); + const groupKeyHex = wallet.asset.taproot_asset_details?.group_key; + + return { + kind: 'node', + restHost: node.restHost, + macaroon, + tlsCert: node.tlsCert, + isAsset, + ...(groupKeyHex ? { groupKeyBase64: hexGroupKeyToBase64(groupKeyHex) } : {}), + }; + } } diff --git a/packages/payments/src/resources/transactions.types.ts b/packages/payments/src/resources/transactions.types.ts index 03e2998..75615dc 100644 --- a/packages/payments/src/resources/transactions.types.ts +++ b/packages/payments/src/resources/transactions.types.ts @@ -15,7 +15,12 @@ export interface SendProgress { status: PaymentLifecycleStatus; } -export interface SendParams { +/** + * Credentials needed to resolve a wallet's send context — everything + * {@link SendParams} carries except the payment itself. Used by + * `Transactions.prepareSend` and by the `send` option on `PaymentsConfig`. + */ +export interface PrepareSendParams { /** Wallet to send from. */ walletId: string; /** @@ -30,6 +35,26 @@ export interface SendParams { * value. */ teamId?: string; +} + +/** + * Everything `send()` needs before it can create and pay a transaction, + * resolved once and cached per wallet. Only the decrypted macaroon is retained + * — the Argon2 master key is discarded after the decrypt. + */ +export type PreparedSend = + | { kind: 'sandbox' } + | { + kind: 'node'; + restHost: string; + macaroon: string; + tlsCert?: string | null; + isAsset: boolean; + /** Taproot asset group key, already converted to the base64 litd expects. */ + groupKeyBase64?: string; + }; + +export interface SendParams extends PrepareSendParams { destination: SendDestination; /** Idempotency key forwarded to `create_send`. */ idempotencyKey?: string;