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
40 changes: 21 additions & 19 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ new Payments({
baseUrl?: string,
fetch?: typeof fetch,
timeoutMs?: number,
send?: readonly PrepareSendParams[], // wallets to pre-warm for sending, in the background
send?: readonly PrepareSendParams[], // retry credentials to prepare in the background
});
```

Expand All @@ -83,9 +83,12 @@ Resource getters are lazy and call `requireServiceApiKey`:
- `send` creates the send transaction, decrypts the node admin macaroon
**in-process** using the team password (never sent to the API), then pays
directly against the node's REST endpoint. Base-asset wallets pay over LND;
Taproot Asset wallets over litd. Sandbox wallets need no password — the
backend settles asynchronously (`payment` resolves `null`); behavior is
driven by `metadata.amb_sandbox_behavior` (`complete` / `fail` / `expire`).
Taproot Asset wallets over litd. A non-empty `password` is mandatory for
sandbox and live sends so their call shapes match. Live requires the real
team password; sandbox accepts any non-empty value because it does not use
it. The backend settles sandbox sends asynchronously (`payment` resolves
`null`); behavior is 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
Expand All @@ -96,26 +99,25 @@ Resource getters are lazy and call `requireServiceApiKey`:
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 →
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 →
`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
Argon2id passes; nip44 decrypt) and then runs the payment itself
(`CreateSendTransaction` + node REST call). Every new `send` derives afresh.
`prepareSend` caches the macaroon for `retryPayment`; `isSendReady(walletId)`
reports whether one is resident and `forgetSend(walletId)` drops it.
`PaymentsConfig.send` prepares retry credentials for 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 `retryPayment` reads it, and only
`prepareSend` writes it. Every `send` requires a password and 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.
letting 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`.
Expand Down
45 changes: 24 additions & 21 deletions docs/INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ new Payments({
baseUrl?: string, // default: https://app.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
send?: Array<{ walletId, password?, teamId? }>, // pre-cache retry credentials — see Step 4
});
```

