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
29 changes: 28 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
```

Expand All @@ -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.
Expand All @@ -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

Expand Down
54 changes: 53 additions & 1 deletion docs/INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
```

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
61 changes: 60 additions & 1 deletion packages/payments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
```

Expand Down Expand Up @@ -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' | ...
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/payments/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions packages/payments/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
40 changes: 40 additions & 0 deletions packages/payments/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<void> {
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 {
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 @@ -12,6 +12,7 @@ export { WebhookVerificationError, DecryptionError, PaymentSendError } from './e
export type { WebhookVerificationErrorCode } from './errors.js';

export type {
PrepareSendParams,
SendDestination,
SendParams,
SendProgress,
Expand Down
Loading
Loading