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
32 changes: 16 additions & 16 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,30 +100,30 @@ Resource getters are lazy and call `requireServiceApiKey`:
invoice instead of letting the existing failed row's status update.
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.
password — it reads the wallet's macaroon from the send cache (filled by
`prepareSend` or `send`) 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 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
(`CreateSendTransaction` + node REST call). `send` and `prepareSend` both go
through one cached derivation per wallet; `isSendReady(walletId)` reports
whether a macaroon is resident and `forgetSend(walletId)` drops it.
`PaymentsConfig.send` prepares 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 sends hit the cache without reintroducing all of it.
- **Cache rule:** `send`, `prepareSend` and `retryPayment` share one per-wallet
cache. A cache hit wins even when `send` is given a `password`, so a wrong
password does not fail a send while credentials are cached. The first `send`
fills an empty cache. The cache is not keyed by credentials and has no
expiry: `forgetSend` is the only way to drop it or to pick up rotated node
credentials.
- 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.
- A failed derivation is never cached, and concurrent sends share one
derivation. A sender that joined a derivation which then failed derives
again with its own credentials, so one caller's bad password cannot fail
another's send.
- `forgetSend` mid-preparation wins: a result landing afterwards is discarded
rather than resurrecting the macaroon.
- Argon2id runs on a shared worker thread, so it does not block the event loop.
Expand Down
27 changes: 16 additions & 11 deletions docs/INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,13 @@ await payments.transactions.send({
});
```

### Preparing credentials for retries
### Preparing credentials for sends and retries

`retryPayment` takes no password argument, so prepare and cache its node
credentials before retrying. You can do this at startup:
The first `send` for a wallet derives its node credentials (7 to 15 s) and
caches them in memory. Later sends reuse them and skip that cost, even when you
pass a different `password`. `retryPayment` takes no password argument and uses
the same cache. To pay the derivation cost before the first payment, prepare the
wallet at startup:

```ts
const payments = new Payments({
Expand All @@ -171,8 +174,8 @@ await payments.transactions.prepareSend({
});
```

Either way the wallet's macaroon ends up decrypted in memory for
`retryPayment`. New sends always require a password and derive afresh:
Either way the wallet's macaroon ends up decrypted in memory for `send` and
`retryPayment`:

```ts
payments.transactions.isSendReady(walletId); // true once prepared
Expand All @@ -185,13 +188,15 @@ await payments.transactions.send({

Three things to plan around:

- **Always pass `password` to `send`.** Prepared credentials are reserved for
`retryPayment`; they do not bypass the send-time password requirement.
- **Always pass `password` to `send`.** It is required on every call, but it is
not checked while credentials for the wallet are cached. A wrong password
fails only the send that has to derive. Concurrent sends for one wallet share
one derivation.
- **Argon2id runs on a shared worker thread.** Its CPU work does not block the
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
- **You are holding node admin credentials in memory** from the first `send` or
`prepareSend` until you drop them, 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.
Expand Down Expand Up @@ -356,8 +361,8 @@ Send-specific: `DecryptionError` (wrong team password) and `PaymentSendError`
- [ ] Sends handle `DecryptionError` / `PaymentSendError` distinctly.
- [ ] 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.
- [ ] `forgetSend` runs when node credentials rotate; `retryPayment` needs a
prior `send` or `prepareSend` for its wallet.
- [ ] The full flow was exercised against a `SANDBOX` environment first
(`amb_sandbox_behavior: 'complete' | 'fail' | 'expire'` covers all
outcomes).
Expand Down
30 changes: 19 additions & 11 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-cache retry credentials
send?: Array<{ walletId: string, password?: string, teamId?: string }>, // pre-cache send credentials
});
```

Expand Down Expand Up @@ -224,6 +224,10 @@ Base-asset wallets pay over LND; Taproot Asset wallets pay over litd — the SDK
selects the endpoint automatically from the wallet's asset. A wrong password
throws `DecryptionError`; a node-side failure throws `PaymentSendError`.

The first `send` for a wallet derives its node credentials (a few seconds of
Argon2id) and caches them in memory. Later sends reuse the cache, even when you
pass a different `password`; see [Preparing credentials](#preparing-credentials-for-sends-and-retries).

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.
Expand All @@ -247,12 +251,13 @@ const { transaction, payment } = await payments.transactions.send({
payment; // null — settlement happens server-side
```

#### Preparing credentials for retries
#### Preparing credentials for sends and retries

`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.
two Argon2id passes, and caches only the decrypted macaroon. `send` and
`retryPayment` both use this cache. `send` fills it too when it is empty, so
`prepareSend` only moves the cost ahead of the first payment. `send` still
requires a non-empty `password`; `retryPayment` has no password parameter.

```ts
await payments.transactions.prepareSend({ walletId, password });
Expand Down Expand Up @@ -286,11 +291,14 @@ Notes:
overlap anything).
- `isSendReady` is `false` while a preparation is still running, `true` only
once the macaroon is resident.
- `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
- While credentials are cached, `send` uses them and does not check the
`password` you pass. A wrong password fails only the send that has to derive.
A failed derivation is never cached.
- Concurrent sends and `prepareSend` calls for one wallet share a single
derivation.
- The cache has no expiry, and the first `send` fills it. 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 can prepare too; this only caches the fact that no node
payment is needed.
Expand All @@ -313,7 +321,7 @@ 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
or `send` before retrying. Without one, it fails with a `PaymentSendError` asking you to
prepare the wallet first.

## Examples
Expand Down
8 changes: 4 additions & 4 deletions packages/payments/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ import { Webhooks } from './resources/webhooks.js';
export type PaymentsConfig = ClientConfig & {
webhookSecret?: string;
/**
* Wallets whose retry credentials should be prepared in the background. Each
* Wallets whose send credentials should be prepared in the background. Each
* entry's node endpoint is fetched and its admin macaroon decrypted for use
* by `retryPayment()`.
* by `send()` and `retryPayment()`.
*
* Per-wallet failures are ignored here. A new `send()` always performs its
* own derivation and surfaces the real error. Requires
* Per-wallet failures are ignored here. A later `send()` derives the
* credentials itself and surfaces the real error. Requires
* `serviceApiKey`: passing this without one throws `ConfigError` from the
* constructor rather than preparing nothing in silence.
*/
Expand Down
Loading
Loading