@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/CHANGELOG.md +10 -0
- package/README.md +27 -790
- package/package.json +1 -5
- package/CHAINS.md +0 -179
- package/DISCOVERY.md +0 -420
- package/ERRORS.md +0 -283
- package/STANDARDS.md +0 -130
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).
|