@piprail/sdk 1.15.0 → 1.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piprail/sdk",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "description": "Accept x402 crypto payments across 29 chains — every major EVM chain plus Solana, TON, Tron, NEAR, Sui, Aptos, Algorand, Stellar & XRPL — in a couple of lines. No backend, no database, no fee; payments settle straight to your wallet.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -21,10 +21,6 @@
21
21
  "files": [
22
22
  "dist",
23
23
  "README.md",
24
- "CHAINS.md",
25
- "ERRORS.md",
26
- "STANDARDS.md",
27
- "DISCOVERY.md",
28
24
  "CHANGELOG.md",
29
25
  "LICENSE"
30
26
  ],
package/CHAINS.md DELETED
@@ -1,179 +0,0 @@
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**, **Tron**, and **Algorand** (USDC needs a one-time ASA opt-in) —
10
- read those sections before you ship them.
11
-
12
- ## At a glance
13
-
14
- | Chain(s) | Pay in native coin? | Built-in stablecoins | Receiver needs setup? | Wallet input |
15
- |---|:--:|---|---|---|
16
- | **EVM** (Ethereum, Base, Arbitrum, Optimism, Polygon, BNB, Avalanche, Mantle, Sonic, Linea, Scroll, Celo, zkSync, Unichain, World Chain, Sei, Injective, HyperEVM, Monad, Kaia, + any EVM chain) | ✅ ETH/BNB/POL/… | USDC (all **except Kaia**) · USDT (all **except Base, World Chain, Sei, HyperEVM, Monad**) · **EURC** (Ethereum, Base, Avalanche) | No | `{ privateKey }` |
17
- | **Solana** | ✅ SOL | USDC · USDT | No (payer creates the recipient's token account) | `{ secretKey }` |
18
- | **Sui** | ✅ SUI | USDC (no USDT) | No | `{ privateKey }` (`suiprivkey1…`) |
19
- | **Aptos** | ✅ APT | USDC · USDT | No (primary FA store auto-creates) | `{ privateKey }` (`ed25519-priv-0x…`) |
20
- | **Algorand** | ✅ ALGO | **USDC only** (Tether deprecated USDT) | USDC: ⚠️ **ASA opt-in** · **native ALGO: none** | `{ mnemonic }` (25 words) |
21
- | **Stellar** | ✅ XLM | USDC · EURC | ⚠️ **Yes — trustline + funded account** | `{ secret }` (`S…`) |
22
- | **XRP Ledger** | ✅ XRP | USDC · RLUSD (no USDT) | ⚠️ **Yes — trustline + activated account** | `{ seed }` (`s…`) |
23
- | **TON** | ✅ TON | **USD₮ only** (no USDC) | No (payer's gas auto-deploys the jetton wallet) | `{ mnemonic }` (24 words) |
24
- | **Tron** | ✅ TRX | **USD₮ only** (no USDC) | No | `{ privateKey }` |
25
- | **NEAR** | ✅ NEAR | USDC · USDT | tokens: ⚠️ `storage_deposit` · **native NEAR: none** | `{ accountId, privateKey }` |
26
-
27
- > **`token: 'native'`** (paying in the chain's own coin) is accepted on **every family** —
28
- > EVM, Solana, Sui, Aptos, Algorand, Stellar, XRPL, TON, NEAR, **and Tron** (native TRX,
29
- > digest-bound). No exceptions. On NEAR, native is the **zero-setup** path: no `storage_deposit`,
30
- > and a transfer even creates a fresh recipient (the NEP-141 token path still needs
31
- > `storage_deposit`).
32
- >
33
- > **Custom tokens** work everywhere with no allowlist: EVM `{ address, decimals }` ·
34
- > Solana `{ mint, decimals }` · Sui `{ coinType, decimals }` · Aptos `{ metadata, decimals }` ·
35
- > Algorand `{ assetId, decimals }` · TON `{ master, decimals }` ·
36
- > Tron `{ address, decimals }` · NEAR `{ contractId, decimals }` · Stellar
37
- > `{ issuer, code, decimals }` · XRPL `{ issuer, currencyHex, decimals }`.
38
-
39
- **Universal:** the public default RPC on every chain is rate-limited — **pass your own
40
- `rpcUrl`** in production (there's no separate API-key field; fold any key into the URL).
41
-
42
- ---
43
-
44
- ## Chains with no caveats
45
-
46
- ### EVM — Ethereum, Base, Arbitrum, Optimism, Polygon, BNB, Avalanche, …
47
- - **Pay in:** native coin (`'native'`), `'USDC'`, `'USDT'`, `'EURC'` (where issued), or a custom `{ address, decimals }`.
48
- - **USDT gap:** built in on every preset **except Base, World Chain, Sei, HyperEVM, and Monad** (USDC only there). **Kaia** is the inverse — **USD₮ only** (no Circle-native USDC on Kaia).
49
- - **EURC:** Circle's euro stablecoin is built in on **Ethereum, Base, and Avalanche** (EIP-3009, 6-dp, addresses verified on-chain). Its EIP-712 domain name differs per deployment (`"Euro Coin"` on Ethereum/Avalanche, `"EURC"` on Base) — the SDK reads it on-chain, so `exact` payments are correct everywhere. Like USDC, it's `exact`-payable.
50
- - **Decimals:** on **BNB Chain**, Binance-Peg USDC/USDT are **18 decimals**, not 6 (the SDK handles it; don't hardcode 6).
51
- - **Stablecoin provenance — issuer-native vs bridged (every shipped address verified on-chain 2026-06-08, incl. bridge markers).** Every address is the correct, canonical, 1:1-redeemable dollar token on its chain; what varies is *who issues it*. You request it as `'USDC'` / `'USDT'` either way — provenance matters only if you specifically require issuer-native settlement.
52
- - **USDC** is **Circle-native** on every preset **except** **BNB** (Binance-Peg, 18-dp), **Mantle** (OP canonical-bridge), and **Scroll** (Bridged-USDC-Standard) — the last two are backed 1:1 by Circle USDC on Ethereum but are **not** Circle-issued on that chain (absent from Circle's native-USDC list).
53
- - **USDT** is **Tether-native** on **Ethereum, Avalanche, Celo, Kaia** (EVM) and **Solana, Tron, TON, NEAR, Aptos** (non-EVM). Everywhere else it's bridged: **USDT0** (LayerZero omnichain, on-chain `symbol()` = `USD₮0`) on **Arbitrum, Polygon, Unichain**; a **canonical-bridge** token (chain-minted, backed by Tether's Ethereum USDT — not Tether-issued) on **Optimism, zkSync, Sonic, Linea, Injective, Mantle, Scroll**; and **Binance-Peg** (18-dp) on **BNB**. The on-chain `symbol()` may read `USDT`, `USD₮`, `USDt`, or `USD₮0`; all resolve via `token: 'USDT'`.
54
- - **Receiver setup:** none — any `0x…` address receives ERC-20 or native immediately.
55
- - **Any other EVM chain:** pass a viem `Chain` or `{ id, rpcUrl }` + `token: { address, decimals }`.
56
-
57
- ### Solana
58
- - **Pay in:** `'native'` (SOL), `'USDC'`, `'USDT'`, or `{ mint, decimals }`.
59
- - **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.**
60
- - **Payer:** needs SOL for gas + a funded source token account for the SPL token.
61
-
62
- ### Sui
63
- - **Pay in:** `'native'` (SUI), `'USDC'`, or `{ coinType, decimals }`. **No built-in USDT** (only Wormhole-bridged exists — supply it as a custom coin if needed).
64
- - **Receiver setup:** none — any `0x…` (32-byte) Sui address receives immediately.
65
- - **Payer:** needs SUI for gas even when paying USDC, and must already hold a coin object of the asset.
66
-
67
- ---
68
-
69
- ## ⚠️ Chains with caveats — read before shipping
70
-
71
- ### NEAR — native is zero-setup; tokens need `storage_deposit`
72
- - **Native NEAR works and is the easy path.** `token: 'native'` pays in NEAR (24dp) via
73
- digest-binding (like EVM/Solana/Sui) — **no `storage_deposit`, no receiver setup**, and a
74
- transfer even **creates a fresh implicit recipient**. Use it when price volatility is fine
75
- and you want zero setup. *(NEAR is the volatile gas coin; for stable pricing pay in a token.)*
76
- - **Tokens (USDC/USDT/custom NEP-141) need `storage_deposit` (NEP-145).** Before an account
77
- can *receive* a token, it must be storage-registered on **that exact token contract** — a
78
- one-time ~0.00125 NEAR call, **per account per token** (else the payer's `ft_transfer`
79
- panics). Both the **merchant (`payTo`)** and the **payer** must be registered on the token.
80
- Pay in a token via `'USDC'`, `'USDT'`, or a custom `{ contractId, decimals }`.
81
- - **Wallet:** `{ accountId, privateKey }` — NEAR needs *both* an account id and an `ed25519:…` secret key (not just a private key).
82
- - **Implicit accounts** (64-hex) don't exist until funded with NEAR — fund the account first (a native payment to one *creates* it).
83
- - **Built-in USDC is Circle's native contract** (`17208628…36133a1`), **not** the bridged `…factory.bridge.near` (USDC.e). Don't confuse them.
84
- - **Do not route through NEAR Intents/solvers** — that re-introduces a third-party facilitator. PipRail uses plain transfers + local receipt verification on purpose.
85
-
86
- ### TON — USD₮ (or native TON), and you need an API-keyed RPC
87
- - **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 }`.
88
- - **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**:
89
- ```ts
90
- requirePayment({ chain: 'ton', token: 'USDT', amount: '0.05', payTo,
91
- rpcUrl: 'https://toncenter.com/api/v2/jsonRPC?api_key=YOUR_KEY' })
92
- ```
93
- (Free keys: message **@tonapibot** on Telegram, or sign up at toncenter.com.)
94
- - **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₮.
95
- - **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.
96
- - **Wallet:** a 24-word `{ mnemonic }` (or `{ keyPair }`), wallet version `v4` (default) or `v5r1` — must match the version your funded address was created with.
97
-
98
- ### Tron — USD₮ (or native TRX), and gas is real money
99
- - **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.
100
- - **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).
101
- - **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.
102
- - **Wallet:** `{ privateKey }` (32-byte hex, same format as EVM); addresses are Base58 `T…`.
103
-
104
- ### Algorand — USDC needs a one-time ASA opt-in (native ALGO doesn't)
105
- - **Pay in:** `'native'` (ALGO, the zero-setup path), `'USDC'`, or a custom ASA `{ assetId, decimals }`. **USDC only for the stablecoin** — Tether deprecated/froze USDT on Algorand (2025-09-01), so it's not built in (pass it as a custom ASA if you must).
106
- - **Receiving USDC needs an ASA opt-in.** Before an account can *receive* USDC (ASA `31566704`) — or any ASA — it must **opt into that asset** once: a 0-amount asset-transfer to itself, which raises its minimum balance by 0.1 ALGO (locked, recoverable). No opt-in → the payment fails and PipRail returns `RECIPIENT_NOT_READY`. **Native ALGO needs no opt-in.** The payer is implicitly opted-in if it already holds USDC. The one-time opt-in is plain `algosdk` (PipRail stays a payments SDK, not a wallet manager):
107
- ```ts
108
- import algosdk from 'algosdk'
109
- const algod = new algosdk.Algodv2('', 'https://mainnet-api.algonode.cloud', '')
110
- const sp = await algod.getTransactionParams().do()
111
- const optIn = algosdk.makeAssetTransferTxnWithSuggestedParamsFromObject({
112
- sender: account.addr, receiver: account.addr, amount: 0, assetIndex: 31566704, suggestedParams: sp,
113
- })
114
- await algod.sendRawTransaction(optIn.signTxn(account.sk)).do() // one-time, per account per ASA
115
- ```
116
- - **Fast + cheap:** ~3s single-step finality, flat 0.001 ALGO min fee. The challenge nonce rides in the transaction's **note field** (Template A), so the proof is bound to its challenge; verify reads the merchant account's inbound transfers via the indexer.
117
- - **x402:** Algorand's `exact` scheme is part of the official x402 standard, but the incumbent on-chain path uses a hosted **facilitator** — PipRail is the **backendless, no-facilitator** option (payer broadcasts, merchant verifies locally).
118
- - **Wallet:** `{ mnemonic }` (a 25-word Algorand recovery phrase) or `{ account }` (an algosdk `{ addr, sk }`).
119
- - **Endpoints:** `rpcUrl` overrides the **algod** endpoint (submit/params); the verify-side **indexer** uses the public AlgoNode default (override needs are rare; the public indexer is production-grade for the inbound-transfer read).
120
-
121
- ### Stellar — the receiver needs a trustline + a funded account
122
- - **Pay in:** `'native'` (XLM), `'USDC'`, `'EURC'`, or a custom `{ issuer, code, decimals }`.
123
- - **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.
124
- - **Accounts must exist:** this driver sends a payment, it does **not** create accounts — both ends must already be funded above reserve.
125
- - **Reserves are locked, not spent** — recoverable.
126
- - **Wallet:** `{ secret }` (an `S…` Ed25519 seed) or `{ keypair }`.
127
-
128
- ### XRP Ledger — the receiver needs activation + a trustline
129
- - **Pay in:** `'native'` (XRP), `'USDC'`, `'RLUSD'`, or a custom `{ issuer, currencyHex, decimals }`. **No built-in USDT** on XRPL.
130
- - **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.
131
- - **Reserves locked, not spent** (~1 XRP base + an owner reserve per trustline) — recoverable.
132
- - **RLUSD** requires a DestinationTag; the SDK sets a nonce-derived one automatically.
133
- - **Wallet:** `{ seed }` (an `s…` family seed) or `{ wallet }`.
134
-
135
- ---
136
-
137
- ## Errors you'll see — and what they actually mean
138
-
139
- A payment that "won't go through" is almost always a **chain requirement**, not an SDK bug.
140
- PipRail maps every such case to a typed error (stable `.code`) with a plain-language fix, and
141
- **echoes the raw chain code** in the message + keeps the original on `err.cause`. The two you'll
142
- meet in practice:
143
-
144
- **`INSUFFICIENT_FUNDS`** — the **payer** can't cover it → fund the payer (token, native gas, or
145
- the chain's reserve).
146
-
147
- **`RECIPIENT_NOT_READY`** — the **recipient** (`payTo`) isn't set up to receive on this chain yet.
148
- Fix the *recipient*, not the payer:
149
-
150
- | You see (raw → mapped) | Chain | What it means | Fix |
151
- |---|---|---|---|
152
- | `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 |
153
- | `tecNO_LINE` / `tecPATH_DRY` | XRPL | recipient has no trustline for the IOU (USDC/RLUSD) | add the trustline on the recipient |
154
- | `tecDST_TAG_NEEDED` | XRPL | recipient requires a DestinationTag (PipRail sets one automatically) | — |
155
- | `op_no_destination` | Stellar | the `payTo` account doesn't exist | create it with ≥1 XLM (base reserve) |
156
- | `op_no_trust` | Stellar | recipient has no trustline for the asset | add the trustline (+0.5 XLM reserve) |
157
- | `… is not registered` | NEAR | recipient isn't `storage_deposit`-registered on the token | call `storage_deposit` once (~0.00125 NEAR) |
158
- | `must optin` / `asset … missing from <payTo>` | Algorand | recipient hasn't opted into the USDC ASA | opt the recipient into the ASA once (0-amount self-transfer, +0.1 ALGO min balance) |
159
-
160
- Everything else (EVM, Solana, Sui, Tron, native TON/NEAR) needs no recipient setup, so you'll
161
- only ever see `INSUFFICIENT_FUNDS` there if the payer is short. Full taxonomy: **[ERRORS.md](./ERRORS.md)**.
162
-
163
- ---
164
-
165
- ## How the proof is bound (for the security-curious)
166
-
167
- Every chain proves the *same* facts locally (succeeded · recent · moved ≥ amount of the
168
- right asset to `payTo`), but binds the proof to your challenge differently:
169
-
170
- - **Memo-bound** (the challenge nonce is written on-chain): **NEAR tokens** (ft_transfer
171
- memo), **TON** (transfer comment), **Stellar** (`MEMO_HASH = sha256(nonce)`), **XRPL**
172
- (Memo + a derived DestinationTag), **Algorand** (the transaction's note field — native ALGO
173
- and USDC alike).
174
- - **Digest-bound** (no on-chain nonce; the proof is the tx id, made single-use by the gate
175
- + a recency window): **EVM**, **Solana**, **Sui**, **Aptos**, **Tron**, and **native NEAR**. For
176
- these, a persistent `isUsed`/`markUsed` store + a tight `maxTimeoutSeconds` are
177
- load-bearing in multi-instance deployments (the default used-set is single-process).
178
-
179
- (So NEAR uses *both*: its NEP-141 token path is memo-bound, while native NEAR is digest-bound.)
package/DISCOVERY.md DELETED
@@ -1,420 +0,0 @@
1
- # PipRail discovery — the complete reference
2
-
3
- How a PipRail user — a human merchant **or** an AI agent — becomes **discoverable**, and how an
4
- agent **finds** payable resources. This is the single source of truth for the discovery feature.
5
- Companion docs: [README.md](./README.md) (the full API), [STANDARDS.md](./STANDARDS.md) (how it's
6
- built), [ERRORS.md](./ERRORS.md) (the error model). Background research lives in
7
- `.claude/research/x402-discovery.md` (the "what is this") and `x402-discovery-integration.md` (the
8
- "exactly how").
9
-
10
- > **One line:** PipRail makes you discoverable by building on the **open** x402 indexes that already
11
- > exist (402 Index, the CDP Bazaar read API, x402scan) — **it hosts nothing of its own**: no
12
- > registry, no database, no backend, no fee. Every piece is opt-in; the pay path is untouched.
13
-
14
- > **⚠️ Status: EXPERIMENTAL.** Discovery integrates with **third-party** open indexes whose wire
15
- > shapes are a moving, unratified convention — treat this whole layer as experimental and expect to
16
- > re-verify the integration over time. The **read** path + the **402 Index register** flow are
17
- > live-verified (see the log in §10); **x402scan SIWX is not yet live-tested** — exercise it against
18
- > x402scan before relying on it. The pay path and the rest of the SDK are stable; only this layer
19
- > carries the experimental flag.
20
-
21
- ---
22
-
23
- ## 1. The problem (discovery is NOT part of x402)
24
-
25
- The x402 protocol answers exactly one question: *"how do I pay for THIS url?"* You hit a URL, get a
26
- `402` with a machine-readable challenge, pay on-chain, retry with proof, get `200`. It does **not**
27
- answer *"what payable URLs exist?"*
28
-
29
- So a fresh PipRail merchant is in a bind:
30
-
31
- - A **seller** adds `requirePayment()` to `https://api.acme.com/report`. It's now payable — but
32
- nobody knows the URL exists. A shop with no sign, on a street with no name.
33
- - A **buyer** (an AI agent with a budget-bound wallet) wants to *buy a weather feed under $0.01 on
34
- Base*. It has no phone book — it can only pay URLs a human already handed it.
35
-
36
- That missing phone book is **discovery**. It's a separate, optional layer built *around* x402.
37
-
38
- **Why PipRail doesn't host its own directory.** A registry/database is a backend we'd run forever —
39
- a bill that never reaches $0, uptime, an open write endpoint that invites spam + a moderation queue.
40
- That would turn PipRail from *"a tool you `npm install`"* into *"a platform you sign up for"* — the
41
- exact thing the project is defined against. So instead we **consume and contribute to the open
42
- indexes that already exist**, and host nothing.
43
-
44
- ---
45
-
46
- ## 2. The open infrastructure we build on
47
-
48
- All three are external, open, and already running. PipRail reads from and writes to them; it operates
49
- none of them.
50
-
51
- | Index | Read (find) | Write (be listed) | Chains | PipRail role |
52
- |---|---|---|---|---|
53
- | **402 Index** (402index.io) | ✅ free, no auth | ✅ **`POST /register` — no auth, no signature, no payment** | any | **Primary register target** + a free read source. A superset (it also re-ingests the CDP Bazaar). |
54
- | **CDP Bazaar** (api.cdp.coinbase.com) | ✅ free, no key | ❌ listed only when the CDP **facilitator settles** your payment | any | **Read-only source.** PipRail uses no facilitator, so PipRail merchants don't auto-list here — discoverability comes from 402 Index / x402scan. |
55
- | **x402scan** (x402scan.com) | 💲 paid ($0.01–0.02, off by default) | ✅ **SIWX** (one wallet signature; facilitator-free) | **Base + Solana only** | **Secondary register target** (the strongest ownership model); a paid, opt-in read. |
56
-
57
- > The honest framing: the most prominent directory, the CDP Bazaar, is a *facilitator network
58
- > effect* — it lists you only when Coinbase's facilitator settles your payment, which PipRail never
59
- > uses. That's why discoverability for a backendless merchant flows through 402 Index (and x402scan),
60
- > not the Bazaar. PipRail can still freely **read** the Bazaar to help an agent find *other* people's
61
- > endpoints.
62
-
63
- ---
64
-
65
- ## 2.5 Works on EVERY chain (the guarantee)
66
-
67
- **No matter the chain — a built-in preset, a non-EVM family, or a custom `{ id, rpcUrl }` chain we
68
- don't ship — a PipRail user can be indexed and found, and an agent can discover them.** This is a
69
- hard guarantee, proven by the test suite (`test/discovery-e2e.test.ts` parametrizes every family +
70
- a custom chain) and by running it for real:
71
-
72
- - **Emit** is pure serialization — it works for any chain's rails, full stop.
73
- - **Register** defaults to **402 Index**, which needs no signature and has no chain allowlist, so it
74
- lists **every** chain. `payment_network` is optional metadata: it's the chain slug when you
75
- configured the client with one (`'base'`, `'tron'`, …), and omitted for a custom `{ id, rpcUrl }`
76
- chain (pass `network` explicitly if you want it). No chain is ever turned away.
77
- - **Discover** filters by delegating to the bound driver's own `supports()`, and — critically — a
78
- rail whose network it can't resolve to CAIP-2 is **kept, never silently hidden**. So discovery is
79
- never empty on a custom or unmapped chain; at worst it returns a re-checkable extra the agent
80
- confirms at quote time.
81
-
82
- The **one** chain-limited piece is the *optional* x402scan register target (Base/Solana only, its
83
- own limit). It's a bonus, never the path — 402 Index already covers everyone. So: **402 Index +
84
- emit + discover = universal discovery on every chain.**
85
-
86
- **Future chains.** A chain we add later inherits all of this for free — register and emit need no
87
- discovery change at all, and `discover()` already never hides an unmapped chain. The only
88
- discovery touch in the add-a-chain procedure is a one-line entry in `indexes.ts`'s `SLUG_TO_CAIP2`
89
- (slug → the family's exact `caip2`), which sharpens `'self'` filtering precision; it's on the
90
- `add-chain-integration` checklist. Omitting it degrades nothing that matters — the resource is
91
- still found.
92
-
93
- ---
94
-
95
- ## 3. The three moves
96
-
97
- Discovery is three opt-in capabilities. A merchant uses Emit + Register to **be found**; an agent
98
- uses Discover to **find**. Defaults are byte-identical to before — omit all three and nothing changes.
99
-
100
- ### 3.1 EMIT — turn a gate's config into a discovery file (pure, no I/O)
101
-
102
- A gate already knows its price/asset/chain/`payTo`. `gate.describe()` exposes that as static,
103
- **nonce-free** metadata (discovery metadata is long-lived; a live challenge mints a nonce, this does
104
- not). The three pure emitters turn it into the file formats crawlers read. The merchant serves the
105
- result as a **static file on their own origin** — the one wiring step, no backend.
106
-
107
- ```ts
108
- import { createPaymentGate, buildOpenApi, buildWellKnownX402, buildX402DnsTxt } from '@piprail/sdk'
109
-
110
- const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.05', payTo })
111
- const resource = await gate.describe('https://api.example.com/report')
112
- // → { url, description?, accepts: PaymentRail[] } (PaymentRail = scheme/network/asset/payTo/
113
- // amount/amountFormatted/decimals/symbol?/maxTimeoutSeconds)
114
-
115
- // (a) OpenAPI-first — the convention the live indexes parse. Serve at /openapi.json.
116
- const openapi = buildOpenApi({ origin: 'https://api.example.com', resources: [resource] })
117
-
118
- // (b) Legacy x402scan origin file. Serve at /.well-known/x402.
119
- const wellKnown = buildWellKnownX402({ origin: 'https://api.example.com', resources: [resource] })
120
-
121
- // (c) The experimental _x402 DNS pointer — paste into your zone.
122
- const dns = buildX402DnsTxt({ host: 'api.example.com', discoveryUrl: 'https://api.example.com/openapi.json' })
123
- // → { name: '_x402.api.example.com', type: 'TXT', value: 'v=x4021;url=https://api.example.com/openapi.json' }
124
- ```
125
-
126
- | Function | Output | Serve at |
127
- |---|---|---|
128
- | `buildOpenApi(input)` | a minimal valid **OpenAPI 3.1** doc — one path per resource pathname (resources sharing a pathname merge, keyed by HTTP method), `x-payment-info` per paid op, optional `x-agentcash-provenance.ownershipProofs` | `https://<origin>/openapi.json` (primary) |
129
- | `buildWellKnownX402(input)` | `{ version: 1, resources: [urls], ownershipProofs? }` | `https://<origin>/.well-known/x402` (legacy) |
130
- | `buildX402DnsTxt({ host, discoveryUrl, descriptor? })` | `{ name: '_x402.<host>', type: 'TXT', value: 'v=x4021;[descriptor=…;]url=…' }` | a DNS TXT record (experimental) |
131
-
132
- `ManifestInput` = `{ origin, resources, ownershipProofs?, title?, version?, attribution? }`. All three
133
- emitters are **pure** — no network, no chain library — so they're deterministic and trivially testable.
134
- They emit exactly the rails you pass; to be *usefully* listed on the open indexes, also offer a standard
135
- `exact` rail (see §6).
136
-
137
- **Spreading the word — three tasteful, honest channels (no spam, no rule-breaking).**
138
-
139
- 1. **`x-generator` stamp (default on, opt-out).** `buildOpenApi` marks the document root with
140
- `x-generator: "@piprail/sdk · https://piprail.com"` — a standard, unobtrusive "built with" mark
141
- (like Swagger/Hugo emit). It lives in the `/openapi.json` the merchant serves on their *own*
142
- origin — the very file the open indexes **crawl** — so the attribution rides along wherever a
143
- PipRail merchant is found. Metadata only; opt out with `attribution: false`.
144
- 2. **`User-Agent` on every index request (always on).** All reads/registers send
145
- `User-Agent: @piprail/sdk (+https://piprail.com)` — the standard bot-UA-with-contact-URL
146
- convention, so index operators see PipRail-driven traffic in their logs. It's a request *header*,
147
- so it can never affect an index's body validation (zero risk of breaking a register), and the
148
- browser keeps its own UA where it must. *(Live-verified: the server echoes it back.)*
149
- 3. **Opt-in `via` listing tag (default OFF).** `register(url, { attribution: true })` adds
150
- `via: '@piprail/sdk'` to the listing payload. **Off by default** — it's the *merchant's* listing
151
- on a third party, so we never tag it without being asked — and **best-effort** (an index may
152
- ignore an unknown field). *(Live-verified safe: 402 Index tolerates the field — a tagged register
153
- gets the exact same URL-probe response as an untagged one, never a field rejection.)*
154
-
155
- We do **not** hijack the listing's `provider` (that's the merchant's), and the always-on channels (1
156
- + 2) are the reliable ones; (3) is purely opt-in. Honest attribution through the channels that
157
- already exist — never spam.
158
-
159
- **Ownership proof (optional trust badge).** Sign the **bare origin string** with the `payTo` key and
160
- pass it as `ownershipProofs`. x402scan verifies `recoverMessageAddress(origin, sig) === payTo`.
161
-
162
- ```ts
163
- const signer = await client.discoverySigner() // EVM today; null on families without it
164
- const proof = signer ? [await signer.signMessage('https://api.example.com')] : undefined
165
- const openapi = buildOpenApi({ origin: 'https://api.example.com', resources: [resource], ownershipProofs: proof })
166
- ```
167
-
168
- ### 3.2 REGISTER — list yourself on the open registries
169
-
170
- ```ts
171
- const client = new PipRailClient({ wallet: { privateKey: KEY }, chain: 'base' })
172
-
173
- const outcomes = await client.register('https://api.example.com/report', {
174
- name: 'Market Report',
175
- priceUsd: 0.05,
176
- // targets: ['402index'] // default — no auth, no signature
177
- // targets: ['402index', 'x402scan'] // also x402scan via SIWX (EVM + Base/Solana)
178
- })
179
- // → [{ source: '402index', ok: true, status: 200, detail: 'Listed on 402 Index (searchable at 402index.io).' }]
180
- ```
181
-
182
- `RegisterOptions` = `{ name?, description?, priceUsd?, asset?, network?, method?, targets? }`. The
183
- `network` slug defaults to the client's `chain` when it's a slug (e.g. `'base'`). Returns one
184
- `RegisterOutcome` (`{ source, ok, status?, detail?, listingUrl? }`) **per target** — a target the
185
- chain can't satisfy is reported `{ ok: false, detail }`, **never thrown**:
186
-
187
- | target | what happens |
188
- |---|---|
189
- | `'402index'` (default) | one `POST` — no auth/signature/payment. The reliable path on every chain. |
190
- | `'x402scan'` | **SIWX**: `POST` → `402` challenge → sign EIP-4361 with the wallet key → resend with the `SIGN-IN-WITH-X` header. The SDK checks **only** for an EVM `discoverySigner` locally (returns `{ ok:false }` on a non-EVM family); the **Base/Solana-only** limit is enforced by x402scan itself, so any other chain comes back `{ ok:false }` with the HTTP status it returns. **Experimental** — the SIWX handshake is a moving convention; validate against x402scan before relying on it. |
191
- | `'bazaar'` | honestly refused (`{ ok:false }`) — the Bazaar has no write endpoint (facilitator-settle only). |
192
-
193
- Standalone equivalents (no client): `register402Index(input)` and `registerX402Scan({ url }, signer)`.
194
-
195
- ### 3.3 DISCOVER — find payable resources (read-only, free)
196
-
197
- ```ts
198
- const hits = await client.discover({ query: 'weather', maxPrice: 0.01 })
199
- // → DiscoveredResource[] ({ resource, source, name?, description?, priceUsd?, rails: DiscoveredRail[] })
200
- const res = await client.fetch(hits[0].resource) // then the usual quote → plan → pay
201
- ```
202
-
203
- `DiscoverOptions` = `{ query?, network?, maxPrice?, sources?, limit? }`:
204
-
205
- | option | default | meaning |
206
- |---|---|---|
207
- | `query` | — | free-text; matched against name/description/resource (Bazaar is filtered client-side, 402 Index server-side via `?q=`). |
208
- | `network` | `'self'` | `'self'` = only resources payable on the client's bound chain · a **CAIP-2** id = that chain · `'any'` = every chain. |
209
- | `maxPrice` | — | coarse pre-filter: drop results whose *advertised* USD price exceeds it (results with no price pass through — `quote()` gives the exact figure). |
210
- | `sources` | `['bazaar','402index']` | which open indexes to read (both free). |
211
- | `limit` | `20` | max results per source before merge. |
212
-
213
- Results from all sources are **merged and deduped by resource URL** (first source wins). Standalone:
214
- `searchOpenIndexes({ query?, sources?, limit?, signal? })`.
215
-
216
- **Network filtering is forgiving by design.** An index reports networks as slugs (`'base'`) or CAIP-2
217
- (`'eip155:8453'`). `normalizeNetwork()` maps known slugs to the exact CAIP-2 each driver binds (every
218
- family is covered; Solana's reference is the 32-char-truncated form). For `network: 'self'` the filter
219
- delegates to the driver's own `net.supports()`, and — crucially — a rail whose network it **cannot
220
- resolve** is **kept, not silently hidden** (a re-checkable false positive beats an invisible
221
- resource; the agent's next `quote()`/`planPayment()` rejects a wrong chain anyway).
222
-
223
- ---
224
-
225
- ## 4. The signing primitive — `discoverySigner`
226
-
227
- One **optional** addition to the `PaymentDriver` contract:
228
-
229
- ```ts
230
- // drivers/types.ts — ResolvedNetwork
231
- discoverySigner?(wallet: WalletHandle): DiscoverySigner | null
232
- // DiscoverySigner = { address: string; signMessage(message: string): Promise<string> }
233
- ```
234
-
235
- - **Discovery only** — ownership proofs + SIWX registration. It **never signs a payment**.
236
- - **EVM today** (eip191 via the wallet client; works for `{ privateKey }` and `{ walletClient }`).
237
- Recoverable with viem's `recoverMessageAddress` — exactly how x402scan verifies origin ownership.
238
- - **Optional by design** — a family omits it until an open index verifies its signatures. The
239
- primary register path (402 Index) needs no signature, so families without it lose nothing there;
240
- `register(..., { targets: ['x402scan'] })` returns a clear `{ ok:false }` for them.
241
- - It is the SDK's **first optional contract method**, so it does *not* trigger the "implement in all
242
- families" rule that applies to required methods.
243
-
244
- `client.discoverySigner()` surfaces it (or `null`) so a merchant can generate an ownership proof.
245
-
246
- ---
247
-
248
- ## 5. Agent / MCP tools
249
-
250
- `paymentTools(client)` ships five descriptors; the MCP server is a pass-through, so they appear in
251
- `@piprail/mcp` automatically:
252
-
253
- | tool | does |
254
- |---|---|
255
- | **`piprail_discover`** `{ query?, network?, maxPrice?, limit? }` | find payable resources on the open indexes — the phone book. |
256
- | `piprail_quote_payment` `{ url }` | price a gated URL without paying. |
257
- | `piprail_plan_payment` `{ url }` | check you *can* pay (balance/gas/recipient) across every rail. |
258
- | `piprail_pay_request` `{ url, method?, body? }` | pay the 402 and return the result. |
259
- | **`piprail_register`** `{ url, name?, description?, priceUsd? }` | list a resource you run (402 Index, no signature). |
260
-
261
- The discover tool returns a compact list (`resource, name, source, priceUsd, networks`) for the model
262
- to pick from, then quote → pay. Because index results are cross-scheme, the model should always
263
- `quote()` a chosen resource (re-hitting the live URL) before paying.
264
-
265
- ---
266
-
267
- ## 6. The honest caveats (never glossed)
268
-
269
- 1. **Scheme.** PipRail 402s use `scheme: 'onchain-proof'`; the open indexes assume the mainstream
270
- **`exact`** scheme. A naive PipRail 402 risks being marked "skipped." **To be *usefully* indexed,
271
- also advertise a standard `exact` USDC rail on Base/Solana.** `discover()` results are
272
- cross-scheme: `client.fetch()` pays only `onchain-proof` rails directly; paying a discovered
273
- `exact` resource uses the already-exported experimental `drivers/evm/exact.ts` interop.
274
- 2. **x402scan is Base/Solana only** (enforced server-side by x402scan — the SDK does no local chain
275
- check before calling `registerX402Scan`). 402 Index has no such
276
- limit, so it's the default register target and covers every family.
277
- 3. **There is no single ratified discovery standard.** The ratified x402 v2 spec defines discovery
278
- only as the read-only facilitator Bazaar. OpenAPI-first (`x-payment-info`) is an **emerging
279
- multi-vendor convention** (an early IETF draft, Merit Systems + Tempo Labs) — emit it, but treat
280
- it as a moving target, never "the standard." The `_x402` DNS draft is expired; emit it as a
281
- nice-to-have only.
282
-
283
- ---
284
-
285
- ## 7. Step-by-step walkthrough (and exactly what you need)
286
-
287
- Two roles. **A merchant lists their own endpoint so agents can find it; an agent finds and pays.**
288
- There is **no PipRail account and no x402 sign-up anywhere** — you never "register your SDK" with us
289
- or with x402. The only thing that's ever "registered" is a *merchant's own URL* on a public index,
290
- and they do it themselves with one call.
291
-
292
- ### 7a. Merchant — be found (each step says what it needs)
293
-
294
- 1. **Gate the route.** `requirePayment({ chain, token, amount, payTo })`.
295
- *You need:* your **receiving wallet address** (`payTo`) — a public address, **not** a private key.
296
- *No signing, no sign-up.* The route now returns `402` (payable) but is invisible.
297
- 2. **(Optional) Emit a discovery file.** `const r = await gate.describe(url)` →
298
- `buildOpenApi({ origin, resources: [r] })` → serve the JSON at `https://<origin>/openapi.json`.
299
- *You need:* nothing — it's pure, no keys, no network. It's a static file on your own server.
300
- 3. **Register so agents can find you.** `await client.register(url, { name, priceUsd })`.
301
- - **402 Index — the default.** **No sign-up, no API key, no signature, no wallet.** One HTTPS POST;
302
- 402 Index probes your URL (it must return a real `402`) and lists it. Searchable in seconds.
303
- - **x402scan — optional.** Add `targets: ['402index', 'x402scan']`. This one signs a **SIWX**
304
- challenge with **your own wallet's key** (one signature — *no funds move*). Base/Solana only.
305
- This is the **only** signing on the be-found side, and it's optional.
306
- 4. **(Optional) Ownership badge.** Sign your bare origin string with your `payTo` key
307
- (`const s = await client.discoverySigner(); await s.signMessage(origin)`) and pass it as
308
- `buildOpenApi({ ownershipProofs: [...] })`. A trust badge on indexes that verify it; never required.
309
- 5. **Found.** Agents discover you through the open indexes. Nothing is hosted by PipRail.
310
-
311
- ### 7b. Agent — find & pay
312
-
313
- 1. **Discover.** `await client.discover({ query })` — reads the open indexes (free). *No key, no sign-up.*
314
- 2. **Quote.** `await client.quote(resource)` — the exact live price. *No funds move.*
315
- 3. **Plan.** `await client.planPayment(resource)` — can this wallet actually settle it? *No funds move.*
316
- 4. **Pay.** `await client.fetch(resource)` — *you need:* a **funded wallet** (it signs + broadcasts the
317
- payment, then verifies locally). The payment goes **merchant-direct** — no facilitator, and the
318
- index never touches the money.
319
-
320
- ### 7c. What you need at each step (the whole truth, one table)
321
-
322
- | Step | Wallet? | Private key / signing? | Sign-up / account? | Cost |
323
- |---|---|---|---|---|
324
- | Gate an endpoint | a receiving **address** only | **no** | **no** | free |
325
- | Emit `/openapi.json` | — | **no** | **no** | free |
326
- | **Register · 402 Index** (default) | — | **no** | **no** | free |
327
- | Register · x402scan (optional) | your own | yes — **1 SIWX signature, no funds move** | **no** | free |
328
- | Ownership badge (optional) | your own | yes — sign the origin string | **no** | free |
329
- | Discover | — | **no** | **no** | free |
330
- | Quote / plan | — | **no** | **no** | free |
331
- | **Pay** a discovered API | a **funded** wallet | yes — the on-chain payment tx | **no** | the price + gas |
332
-
333
- **The fastest path to discoverable** is the bold row pair: gate it, then `client.register(url)` —
334
- **no wallet, no signature, no account, free.** Everything else is optional polish.
335
-
336
- ---
337
-
338
- ## 8. Constraint compliance
339
-
340
- - **No backend / DB / registry of our own.** Emit = a static file the *merchant* hosts; discover /
341
- register = runtime calls to *third-party* open indexes; payment is merchant-direct + local verify.
342
- - **Protocol layer stays chain-agnostic** (STANDARDS §1): `discovery.ts` + `indexes.ts` import only
343
- `x402.ts`/`drivers/types.ts` + pure utils — zero chain libraries (verified by the lazy-chunk grep).
344
- - **Opt-in, defaults unchanged.** `discover`/`register`/`discoverySigner`/the emitters/`gate.describe`
345
- are all new optional surface; the zero-config pay path is byte-identical.
346
- - **Read-style, never throws.** Search returns `[]` on a dead/garbage index; register returns
347
- `{ ok:false, detail }` on any failure; the pure emitters can't fail at runtime. (See ERRORS.md.)
348
-
349
- ---
350
-
351
- ## 9. Full API surface
352
-
353
- ```ts
354
- // Emit (pure)
355
- buildOpenApi(input: ManifestInput): OpenApiDocument
356
- buildWellKnownX402(input: ManifestInput): WellKnownX402
357
- buildX402DnsTxt(input: { host; discoveryUrl; descriptor? }): X402DnsRecord
358
- gate.describe(resourceUrl?): Promise<ResourceDescription>
359
-
360
- // Register (developer-invoked I/O; never throws)
361
- client.register(url, opts?: RegisterOptions): Promise<RegisterOutcome[]>
362
- register402Index(input: RegisterInput): Promise<RegisterOutcome>
363
- registerX402Scan({ url }, signer: DiscoverySigner): Promise<RegisterOutcome>
364
-
365
- // Discover (read-only I/O; never throws)
366
- client.discover(opts?: DiscoverOptions): Promise<DiscoveredResource[]>
367
- searchOpenIndexes(opts?: SearchOpenIndexesOptions): Promise<DiscoveredResource[]>
368
- normalizeNetwork(network: string): string
369
-
370
- // Sign (discovery only)
371
- client.discoverySigner(): Promise<DiscoverySigner | null>
372
-
373
- // Types
374
- PaymentRail · ResourceDescription · ManifestInput · OpenApiDocument · OpenApiOperation ·
375
- WellKnownX402 · X402DnsRecord · DiscoverySource · DiscoveredResource · DiscoveredRail ·
376
- RegisterOutcome · RegisterInput · SearchOpenIndexesOptions · DiscoverOptions · RegisterOptions ·
377
- DiscoverySigner
378
- ```
379
-
380
- ---
381
-
382
- ## 10. Experimental status & live-integration log
383
-
384
- Discovery is **experimental** because it depends on third-party open indexes (402 Index, CDP Bazaar,
385
- x402scan) whose APIs and conventions are young and moving. The SDK code is stable and tested; what's
386
- experimental is the *integration contract* with those external services. Keep this log current.
387
-
388
- **Live integration test — 2026-06-06** (the SDK's own functions, run against the real services):
389
-
390
- | What | Result |
391
- |---|---|
392
- | `searchOpenIndexes({ sources: ['bazaar'] })` — CDP Bazaar, free | ✅ 20 resources normalized; all `exact`-scheme on `eip155:8453` (confirms the cross-scheme caveat). |
393
- | `searchOpenIndexes({ sources: ['402index'], query })` | ✅ real `{services:[…]}` parsed; the **x402 protocol filter dropped L402/MPP** on live data. |
394
- | `client.discover({ network: 'any' })` | ✅ both indexes merged + deduped (sources: `bazaar` + `402index`). |
395
- | `client.discover()` (default `self`) | ✅ filtered to the client's chain; the never-hide invariant held on real data. |
396
- | `register402Index(...)` (write, no auth) | ✅ POST succeeded end-to-end; **402 Index PROBES the URL** and returned **HTTP 422** for a non-402 URL: *"Your endpoint returned HTTP 200 instead of 402."* Our code reported `{ ok:false, status:422, detail }` **without throwing**, and surfaces the index's own reason. |
397
- | `registerX402Scan(...)` (SIWX write) | ⏳ **NOT yet live-tested.** EVM signing is correct in isolation, but the SIWX handshake against x402scan is unverified — still experimental. |
398
- | **`User-Agent` attribution** | ✅ confirmed sent over the wire (`@piprail/sdk (+https://piprail.com)` echoed back by an external header service). |
399
- | **Opt-in `via` listing tag** | ✅ confirmed **safe**: a `register(..., { attribution: true })` to 402 Index returns the *identical* URL-probe response as an untagged one — the field is tolerated, never causes a rejection. |
400
-
401
- **Key facts learned live:**
402
- - **402 Index validates by probing** — it will only list a URL that actually returns a `402`. So a
403
- successful registration requires a **real, deployed, public** x402 endpoint (PipRail has none to
404
- test with — a marketing site returns 200 and is correctly rejected). This also means our test
405
- created **no junk listing**. The error reason is now surfaced in `RegisterOutcome.detail`.
406
- - **Read is free and works today** on both CDP Bazaar and 402 Index with no key.
407
- - **402 Index totals (2026-06-06):** ~63k endpoints (x402: ~61k), ~1.6k services — a real, populated index.
408
-
409
- **Before relying on it in production:** (1) register a real deployed x402 endpoint and confirm a
410
- `200`/listed outcome end-to-end; (2) live-test the x402scan SIWX path; (3) re-verify the index wire
411
- shapes (they drift — this doc's parser is defensive but the conventions are unratified).
412
-
413
- ---
414
-
415
- ## 11. Sources & further reading
416
-
417
- - `.claude/research/x402-discovery.md` — the from-scratch explainer (concepts, formats, glossary).
418
- - `.claude/research/x402-discovery-integration.md` — the source-level integration plan + verification log.
419
- - 402 Index — https://402index.io · CDP Bazaar — https://docs.cdp.coinbase.com/x402/bazaar ·
420
- x402scan — https://github.com/Merit-Systems/x402scan · x402 spec — https://github.com/coinbase/x402