@piprail/sdk 1.0.0 → 1.1.0

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/CHAINS.md ADDED
@@ -0,0 +1,152 @@
1
+ # Chain support & per-chain setup
2
+
3
+ PipRail works the same way on every chain — `requirePayment({ chain, token, amount, payTo })`
4
+ to charge, `new PipRailClient({ chain, wallet }).fetch(url)` to pay. But the chains
5
+ themselves differ, and a few have **setup steps you must do before a wallet can pay or
6
+ receive**. This page is the exact list.
7
+
8
+ **Most chains need nothing special.** The ones with caveats are **NEAR**, **TON**,
9
+ **Stellar**, **XRPL**, and **Tron** — read those sections before you ship them.
10
+
11
+ ## At a glance
12
+
13
+ | Chain(s) | Pay in native coin? | Built-in stablecoins | Receiver needs setup? | Wallet input |
14
+ |---|:--:|---|---|---|
15
+ | **EVM** (Ethereum, Base, Arbitrum, Optimism, Polygon, BNB, Avalanche, Mantle, Sonic, Linea, Scroll, Celo, zkSync, Unichain, World Chain, Sei, Injective, + any EVM chain) | ✅ ETH/BNB/POL/… | USDC (all) · USDT (all **except Base, World Chain, Sei**) | No | `{ privateKey }` |
16
+ | **Solana** | ✅ SOL | USDC · USDT | No (payer creates the recipient's token account) | `{ secretKey }` |
17
+ | **Sui** | ✅ SUI | USDC (no USDT) | No | `{ privateKey }` (`suiprivkey1…`) |
18
+ | **Stellar** | ✅ XLM | USDC · EURC | ⚠️ **Yes — trustline + funded account** | `{ secret }` (`S…`) |
19
+ | **XRP Ledger** | ✅ XRP | USDC · RLUSD (no USDT) | ⚠️ **Yes — trustline + activated account** | `{ seed }` (`s…`) |
20
+ | **TON** | ✅ TON | **USD₮ only** (no USDC) | No (payer's gas auto-deploys the jetton wallet) | `{ mnemonic }` (24 words) |
21
+ | **Tron** | ✅ TRX | **USD₮ only** (no USDC) | No | `{ privateKey }` |
22
+ | **NEAR** | ✅ NEAR | USDC · USDT | tokens: ⚠️ `storage_deposit` · **native NEAR: none** | `{ accountId, privateKey }` |
23
+
24
+ > **`token: 'native'`** (paying in the chain's own coin) is accepted on **every family** —
25
+ > EVM, Solana, Sui, Stellar, XRPL, TON, NEAR, **and Tron** (native TRX, digest-bound). No
26
+ > exceptions. On NEAR, native is the **zero-setup** path: no `storage_deposit`, and a transfer
27
+ > even creates a fresh recipient (the NEP-141 token path still needs `storage_deposit`).
28
+ >
29
+ > **Custom tokens** work everywhere with no allowlist: EVM `{ address, decimals }` ·
30
+ > Solana `{ mint, decimals }` · Sui `{ coinType, decimals }` · TON `{ master, decimals }` ·
31
+ > Tron `{ address, decimals }` · NEAR `{ contractId, decimals }` · Stellar
32
+ > `{ issuer, code, decimals }` · XRPL `{ issuer, currencyHex, decimals }`.
33
+
34
+ **Universal:** the public default RPC on every chain is rate-limited — **pass your own
35
+ `rpcUrl`** in production (there's no separate API-key field; fold any key into the URL).
36
+
37
+ ---
38
+
39
+ ## Chains with no caveats
40
+
41
+ ### EVM — Ethereum, Base, Arbitrum, Optimism, Polygon, BNB, Avalanche, …
42
+ - **Pay in:** native coin (`'native'`), `'USDC'`, `'USDT'`, or a custom `{ address, decimals }`.
43
+ - **USDT gap:** built in on every preset **except Base, World Chain, and Sei** (USDC only there).
44
+ - **Decimals:** on **BNB Chain**, Binance-Peg USDC/USDT are **18 decimals**, not 6 (the SDK handles it; don't hardcode 6).
45
+ - **USDT branding:** on **Arbitrum, Polygon, and Unichain** the canonical Tether is the omnichain **USD₮0 / USDT0** (LayerZero), and on **Celo** it's native **USD₮** — all genuine, Tether-issued, 6-decimal USDT at the addresses shipped (verified live on-chain by symbol/decimals/supply). You still ask for it as `token: 'USDT'`; only the on-chain `symbol()` string differs from the plain `USDT` your wallet may show elsewhere.
46
+ - **Receiver setup:** none — any `0x…` address receives ERC-20 or native immediately.
47
+ - **Any other EVM chain:** pass a viem `Chain` or `{ id, rpcUrl }` + `token: { address, decimals }`.
48
+
49
+ ### Solana
50
+ - **Pay in:** `'native'` (SOL), `'USDC'`, `'USDT'`, or `{ mint, decimals }`.
51
+ - **Receiver setup:** none — the payer's transaction idempotently creates the recipient's token account and pays its ~0.00204 SOL rent. **Pass the recipient's wallet address as `payTo`, not a token-account address.**
52
+ - **Payer:** needs SOL for gas + a funded source token account for the SPL token.
53
+
54
+ ### Sui
55
+ - **Pay in:** `'native'` (SUI), `'USDC'`, or `{ coinType, decimals }`. **No built-in USDT** (only Wormhole-bridged exists — supply it as a custom coin if needed).
56
+ - **Receiver setup:** none — any `0x…` (32-byte) Sui address receives immediately.
57
+ - **Payer:** needs SUI for gas even when paying USDC, and must already hold a coin object of the asset.
58
+
59
+ ---
60
+
61
+ ## ⚠️ Chains with caveats — read before shipping
62
+
63
+ ### NEAR — native is zero-setup; tokens need `storage_deposit`
64
+ - **Native NEAR works and is the easy path.** `token: 'native'` pays in NEAR (24dp) via
65
+ digest-binding (like EVM/Solana/Sui) — **no `storage_deposit`, no receiver setup**, and a
66
+ transfer even **creates a fresh implicit recipient**. Use it when price volatility is fine
67
+ and you want zero setup. *(NEAR is the volatile gas coin; for stable pricing pay in a token.)*
68
+ - **Tokens (USDC/USDT/custom NEP-141) need `storage_deposit` (NEP-145).** Before an account
69
+ can *receive* a token, it must be storage-registered on **that exact token contract** — a
70
+ one-time ~0.00125 NEAR call, **per account per token** (else the payer's `ft_transfer`
71
+ panics). Both the **merchant (`payTo`)** and the **payer** must be registered on the token.
72
+ Pay in a token via `'USDC'`, `'USDT'`, or a custom `{ contractId, decimals }`.
73
+ - **Wallet:** `{ accountId, privateKey }` — NEAR needs *both* an account id and an `ed25519:…` secret key (not just a private key).
74
+ - **Implicit accounts** (64-hex) don't exist until funded with NEAR — fund the account first (a native payment to one *creates* it).
75
+ - **Built-in USDC is Circle's native contract** (`17208628…36133a1`), **not** the bridged `…factory.bridge.near` (USDC.e). Don't confuse them.
76
+ - **Do not route through NEAR Intents/solvers** — that re-introduces a third-party facilitator. PipRail uses plain transfers + local receipt verification on purpose.
77
+
78
+ ### TON — USD₮ (or native TON), and you need an API-keyed RPC
79
+ - **Built-in token is USD₮ only** — **native USDC does not exist on TON** (Circle doesn't issue it; `token: 'USDC'` throws). Pay in `'USDT'`, `'native'` (Toncoin), or a custom jetton `{ master, decimals }`.
80
+ - **An RPC API key is effectively required.** The default keyless toncenter endpoint is rate-limited (~1 req/s) and will stall `confirm()`/`verify()` (they poll + read archival history). Use a keyed, archival-capable endpoint and **put the key in the URL**:
81
+ ```ts
82
+ requirePayment({ chain: 'ton', token: 'USDT', amount: '0.05', payTo,
83
+ rpcUrl: 'https://toncenter.com/api/v2/jsonRPC?api_key=YOUR_KEY' })
84
+ ```
85
+ (Free keys: message **@tonapibot** on Telegram, or sign up at toncenter.com.)
86
+ - **Receiver setup:** none — the payer's attached gas (~0.05 TON, leftover refunded) auto-deploys the merchant's jetton wallet on first receipt. The payer needs Toncoin for gas even when paying USD₮.
87
+ - **Async settlement:** value crosses contracts, so a credit can take seconds to appear; the proof is a locator (`ton:<jetton-wallet>|<nonce>`), not a tx hash, and the nonce rides in the transfer comment to bind it.
88
+ - **Wallet:** a 24-word `{ mnemonic }` (or `{ keyPair }`), wallet version `v4` (default) or `v5r1` — must match the version your funded address was created with.
89
+
90
+ ### Tron — USD₮ (or native TRX), and gas is real money
91
+ - **Pay in:** `'USDT'` (built in), **`'native'` (TRX, digest-bound)**, or a custom TRC-20 `{ address, decimals }`. **No built-in USDC** (Circle discontinued native USDC on Tron). USD₮ is the default (TRX is volatile gas); native TRX is there for completeness — a plain TransferContract, verified by txid + recency + single-use.
92
+ - **Gas is expensive and paid in TRX.** A USD₮ transfer burns Energy (~30k unstaked ≈ several TRX). The payer must hold **TRX as well as USDT**. Use `client.estimateCost(url)` to budget payment + TRX gas. (Tip for tiny test sends: rent energy from a service like TronZap/feee.io for ~1–2 TRX instead of burning ~27.) A first **native** TRX payment to a brand-new recipient also pays Tron's ~1 TRX account-creation fee (sender side).
93
+ - **Finality is slow-ish:** verification waits for the tx to solidify (~19 blocks, ~57s); until then it reads as `tx_not_found` and is retried.
94
+ - **Wallet:** `{ privateKey }` (32-byte hex, same format as EVM); addresses are Base58 `T…`.
95
+
96
+ ### Stellar — the receiver needs a trustline + a funded account
97
+ - **Pay in:** `'native'` (XLM), `'USDC'`, `'EURC'`, or a custom `{ issuer, code, decimals }`.
98
+ - **Receiving an issued asset needs a one-time TRUSTLINE.** The merchant (`payTo`) must (1) **exist** on-chain (funded above the ~1 XLM base reserve) and (2) hold a **trustline** (`changeTrust`) for that exact `code+issuer` *before* it can receive. No trustline → the payment fails. Each trustline locks **+0.5 XLM** of reserve. The **payer** likewise needs its own trustline to hold/send the asset.
99
+ - **Accounts must exist:** this driver sends a payment, it does **not** create accounts — both ends must already be funded above reserve.
100
+ - **Reserves are locked, not spent** — recoverable.
101
+ - **Wallet:** `{ secret }` (an `S…` Ed25519 seed) or `{ keypair }`.
102
+
103
+ ### XRP Ledger — the receiver needs activation + a trustline
104
+ - **Pay in:** `'native'` (XRP), `'USDC'`, `'RLUSD'`, or a custom `{ issuer, currencyHex, decimals }`. **No built-in USDT** on XRPL.
105
+ - **Receiving an IOU needs activation + a TRUSTLINE.** The merchant (`payTo`) must be an **activated** account (holding the ~1 XRP base reserve) **and** hold a **trustline** to the issuer's currency before its first IOU payment — otherwise it fails. Native XRP needs no trustline. The payer must be activated + trustlined too.
106
+ - **Reserves locked, not spent** (~1 XRP base + an owner reserve per trustline) — recoverable.
107
+ - **RLUSD** requires a DestinationTag; the SDK sets a nonce-derived one automatically.
108
+ - **Wallet:** `{ seed }` (an `s…` family seed) or `{ wallet }`.
109
+
110
+ ---
111
+
112
+ ## Errors you'll see — and what they actually mean
113
+
114
+ A payment that "won't go through" is almost always a **chain requirement**, not an SDK bug.
115
+ PipRail maps every such case to a typed error (stable `.code`) with a plain-language fix, and
116
+ **echoes the raw chain code** in the message + keeps the original on `err.cause`. The two you'll
117
+ meet in practice:
118
+
119
+ **`INSUFFICIENT_FUNDS`** — the **payer** can't cover it → fund the payer (token, native gas, or
120
+ the chain's reserve).
121
+
122
+ **`RECIPIENT_NOT_READY`** — the **recipient** (`payTo`) isn't set up to receive on this chain yet.
123
+ Fix the *recipient*, not the payer:
124
+
125
+ | You see (raw → mapped) | Chain | What it means | Fix |
126
+ |---|---|---|---|
127
+ | `tecNO_DST_INSUF_XRP` / `tecNO_DST` | XRPL | the `payTo` account isn't activated (an XRPL account needs ≥1 XRP base reserve to exist) | send the recipient ≥1 XRP to activate it |
128
+ | `tecNO_LINE` / `tecPATH_DRY` | XRPL | recipient has no trustline for the IOU (USDC/RLUSD) | add the trustline on the recipient |
129
+ | `tecDST_TAG_NEEDED` | XRPL | recipient requires a DestinationTag (PipRail sets one automatically) | — |
130
+ | `op_no_destination` | Stellar | the `payTo` account doesn't exist | create it with ≥1 XLM (base reserve) |
131
+ | `op_no_trust` | Stellar | recipient has no trustline for the asset | add the trustline (+0.5 XLM reserve) |
132
+ | `… is not registered` | NEAR | recipient isn't `storage_deposit`-registered on the token | call `storage_deposit` once (~0.00125 NEAR) |
133
+
134
+ Everything else (EVM, Solana, Sui, Tron, native TON/NEAR) needs no recipient setup, so you'll
135
+ only ever see `INSUFFICIENT_FUNDS` there if the payer is short. Full taxonomy: **[ERRORS.md](./ERRORS.md)**.
136
+
137
+ ---
138
+
139
+ ## How the proof is bound (for the security-curious)
140
+
141
+ Every chain proves the *same* facts locally (succeeded · recent · moved ≥ amount of the
142
+ right asset to `payTo`), but binds the proof to your challenge differently:
143
+
144
+ - **Memo-bound** (the challenge nonce is written on-chain): **NEAR tokens** (ft_transfer
145
+ memo), **TON** (transfer comment), **Stellar** (`MEMO_HASH = sha256(nonce)`), **XRPL**
146
+ (Memo + a derived DestinationTag).
147
+ - **Digest-bound** (no on-chain nonce; the proof is the tx id, made single-use by the gate
148
+ + a recency window): **EVM**, **Solana**, **Sui**, **Tron**, and **native NEAR**. For
149
+ these, a persistent `isUsed`/`markUsed` store + a tight `maxTimeoutSeconds` are
150
+ load-bearing in multi-instance deployments (the default used-set is single-process).
151
+
152
+ (So NEAR uses *both*: its NEP-141 token path is memo-bound, while native NEAR is digest-bound.)
package/CHANGELOG.md CHANGED
@@ -4,6 +4,86 @@ All notable changes to `@piprail/sdk` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
5
5
  versions follow [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.1.0] — 2026-06-03
8
+
9
+ Found by the live-test campaign: **native NEAR + native TRX are now payment assets** (native
10
+ coin now works on all eight families), a native-TON verify fix, **double-pay-safe handling of a
11
+ flaky RPC after broadcast**, **per-chain `rpcUrl` in multi-chain accepts**, and a new per-chain
12
+ setup reference. Fully backward-compatible — the public API and every existing chain/token behave
13
+ exactly as before; the only behaviour change is that a post-broadcast confirmation timeout now
14
+ recovers (submits the proof) instead of throwing the proof away.
15
+
16
+ ### Added
17
+ - **Native NEAR (`token: 'native'`) is now supported.** Previously NEAR was NEP-141-only
18
+ (`token: 'native'` threw). Native NEAR now works via **digest-binding** — exactly like
19
+ EVM/Solana/Sui: a plain `Transfer`, verified by tx hash + a recency window + the gate's
20
+ single-use set (the NEP-141 path stays memo-bound, unchanged). The big win: native NEAR
21
+ needs **no `storage_deposit`** and a transfer even **creates a fresh implicit recipient** —
22
+ the zero-setup NEAR path. (NEAR is the volatile gas coin, so for stable pricing pay in
23
+ USDC/USDT; native is ideal for no-setup flows.) `decimals: 24`. Live-mainnet validated;
24
+ pay + verify unit tests added.
25
+ - **Native TRX (`token: 'native'`) is now supported.** Previously Tron was TRC-20-only
26
+ (`token: 'native'` threw). Native TRX now works via **digest-binding** — a plain
27
+ `TransferContract`, verified by txid + a recency window + the gate's single-use set
28
+ (the verifier reads the tx's TransferContract instead of a Transfer event log, and gates
29
+ finality on the solidity node). USD₮ stays the default (TRX is volatile gas); native is
30
+ there for completeness. A first native payment to a brand-new recipient also pays Tron's
31
+ ~1 TRX account-creation fee (sender side). `decimals: 6`. Live-mainnet validated; pay +
32
+ verify unit tests added. **With this, native coin is a valid payment asset on every one
33
+ of the eight families — no exceptions.** (Tron still has no native USDC — Circle
34
+ discontinued it — so USD₮ remains its only built-in stablecoin.)
35
+ - **New typed error `RecipientNotReadyError` (`code: 'RECIPIENT_NOT_READY'`)** — surfaced when a
36
+ payment can't be delivered because the **recipient** isn't set up to receive on that chain (a
37
+ chain *state* requirement, not the payer's balance), so it's never mistaken for an SDK bug or
38
+ for affordability. `send()` now maps the recipient-side chain signals to it with a plain-language
39
+ fix that **echoes the raw chain code** and preserves the original error on `.cause`:
40
+ XRPL `tecNO_DST*` (account not activated — needs ≥1 XRP base reserve) / `tecNO_LINE*` ·
41
+ `tecPATH_DRY` · `tecDST_TAG_NEEDED` (no trustline / tag); Stellar `op_no_destination` (account
42
+ doesn't exist) / `op_no_trust` (no trustline); NEAR `… is not registered` (needs `storage_deposit`).
43
+ Sender affordability still converges on `InsufficientFundsError` everywhere — the two are now
44
+ cleanly separable by `.code` (fund the payer vs. set up the recipient). Pay-path unit tests added
45
+ for Stellar/XRPL/NEAR; exported from the package root.
46
+ - **Per-chain `rpcUrl` in multi-chain `accept[]`.** Each accept option already resolved with its
47
+ own `rpcUrl` (falling back to the top-level) — now **documented and unit-tested**, so a
48
+ multi-chain merchant can pin a reliable endpoint per chain and one throttled public RPC can't
49
+ take down verification for the others. The `rpcUrl` stays server-side (never leaked into the challenge).
50
+
51
+ ### Hardened
52
+ - **A broadcast payment is never silently lost to a flaky RPC (double-pay prevention).** If the
53
+ transfer broadcasts but the client's own `confirm()` times out — the classic free-RPC failure
54
+ where the tx *lands* but the status poll 429s past the validity window — the client no longer
55
+ throws the proof away (which would orphan a real payment and invite a re-pay). It now emits a new
56
+ **`payment-unconfirmed`** event, submits the proof to the server (the on-chain authority) with
57
+ **more patient retries** (a floor of 6), and **never re-broadcasts**. If the server still can't
58
+ confirm, `MaxRetriesExceededError` / `PaymentTimeoutError` now carry **`.ref`** (the broadcast proof)
59
+ so a caller re-verifies instead of re-paying. The server side was already safe — a failed
60
+ verification read returns `tx_not_found` → 402 (locked), never a false `paid`, and releases the
61
+ replay claim so the same proof can be re-submitted once the RPC recovers. Found by the live-test
62
+ campaign (a Solana tx that finalized while the public RPC 429'd the read-back). Unit tests added
63
+ (`test/client-confirm-timeout.test.ts`); documented in README + `ERRORS.md` §4.1.
64
+
65
+ ### Fixed
66
+ - **Native TON (Toncoin) payments to a brand-new recipient now verify.** A native TON
67
+ transfer to an *uninitialized* `payTo` (a fresh wallet that has never deployed its
68
+ contract) credits the recipient, but TON marks that recipient's receiving transaction
69
+ `aborted` — there's no contract code to run the comment message. `verifyTon`'s
70
+ `txSucceeded()` compute-phase check read that as a revert and returned `tx_reverted`,
71
+ rejecting a payment the merchant had **actually received**. The check is now applied to
72
+ **jetton** credits only (a jetton credit must execute the recipient's jetton-wallet
73
+ contract); a **native** receipt is valid by message delivery itself — a non-bounced
74
+ internal message always credits its value, regardless of the recipient's compute phase.
75
+ USD₮ (jetton) verification is unchanged. Regression test added in `test/ton/verify.test.ts`.
76
+
77
+ ### Docs
78
+ - Added **[`CHAINS.md`](CHAINS.md)** — a per-chain setup & caveats reference: native-vs-token
79
+ support per chain, NEAR `storage_deposit`, TON's API-keyed RPC requirement, Stellar/XRPL
80
+ trustlines + reserves, Tron gas, the wallet shape per family, and how each proof binds.
81
+ Linked from the README, with the headline caveats also called out there and on piprail.com.
82
+ - **"Why did my payment fail?" docs** — README and `CHAINS.md` now spell out, per chain, what the
83
+ *recipient* must have to receive (activation / trustline / account / `storage_deposit`) and which
84
+ error (`INSUFFICIENT_FUNDS` vs `RECIPIENT_NOT_READY`) maps to which raw chain code + fix; `ERRORS.md`
85
+ documents the new code (§2) and the sender-vs-recipient split (§6.1).
86
+
7
87
  ## [1.0.0] — 2026-06-02
8
88
 
9
89
  The multi-chain rewrite and first stable release. **24 chains across 8 families**
@@ -156,5 +236,6 @@ straight into your wallet. The API is small and self-contained.
156
236
  to your wallet; PipRail never holds funds.
157
237
  - `viem ^2.21` is a peer dependency. Node 20+ or a modern browser.
158
238
 
239
+ [1.1.0]: https://www.npmjs.com/package/@piprail/sdk
159
240
  [1.0.0]: https://www.npmjs.com/package/@piprail/sdk
160
241
  [0.1.0]: https://www.npmjs.com/package/@piprail/sdk
package/ERRORS.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  This is the **single source of truth** for how `@piprail/sdk` reports errors. It is
4
4
  deliberately small and uniform: every module — the client, the server gate, the registry,
5
- and every chain driver (EVM, Solana, TON, Stellar, and any future family) — follows it
5
+ and every chain driver (all eight families: EVM, Solana, TON, Tron, NEAR, Sui, Stellar, XRPL,
6
+ and any future one) — follows it
6
7
  *exactly*, so a human developer, a merchant server, or an AI agent always gets a **typed,
7
8
  understandable** reason, never an opaque chain-library blob.
8
9
 
@@ -43,11 +44,12 @@ Base class [`PipRailError`](src/errors.ts) (abstract; `.name` = the subclass nam
43
44
  |---|---|---|---|
44
45
  | `WRONG_FAMILY` | `WrongFamilyError` | wallet / `payTo` / token given in another family's shape (or a malformed same-family shape) | every driver (`bindWallet`, `assertValidPayTo`, `resolveToken`) |
45
46
  | `UNKNOWN_TOKEN` | `UnknownTokenError` | a built-in token symbol the chain doesn't ship (e.g. `token: 'DOGE'`) | every driver (`resolveToken`) |
46
- | `INSUFFICIENT_FUNDS` | `InsufficientFundsError` | wallet can't cover the transfer (+ fees/reserve/trustline) | every driver (`send`) — see §6 |
47
+ | `INSUFFICIENT_FUNDS` | `InsufficientFundsError` | the **payer** can't cover the transfer (+ fees / reserve / its own trustline) | every driver (`send`) — see §6 |
48
+ | `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 |
47
49
  | `WRONG_CHAIN` | `WrongChainError` | a bring-your-own `walletClient` is on a different chain than configured | EVM wallet adapter; client pre-send guard |
48
50
  | `CONFIRMATION_TIMEOUT` | `ConfirmationTimeoutError` | broadcast OK but the tx didn't confirm within the driver's window (re-check the ref) | every driver (`confirm`) |
49
- | `PAYMENT_TIMEOUT` | `PaymentTimeoutError` | the **server** didn't respond within `retryTimeoutMs` *after* a confirmed payment | client |
50
- | `MAX_RETRIES_EXCEEDED` | `MaxRetriesExceededError` | server kept returning 402 after the proof confirmed — **message embeds the last server `error — detail`** | client |
51
+ | `PAYMENT_TIMEOUT` | `PaymentTimeoutError` | the **server** didn't respond within `retryTimeoutMs` *after* broadcast **carries `.ref`** | client |
52
+ | `MAX_RETRIES_EXCEEDED` | `MaxRetriesExceededError` | server kept returning 402 after broadcast — **message embeds the last server `error — detail`, and carries `.ref`** | client |
51
53
  | `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 |
52
54
  | `INVALID_ENVELOPE` | `InvalidEnvelopeError` | a 402 had no parseable x402 challenge | client |
53
55
  | `NO_COMPATIBLE_ACCEPT` | `NoCompatibleAcceptError` | the challenge offered no `accepts[]` entry for the client's network | client |
@@ -109,6 +111,31 @@ abort a payment.
109
111
 
110
112
  ---
111
113
 
114
+ ## 4.1. A broadcast proof is never discarded (no false-positive, no double-pay)
115
+
116
+ Once `send()` returns, the transaction is **on-chain** and funds may have moved. Two design
117
+ rules make a flaky RPC safe in both directions:
118
+
119
+ - **Verify fails closed (server).** If the gate's `verify()` RPC read fails, it returns
120
+ `tx_not_found` → the gate replies **402 (locked)**, *never* `paid`. An RPC outage can never
121
+ trick a merchant into unlocking without a real, on-chain-confirmed payment. And the gate
122
+ **releases the replay claim** when verification fails, so the payer can re-submit the *same*
123
+ proof once the RPC recovers — the proof is not burned.
124
+ - **Confirm-timeout keeps the proof (client).** If the broadcast succeeds but the client's own
125
+ `confirm()` times out (a throttled RPC that 429s its status polls past the validity window
126
+ while the tx in fact lands), the client does **not** throw it away. It emits
127
+ `payment-unconfirmed` and submits the proof to the server anyway — deferring to the server's
128
+ on-chain verify (the authority) with **more patient retries** — and it **never re-broadcasts**.
129
+ If the server ultimately can't confirm, the client throws `MaxRetriesExceededError` /
130
+ `PaymentTimeoutError` carrying **`.ref`** (the broadcast proof).
131
+
132
+ > **The recovery rule for agents:** on `MAX_RETRIES_EXCEEDED` / `PAYMENT_TIMEOUT`, read `.ref`
133
+ > and **re-verify or re-submit that proof — never re-pay.** A fresh payment would double-spend.
134
+ > The same proof stays redeemable until the server's `maxTimeoutSeconds` recency window elapses
135
+ > (default 600s).
136
+
137
+ ---
138
+
112
139
  ## 5. The driver error contract (follow this verbatim)
113
140
 
114
141
  Every `PaymentDriver` / `ResolvedNetwork` method has a fixed error behaviour:
@@ -119,7 +146,7 @@ Every `PaymentDriver` / `ResolvedNetwork` method has a fixed error behaviour:
119
146
  | `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`. |
120
147
  | `assertValidPayTo(payTo)` | a non-family address → `WrongFamilyError`. |
121
148
  | `bindWallet(wallet)` | a foreign / unusable wallet shape → `WrongFamilyError`. |
122
- | `send(wallet, accept)` | wrap the broadcast; map affordability → `InsufficientFundsError` (§6); **rethrow everything else unchanged** (never swallow). |
149
+ | `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. |
123
150
  | `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. |
124
151
  | `confirm(ref, n)` | broadcast-but-not-confirmed / timeout → `ConfirmationTimeoutError`. |
125
152
 
@@ -140,6 +167,29 @@ is per-chain, because each library exposes a different signal:
140
167
 
141
168
  Either way the caller sees one `InsufficientFundsError` with `.code === 'INSUFFICIENT_FUNDS'`.
142
169
 
170
+ ### 6.1. Sender vs recipient: `INSUFFICIENT_FUNDS` vs `RECIPIENT_NOT_READY`
171
+
172
+ Many chains require the **recipient** to be provisioned before it can receive — a chain
173
+ *state* rule, not the payer's balance. These must NOT masquerade as affordability, because
174
+ the fix is the opposite (set up the *recipient*, not fund the *payer*). So `send()` maps them
175
+ to **`RecipientNotReadyError`** (`RECIPIENT_NOT_READY`), distinct from `InsufficientFundsError`:
176
+
177
+ | Chain | Raw signal | → mapped to | Because the recipient needs… |
178
+ |---|---|---|---|
179
+ | **XRPL** | `tecNO_DST*` | `RecipientNotReadyError` | activation — an account must hold ≥1 XRP (base reserve) to exist |
180
+ | **XRPL** | `tecNO_LINE*`, `tecPATH_DRY`, `tecDST_TAG_NEEDED`, `tecNO_AUTH` | `RecipientNotReadyError` | a trustline for the IOU / a DestinationTag / authorization |
181
+ | **XRPL** | `tecUNFUNDED*`, `terINSUF*`, `tecINSUFF*` | `InsufficientFundsError` | (sender side — fund the payer) |
182
+ | **Stellar** | `op_no_destination` | `RecipientNotReadyError` | the account to exist (created with ≥1 XLM reserve) |
183
+ | **Stellar** | `op_no_trust`, `op_line_full`, `op_not_authorized` | `RecipientNotReadyError` | a trustline for the asset (and authorization) |
184
+ | **Stellar** | `op_underfunded`, `op_src_no_trust`, `op_low_reserve` | `InsufficientFundsError` | (sender side) |
185
+ | **NEAR** | `… is not registered` (NEP-141 panic) | `RecipientNotReadyError` | `storage_deposit` (NEP-145, ~0.00125 NEAR) |
186
+
187
+ **Two rules for these messages:** (1) state the requirement and the fix in plain language so a
188
+ human or an AI agent can act on it, and **echo the raw chain code** in the message (e.g.
189
+ `(XRPL: tecNO_DST_INSUF_XRP)`); (2) preserve the untouched chain error on `.cause`. Clarity for
190
+ the reader, full raw detail for the debugger — both, always. Chains with no receive prerequisite
191
+ (EVM, Solana, Sui, Tron, native TON/NEAR) never throw `RecipientNotReadyError`.
192
+
143
193
  ---
144
194
 
145
195
  ## 7. Registry / loader pattern
@@ -174,6 +224,8 @@ Either way the caller sees one `InsufficientFundsError` with `.code === 'INSUFFI
174
224
  as other drivers for the same condition; every RPC read guarded → tx_not_found on failure.
175
225
  - [ ] send() wraps the broadcast and maps affordability → InsufficientFundsError
176
226
  (toInsufficientFundsError for message-only chains; structured detection + that backstop otherwise).
227
+ - [ ] send() maps any RECIPIENT-side setup requirement (activation / trustline / account / storage)
228
+ → RecipientNotReadyError, with a plain-language fix + the raw chain code echoed + { cause } (§6.1).
177
229
  - [ ] confirm() → ConfirmationTimeoutError on broadcast-but-not-confirmed.
178
230
  - [ ] resolveToken(): unknown symbol → UnknownTokenError; foreign token → rejectForeignToken(...).
179
231
  - [ ] bindWallet() / assertValidPayTo() → WrongFamilyError for the wrong shape, message names the right one.
package/README.md CHANGED
@@ -95,17 +95,19 @@ See [`examples/agent-tools.mjs`](../examples/agent-tools.mjs) for MCP / AI-SDK w
95
95
  ```ts
96
96
  requirePayment({
97
97
  accept: [
98
- { chain: 'base', token: 'USDC', amount: '0.05', payTo: '0xYourEvmWallet…' },
99
- { chain: 'tron', token: 'USDT', amount: '0.05', payTo: 'TYourTronWallet…' },
98
+ { chain: 'base', token: 'USDC', amount: '0.05', payTo: '0xYourEvmWallet…', rpcUrl: BASE_RPC },
99
+ { chain: 'tron', token: 'USDT', amount: '0.05', payTo: 'TYourTronWallet…', rpcUrl: TRON_RPC },
100
100
  { chain: 'xrpl', token: 'USDC', amount: '0.05', payTo: 'rYourXrplWallet…' },
101
- { chain: 'solana', token: 'USDC', amount: '0.05', payTo: 'YourSolWallet…' },
101
+ { chain: 'solana', token: 'USDC', amount: '0.05', payTo: 'YourSolWallet…', rpcUrl: SOL_RPC },
102
102
  ],
103
103
  })
104
104
  ```
105
105
 
106
+ Each option takes its **own optional `rpcUrl`** (falling back to the top-level `rpcUrl` when omitted), so a multi-chain merchant pins a reliable endpoint **per chain** — one throttled public RPC can't take down verification for the others. (The `rpcUrl` is used server-side only; it's never leaked into the challenge.) **In production, set it on every chain** — public RPCs are rate-limited.
107
+
106
108
  How the multi-chain case is handled, end-to-end:
107
109
 
108
- - **Gate:** each option resolves through its own driver (its `payTo` is validated and its token resolved) and is listed in the challenge's `accepts[]`, sharing one nonce. `payTo` falls back to the top-level `payTo` when omitted — but address shapes differ per family, so give a per-option `payTo` for each non-EVM chain.
110
+ - **Gate:** each option resolves through its own driver with its own `rpcUrl` (its `payTo` is validated and its token resolved) and is listed in the challenge's `accepts[]`, sharing one nonce. `payTo` falls back to the top-level `payTo` when omitted — but address shapes differ per family, so give a per-option `payTo` for each non-EVM chain.
109
111
  - **Payer:** a `PipRailClient` is bound to **one** chain (its `chain` + wallet). It picks the offered accept whose network it supports **and** its `policy` allows, pays that one, and ignores the rest. `quote(url)` and `estimateCost(url)` price/estimate **that** chosen chain — so to compare cost across chains, point one client per chain at the same URL and compare their `estimateCost` results.
110
112
  - **Verify:** the gate selects the matching requirement by **network + asset** and re-derives every checked field from **its own** trusted spec — a forged `accepted` echo can't redirect it (a wrong asset/network simply doesn't match). The same proof can't be redeemed twice.
111
113
 
@@ -131,7 +133,7 @@ requirePayment({ chain: 'ton', token: 'native', amount: '1', payTo }) /
131
133
  requirePayment({ chain: 'xrpl', token: 'native', amount: '1', payTo }) // XRP
132
134
  ```
133
135
 
134
- **Native or stablecoin — your choice, on most chains.** Every gate accepts the chain's native coin (ETH, BNB, POL, AVAX, SOL, TON, XLM, XRP, SUI, …) just as readily as a stablecoin — set `token: 'native'` and the SDK fills in the right decimals (18 on EVM, 9 on Solana/TON/Sui, 7 on Stellar, 6 on XRPL). Verification, replay protection, and self-custody are identical to the stablecoin path. (**Two exceptions token-only chains:** **Tron** is TRC-20-only and **NEAR** is NEP-141-only; both ship USDC/USDT but their native coin isn't a payment asset a Tron/NEAR token transfer is what binds + verifies.)
136
+ **Native or stablecoin — your choice, on every chain.** Every gate accepts the chain's native coin (ETH, BNB, POL, AVAX, SOL, TON, XLM, XRP, SUI, NEAR, **TRX**, …) just as readily as a stablecoin — set `token: 'native'` and the SDK fills in the right decimals (18 on EVM, 9 on Solana/TON/Sui, 7 on Stellar, 6 on XRPL/Tron, 24 on NEAR). Verification, replay protection, and self-custody are identical to the stablecoin path — across **all eight families, no exceptions**. (On **NEAR**, native is the zero-setup path no `storage_deposit` — while the NEP-141 token path needs registration; see the NEAR note. On **Tron**, USD₮ is the default since TRX is volatile gas, but native TRX works too.)
135
137
 
136
138
  `token` is **required** — every gate states exactly what it accepts, so there's never any doubt whether a route takes USDC, USDT, or the native coin. Name a built-in symbol (`'USDC'`, `'USDT'`), use `'native'` for the chain's own coin (ETH, BNB, SOL, TON, XLM, …), or pass a custom token by address. The symbol is all you write — the SDK fills in the contract + decimals.
137
139
 
@@ -168,12 +170,44 @@ Every token address below was verified on-chain (symbol + decimals) before shipp
168
170
 
169
171
  **TON note:** native **USDC does not exist on TON** (Circle doesn't issue it there) — so it's intentionally absent. USD₮ (Tether) is native and built in; for USDe / bridged tokens pass a custom jetton (below).
170
172
 
171
- **Tron note:** native **USDC doesn't exist on Tron either** (Circle discontinued it; the only USDC there is a third-party bridge) — so it's intentionally absent. USD₮ (TRC-20) is native and built in. Tron is **TRC-20 only**: native TRX isn't a payment asset (pass USDT or a custom TRC-20).
173
+ **Tron note:** native **USDC doesn't exist on Tron** (Circle discontinued it; the only USDC there is a third-party bridge) — so it's intentionally absent. USD₮ (TRC-20) is native and built in, and is the default since TRX is volatile gas. **Native TRX is also supported** (`token: 'native'`, digest-bound) for completeness — or pass a custom TRC-20.
172
174
 
173
- **NEAR note:** ships **both native USDC + USDT** (Circle's native USDC `17208628…`, NOT the bridged `…factory.bridge.near`; Tether's native `usdt.tether-token.near`). NEAR is **NEP-141 only** — native NEAR isn't a payment asset (its transfer carries no memo to bind). A recipient must be **`storage_deposit`-registered** on the token once before it can receive (see the NEAR section).
175
+ **NEAR note:** **native NEAR works** (`token: 'native'`, 24dp) and is the **zero-setup** path — no `storage_deposit`, and a transfer even *creates* a fresh implicit recipient. Or pay in a token: ships **both native USDC + USDT** (Circle's native USDC `17208628…`, NOT the bridged `…factory.bridge.near`; Tether's native `usdt.tether-token.near`) but a NEP-141 recipient (and the payer) must be **`storage_deposit`-registered** on that token once before it can receive (see CHAINS.md). NEAR is the volatile gas coin, so for stable pricing pay in USDC/USDT; for no-setup flows, native NEAR is ideal.
174
176
 
175
177
  **Sui note:** **USDC only** — no native USDT on Sui (Wormhole-bridged only). Native SUI works with `token: 'native'`.
176
178
 
179
+ **Stellar / XRPL note:** to **receive** an issued asset (USDC/EURC on Stellar; USDC/RLUSD on XRPL) the recipient needs a one-time **trustline** for that asset, and the account must already exist / be activated (a small native reserve — **locked, not spent**). Native XLM/XRP need no trustline. The payer needs its own trustline too.
180
+
181
+ ### Using TON? Grab one free API key (≈30 seconds)
182
+
183
+ TON is the only chain with a one-time setup step — and it's tiny. TON's free public RPC
184
+ (toncenter) is **rate-limited**, so without your own key, payment confirmation stalls or
185
+ times out. The fix is exactly **one parameter**: a `rpcUrl` with a free key in the URL.
186
+
187
+ 1. **Get a free key** — message **[@tonapibot](https://t.me/tonapibot)** on Telegram (or sign
188
+ up at [toncenter.com](https://toncenter.com/)). ~30 seconds, no card, no KYC.
189
+ 2. **Drop it into `rpcUrl`** on the gate (and the client) — that's it:
190
+
191
+ ```ts
192
+ const TON_RPC = 'https://toncenter.com/api/v2/jsonRPC?api_key=YOUR_KEY' // ← your free key in the URL
193
+
194
+ // Take a TON payment — one extra field vs any other chain:
195
+ app.get('/report',
196
+ requirePayment({ chain: 'ton', token: 'USDT', amount: '0.05', payTo: 'UQ…', rpcUrl: TON_RPC }),
197
+ (_req, res) => res.json({ report: 'TOP SECRET' }),
198
+ )
199
+
200
+ // Pay on TON — same one extra field:
201
+ const client = new PipRailClient({ chain: 'ton', wallet: { mnemonic }, rpcUrl: TON_RPC })
202
+ ```
203
+
204
+ That's the **whole** TON setup. Everything else is automatic: USD₮ is built in (native USDC
205
+ doesn't exist on TON), native TON works too (`token: 'native'`), and the merchant needs no
206
+ setup — the payer's gas deploys its jetton wallet on first receipt. **Skip the key → rate
207
+ limits; add it → TON is as seamless as every other chain.**
208
+
209
+ > 📖 **Per-chain setup, caveats & wallet formats → [CHAINS.md](CHAINS.md).** Exactly what each chain needs *before* it can pay or receive — the NEAR `storage_deposit`, Stellar/XRPL trustlines, TON API key, Tron gas, which chains accept `native`, and the wallet shape per family. **Most chains need nothing; NEAR, TON, Stellar, XRPL and Tron have caveats — read them before shipping those.**
210
+
177
211
  If a chain you need doesn't ship the token you want, pass it by address (below). `token` is required on every gate — no silent default.
178
212
 
179
213
  ### Any other chain or token — no allowlist
@@ -262,7 +296,7 @@ requirePayment({ chain: 'tron', token: 'USDT', amount: '1', payTo: 'T…' })
262
296
  new PipRailClient({ wallet: { privateKey: process.env.TRON_KEY }, chain: 'tron' })
263
297
  ```
264
298
 
265
- Tron wallets are `{ privateKey }` (a 32-byte hex key — Tron uses secp256k1, like EVM). `payTo` is a Base58 `T…` address (an `0x…` address throws `WrongFamilyError`). **USD₮ (TRC-20) is built in; Tron is TRC-20 only** native USDC doesn't exist there, and native TRX isn't a payment asset (pass USDT or a custom `{ address, decimals }`). Verification is **digest-bound** (the proof is the txid): the merchant verifies the confirmed transfer on the **solidity node** (the finality gate) and the proof is single-use — so for multi-instance deployments use a persistent `isUsed`/`markUsed` store and keep `maxTimeoutSeconds` tight. The payer needs a little **TRX for energy/bandwidth** to send; receiving USDT needs no account setup.
299
+ Tron wallets are `{ privateKey }` (a 32-byte hex key — Tron uses secp256k1, like EVM). `payTo` is a Base58 `T…` address (an `0x…` address throws `WrongFamilyError`). **USD₮ (TRC-20) is built in, and native TRX is also supported** (`token: 'native'`, digest-bound) native USDC doesn't exist on Tron (pass a custom `{ address, decimals }` for other TRC-20s). Verification is **digest-bound** (the proof is the txid): the merchant verifies the confirmed transfer on the **solidity node** (the finality gate) and the proof is single-use — so for multi-instance deployments use a persistent `isUsed`/`markUsed` store and keep `maxTimeoutSeconds` tight. The payer needs a little **TRX for energy/bandwidth** to send; receiving USDT needs no account setup.
266
300
 
267
301
  ## Stellar
268
302
 
@@ -313,7 +347,7 @@ requirePayment({ chain: 'near', token: 'USDC', amount: '0.05', payTo: 'merchant.
313
347
  new PipRailClient({ wallet: { accountId: 'agent.near', privateKey: process.env.NEAR_KEY }, chain: 'near' })
314
348
  ```
315
349
 
316
- NEAR wallets are `{ accountId, privateKey }` (privateKey = an `ed25519:…` secret); `payTo` is a NEAR account id (`name.near` or a 64-hex implicit account). **Both USDC + USDT are native and built in** (Circle's `17208628…`, Tether's `usdt.tether-token.near`); NEAR is **NEP-141 only** native NEAR isn't a payment asset. The challenge nonce rides in the NEP-141 `ft_transfer` **`memo`** (Template A binding) and is verified by tx hash (NEAR has no account-history RPC): the proof is `<accountId>:<txHash>`, and verify only trusts an `ft_transfer` event emitted by the real token contract (provenance). **`storage_deposit` (real):** a recipient must be NEP-145-registered on the token once (~0.00125 NEAR) before it can receive, or `ft_transfer` panics — register `payTo` out of band. The payer needs a little **NEAR for gas** + the mandatory 1 yoctoNEAR per transfer. (Never route through NEAR Intents/solvers — that re-adds a facilitator; a plain `ft_transfer` is what we do.)
350
+ NEAR wallets are `{ accountId, privateKey }` (privateKey = an `ed25519:…` secret); `payTo` is a NEAR account id (`name.near` or a 64-hex implicit account). **Native NEAR is supported** (`token: 'native'`, 24dp) and is the **zero-setup** path — digest-bound (proof `<accountId>:<txHash>`, verified by tx hash + recency + single-use), needing **no `storage_deposit`**; a native transfer even *creates* a fresh implicit recipient. **Or pay in a token:** both USDC + USDT are native and built in (Circle's `17208628…`, Tether's `usdt.tether-token.near`) the NEP-141 path is memo-bound (the nonce rides in the `ft_transfer` **`memo`**, verified by tx hash; verify only trusts an `ft_transfer` event from the real token contract), but **`storage_deposit` is required:** a recipient (and the payer) must be NEP-145-registered on that token once (~0.00125 NEAR) before it can receive/hold it, or `ft_transfer` panics. The payer needs a little **NEAR for gas** either way. (Never route through NEAR Intents/solvers — that re-adds a facilitator; plain transfers are what we do.)
317
351
 
318
352
  ## Sui
319
353
 
@@ -427,9 +461,37 @@ Two layers, one contract. Worth knowing if you're extending the SDK or auditing
427
461
 
428
462
  Every failure is **typed and understandable** — never a raw chain-library blob. Two channels:
429
463
 
430
- - **Thrown** — a `PipRailError` subclass with a stable `.code` (`INSUFFICIENT_FUNDS`, `WRONG_FAMILY`, `UNKNOWN_TOKEN`, `CONFIRMATION_TIMEOUT`, `MAX_RETRIES_EXCEEDED`, `PAYMENT_DECLINED`, …). Catch with `err instanceof PipRailError` or branch on `err.code`. Affordability always surfaces as one `InsufficientFundsError`, on every chain. A `policy`/`onBeforePay` refusal is `PaymentDeclinedError`, thrown before any send.
464
+ - **Thrown** — a `PipRailError` subclass with a stable `.code` (`INSUFFICIENT_FUNDS`, `RECIPIENT_NOT_READY`, `WRONG_FAMILY`, `UNKNOWN_TOKEN`, `CONFIRMATION_TIMEOUT`, `MAX_RETRIES_EXCEEDED`, `PAYMENT_DECLINED`, …). Catch with `err instanceof PipRailError` or branch on `err.code`. Affordability always surfaces as one `InsufficientFundsError`, on every chain. A `policy`/`onBeforePay` refusal is `PaymentDeclinedError`, thrown before any send.
431
465
  - **Returned** — server-side `verify()` rejects a proof with a `VerifyErrorCode` (`amount_too_low`, `transfer_not_found`, `payment_expired`, `tx_reverted`, …). The gate emits a 402 body `{ x402Version: 2, status: 'invalid', error, detail }` (build it with `toInvalidBody`), and the client relays the reason — so a rejected agent learns *why* (`MaxRetriesExceededError: … amount_too_low — Paid 40000, required 500000`).
432
466
 
467
+ ### "Why did my payment fail?" — payer vs. recipient
468
+
469
+ A failed payment is almost always one of two things, and PipRail tells them apart so a human **or an AI agent** knows exactly what to fix — never an opaque `tecNO_DST_INSUF_XRP`:
470
+
471
+ - **`INSUFFICIENT_FUNDS`** → the **payer** can't cover it. Fund the payer (more token, native gas, or the chain's reserve).
472
+ - **`RECIPIENT_NOT_READY`** → the **recipient** isn't set up to receive *on this chain yet*. This is a **chain requirement, not an SDK bug** — most chains gate receiving behind some one-time state. Every such message says what's needed and the fix, **echoes the raw chain code** (e.g. `(XRPL: tecNO_DST_INSUF_XRP)`), and keeps the untouched chain error on `err.cause` for debugging.
473
+
474
+ **What each chain needs to *receive* (and who sets it up):**
475
+
476
+ | Chain | The recipient must… | Sender also needs |
477
+ |---|---|---|
478
+ | **EVM · Solana · Sui · Tron** | nothing (just be a valid address; Solana's token account is auto-created by the SDK) | native gas |
479
+ | **TON** | nothing for native; a jetton wallet auto-deploys on first receipt (sender pays the gas) | TON for gas |
480
+ | **NEAR** | nothing for native; for a token, be `storage_deposit`-registered on it (NEP-145, ~0.00125 NEAR, one-time) | NEAR for gas |
481
+ | **Stellar** | exist (created with ≥1 XLM base reserve); for USDC/EURC, hold a **trustline** (+0.5 XLM each) | base + trustline reserves |
482
+ | **XRP Ledger** | be **activated** — hold ≥1 XRP base reserve to exist; for USDC/RLUSD, a **trustline** | keep its own 1 XRP reserve |
483
+
484
+ > These are anti-spam "state rent" rules built into each ledger — e.g. an XRPL account can't receive a sub-1-XRP first payment because that payment must create the account at its ≥1 XRP base reserve. PipRail surfaces them as `RECIPIENT_NOT_READY` with the fix, so a payment that "can't go through" is self-explanatory. Per-chain specifics live in **[CHAINS.md](./CHAINS.md)**.
485
+
486
+ ### Flaky RPC? No false unlocks, no double-pays
487
+
488
+ Public RPCs are rate-limited, so reads sometimes fail *after* a transaction is already on-chain. PipRail is built so that never costs you money or a leaked unlock:
489
+
490
+ - **The merchant never unlocks without a real payment.** If the gate's verification read fails, it returns `tx_not_found` → **402 (locked)**, never `paid`. Verification *fails closed* — an RPC outage can't be exploited to get free access. And the gate **releases the replay claim** on failure, so the payer can re-submit the *same* proof once the RPC recovers (the proof isn't burned).
491
+ - **The payer never loses a broadcast payment.** If the transfer broadcasts but the client's own confirmation times out (a throttled RPC that lands the tx but 429s the status read), the client does **not** throw the proof away — it emits a `payment-unconfirmed` event, submits the proof to the server (the on-chain authority) with more patient retries, and **never re-broadcasts**. If it still can't confirm, it throws `MaxRetriesExceededError` / `PaymentTimeoutError` carrying **`.ref`**.
492
+
493
+ > **Agent recovery rule:** on `MAX_RETRIES_EXCEEDED` / `PAYMENT_TIMEOUT`, read `err.ref` and **re-verify or re-submit that proof — never re-pay** (a fresh payment double-spends). The proof stays redeemable until the gate's `maxTimeoutSeconds` window (default 600s). The real fix for repeated lag is a dedicated `rpcUrl` (per chain in multi-accept) instead of the public default.
494
+
433
495
  The full standard every module follows is **[ERRORS.md](./ERRORS.md)**.
434
496
 
435
497
  ## API
@@ -464,7 +526,7 @@ Provide **either** `chain` + `token` + `amount` (single) **or** a non-empty `acc
464
526
  | `onBeforePay` | — | `(quote) => boolean \| Promise<boolean>` — final approval per payment; `false`/throw declines |
465
527
  | `maxPaymentRetries` | `3` | Re-sends with proof after paying (absorbs RPC propagation lag) |
466
528
  | `retryTimeoutMs` | `30000` | Timeout for the retry leg after broadcast |
467
- | `onEvent` | — | `(event) => void` observability: `payment-required` · `payment-broadcast` · `payment-confirmed` · `payment-settled` · `payment-failed` |
529
+ | `onEvent` | — | `(event) => void` observability: `payment-required` · `payment-broadcast` · `payment-confirmed` · `payment-unconfirmed` (broadcast OK, local confirm timed out → deferring to server) · `payment-settled` · `payment-failed` |
468
530
 
469
531
  Methods: `fetch` · `get` · `post` (return the gated `Response` after settlement) · **`quote(url)`** (price without paying → `PipRailQuote \| null`) · **`estimateCost(url)`** (price **+** native-coin gas estimate → `PipRailCostQuote \| null`) · **`spent()`** (per-asset ledger snapshot).
470
532
 
@@ -492,6 +554,6 @@ Methods: `fetch` · `get` · `post` (return the gated `Response` after settlemen
492
554
  - Node 20+ or a modern browser.
493
555
  - `viem ^2.21` (peer dep). Solana: `@solana/web3.js`, `@solana/spl-token`, `bs58` (optional peers). TON: `@ton/ton`, `@ton/core`, `@ton/crypto` (optional peers). Stellar: `@stellar/stellar-sdk` (optional peer). XRPL: `xrpl` (optional peer). Tron: `tronweb` (optional peer). NEAR: `near-api-js` (optional peer). Sui: `@mysten/sui` (optional peer).
494
556
 
495
- ## License
557
+ ## License & trademark
496
558
 
497
- MIT — pure open source.
559
+ The code is **MIT**use it, fork it, ship it. **PipRail™**, the logo, and the `@piprail` npm scope are trademarks of the PipRail project: build on the code freely, but please don't call a fork "PipRail" or imply it's official. See [TRADEMARK.md](https://github.com/piprail/piprail/blob/main/TRADEMARK.md).
@@ -8,6 +8,9 @@ var PipRailError = class extends Error {
8
8
  var InsufficientFundsError = class extends PipRailError {
9
9
  code = "INSUFFICIENT_FUNDS";
10
10
  };
11
+ var RecipientNotReadyError = class extends PipRailError {
12
+ code = "RECIPIENT_NOT_READY";
13
+ };
11
14
  function toInsufficientFundsError(err) {
12
15
  const message = err instanceof Error ? err.message : String(err);
13
16
  if (/insufficient (funds|balance|lamports|fee)|not enough|exceeds (the )?balance|underfunded|low[_ ]?reserve|debit the account/i.test(
@@ -25,9 +28,21 @@ var WrongChainError = class extends PipRailError {
25
28
  };
26
29
  var PaymentTimeoutError = class extends PipRailError {
27
30
  code = "PAYMENT_TIMEOUT";
31
+ /** The already-broadcast proof ref — recover with it, don't re-pay. */
32
+ ref;
33
+ constructor(message, options) {
34
+ super(message, options);
35
+ this.ref = options?.ref;
36
+ }
28
37
  };
29
38
  var MaxRetriesExceededError = class extends PipRailError {
30
39
  code = "MAX_RETRIES_EXCEEDED";
40
+ /** The already-broadcast proof ref — recover with it, don't re-pay. */
41
+ ref;
42
+ constructor(message, options) {
43
+ super(message, options);
44
+ this.ref = options?.ref;
45
+ }
31
46
  };
32
47
  var PaymentDeclinedError = class extends PipRailError {
33
48
  code = "PAYMENT_DECLINED";
@@ -136,6 +151,7 @@ function nativeCost(opts) {
136
151
  export {
137
152
  PipRailError,
138
153
  InsufficientFundsError,
154
+ RecipientNotReadyError,
139
155
  toInsufficientFundsError,
140
156
  WrongChainError,
141
157
  PaymentTimeoutError,