@piprail/sdk 1.15.0 → 1.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ERRORS.md DELETED
@@ -1,283 +0,0 @@
1
- # PipRail error handling — the standard
2
-
3
- This is the **single source of truth** for how `@piprail/sdk` reports errors. It is
4
- deliberately small and uniform: every module — the client, the server gate, the registry,
5
- and every chain driver (all ten families: EVM, Solana, TON, Tron, NEAR, Sui, Stellar, XRPL, Aptos,
6
- Algorand, and any future one) — follows it
7
- *exactly*, so a human developer, a merchant server, or an AI agent always gets a **typed,
8
- understandable** reason, never an opaque chain-library blob.
9
-
10
- > If you're adding a chain/family/token, the `add-chain-integration` skill points here.
11
- > Follow §5 (the driver contract) verbatim and your module is consistent by construction.
12
-
13
- ---
14
-
15
- ## 1. Two channels, and only two
16
-
17
- Every failure surfaces through exactly one of two chain-agnostic channels:
18
-
19
- | | Channel | Shape | For |
20
- |---|---|---|---|
21
- | **1** | **THROWN** | a typed [`PipRailError`](src/errors.ts) subclass with a stable `.code` | config / flow / wallet / registry / affordability problems the caller acts on |
22
- | **2** | **RETURNED** | `VerifyResult` `{ ok: false, error, detail }` where `error` is a `VerifyErrorCode` | the outcome of verifying an on-chain proof (server side) |
23
-
24
- - **Thrown** errors are caught with `err instanceof PipRailError`, or branched on `err.code`
25
- (a stable `SCREAMING_SNAKE` string). They never leak a raw `viem`/`@solana`/`@ton`/
26
- `@stellar` error for a condition the SDK recognises.
27
- - **Returned** `VerifyResult` is how a driver's `verify()` reports *why a proof was rejected*
28
- without throwing. The gate turns `{ ok: false, error, detail }` into a **conformant v2
29
- `PaymentRequired` re-challenge** — a full 402 body with `accepts[]` (so a standard x402 client
30
- can retry), the human reason in `error`, and the machine code in `extensions.piprail.{code,detail}`.
31
- The built-in `requirePayment` adapter emits it + the `PAYMENT-REQUIRED` header automatically; the
32
- client reads the structured reason and relays it to the agent. (The legacy
33
- [`toInvalidBody`](src/server.ts) `{ status: 'invalid', … }` helper is **deprecated** — it has no
34
- `accepts[]`, so a standard client can't retry; prefer the gate's `result.challenge`.)
35
-
36
- Rule of thumb: **config/flow/wallet/registry/affordability → throw; proof-verification
37
- outcome → return.** Replay (`tx_already_used`) is the one verify-style code emitted by the
38
- *gate* (not a driver), because only the gate owns the used-proof set.
39
-
40
- ---
41
-
42
- ## 2. Channel 1 — thrown `PipRailError`
43
-
44
- Base class [`PipRailError`](src/errors.ts) (abstract; `.name` = the subclass name; supports
45
- `{ cause }`). All are exported from the package root.
46
-
47
- | `.code` | Class | Thrown when | Thrown by |
48
- |---|---|---|---|
49
- | `WRONG_FAMILY` | `WrongFamilyError` | wallet / `payTo` / token given in another family's shape (or a malformed same-family shape) | every driver (`bindWallet`, `assertValidPayTo`, `resolveToken`) |
50
- | `UNKNOWN_TOKEN` | `UnknownTokenError` | a built-in token symbol the chain doesn't ship (e.g. `token: 'DOGE'`) | every driver (`resolveToken`) |
51
- | `INSUFFICIENT_FUNDS` | `InsufficientFundsError` | the **payer** can't cover the transfer (+ fees / reserve / its own trustline) | every driver (`send`) — see §6 |
52
- | `RECIPIENT_NOT_READY` | `RecipientNotReadyError` | the **recipient** (`payTo`) isn't set up to receive on this chain — XRPL not activated (needs ≥1 XRP base reserve); Stellar account missing / no trustline; NEAR not `storage_deposit`-registered | Stellar / XRPL / NEAR drivers (`send`) — see §6.1 |
53
- | `WRONG_CHAIN` | `WrongChainError` | a bring-your-own `walletClient` is on a different chain than configured | EVM wallet adapter; client pre-send guard |
54
- | `CONFIRMATION_TIMEOUT` | `ConfirmationTimeoutError` | broadcast OK but the tx didn't confirm within the driver's window (re-check the ref) | every driver (`confirm`) |
55
- | `PAYMENT_TIMEOUT` | `PaymentTimeoutError` | the **server** didn't respond within `retryTimeoutMs` *after* broadcast — **carries `.ref`** | client |
56
- | `MAX_RETRIES_EXCEEDED` | `MaxRetriesExceededError` | server kept returning 402 after broadcast — **message embeds the last server `error — detail`, and carries `.ref`** | client |
57
- | `PAYMENT_DECLINED` | `PaymentDeclinedError` | the client refused to pay BEFORE any send — over the spend `policy` (amount/total/chain/token/host), or an `onBeforePay` hook returned false / threw | client |
58
- | `INVALID_ENVELOPE` | `InvalidEnvelopeError` | a 402 had no parseable x402 challenge | client |
59
- | `NO_COMPATIBLE_ACCEPT` | `NoCompatibleAcceptError` | the challenge offered no `accepts[]` entry the client can pay on its network + enabled `schemes` (message names the enabled schemes) | client |
60
- | `UNSUPPORTED_SCHEME` | `UnsupportedSchemeError` | asked to pay a scheme the bound family/asset/signer can't settle, with no fallback: `exact` on a non-EVM family, a non-EIP-3009 token (USDT/native/plain ERC-20), or a contract / EIP-1271 / EIP-7702 signer | client / EVM `exact` (`payExact`) |
61
- | `NON_REPLAYABLE_BODY` | `NonReplayableBodyError` | `init.body` isn't replayable (e.g. a one-shot stream) | client |
62
- | `MISSING_DRIVER` | `MissingDriverError` | a family's **optional peer deps aren't installed** (the lazy `import()` failed) — message names the exact `npm install` and sets `{ cause }` | registry loaders |
63
- | `UNSUPPORTED_NETWORK` | `UnsupportedNetworkError` | no driver for the family, or the driver's `resolve()` returned `null` (unrecognised `chain`) | registry |
64
- | `SETTLEMENT_FAILED` | `SettlementError` | the standard `exact` rail: a payment was VALID (sig recovered, simulated) but **settlement failed server-side** — the merchant's relayer couldn't broadcast, or a Mode-B facilitator returned a transport/auth error. NOT the payer's fault (their authorization stays valid + unused), so the adapter returns **5xx**, never 402 | gate (`exact` rail) |
65
-
66
- `MISSING_DRIVER` vs `UNSUPPORTED_NETWORK` is a deliberate split: *deps not installed* vs
67
- *chain not supported*. Don't reuse one for the other.
68
-
69
- ---
70
-
71
- ## 3. Channel 2 — returned `VerifyErrorCode`
72
-
73
- A closed `snake_case` union ([`x402.ts`](src/x402.ts)). A driver's `verify()` returns one of
74
- these on `{ ok: false, error, detail }`. **The compiler enforces the set** — you can't invent
75
- a code, and you must use the same code other drivers use for the same condition.
76
-
77
- | code | meaning | transient? | who emits it |
78
- |---|---|---|---|
79
- | `tx_not_found` | proof tx not on chain yet (RPC lag) or a transient RPC read failed | **transient** | all drivers |
80
- | `insufficient_confirmations` | mined, but `< minConfirmations` | **transient** | EVM (chains with a discrete confirmation count) |
81
- | `tx_reverted` | the tx is on chain but failed / reverted | definitive | all |
82
- | `no_meta` | the tx carries no metadata to inspect | definitive | Solana |
83
- | `wrong_recipient` | paid, but not to `payTo` | definitive | EVM / Solana native path |
84
- | `amount_too_low` | paid to `payTo`, but `< required` | definitive | all |
85
- | `transfer_not_found` | no matching transfer (asset / amount / nonce) to `payTo` | definitive | all |
86
- | `payment_expired` | older than `maxTimeoutSeconds` (replay window); on `exact`, an expired/not-yet-valid EIP-3009 authorization | definitive | all |
87
- | `tx_already_used` | this proof was already redeemed (replay); on `exact`, an on-chain-consumed authorization nonce | definitive | the **gate** (+ EVM `exact` via `authorizationState`) |
88
- | `signature_invalid` | `exact` rail: the EIP-712 authorization signature didn't recover to the payer | definitive | EVM `exact` |
89
-
90
- **Family-specificity is structural, not drift.** Account-watch chains (TON, Stellar) scan the
91
- merchant account and can't tell "wrong recipient" from "no payment", so both collapse to
92
- `transfer_not_found`; `no_meta` is Solana-only; `insufficient_confirmations` needs a discrete
93
- confirmation count (EVM). Likewise EVM/Solana digest verifiers report a short token payment as
94
- `transfer_not_found` (no nonce binding to point at), while nonce-bound chains (TON/Stellar)
95
- can say `amount_too_low`. All correct.
96
-
97
- **`transient`/`definitive` are informational.** The built-in client retries **every** code up
98
- to `maxPaymentRetries` with a short backoff (which absorbs RPC lag) — it does *not* branch on
99
- the code. A consumer building a custom client may branch on it.
100
-
101
- ---
102
-
103
- ## 4. What the agent receives
104
-
105
- - **Rejected proof →** a conformant `402` **re-challenge**: a full v2 `PaymentRequired` body with
106
- `accepts[]` (so a standard x402 client can retry), the reason in `error`, and the machine code in
107
- `extensions.piprail.{code,detail}`, plus the `PAYMENT-REQUIRED` header. The built-in
108
- `requirePayment` adapter emits `result.challenge` automatically; other adapters should do the same
109
- (NOT the deprecated bare [`toInvalidBody`](src/server.ts), which omits `accepts[]`).
110
- - **`exact`-rail settlement failed server-side →** a `5xx` (a thrown `SettlementError`), never a 402:
111
- the payer's EIP-3009 authorization is still valid and its nonce unused, so re-presenting it once the
112
- merchant fixes their relayer/facilitator settles — re-paying would be wrong.
113
- - **Client gave up →** `MaxRetriesExceededError` whose message embeds the last server
114
- `error — detail` (e.g. `… Last server rejection: amount_too_low — Paid 40000, required
115
- 500000.`), and a `payment-failed` event carrying the same reason.
116
- - **Client refused to pay →** `PaymentDeclinedError` thrown *before* any on-chain send — the
117
- quote exceeded the client's `policy` (amount/total/chain/token/host, or the session's **time
118
- envelope**), or an `onBeforePay` hook returned false. Nothing moved. It carries an optional typed
119
- `reasonCode` (`'POLICY' | 'BUDGET' | 'OUTSIDE_WINDOW' | 'SESSION_EXPIRED' | 'APPROVAL'`) so an agent
120
- branches on the cause — and recognises a **TERMINAL** `SESSION_EXPIRED` / `APPROVAL` it must not
121
- retry — without parsing the message. The session TTL (`SESSION_EXPIRED`) and rolling window
122
- (`OUTSIDE_WINDOW`) reuse this EXISTING `PaymentDeclinedError` (`.code` stays `'PAYMENT_DECLINED'`):
123
- **no new error class, no new `VerifyErrorCode`** — only the closed `PayBlocker` union gains `OUTSIDE_WINDOW`.
124
- - **Config / flow / wallet problem →** a thrown `PipRailError` with a stable `.code`.
125
-
126
- > **The agent toolkit funnels all of this.** The `piprail_pay_request` tool catches **every**
127
- > `PipRailError` and returns a structured `{ ok:false, code, reason, explain, ref?, reasonCode?,
128
- > declined? }` instead of letting it crash the agent loop — so a broadcast-but-unconfirmed
129
- > `PAYMENT_TIMEOUT`/`MAX_RETRIES_EXCEEDED`/`CONFIRMATION_TIMEOUT` reaches the model with its `.ref` and
130
- > the never-re-pay rule (via `explainDecline`). Only a genuine non-`PipRailError` bug rethrows.
131
- > The pure renderers (`render.ts`) and `classifyChallenge` (`classify.ts`) are viem-free protocol-layer
132
- > modules; `render.ts`'s VALUE import of `errors.ts` is allowed (errors.ts is chain-agnostic).
133
-
134
- Observability hooks never change control flow: the gate wraps `onPaid`, and the client routes
135
- every event through a private `safeEmit()` that swallows handler throws — a logging bug can't
136
- abort a payment.
137
-
138
- ---
139
-
140
- ## 4.1. A broadcast proof is never discarded (no false-positive, no double-pay)
141
-
142
- Once `send()` returns, the transaction is **on-chain** and funds may have moved. Two design
143
- rules make a flaky RPC safe in both directions:
144
-
145
- - **Verify fails closed (server).** If the gate's `verify()` RPC read fails, it returns
146
- `tx_not_found` → the gate replies **402 (locked)**, *never* `paid`. An RPC outage can never
147
- trick a merchant into unlocking without a real, on-chain-confirmed payment. And the gate
148
- **releases the replay claim** when verification fails, so the payer can re-submit the *same*
149
- proof once the RPC recovers — the proof is not burned.
150
- - **Confirm-timeout keeps the proof (client).** If the broadcast succeeds but the client's own
151
- `confirm()` times out (a throttled RPC that 429s its status polls past the validity window
152
- while the tx in fact lands), the client does **not** throw it away. It emits
153
- `payment-unconfirmed` and submits the proof to the server anyway — deferring to the server's
154
- on-chain verify (the authority) with **more patient retries** — and it **never re-broadcasts**.
155
- If the server ultimately can't confirm, the client throws `MaxRetriesExceededError` /
156
- `PaymentTimeoutError` carrying **`.ref`** (the broadcast proof).
157
-
158
- > **The recovery rule for agents:** on `MAX_RETRIES_EXCEEDED` / `PAYMENT_TIMEOUT`, read `.ref`
159
- > and **re-verify or re-submit that proof — never re-pay.** A fresh payment would double-spend.
160
- > The same proof stays redeemable until the server's `maxTimeoutSeconds` recency window elapses
161
- > (default 600s).
162
-
163
- ---
164
-
165
- ## 5. The driver error contract (follow this verbatim)
166
-
167
- Every `PaymentDriver` / `ResolvedNetwork` method has a fixed error behaviour:
168
-
169
- | method | on error |
170
- |---|---|
171
- | `resolve(opts)` | recognise + bind, **or return `null`** (registry maps `null` → `UnsupportedNetworkError`). Never throw a raw chain error for unrecognised input. |
172
- | `resolveToken(token)` | unknown built-in symbol → `UnknownTokenError`; a foreign-family object token → `WrongFamilyError` (call the shared [`rejectForeignToken(token, family, network)`](src/drivers/shared.ts)); a malformed own-family token → `WrongFamilyError`. |
173
- | `assertValidPayTo(payTo)` | a non-family address → `WrongFamilyError`. |
174
- | `bindWallet(wallet)` | a foreign / unusable wallet shape → `WrongFamilyError`. |
175
- | `send(wallet, accept)` | wrap the broadcast; map **sender** affordability → `InsufficientFundsError` (§6) and **recipient** setup → `RecipientNotReadyError` (§6.1); **rethrow everything else unchanged** (never swallow). Every mapped throw carries `{ cause }` = the raw chain error. |
176
- | `verify(ref, accept)` | **return** a `VerifyResult` with a canonical `VerifyErrorCode`. **Guard every RPC read** so a transient failure returns `tx_not_found` — `verify()` must not throw for an RPC hiccup. Re-derive the watched account from the trusted `accept`, never the client ref. |
177
- | `exactDomain?(asset)` *(optional, EVM)* | **never throw for a non-EIP-3009 token** — return `null` (the gate raises a clear config error). May throw only on a hard RPC failure at gate setup. |
178
- | `settleExactSelf?(input)` *(optional, EVM)* | **return** a `VerifyResult` for a CLIENT-fixable fault (`signature_invalid`/`wrong_recipient`/`amount_too_low`/`payment_expired`/`tx_already_used`/`tx_reverted` → 402); **throw `SettlementError`** when a valid+simulated payment fails to BROADCAST (relayer/RPC → 5xx). Re-derive every checked field from the trusted `accept`, never the client echo. |
179
- | `confirm(ref, n)` | broadcast-but-not-confirmed / timeout → `ConfirmationTimeoutError`. |
180
- | `estimateCost(accept, opts?)` | **never throw** — guard the RPC read and fall back to a `'heuristic'` constant; always return a valid `CostEstimate`. |
181
- | `balanceOf(wallet, asset)` | **never throw** — RPC-read-only. A field whose read was unavailable (transient/rate-limit) returns `null`, NOT `0` (a false 0 reads as "broke"). For `asset==='native'`, `token === native`. |
182
- | `recipientReady(payTo, asset)` | **never throw** — report the receive prerequisite: `{ ready:'n/a' }` (no prerequisite on this family/native), `{ ready:true }`, `{ ready:false, reason }` (a `RecipientReason`), or `{ ready:'unknown' }` on a transient read. `'n/a'` must be TRUTHFUL — never a stand-in for "didn't check". |
183
-
184
- > **`planPayment` is a RETURN-channel feature.** The client's `planPayment`/`canAfford` compose
185
- > `balanceOf` + `recipientReady` + `estimateCost` + the policy verdict into a `PaymentPlan` — and,
186
- > like `verify()`, they **return** the outcome rather than throwing: a transient read becomes a rail
187
- > in `state:'unknown'` (+ a warning), an unsettleable rail carries typed `blockers`, and a 402 with
188
- > no rail on the client's chain is *explained* in the plan. The only throw is `InvalidEnvelopeError`
189
- > on an unparseable challenge. (`fetch({ autoRoute:true })` is the one place a plan turns into a
190
- > THROWN `PaymentDeclinedError` — refusing before any send when nothing is settleable.)
191
-
192
- > **Discovery is read-style too — it reports, it doesn't throw.** `client.discover()` /
193
- > `searchOpenIndexes()` read third-party OPEN indexes: an index that's down, slow, or shape-changed
194
- > simply contributes nothing (→ `[]`), never an exception — one dead index can't sink the others.
195
- > `client.register()` / `register402Index()` / `registerX402Scan()` return one `RegisterOutcome` per
196
- > target — a step the chain can't satisfy (x402scan on a non-Base/Solana client, no `discoverySigner`,
197
- > or an HTTP error) comes back `{ ok:false, detail }`, surfaced not swallowed. The pure emitters
198
- > (`buildOpenApi` / `buildWellKnownX402` / `buildX402DnsTxt`) do no I/O and can't fail at runtime. The
199
- > optional `discoverySigner(wallet)` is discovery-only (ownership proofs / SIWX) — it never signs a payment.
200
-
201
- ### 6. Affordability converges on one error, by two mechanisms
202
-
203
- "Wallet can't pay" must always surface as **`InsufficientFundsError`** — but the *detection*
204
- is per-chain, because each library exposes a different signal:
205
-
206
- - **Message-regex drivers (Solana, TON):** `send()` does
207
- `catch (err) { throw toInsufficientFundsError(err) ?? err }`. The shared
208
- [`toInsufficientFundsError`](src/errors.ts) matches the common "can't afford it" messages and
209
- returns `null` on a miss (so the original error propagates unchanged — never swallowed).
210
- - **Structured-error drivers (EVM, Stellar):** detect from typed data — EVM walks viem's
211
- `BaseError` chain for a nested `InsufficientFundsError`; Stellar reads Horizon
212
- `result_codes` (and treats an unfunded source account's `loadAccount` 404 as the same). Both
213
- then *also* fall through to `toInsufficientFundsError` as a message-level backstop, so the
214
- two paths can't drift in vocabulary.
215
-
216
- Either way the caller sees one `InsufficientFundsError` with `.code === 'INSUFFICIENT_FUNDS'`.
217
-
218
- ### 6.1. Sender vs recipient: `INSUFFICIENT_FUNDS` vs `RECIPIENT_NOT_READY`
219
-
220
- Many chains require the **recipient** to be provisioned before it can receive — a chain
221
- *state* rule, not the payer's balance. These must NOT masquerade as affordability, because
222
- the fix is the opposite (set up the *recipient*, not fund the *payer*). So `send()` maps them
223
- to **`RecipientNotReadyError`** (`RECIPIENT_NOT_READY`), distinct from `InsufficientFundsError`:
224
-
225
- | Chain | Raw signal | → mapped to | Because the recipient needs… |
226
- |---|---|---|---|
227
- | **XRPL** | `tecNO_DST*` | `RecipientNotReadyError` | activation — an account must hold ≥1 XRP (base reserve) to exist |
228
- | **XRPL** | `tecNO_LINE*`, `tecPATH_DRY`, `tecDST_TAG_NEEDED`, `tecNO_AUTH` | `RecipientNotReadyError` | a trustline for the IOU / a DestinationTag / authorization |
229
- | **XRPL** | `tecUNFUNDED*`, `terINSUF*`, `tecINSUFF*` | `InsufficientFundsError` | (sender side — fund the payer) |
230
- | **Stellar** | `op_no_destination` | `RecipientNotReadyError` | the account to exist (created with ≥1 XLM reserve) |
231
- | **Stellar** | `op_no_trust`, `op_line_full`, `op_not_authorized` | `RecipientNotReadyError` | a trustline for the asset (and authorization) |
232
- | **Stellar** | `op_underfunded`, `op_src_no_trust`, `op_low_reserve` | `InsufficientFundsError` | (sender side) |
233
- | **NEAR** | `… is not registered` (NEP-141 panic) | `RecipientNotReadyError` | `storage_deposit` (NEP-145, ~0.00125 NEAR) |
234
-
235
- **Two rules for these messages:** (1) state the requirement and the fix in plain language so a
236
- human or an AI agent can act on it, and **echo the raw chain code** in the message (e.g.
237
- `(XRPL: tecNO_DST_INSUF_XRP)`); (2) preserve the untouched chain error on `.cause`. Clarity for
238
- the reader, full raw detail for the debugger — both, always. Chains with no receive prerequisite
239
- (EVM, Solana, Sui, Tron, native TON/NEAR) never throw `RecipientNotReadyError`.
240
-
241
- ---
242
-
243
- ## 7. Registry / loader pattern
244
-
245
- - EVM is registered eagerly (`viem` is the one hard peer dep). Every non-EVM family (Solana,
246
- TON, Tron, NEAR, Sui, Stellar, XRPL, Aptos, Algorand) mounts lazily via a single dynamic
247
- `import()` in [`drivers/index.ts`](src/drivers/index.ts) the first time its `chain` is
248
- named — no setup call.
249
- - A failed lazy `import()` → `MissingDriverError` naming the exact `npm install` + `{ cause }`.
250
- The in-flight promise isn't cached on failure, so a later call can retry.
251
- - No driver for the family, or `resolve()` → `null` → `UnsupportedNetworkError`.
252
- - Add a family = one loader entry + a mirrored `drivers/<family>/` folder. Nothing else in the
253
- protocol layer changes.
254
-
255
- ---
256
-
257
- ## 8. Shared building blocks (don't reinvent per chain)
258
-
259
- | Helper | Where | Purpose |
260
- |---|---|---|
261
- | `toInsufficientFundsError(err)` | [`errors.ts`](src/errors.ts) | message → `InsufficientFundsError \| null` (the affordability backstop) |
262
- | `rejectForeignToken(token, family, network)` | [`drivers/shared.ts`](src/drivers/shared.ts) | uniform `WrongFamilyError` for a foreign-family object token (data-driven; a new family is auto-covered) |
263
- | `toInvalidBody(result)` | [`server.ts`](src/server.ts) | the canonical 402 'invalid' JSON body for every framework adapter |
264
- | `delay(ms)` | [`util/async.ts`](src/util/async.ts) | the one poll/confirm delay shared by all drivers |
265
- | `safeEmit(event)` | client (private); the gate mirrors it with an inline try/catch around `onPaid` | observability hooks never abort the flow |
266
-
267
- ---
268
-
269
- ## 9. New-module error checklist
270
-
271
- ```
272
- - [ ] verify() returns ONLY canonical VerifyErrorCode values (compiler-enforced); same code
273
- as other drivers for the same condition; every RPC read guarded → tx_not_found on failure.
274
- - [ ] send() wraps the broadcast and maps affordability → InsufficientFundsError
275
- (toInsufficientFundsError for message-only chains; structured detection + that backstop otherwise).
276
- - [ ] send() maps any RECIPIENT-side setup requirement (activation / trustline / account / storage)
277
- → RecipientNotReadyError, with a plain-language fix + the raw chain code echoed + { cause } (§6.1).
278
- - [ ] confirm() → ConfirmationTimeoutError on broadcast-but-not-confirmed.
279
- - [ ] resolveToken(): unknown symbol → UnknownTokenError; foreign token → rejectForeignToken(...).
280
- - [ ] bindWallet() / assertValidPayTo() → WrongFamilyError for the wrong shape, message names the right one.
281
- - [ ] loader entry throws MissingDriverError with the exact `npm install` + { cause }.
282
- - [ ] No raw chain error escapes for a condition the SDK recognises; the rest rethrow unchanged.
283
- ```
package/STANDARDS.md DELETED
@@ -1,130 +0,0 @@
1
- # PipRail SDK — the build standard
2
-
3
- How we build *anything* in `@piprail/sdk`. This is the repeatable procedure so every
4
- feature lands the same way and the SDK stays the simplest, clearest agent-payments SDK on
5
- the market. Companion docs: **[ERRORS.md](./ERRORS.md)** (the error standard) and the
6
- **`add-chain-integration`** skill (adding a chain/token/family). When those apply, they win
7
- for their topic; this doc covers everything else.
8
-
9
- ---
10
-
11
- ## 0. The prime directive — simplicity is the product
12
-
13
- Every change must make the SDK *easier*, never heavier. Before adding anything, ask: does the
14
- zero-config path still read in one line? If a feature can't be opt-in, reconsider it.
15
-
16
- - **Opt-in, defaults unchanged.** New capability is a new optional field/method. Omitting it
17
- leaves behaviour byte-identical. (`policy`, `onBeforePay`, `accept[]`, `quote()` all obey this.)
18
- - **No backend, no database, no auth, no dashboard, no fee, no PipRail-hosted facilitator.** Ever. If
19
- a feature needs one of those, it's the wrong feature for this SDK. (The opt-in standard `exact` rail
20
- is consistent with this: settlement is either **merchant-self-hosted** — the merchant's own relayer
21
- key broadcasts in-process, which x402 v2 §7 explicitly blesses — or **delegated to a third-party
22
- facilitator the merchant chooses**. PipRail still hosts nothing.)
23
- - **One obvious way.** Prefer one clear API over flags. `token` is required so a gate is never
24
- ambiguous; `chain` is one word. Don't add a second way to do the same thing.
25
-
26
- ---
27
-
28
- ## 1. The layering (never violate)
29
-
30
- ```
31
- protocol layer index · server · client · x402 · policy · ledger · agent · discovery · indexes · errors · util/*
32
- (chain-agnostic — ZERO viem / @solana / @ton / @stellar imports)
33
- │ depends only on …
34
-
35
- driver contract drivers/types.ts (PaymentDriver / ResolvedNetwork)
36
- ▲ implemented by …
37
-
38
- chain drivers drivers/<family>/ chains · wallet · pay · verify · index (family-symmetric)
39
- registry.ts (routes a chain → family) index.ts (eager EVM + lazy auto-mount of the rest)
40
- ```
41
-
42
- - **The protocol layer is chain-agnostic.** `server`/`client`/`x402`/`policy`/`ledger`/`agent`/
43
- `discovery`/`indexes` import only `drivers/types.ts` + pure utils — never a chain library.
44
- Verified by the lazy-chunk invariant (below).
45
- - **Drivers mirror each other** file-for-file (`chains`/`wallet`/`pay`/`verify`/`index`),
46
- functions family-suffixed (`payEvm`/`verifyStellar`). A new contract method is implemented in
47
- **all** families.
48
- - **Pure logic is a pure module.** Anything decidable without I/O (amount math, policy, ledger
49
- aggregation) lives in its own dependency-free, unit-testable file. `policy.ts`/`ledger.ts`
50
- import no driver; `util/units.ts` imports nothing.
51
- - **Lazy-chunk invariant.** The built EVM entry must contain **zero static** `@solana`/`@ton`/
52
- `@stellar` imports (they load on first use). New optional-peer code goes under `drivers/<family>/`
53
- and is reached only via the dynamic loader in `drivers/index.ts`.
54
-
55
- ---
56
-
57
- ## 2. Errors — one standard
58
-
59
- Follow **[ERRORS.md](./ERRORS.md)** exactly. Two channels only: a **thrown** `PipRailError`
60
- subclass with a stable `SCREAMING_SNAKE` `.code` (config/flow/wallet/registry/affordability), or
61
- a **returned** `VerifyResult` with a closed `VerifyErrorCode` (proof verification). A new thrown
62
- error gets a row in ERRORS.md §2 and is exported from the root. Never leak a raw chain-library
63
- error for a condition the SDK recognises. Observability hooks (`onEvent`, `onPaid`,
64
- `onBeforePay`) never abort the flow on a throw — isolate them.
65
-
66
- ---
67
-
68
- ## 3. Adding a feature — the procedure
69
-
70
- 1. **Write the plan first** under `.claude/plans/<feature>/` (one README + numbered phases,
71
- referencing the exact files/lines). Tests-as-contract: the test changes *before* the behaviour.
72
- 2. **Put it in the right layer** (§1). Pure logic → its own module. Chain-specific → the driver
73
- (add to the contract + all families if it's cross-family).
74
- 3. **Make it opt-in** (§0). Add an optional option/method; default leaves today's behaviour.
75
- 4. **Type it precisely**, export the public types from `index.ts`, and keep internals private.
76
- 5. **Document everywhere** (§5).
77
- 6. **Test every spectrum** (§4) and pass the gate (§6).
78
-
79
- ## 4. The test contract
80
-
81
- `test/` (Vitest) **is** the spec. For every feature:
82
-
83
- - **Unit (pure):** truth tables for pure modules (`policy`, `ledger`, `units`), deterministic
84
- vectors for crypto (`exact` via signature recovery). No I/O.
85
- - **Flow (fake driver + stubbed `fetch`):** register a fake `ResolvedNetwork`; stub `globalThis.fetch`.
86
- Assert the happy path **and** that refusals happen **before** side effects (e.g. a `send` spy
87
- stays at 0 when policy declines).
88
- - **Adversarial — try to break it:** a hostile/buggy server (lies about decimals/symbol, forged
89
- `accepted`, malformed 402), boundary inputs (excess precision, ports in hosts, zero/huge amounts),
90
- concurrency (parallel payments), and replay. Whatever breaks, fix — then keep the test.
91
- - **Symmetry:** a cross-family test that exercises the same behaviour on every driver
92
- (e.g. `describe-asset.test.ts`).
93
-
94
- ## 5. Documentation (a feature isn't done until all are updated)
95
-
96
- - **`README.md`** — a section + the API table.
97
- - **`site/src/pages/index.astro`** — a landing block in the existing visual language, if it's
98
- user-facing.
99
- - **`ERRORS.md`** — any new error code.
100
- - **`CHANGELOG.md`** — an `Unreleased` entry.
101
- - **`examples/`** — a runnable example if it changes how an agent/merchant integrates.
102
-
103
- ## 6. The verification gate (must be green before "done")
104
-
105
- ```bash
106
- npm run typecheck # src type-checks
107
- npm run typecheck:test # src + tests type-check together (tests are excluded from the build)
108
- npm test # full Vitest suite
109
- npm run build # tsup build succeeds
110
- # lazy-chunk invariant — the EVM bundle pulls in no non-EVM chain lib:
111
- grep -E "from ?['\"]@(solana|ton|stellar)" dist/index.js # → expect NO matches
112
- # viem-free protocol layer — the chain-agnostic core never imports a chain SDK
113
- # (includes the pure agent-ergonomics modules render/classify/agentGuide; render.ts's
114
- # VALUE import of errors.ts is allowed — errors.ts is chain-agnostic, the grep targets viem):
115
- grep -lE "from ['\"]viem" src/client.ts src/x402.ts src/policy.ts src/ledger.ts src/server.ts src/agent.ts src/render.ts src/classify.ts src/agentGuide.ts # → expect NO matches
116
- ```
117
-
118
- `prepublishOnly` runs build + test + both typechecks. Never ship with any of these red.
119
-
120
- ---
121
-
122
- ## 7. Known, intentional limitations (document; don't silently fix with complexity)
123
-
124
- - **`policy.maxTotal` under high concurrency is best-effort.** It's checked against spend recorded
125
- *so far*; many payments in flight at once could race past it. Agents that need a hard concurrent
126
- cap should serialise (the common case is sequential `await`ed calls). We don't add a reservation
127
- system — it would cost more simplicity than it's worth. State limits like this; never hide them.
128
- - **`policy.chains` string entries match the configured selector form.** A `'base'` entry matches a
129
- client configured with `'base'`; an `{ id }` entry matches by resolved network. Use the same form
130
- you configured the client with (the pure policy layer can't resolve a name → id without the EVM table).