Expand Down Expand Up @@ -110,7 +110,7 @@ node and resolves with the terminal result.
```ts
const { transaction, payment } = await payments.transactions.send({
walletId,
password: process.env.TEAM_PASSWORD, // live wallets only
password: process.env.TEAM_PASSWORD, // real team password for live wallets
teamId, // optional — resolved from the wallet unless you override it
destination: { bolt11: 'lnbc1...' },
// or: destination: { lightningAddress: 'user@domain.com', amountSats: '1000' }
Expand All @@ -130,26 +130,25 @@ Notes:
- 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
**Sandbox wallets** need no node, but a non-empty `password` remains mandatory
so the sandbox request matches production. Any non-empty value such as
`Password123` works because sandbox does not use it. The backend settles the
transaction asynchronously and `payment` resolves `null`. Control the outcome
with metadata and observe it via webhooks:

```ts
await payments.transactions.send({
walletId: sandboxWalletId,
password: 'Password123', // any non-empty value works in sandbox
destination: { bolt11: 'lnbc1...' },
metadata: { amb_sandbox_behavior: 'complete' }, // 'complete' | 'fail' | 'expire' (default)
});
```

### Making sends fast
### Preparing credentials for retries

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:
`retryPayment` takes no password argument, so prepare and cache its node
credentials before retrying. You can do this at startup:

```ts
const payments = new Payments({
Expand All @@ -167,22 +166,25 @@ await payments.transactions.prepareSend({
});
```

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:
Either way the wallet's macaroon ends up decrypted in memory for
`retryPayment`. New sends always require a password and derive afresh:

```ts
payments.transactions.isSendReady(walletId); // true once prepared
await payments.transactions.send({ walletId, destination: { bolt11: 'lnbc1...' } });
await payments.transactions.send({
walletId,
password: process.env.TEAM_PASSWORD,
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.
- **Always pass `password` to `send`.** Prepared credentials are reserved for
`retryPayment`; they do not bypass the send-time password requirement.
- **Argon2id runs on a shared worker thread.** Its CPU work does not block the
event loop. Prepare during startup or a warm-up hook to avoid that latency on
the first payment request. The constructor `send` option starts it in the background.
event loop. The constructor `send` option prepares retry credentials in the
background.
- **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
Expand Down Expand Up @@ -347,9 +349,10 @@ 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.
- [ ] Every send includes a non-empty password; live uses the real team
password, while sandbox may use a placeholder such as `Password123`.
- [ ] Wallets used by `retryPayment` are prepared first, 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).
Expand Down
59 changes: 25 additions & 34 deletions packages/payments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ new Payments({
baseUrl?: string, // default: https://app.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
send?: Array<{ walletId: string, password?: string, teamId?: string }>, // pre-cache retry credentials
});
```

Expand Down Expand Up @@ -220,40 +220,37 @@ 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.
The backend settles asynchronously per the `amb_sandbox_behavior` metadata
(`complete` / `fail` / `expire`; default `expire`):
**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
use it. `payment` comes back `null`; observe the outcome
via webhooks or by polling the transaction status. The backend settles
asynchronously per the `amb_sandbox_behavior` metadata (`complete` / `fail` /
`expire`; default `expire`):

```ts
const { transaction, payment } = await payments.transactions.send({
walletId, // a sandbox wallet — no password required
walletId, // a sandbox wallet
password: 'Password123', // any non-empty value works in sandbox
destination: { bolt11: 'lnbc1...' },
metadata: { amb_sandbox_behavior: 'complete' }, // force success in sandbox
});

payment; // null — settlement happens server-side
```

#### Pre-warming a wallet
#### Preparing credentials for retries

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:
`prepareSend` fetches a wallet's send context and node permissions, performs the
two Argon2id passes, and caches only the decrypted macaroon. The cache is used
by `retryPayment`, which intentionally has no password parameter. A new `send`
always requires `password` and derives credentials afresh.

```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
```

Expand All @@ -269,31 +266,26 @@ const payments = new Payments({
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.
The constructor form is fire-and-forget and **ignores failures**. Use
`await prepareSend(...)` when you need to observe preparation errors.

Notes:

- **Argon2id runs on a shared worker thread**, so its CPU-bound work does not
block the event loop. Prepare at startup to avoid its latency on the first
payment request.
block the event loop.
- 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.
- `send` never reads or replaces the cache. A typo'd password fails that call
without disturbing credentials prepared for a later retry.
- 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.
- Sandbox wallets can prepare too; this only caches the fact that no node
payment is needed.

#### Retrying a failed send

Expand All @@ -309,10 +301,9 @@ transaction isn't retryable.
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.
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.

## Examples

Expand Down
8 changes: 6 additions & 2 deletions packages/payments/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,12 @@ Destinations (set one):
- `BOLT11` — a BOLT11 invoice (`AMOUNT_SATS` only needed for zero-amount invoices).
- `LIGHTNING_ADDRESS` + `AMOUNT_SATS` — pay a Lightning address.

The team password is used only to decrypt the node admin macaroon locally; it is
never sent to the API. See `.env.example` for every supported variable.
A non-empty password is mandatory for both sandbox and live sends. Live wallets
require the real team password, which decrypts the node admin macaroon locally
and is never sent to the API. Sandbox accepts any non-empty value such as
`Password123` because settlement does not use it; requiring the field keeps the
call shape identical to production. See `.env.example` for every supported
variable.

### `verify-webhook.mjs` / `verify-webhook.cjs` — offline webhook verification

Expand Down
14 changes: 7 additions & 7 deletions packages/payments/examples/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,20 +73,20 @@ async function main(): Promise<void> {
const walletId = process.env.WALLET_ID;
const password = process.env.TEAM_PASSWORD;

// Live wallets need TEAM_PASSWORD; sandbox wallets do not (the backend
// settles the send itself — set IDEMPOTENCY_KEY/metadata as needed).
if (!walletId || !destination) {
// Every send requires TEAM_PASSWORD so sandbox exercises the production
// call shape. Sandbox accepts any non-empty placeholder because settlement
// does not use the value; live sends require the real team password.
if (!walletId || !password || !destination) {
console.log(
'\nSkipping send — set WALLET_ID and a destination ' +
'(BOLT11, or LIGHTNING_ADDRESS + AMOUNT_SATS) to send. ' +
'Live wallets also need TEAM_PASSWORD.',
'\nSkipping send — set WALLET_ID, TEAM_PASSWORD, and a destination ' +
'(BOLT11, or LIGHTNING_ADDRESS + AMOUNT_SATS) to send.',
);
return;
}

const params: SendParams = {
walletId,
...(password ? { password } : {}),
password,
destination,
...(process.env.TEAM_ID ? { teamId: process.env.TEAM_ID } : {}),
...(process.env.IDEMPOTENCY_KEY ? { idempotencyKey: process.env.IDEMPOTENCY_KEY } : {}),
Expand Down
20 changes: 10 additions & 10 deletions packages/payments/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ 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.
* Wallets whose retry credentials should be prepared in the background. Each
* entry's node endpoint is fetched and its admin macaroon decrypted for use
* by `retryPayment()`.
*
* Per-wallet failures are ignored here — pre-warming is an optimization, and
* `send()` redoes the work and surfaces the real error. Requires
* Per-wallet failures are ignored here. A new `send()` always performs its
* own derivation and surfaces the real error. Requires
* `serviceApiKey`: passing this without one throws `ConfigError` from the
* constructor rather than pre-warming nothing in silence.
* constructor rather than preparing nothing in silence.
*/
send?: readonly PrepareSendParams[];
};
Expand All @@ -34,12 +34,12 @@ export class Payments extends AmbossClient {
// 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.
// prepare 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:
* Fire-and-forget preparation of the configured wallets. Sequential on purpose:
* each wallet needs two memory-hard Argon2id passes, so limiting work to one
* wallet at a time avoids excessive memory pressure during startup.
*
Expand All @@ -55,8 +55,8 @@ export class Payments extends AmbossClient {
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.
// Deliberately swallowed: use explicit prepareSend() when callers need
// to observe a DecryptionError / ApiError.
}
}
}
Expand Down
Loading
Loading