@piprail/sdk 1.0.0 → 1.1.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/CHAINS.md +152 -0
- package/CHANGELOG.md +98 -0
- package/ERRORS.md +57 -5
- package/README.md +96 -13
- package/dist/{chunk-3TQJJ4SQ.js → chunk-DTIJYDG6.js} +16 -0
- package/dist/chunk-NK64H3RM.cjs +173 -0
- package/dist/index.cjs +77 -62
- package/dist/index.d.cts +75 -17
- package/dist/index.d.ts +75 -17
- package/dist/index.js +39 -24
- package/dist/{near-RVXGF7TW.js → near-RJUETWY3.js} +99 -12
- package/dist/{near-4P5XNMMB.cjs → near-VZ6XGVNJ.cjs} +116 -29
- package/dist/{solana-F7H4YDW5.cjs → solana-CRLWAM7C.cjs} +14 -14
- package/dist/{solana-7PZG3CDO.js → solana-USZHRZFN.js} +1 -1
- package/dist/{stellar-PAZ352JL.js → stellar-JZBVCLNV.js} +19 -4
- package/dist/{stellar-BPPQTLNI.cjs → stellar-LIGJKRRK.cjs} +36 -21
- package/dist/{sui-XV4YYSGV.cjs → sui-JLVWFDOS.cjs} +17 -17
- package/dist/{sui-6N4ZPAGD.js → sui-UBDATSQV.js} +1 -1
- package/dist/{ton-EFZKQAAK.js → ton-2N74GKNB.js} +2 -2
- package/dist/{ton-E5RLUPD2.cjs → ton-OVSQZ4OM.cjs} +15 -15
- package/dist/{tron-243DT6PF.js → tron-N3EAAKU7.js} +123 -5
- package/dist/{tron-3UDH7KGF.cjs → tron-V3A6L3X3.cjs} +148 -30
- package/dist/{xrpl-6NRFT5CA.cjs → xrpl-QECPQCFS.cjs} +42 -26
- package/dist/{xrpl-7GWXDAVZ.js → xrpl-RTT3UOLX.js} +25 -9
- package/package.json +5 -1
- package/dist/chunk-WQWNPAYQ.cjs +0 -157
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,103 @@ 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.1] — 2026-06-03
|
|
8
|
+
|
|
9
|
+
Docs + examples only — **no code change**; the API and every chain behave exactly as 1.1.0.
|
|
10
|
+
|
|
11
|
+
### Docs
|
|
12
|
+
- **"In the browser — no build, no npm" guide** in the README. `@piprail/sdk` is browser-clean
|
|
13
|
+
and runs from any npm-mirroring CDN (`esm.sh` / `jsDelivr`), so a plain HTML page can take or
|
|
14
|
+
make payments with no bundler — the CDN resolves `viem` and any lazily-imported chain lib.
|
|
15
|
+
Verified end-to-end (gate + client, Node + browser, plus a real on-chain payment made **from a
|
|
16
|
+
browser**). Includes the injected-wallet pattern and a loud "never ship a raw key in client-side
|
|
17
|
+
HTML" warning.
|
|
18
|
+
|
|
19
|
+
### Examples
|
|
20
|
+
- **New `examples/browser/`** — a single self-contained HTML file that loads the SDK from a CDN and
|
|
21
|
+
runs a live in-browser x402 demo (build a real `402` challenge, quote it), no build step. A hosted,
|
|
22
|
+
interactive version of the same demo is live at https://piprail.com/demo.
|
|
23
|
+
|
|
24
|
+
## [1.1.0] — 2026-06-03
|
|
25
|
+
|
|
26
|
+
Found by the live-test campaign: **native NEAR + native TRX are now payment assets** (native
|
|
27
|
+
coin now works on all eight families), a native-TON verify fix, **double-pay-safe handling of a
|
|
28
|
+
flaky RPC after broadcast**, **per-chain `rpcUrl` in multi-chain accepts**, and a new per-chain
|
|
29
|
+
setup reference. Fully backward-compatible — the public API and every existing chain/token behave
|
|
30
|
+
exactly as before; the only behaviour change is that a post-broadcast confirmation timeout now
|
|
31
|
+
recovers (submits the proof) instead of throwing the proof away.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
- **Native NEAR (`token: 'native'`) is now supported.** Previously NEAR was NEP-141-only
|
|
35
|
+
(`token: 'native'` threw). Native NEAR now works via **digest-binding** — exactly like
|
|
36
|
+
EVM/Solana/Sui: a plain `Transfer`, verified by tx hash + a recency window + the gate's
|
|
37
|
+
single-use set (the NEP-141 path stays memo-bound, unchanged). The big win: native NEAR
|
|
38
|
+
needs **no `storage_deposit`** and a transfer even **creates a fresh implicit recipient** —
|
|
39
|
+
the zero-setup NEAR path. (NEAR is the volatile gas coin, so for stable pricing pay in
|
|
40
|
+
USDC/USDT; native is ideal for no-setup flows.) `decimals: 24`. Live-mainnet validated;
|
|
41
|
+
pay + verify unit tests added.
|
|
42
|
+
- **Native TRX (`token: 'native'`) is now supported.** Previously Tron was TRC-20-only
|
|
43
|
+
(`token: 'native'` threw). Native TRX now works via **digest-binding** — a plain
|
|
44
|
+
`TransferContract`, verified by txid + a recency window + the gate's single-use set
|
|
45
|
+
(the verifier reads the tx's TransferContract instead of a Transfer event log, and gates
|
|
46
|
+
finality on the solidity node). USD₮ stays the default (TRX is volatile gas); native is
|
|
47
|
+
there for completeness. A first native payment to a brand-new recipient also pays Tron's
|
|
48
|
+
~1 TRX account-creation fee (sender side). `decimals: 6`. Live-mainnet validated; pay +
|
|
49
|
+
verify unit tests added. **With this, native coin is a valid payment asset on every one
|
|
50
|
+
of the eight families — no exceptions.** (Tron still has no native USDC — Circle
|
|
51
|
+
discontinued it — so USD₮ remains its only built-in stablecoin.)
|
|
52
|
+
- **New typed error `RecipientNotReadyError` (`code: 'RECIPIENT_NOT_READY'`)** — surfaced when a
|
|
53
|
+
payment can't be delivered because the **recipient** isn't set up to receive on that chain (a
|
|
54
|
+
chain *state* requirement, not the payer's balance), so it's never mistaken for an SDK bug or
|
|
55
|
+
for affordability. `send()` now maps the recipient-side chain signals to it with a plain-language
|
|
56
|
+
fix that **echoes the raw chain code** and preserves the original error on `.cause`:
|
|
57
|
+
XRPL `tecNO_DST*` (account not activated — needs ≥1 XRP base reserve) / `tecNO_LINE*` ·
|
|
58
|
+
`tecPATH_DRY` · `tecDST_TAG_NEEDED` (no trustline / tag); Stellar `op_no_destination` (account
|
|
59
|
+
doesn't exist) / `op_no_trust` (no trustline); NEAR `… is not registered` (needs `storage_deposit`).
|
|
60
|
+
Sender affordability still converges on `InsufficientFundsError` everywhere — the two are now
|
|
61
|
+
cleanly separable by `.code` (fund the payer vs. set up the recipient). Pay-path unit tests added
|
|
62
|
+
for Stellar/XRPL/NEAR; exported from the package root.
|
|
63
|
+
- **Per-chain `rpcUrl` in multi-chain `accept[]`.** Each accept option already resolved with its
|
|
64
|
+
own `rpcUrl` (falling back to the top-level) — now **documented and unit-tested**, so a
|
|
65
|
+
multi-chain merchant can pin a reliable endpoint per chain and one throttled public RPC can't
|
|
66
|
+
take down verification for the others. The `rpcUrl` stays server-side (never leaked into the challenge).
|
|
67
|
+
|
|
68
|
+
### Hardened
|
|
69
|
+
- **A broadcast payment is never silently lost to a flaky RPC (double-pay prevention).** If the
|
|
70
|
+
transfer broadcasts but the client's own `confirm()` times out — the classic free-RPC failure
|
|
71
|
+
where the tx *lands* but the status poll 429s past the validity window — the client no longer
|
|
72
|
+
throws the proof away (which would orphan a real payment and invite a re-pay). It now emits a new
|
|
73
|
+
**`payment-unconfirmed`** event, submits the proof to the server (the on-chain authority) with
|
|
74
|
+
**more patient retries** (a floor of 6), and **never re-broadcasts**. If the server still can't
|
|
75
|
+
confirm, `MaxRetriesExceededError` / `PaymentTimeoutError` now carry **`.ref`** (the broadcast proof)
|
|
76
|
+
so a caller re-verifies instead of re-paying. The server side was already safe — a failed
|
|
77
|
+
verification read returns `tx_not_found` → 402 (locked), never a false `paid`, and releases the
|
|
78
|
+
replay claim so the same proof can be re-submitted once the RPC recovers. Found by the live-test
|
|
79
|
+
campaign (a Solana tx that finalized while the public RPC 429'd the read-back). Unit tests added
|
|
80
|
+
(`test/client-confirm-timeout.test.ts`); documented in README + `ERRORS.md` §4.1.
|
|
81
|
+
|
|
82
|
+
### Fixed
|
|
83
|
+
- **Native TON (Toncoin) payments to a brand-new recipient now verify.** A native TON
|
|
84
|
+
transfer to an *uninitialized* `payTo` (a fresh wallet that has never deployed its
|
|
85
|
+
contract) credits the recipient, but TON marks that recipient's receiving transaction
|
|
86
|
+
`aborted` — there's no contract code to run the comment message. `verifyTon`'s
|
|
87
|
+
`txSucceeded()` compute-phase check read that as a revert and returned `tx_reverted`,
|
|
88
|
+
rejecting a payment the merchant had **actually received**. The check is now applied to
|
|
89
|
+
**jetton** credits only (a jetton credit must execute the recipient's jetton-wallet
|
|
90
|
+
contract); a **native** receipt is valid by message delivery itself — a non-bounced
|
|
91
|
+
internal message always credits its value, regardless of the recipient's compute phase.
|
|
92
|
+
USD₮ (jetton) verification is unchanged. Regression test added in `test/ton/verify.test.ts`.
|
|
93
|
+
|
|
94
|
+
### Docs
|
|
95
|
+
- Added **[`CHAINS.md`](CHAINS.md)** — a per-chain setup & caveats reference: native-vs-token
|
|
96
|
+
support per chain, NEAR `storage_deposit`, TON's API-keyed RPC requirement, Stellar/XRPL
|
|
97
|
+
trustlines + reserves, Tron gas, the wallet shape per family, and how each proof binds.
|
|
98
|
+
Linked from the README, with the headline caveats also called out there and on piprail.com.
|
|
99
|
+
- **"Why did my payment fail?" docs** — README and `CHAINS.md` now spell out, per chain, what the
|
|
100
|
+
*recipient* must have to receive (activation / trustline / account / `storage_deposit`) and which
|
|
101
|
+
error (`INSUFFICIENT_FUNDS` vs `RECIPIENT_NOT_READY`) maps to which raw chain code + fix; `ERRORS.md`
|
|
102
|
+
documents the new code (§2) and the sender-vs-recipient split (§6.1).
|
|
103
|
+
|
|
7
104
|
## [1.0.0] — 2026-06-02
|
|
8
105
|
|
|
9
106
|
The multi-chain rewrite and first stable release. **24 chains across 8 families**
|
|
@@ -156,5 +253,6 @@ straight into your wallet. The API is small and self-contained.
|
|
|
156
253
|
to your wallet; PipRail never holds funds.
|
|
157
254
|
- `viem ^2.21` is a peer dependency. Node 20+ or a modern browser.
|
|
158
255
|
|
|
256
|
+
[1.1.0]: https://www.npmjs.com/package/@piprail/sdk
|
|
159
257
|
[1.0.0]: https://www.npmjs.com/package/@piprail/sdk
|
|
160
258
|
[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,
|
|
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` |
|
|
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*
|
|
50
|
-
| `MAX_RETRIES_EXCEEDED` | `MaxRetriesExceededError` | server kept returning 402 after
|
|
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
|
|
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
|
|
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`)
|
|
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
|
|
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). **
|
|
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
|
|
|
@@ -411,6 +445,27 @@ export async function handler(req: Request): Promise<Response> {
|
|
|
411
445
|
|
|
412
446
|
Reuse one gate per route — its in-memory replay guard stops a proof being spent twice. Running multiple instances? Pass your own `isUsed` / `markUsed` (e.g. Redis `SET NX`).
|
|
413
447
|
|
|
448
|
+
## In the browser — no build, no npm
|
|
449
|
+
|
|
450
|
+
The SDK is browser-clean (no Node-only globals in the protocol layer), so a plain HTML page can take **or** make payments straight from a CDN — every npm-mirroring CDN serves it automatically:
|
|
451
|
+
|
|
452
|
+
```html
|
|
453
|
+
<script type="module">
|
|
454
|
+
import { PipRailClient } from 'https://esm.sh/@piprail/sdk' // or jsDelivr: .../npm/@piprail/sdk@1/+esm
|
|
455
|
+
// In a browser, sign with the visitor's wallet — never a raw key (page source is public):
|
|
456
|
+
import { createWalletClient, custom } from 'https://esm.sh/viem'
|
|
457
|
+
const walletClient = createWalletClient({ transport: custom(window.ethereum) })
|
|
458
|
+
|
|
459
|
+
const client = new PipRailClient({ chain: 'base', wallet: { walletClient } })
|
|
460
|
+
const res = await client.fetch('https://api.example.com/paid') // 402 → wallet signs → 200
|
|
461
|
+
</script>
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
- **Which chains run in the browser.** EVM (viem) works out of the box; **Solana, Sui, and NEAR** load their libs from the CDN too (an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) pins each to a browser-ESM build — see [`examples/browser/`](../examples/browser)). A few chains' libraries (**TON, Tron, XRPL, Stellar**) don't ship a clean browser ESM build yet, so use those **server-side** — the identical one line, on Node/Bun/Deno/Workers. The lazy import means a pure-EVM page never downloads any of them.
|
|
465
|
+
- **The merchant gate runs anywhere.** `createPaymentGate` needs only a `payTo` address — no key — so building challenges and verifying proofs works in the browser too (the typical *deployment* is still a server, since a browser can't receive inbound HTTP to gate).
|
|
466
|
+
- **Both halves verified on Node and in a real browser**, against the published package. Runnable showcase: [`examples/browser/`](../examples/browser) — a single HTML file with a live, in-browser 402 demo; or try it hosted at [piprail.com/demo](https://piprail.com/demo).
|
|
467
|
+
- **Keys:** raw `{ privateKey }` wallets belong only in a **server's** environment. In a browser, use an injected `walletClient` as above.
|
|
468
|
+
|
|
414
469
|
## Architecture (under the hood)
|
|
415
470
|
|
|
416
471
|
Two layers, one contract. Worth knowing if you're extending the SDK or auditing it.
|
|
@@ -427,9 +482,37 @@ Two layers, one contract. Worth knowing if you're extending the SDK or auditing
|
|
|
427
482
|
|
|
428
483
|
Every failure is **typed and understandable** — never a raw chain-library blob. Two channels:
|
|
429
484
|
|
|
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.
|
|
485
|
+
- **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
486
|
- **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
487
|
|
|
488
|
+
### "Why did my payment fail?" — payer vs. recipient
|
|
489
|
+
|
|
490
|
+
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`:
|
|
491
|
+
|
|
492
|
+
- **`INSUFFICIENT_FUNDS`** → the **payer** can't cover it. Fund the payer (more token, native gas, or the chain's reserve).
|
|
493
|
+
- **`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.
|
|
494
|
+
|
|
495
|
+
**What each chain needs to *receive* (and who sets it up):**
|
|
496
|
+
|
|
497
|
+
| Chain | The recipient must… | Sender also needs |
|
|
498
|
+
|---|---|---|
|
|
499
|
+
| **EVM · Solana · Sui · Tron** | nothing (just be a valid address; Solana's token account is auto-created by the SDK) | native gas |
|
|
500
|
+
| **TON** | nothing for native; a jetton wallet auto-deploys on first receipt (sender pays the gas) | TON for gas |
|
|
501
|
+
| **NEAR** | nothing for native; for a token, be `storage_deposit`-registered on it (NEP-145, ~0.00125 NEAR, one-time) | NEAR for gas |
|
|
502
|
+
| **Stellar** | exist (created with ≥1 XLM base reserve); for USDC/EURC, hold a **trustline** (+0.5 XLM each) | base + trustline reserves |
|
|
503
|
+
| **XRP Ledger** | be **activated** — hold ≥1 XRP base reserve to exist; for USDC/RLUSD, a **trustline** | keep its own 1 XRP reserve |
|
|
504
|
+
|
|
505
|
+
> 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)**.
|
|
506
|
+
|
|
507
|
+
### Flaky RPC? No false unlocks, no double-pays
|
|
508
|
+
|
|
509
|
+
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:
|
|
510
|
+
|
|
511
|
+
- **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).
|
|
512
|
+
- **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`**.
|
|
513
|
+
|
|
514
|
+
> **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.
|
|
515
|
+
|
|
433
516
|
The full standard every module follows is **[ERRORS.md](./ERRORS.md)**.
|
|
434
517
|
|
|
435
518
|
## API
|
|
@@ -464,7 +547,7 @@ Provide **either** `chain` + `token` + `amount` (single) **or** a non-empty `acc
|
|
|
464
547
|
| `onBeforePay` | — | `(quote) => boolean \| Promise<boolean>` — final approval per payment; `false`/throw declines |
|
|
465
548
|
| `maxPaymentRetries` | `3` | Re-sends with proof after paying (absorbs RPC propagation lag) |
|
|
466
549
|
| `retryTimeoutMs` | `30000` | Timeout for the retry leg after broadcast |
|
|
467
|
-
| `onEvent` | — | `(event) => void` observability: `payment-required` · `payment-broadcast` · `payment-confirmed` · `payment-settled` · `payment-failed` |
|
|
550
|
+
| `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
551
|
|
|
469
552
|
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
553
|
|
|
@@ -492,6 +575,6 @@ Methods: `fetch` · `get` · `post` (return the gated `Response` after settlemen
|
|
|
492
575
|
- Node 20+ or a modern browser.
|
|
493
576
|
- `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
577
|
|
|
495
|
-
## License
|
|
578
|
+
## License & trademark
|
|
496
579
|
|
|
497
|
-
MIT —
|
|
580
|
+
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,
|