@piprail/sdk 2.16.2 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +448 -0
- package/README.md +1 -0
- package/dist/{algorand-Q3TUQLPK.cjs → algorand-4FTTEV7X.cjs} +212 -51
- package/dist/{algorand-25FMBCT3.js → algorand-KJ5XHSMT.js} +164 -3
- package/dist/{aptos-VBJONBFY.cjs → aptos-5QBXJ6NV.cjs} +255 -39
- package/dist/{aptos-SBV6SGDP.js → aptos-H5VLH2QL.js} +217 -1
- package/dist/{chunk-QONQSZHJ.cjs → chunk-6XTNI2OQ.cjs} +44 -44
- package/dist/{chunk-OXEFPLZA.cjs → chunk-6ZRAIQXF.cjs} +4 -4
- package/dist/chunk-GVCGUSTE.js +30 -0
- package/dist/{chunk-V2IJ5HUW.cjs → chunk-MZAJQYM3.cjs} +1 -0
- package/dist/chunk-MZXVXC3C.cjs +30 -0
- package/dist/{chunk-2CX7XRZK.js → chunk-TZDVZCTC.js} +1 -0
- package/dist/{chunk-C52H5TYB.js → chunk-YXBQKBDH.js} +2 -2
- package/dist/index.cjs +2906 -1262
- package/dist/index.d.cts +679 -21
- package/dist/index.d.ts +679 -21
- package/dist/index.js +2678 -1034
- package/dist/{ledger-Crc1bZox.d.cts → ledger-DkHUORUe.d.cts} +7 -0
- package/dist/{ledger-Crc1bZox.d.ts → ledger-DkHUORUe.d.ts} +7 -0
- package/dist/{near-OLKCMTBI.js → near-5X2CQGML.js} +197 -1
- package/dist/{near-5LTTDU6G.cjs → near-XSQXEIHT.cjs} +242 -46
- package/dist/node.d.cts +2 -2
- package/dist/node.d.ts +2 -2
- package/dist/{solana-AI2G7V33.cjs → solana-TIEJV742.cjs} +218 -53
- package/dist/{solana-HTKDRTD3.js → solana-WRXL54MR.js} +174 -9
- package/dist/{stellar-E2KWEV2E.cjs → stellar-5GMZJBTA.cjs} +200 -30
- package/dist/{stellar-EUFZLX6J.js → stellar-YQLOIFDD.js} +172 -2
- package/dist/{sui-KCIITCYH.js → sui-JFQSDNSZ.js} +165 -3
- package/dist/{sui-Q3NJOJZS.cjs → sui-WIZEAO7E.cjs} +185 -23
- package/dist/{ton-J7TQWRN4.cjs → ton-AGTKLQA5.cjs} +264 -22
- package/dist/{ton-WH2JVQOO.js → ton-OZKFNRLT.js} +244 -2
- package/dist/{tron-FXBXDNEY.js → tron-HG3IOOCG.js} +283 -1
- package/dist/{tron-HIPMOX7S.cjs → tron-S4WX7OHY.cjs} +323 -41
- package/dist/{xrpl-RUOB37QH.js → xrpl-5K444RGX.js} +238 -6
- package/dist/{xrpl-SVVS445B.cjs → xrpl-G2FKFXRI.cjs} +289 -57
- package/package.json +12 -2
- package/dist/{chunk-QU25LSVS.js → chunk-5GRBEMCA.js} +44 -44
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,452 @@ 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
|
+
## [3.1.0] — 2026-09-09 — spendable is not held, and a swap refused for no reason
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- 🔴 **A spend cap could be breached by CONCURRENT payments.** A cap is read when a quote is
|
|
12
|
+
priced and written when the payment settles, and a whole network round trip sits between the
|
|
13
|
+
two. Six simultaneous `fetch()` calls against a `maxTotal` of `'2.50'` each priced against the
|
|
14
|
+
same "spent so far", each passed, and four settled: 4.00 spent against a 2.50 leash while every
|
|
15
|
+
individual check was correct. `maxPayments` and `maxTotalPerDenom` leaked the same way. It is
|
|
16
|
+
the same read-await-write shape as the replay race, on the buyer's side of the wire, and the
|
|
17
|
+
leash is the entire safety story for the default `budgeted` mode.
|
|
18
|
+
|
|
19
|
+
`authorize()` now RESERVES the budget synchronously before anything is signed or sent, so an
|
|
20
|
+
in-flight payment is visible to the next one's check. `recordSpend()` commits the reservation
|
|
21
|
+
as the real record lands, and every failure path releases it, so a refused or failed payment
|
|
22
|
+
never permanently consumes the leash. Sequential behaviour is unchanged: a 3.00 cap still
|
|
23
|
+
spends exactly 3.00, no more and no fewer.
|
|
24
|
+
|
|
25
|
+
- 🔴 **A custom `isUsed`/`markUsed` replay store could redeem ONE proof N times, concurrently.**
|
|
26
|
+
The gate's built-in set has always reserved a ref synchronously, which is what stops two
|
|
27
|
+
simultaneous requests carrying the same proof from both settling. The CUSTOM-store branch did
|
|
28
|
+
not: it `await`ed `isUsed(ref)` before verification and wrote only after it, so every concurrent
|
|
29
|
+
request read "unused" and every one settled. Five simultaneous requests redeemed one payment
|
|
30
|
+
five times. The gate now takes the same synchronous in-process reservation BEFORE consulting a
|
|
31
|
+
custom store, so a single instance is safe whichever store is configured. Across several
|
|
32
|
+
processes that set cannot help, so an atomic check-and-reserve (Redis `SET NX`) is still what
|
|
33
|
+
protects a multi-instance deployment, and the documented example now uses one: it previously
|
|
34
|
+
showed `exists()` then `set()`, which is the exact non-atomic pattern the surrounding text
|
|
35
|
+
warned against. Found by a concurrency sweep; the suite had no concurrent replay test at all.
|
|
36
|
+
|
|
37
|
+
- 🔴 **A sovereign agent could deliver N goods for ONE payment.** `piprail_sell` gives every offer
|
|
38
|
+
its own gate, and a gate's replay set is scoped to itself, so cross-offer replay was guarded by
|
|
39
|
+
a store-level `isUsed`/`markUsed` pair sharing the race above. Sequentially it refused correctly;
|
|
40
|
+
five concurrent `piprail_collect` calls against five offers all collected the same settlement,
|
|
41
|
+
which is the normal shape of a shop with more than one buyer. `collect` now reserves the proof
|
|
42
|
+
ref synchronously before any await, and releases it if the payment does not settle.
|
|
43
|
+
|
|
44
|
+
- **A chain id that is not a chain id is refused at config time.** `{ id: NaN }` (and negative,
|
|
45
|
+
fractional or unsafe-integer ids) resolved happily, and the gate went on to publish
|
|
46
|
+
`network: "eip155:NaN"` in a live 402 — an unparseable CAIP-2 that a standard x402 client cannot
|
|
47
|
+
read. It failed closed later, but surfaced as `tx_not_found` at payment time rather than as the
|
|
48
|
+
configuration error it is.
|
|
49
|
+
|
|
50
|
+
- **A gate must charge more than zero.** `amount: '0'` built a rail whose amount check any transfer
|
|
51
|
+
satisfies, so a paywall could read as configured and gate nothing. A metered `upto` SETTLE of
|
|
52
|
+
zero stays legitimate, because that is the settled amount and not the advertised one.
|
|
53
|
+
`piprail_sell` inherits the same floor, so a zero price can no longer mint an offer.
|
|
54
|
+
|
|
55
|
+
- **A facilitator's cold start no longer costs the buyer gas.** The lazy `/supported` probe that
|
|
56
|
+
discovers a facilitator's fee payer timed out at 8s. These are serverless hosts: measured
|
|
57
|
+
2026-09-09, `x402.dexter.cash` answered in **8497ms cold** and ~310ms warm, so a cold facilitator
|
|
58
|
+
read as absent, the gasless `exact` rail was dropped, and the buyer paid gas instead. The probe
|
|
59
|
+
now allows 15s. It only runs for a family that cannot resolve `exact` without a fee payer
|
|
60
|
+
(Solana; EVM never reaches it), at most once per gate, and it still fails safe with the same
|
|
61
|
+
`skipReason` when the host really is down. Live re-probe after the change: **9/9 registry hosts
|
|
62
|
+
answering, 0 contradicted claims**.
|
|
63
|
+
|
|
64
|
+
- **Authority is sealed on the client instance.** `paymentTools()` picks a model's tool set from
|
|
65
|
+
`canAgentSell()` / `canAgentSwap()`, which read `mode()`. Those were plain prototype methods, so
|
|
66
|
+
any code holding the client could reassign one and turn a budgeted client's eight tools into
|
|
67
|
+
sovereign's fourteen. A MODEL could never do that — it sends JSON tool arguments and does not
|
|
68
|
+
hold the object — so this is defence in depth for a client passing through an agent framework,
|
|
69
|
+
a plugin, or middleware that wraps objects. All three are now non-writable and non-configurable:
|
|
70
|
+
authority is set once, by whoever provisioned the key.
|
|
71
|
+
|
|
72
|
+
- 🔴 **`planPayment` called a native payment affordable that the chain then refused, on FOUR
|
|
73
|
+
families.** Some chains require an account to retain a minimum it can never send, and
|
|
74
|
+
affordability was measured against the raw balance on every one of them:
|
|
75
|
+
|
|
76
|
+
| | retained minimum |
|
|
77
|
+
|---|---|
|
|
78
|
+
| Solana | the account's rent-exempt minimum |
|
|
79
|
+
| XRPL | a base reserve, plus an increment for each owned object (a trustline is one) |
|
|
80
|
+
| Stellar | `(2 + subentries) x` the base reserve, and a trustline is a subentry |
|
|
81
|
+
| Algorand | 0.1 ALGO, plus 0.1 for every ASA opted into |
|
|
82
|
+
|
|
83
|
+
A live wallet holding 0.0011 SOL was told it could send 0.0005 SOL; the transfer failed
|
|
84
|
+
simulation with a bare `SendTransactionError` after the agent had already signed, which is the
|
|
85
|
+
exact outcome the pre-flight check exists to prevent.
|
|
86
|
+
|
|
87
|
+
`WalletBalance.token` is now documented as the SPENDABLE figure (the reserve deducted) with
|
|
88
|
+
`native` staying the true balance for gas, all four drivers report it that way (reading the
|
|
89
|
+
chain's own figure where it offers one), and the client measures a native payment against it.
|
|
90
|
+
A family with no reserve reports the two as equal and is unchanged. The refusal names the
|
|
91
|
+
exact shortfall, and a payment comfortably inside the spendable balance still goes through.
|
|
92
|
+
|
|
93
|
+
- **XRPL: a throttled ledger read no longer surfaces as a serializer error.** `Sequence` and
|
|
94
|
+
`LastLedgerSequence` are UInt32 fields, so a failed or rate-limited pre-flight read left one
|
|
95
|
+
`undefined` (and `undefined + 20` is `NaN`), and xrpl.js rejected the transaction with
|
|
96
|
+
"Cannot construct UInt32 from given value": no field, no cause and no remedy, from a library
|
|
97
|
+
the caller never imported. Found under a batch of live mainnet payments, where the public
|
|
98
|
+
cluster throttles. Both reads are now checked before the transaction is built, and the refusal
|
|
99
|
+
says the thing that matters most on an ambiguous payment error: nothing was signed or
|
|
100
|
+
submitted, so retrying cannot double-pay.
|
|
101
|
+
|
|
102
|
+
### Added
|
|
103
|
+
|
|
104
|
+
- **`releaseUsed` — the third replay hook.** Optional, and only meaningful when `isUsed` RESERVES
|
|
105
|
+
(the `SET NX` shape). Without it, making a custom store atomic traded a double-spend for a worse
|
|
106
|
+
failure: a transient RPC error left the reservation standing, so a buyer whose funds had already
|
|
107
|
+
moved could never redeem the proof. The built-in store has always released on failure; this is
|
|
108
|
+
how a custom store does the same. Supplying it without `isUsed`/`markUsed` throws, because it
|
|
109
|
+
would never fire.
|
|
110
|
+
|
|
111
|
+
## [3.0.0] — 2026-09-09 — agent modes: a wallet an agent can EARN with, not only spend from
|
|
112
|
+
|
|
113
|
+
### BREAKING
|
|
114
|
+
|
|
115
|
+
- **`ResolvedNetwork` gains a required `addressOf(wallet)`.** Only affects code that implements
|
|
116
|
+
a CUSTOM driver via `registerDriver`; every built-in family already has it. The migration is one
|
|
117
|
+
method: return the bound wallet's own address, derived from the key, with no RPC read. Nothing
|
|
118
|
+
else in the public API changed, and no existing call site needs touching.
|
|
119
|
+
|
|
120
|
+
### Added
|
|
121
|
+
|
|
122
|
+
- **Agent modes: capability follows AUTHORITY, and `sovereign` gives an agent BOTH halves of a
|
|
123
|
+
wallet.** A new `mode` option on `PipRailClient` (`'supervised' | 'budgeted' | 'sovereign'`,
|
|
124
|
+
default `'budgeted'`) and `PIPRAIL_MODE` on the MCP server. **The default is unchanged**: omit
|
|
125
|
+
it and `paymentTools()` returns exactly the same eight tools, in the same order, so nothing
|
|
126
|
+
moves for anyone who does not opt in.
|
|
127
|
+
|
|
128
|
+
Before this, whether a model could swap depended on which PACKAGE it imported: a model driving
|
|
129
|
+
`@piprail/mcp` never could, while the same wallet driven through the SDK always could. That is
|
|
130
|
+
the wrong axis. `mode` moves the decision onto the only question that matters, which is who is
|
|
131
|
+
answerable for the wallet, and it is set by whoever provisions the key. A model can never set
|
|
132
|
+
it: `mode()` reads, nothing writes, and no tool takes a `mode` argument.
|
|
133
|
+
|
|
134
|
+
⭐ **`'sovereign'` is the whole wallet, not a bigger allowance.** A budget only ever lets an
|
|
135
|
+
agent SPEND, however large the cap. Sovereign appends five tools: `piprail_quote_swap` and
|
|
136
|
+
`piprail_swap`, plus the seller tools **`piprail_sell`**, **`piprail_collect`** and
|
|
137
|
+
**`piprail_earnings`**, so an agent can be PAID as well as pay. `piprail_sell` prices something
|
|
138
|
+
and returns an x402 challenge; because a challenge is only data, the agent hands it to a buyer
|
|
139
|
+
over any channel it already has, with **no web server and no open port anywhere**.
|
|
140
|
+
`piprail_collect` verifies the returned proof against the chain, and only `paid: true` clears
|
|
141
|
+
delivery. Each proof is single-use, so a replay is never a second sale.
|
|
142
|
+
|
|
143
|
+
**Two properties make selling safe to grant a model where spending is not.** The receiving side
|
|
144
|
+
holds **no private key at all** (`payTo` is a public address), so the earning half cannot spend
|
|
145
|
+
and cannot be drained even if the host is taken. And `payTo` defaults to the agent's **own**
|
|
146
|
+
address, which it otherwise has no way to learn, so it is never told to trust one a buyer sent.
|
|
147
|
+
|
|
148
|
+
Swapping is bounded by a separate instrument, because the payment caps cannot bound it: a swap
|
|
149
|
+
moves the holder's own funds between denominations, so `maxAmount`, `maxTotal`, the count caps
|
|
150
|
+
and the session TTL all pass over it untouched. `swapPolicy` adds a ceiling per swap, a slippage
|
|
151
|
+
ceiling and an optional destination allowlist, and the ceiling binds on the quote's on-chain
|
|
152
|
+
`maxSpend`, never the estimate. The MCP server **refuses to boot** on
|
|
153
|
+
`PIPRAIL_MODE=sovereign` without `PIPRAIL_MAX_PER_SWAP`, so an operator finds out while reading
|
|
154
|
+
their own config rather than mid-spend. Selling needs no ceiling, because it takes money rather
|
|
155
|
+
than spending it.
|
|
156
|
+
|
|
157
|
+
- **`client.balanceOf(assets)` + the `piprail_wallet` tool: an agent can finally see its own
|
|
158
|
+
balance sheet.** A sovereign agent had thirteen tools and could not answer *"what do I have?"* —
|
|
159
|
+
`budget()` reports how much of an allowance is left, which is a different question, and
|
|
160
|
+
`planPayment()` answers it only for one URL at a time. Owning finances starts with seeing them.
|
|
161
|
+
An unavailable read reports `null`, **never `0`**: an agent told it holds nothing when the RPC
|
|
162
|
+
merely failed would fire-sale to recover from a loss that never happened. On a `MultiChainPayer`
|
|
163
|
+
the holdings span every chain, because reporting only the primary would call an agent broke
|
|
164
|
+
while it held funds one client along.
|
|
165
|
+
|
|
166
|
+
- **`ResolvedNetwork.addressOf(wallet)` and `client.address()`: a wallet can finally report where
|
|
167
|
+
it gets PAID.** Every driver already derived this internally for `balanceOf`; nothing exposed
|
|
168
|
+
it. Without it an agent handed a key it never chose has no way to learn its own address, so it
|
|
169
|
+
could pay for anything and be paid for nothing. Implemented across all ten families. Pure:
|
|
170
|
+
derived from the key, no RPC read, nothing moved. Also `client.chain()`, and `canAgentSell()`
|
|
171
|
+
alongside the existing `canAgentSwap()`. `MultiChainPayer` delegates all of them to the primary
|
|
172
|
+
client, without which sovereign mode would be unreachable through the MCP, which always wraps
|
|
173
|
+
its accounts in one.
|
|
174
|
+
|
|
175
|
+
### Fixed
|
|
176
|
+
|
|
177
|
+
- **An agent paying twice in a row collided with itself on EVM.** Found live on Base: the first
|
|
178
|
+
payment settled and the next two were refused by the node with "nonce lower than the current
|
|
179
|
+
nonce of the account", because viem asks the RPC for the nonce on every send and two payments
|
|
180
|
+
issued back to back both read the same value before either was mined. It failed SAFELY (nothing
|
|
181
|
+
broadcast, nothing recorded as spent, `send` called exactly once with no retry), so it never
|
|
182
|
+
risked a double payment, but paying several times in quick succession is the ordinary behaviour
|
|
183
|
+
of an autonomous agent rather than an edge case. A raw `{ key }` is now bound through viem's
|
|
184
|
+
`nonceManager`, which assigns nonces locally per (chain, account) and reconciles with the chain.
|
|
185
|
+
A caller who brings their own `walletClient` keeps their own nonce policy, untouched. The same
|
|
186
|
+
three back-to-back Base payments now all settle.
|
|
187
|
+
|
|
188
|
+
- **TON reported a BOUNCEABLE address as "where I get paid".** `addressOf` was added to all ten
|
|
189
|
+
families at once, and checking each against the address recorded in its funded test wallet came
|
|
190
|
+
back 9/10: TON derived `EQ…` where the wallet file (and every TON wallet UI) says `UQ…`, the
|
|
191
|
+
same account in the other encoding. A bounceable address returns funds to the sender when the
|
|
192
|
+
destination contract is not yet initialised, and a wallet contract stays uninitialised until it
|
|
193
|
+
has sent its first transaction, so a freshly generated agent advertising `EQ…` would have
|
|
194
|
+
bounced the first payment it was ever sent. Verification was never affected (both forms parse to
|
|
195
|
+
one account); what was wrong is the address an agent hands out. Now 10/10 against ground truth.
|
|
196
|
+
|
|
197
|
+
- **🔴 One payment could collect TWO offers.** Found by attacking the seller tools. Each offer
|
|
198
|
+
owns its own gate, and a gate's replay set is scoped to itself, which is right for
|
|
199
|
+
`requirePayment` (one gate, one resource) and wrong for a store of offers: two offers priced the
|
|
200
|
+
same to the same address are indistinguishable to any driver, because a driver can only ask "did
|
|
201
|
+
this settlement move at least X to this address?". A buyer could pay for a haiku and collect the
|
|
202
|
+
expensive listing with the same money. Two bindings now close it, both mutation-proven and
|
|
203
|
+
re-verified on mainnet: `collect` refuses a proof minted for a different offer
|
|
204
|
+
(`code: 'wrong_offer'`) before the chain is consulted, and the used-proof set belongs to the
|
|
205
|
+
SELLER rather than to each gate, so one settlement is dead everywhere once redeemed.
|
|
206
|
+
|
|
207
|
+
- **`mode: 'supervised'` supervised nothing, and `PIPRAIL_MODE=supervised` wired nothing.** The
|
|
208
|
+
mode↔confirm inference ran ONE WAY: `PIPRAIL_CONFIRM=1` meant supervised, but naming the mode
|
|
209
|
+
did not turn confirmation on. An operator who asked for a human in the loop got an agent that
|
|
210
|
+
paid without asking anybody, with the banner, the docs and the mode name all agreeing it was
|
|
211
|
+
supervised. Both directions now agree; a config that says both things at once is refused rather
|
|
212
|
+
than quietly resolved; and the SDK refuses `mode: 'supervised'` with no `onBeforePay` to perform
|
|
213
|
+
it. `sovereign` + confirmation is not a contradiction and stays allowed.
|
|
214
|
+
|
|
215
|
+
- **A SWAP was invisible to the approver.** `onBeforePay` never sees a swap, because a swap is not
|
|
216
|
+
a payment, which is the whole reason `swapPolicy` bounds it instead. But an operator who wired
|
|
217
|
+
an approver did not mean "ask me before payments and let value move silently any other way", so
|
|
218
|
+
a supervised sovereign agent could swap its whole balance without one prompt. New
|
|
219
|
+
**`onBeforeSwap`**, same fail-safe contract, wired automatically by `@piprail/mcp` whenever
|
|
220
|
+
confirmation is on. Proven against the bug: a refusing approver was never called and the swap
|
|
221
|
+
executed.
|
|
222
|
+
|
|
223
|
+
- **The SDK let `'sovereign'` hand a model the swap tools with NO ceiling.** `@piprail/mcp`
|
|
224
|
+
already refused to boot in that state, so the safer surface could be sidestepped by importing
|
|
225
|
+
the other one. Proven before the fix: a 99,999-unit swap executed with nothing refusing it.
|
|
226
|
+
`mode: 'sovereign'` now requires `swapPolicy.maxPerSwap` in the SDK too.
|
|
227
|
+
|
|
228
|
+
- **The MCP banner under-reported what the agent could do.** It printed a hardcoded eight tools
|
|
229
|
+
even in sovereign mode, where the model actually holds thirteen. It is now handed the real list
|
|
230
|
+
at startup, so an operator can see the capability they granted.
|
|
231
|
+
|
|
232
|
+
- **Docs claimed the MCP has no swap tool "and there will not be one".** True when written and
|
|
233
|
+
false since modes shipped, in `mcp/README.md` and the tools reference. Both now describe the
|
|
234
|
+
mode-dependent contract, and a sync rule pins it so the claim cannot rot again.
|
|
235
|
+
|
|
236
|
+
- **Swapping reaches every family: Aptos, TON and Tron close the last three gaps.** Seven routes
|
|
237
|
+
became **ten, across 18 chains, with 21 mainnet proofs**. Nothing about the existing routes
|
|
238
|
+
changed, and swapping stays opt-in.
|
|
239
|
+
|
|
240
|
+
**Aptos via Hyperion** and **Tron via SunSwap V2** need **no API at all**: the quote is an
|
|
241
|
+
on-chain read and the swap is a contract call the user's own key signs. There is no host to go
|
|
242
|
+
down, no key to rotate and no vendor in the middle. That was deliberate on Tron, whose own front
|
|
243
|
+
end talks to an undocumented, obfuscated hostname a payments SDK should not depend on. **TON via
|
|
244
|
+
STON.fi** uses a keyless REST simulation and one message the wallet signs.
|
|
245
|
+
|
|
246
|
+
⭐ **Three of them are exact-output natively**, which no earlier route was. Hyperion's
|
|
247
|
+
`exact_output_swap_entry`, SunSwap's `swapTokensForExactTokens` and STON.fi's `reverse_swap`
|
|
248
|
+
all take the invoice amount and cap the input **on-chain**, so an x402 invoice is priced from
|
|
249
|
+
its output rather than sized from a probe. Proven to the unit on Aptos: four swaps delivered
|
|
250
|
+
exactly 0.05 USDT, 0.04 USDC, 0.03 USDC and 0.02 APT. On TON the guarantee is a floor rather
|
|
251
|
+
than a point: STON.fi's `reverse_swap` fixes the ask side, and PipRail pads the request so the
|
|
252
|
+
router's on-chain `min_ask_units` is at or above the invoice. Slightly more can arrive; less is
|
|
253
|
+
refused by the router.
|
|
254
|
+
|
|
255
|
+
**Live-proven on mainnet**: four Aptos swaps (both stable directions plus native APT in and out)
|
|
256
|
+
and two TON swaps (native in, jetton in, which the simulation routed through two *different*
|
|
257
|
+
router versions). **Tron ships WITHOUT a proof and says so on the site and in the docs**: the
|
|
258
|
+
quote is verified live and the swap executes cleanly in simulation, but Tron charges about
|
|
259
|
+
230,629 ENERGY per swap, which without staked energy is roughly 23 TRX (~$7.79) *regardless of
|
|
260
|
+
trade size*, and the test wallets hold 8.1 TRX. A new `unproven` field carries that reason, and
|
|
261
|
+
a guard test fails if a route ever ships with neither a proof nor a stated reason.
|
|
262
|
+
|
|
263
|
+
Adds two OPTIONAL peer dependencies for TON only (`@ston-fi/sdk`, `@ston-fi/api`, both MIT and
|
|
264
|
+
lazily imported); the lazy-chunk invariant still holds, so a pure-EVM install downloads neither.
|
|
265
|
+
Aptos and Tron needed no new dependency at all.
|
|
266
|
+
|
|
267
|
+
**Four more bugs found the same way as always, by spending real money.** Aptos aborted with
|
|
268
|
+
`ESQRT_PRICE_LIMIT_UNAVAILABLE` because a concentrated-liquidity swap needs a directional price
|
|
269
|
+
bound and `0` is not one; the bounds are now read off the chain rather than copied from another
|
|
270
|
+
DEX. The entry function's two amount slots are "what leaves, what arrives", **not** "the exact
|
|
271
|
+
one, the limit", and having them backwards aborts with `EINSUFFICIENT_BALANCE`, which reads like
|
|
272
|
+
a funding problem and is not one. A pool's `token0` field comes back as all-zeros for native-APT
|
|
273
|
+
pools, so the direction is now derived by comparing addresses. And pricing every fee tier blindly
|
|
274
|
+
burned the public node's anonymous compute quota (40k units per 300s), after which good routes
|
|
275
|
+
came back as "no route" — the quote now cheap-filters empty pools before paying to price them.
|
|
276
|
+
|
|
277
|
+
- **Robinhood Chain (`chain: 'robinhood'`, eip155:4663) — the 30th chain, and the first with a
|
|
278
|
+
Paxos-issued stablecoin.** The tokenized-equity Arbitrum Orbit L2, mainnet since 2026-07-01,
|
|
279
|
+
running 100ms blocks and settling to Ethereum. viem ships no preset and it is absent from
|
|
280
|
+
chainid.network, so the chain is defined inline from the values Robinhood publishes.
|
|
281
|
+
|
|
282
|
+
**One stablecoin on purpose: USDG**, the Paxos Global Dollar, which is what the chain's own
|
|
283
|
+
markets quote against. Its address was agreed by four independent sources before shipping —
|
|
284
|
+
Paxos's published list, our own on-chain read (`Global Dollar`, 6dp), and both facilitators'
|
|
285
|
+
`/supported`. **Circle issues no native USDC here** (checked against Circle's own contract
|
|
286
|
+
list), so no USDC or USDT preset ships: a bridged one would break the issuer-native token rule.
|
|
287
|
+
Native ETH is a payment asset as on every EVM chain.
|
|
288
|
+
|
|
289
|
+
**Gasless from day one, proven twice.** USDG is EIP-3009 **and** EIP-2612, verified by calling
|
|
290
|
+
the contract rather than by scanning bytecode — a bytecode scan said neither was present, and
|
|
291
|
+
was wrong. Its EIP-712 domain is `name: "Global Dollar", version: "1"`, which we confirmed by
|
|
292
|
+
recomputing the on-chain `DOMAIN_SEPARATOR`. Two keyless facilitators already settle there and
|
|
293
|
+
**both were live-settled by us**, with buyer *and* merchant paying zero ETH:
|
|
294
|
+
Ultravioleta DAO (tx `0x35361e57…`) and Dexter (tx `0x1ba66e3d…`, permit2, ~$0.0066 floor).
|
|
295
|
+
A self-settled EIP-3009 round trip also passed end to end (tx `0xe6d35460…`): 402 → sign →
|
|
296
|
+
settle → 200, replay rejected as `tx_already_used`, payer ETH delta exactly zero.
|
|
297
|
+
|
|
298
|
+
**Swapping works there too**, through the existing KyberSwap route — native ETH → 0.360518 USDG,
|
|
299
|
+
tx `0x424299e6…`. Worth knowing: at 100ms blocks a route goes stale fast, and the default
|
|
300
|
+
0.5% slippage reverted before 3% cleared. Reaching the chain at all is a bridge; Relay moved
|
|
301
|
+
ETH from Base in about three seconds for roughly $0.04, and Across and LI.FI both list it.
|
|
302
|
+
|
|
303
|
+
- **`quoteSwap()` / `swap()`: an optional helper for when you hold the wrong token.** A 402 names a
|
|
304
|
+
token, a wallet holds what it holds, and when those disagree `planPayment()` reported
|
|
305
|
+
`INSUFFICIENT_TOKEN` and stopped. This turns that dead end into an option the caller may take or
|
|
306
|
+
ignore.
|
|
307
|
+
|
|
308
|
+
**It is opt-in and it stays that way.** Paying never swaps, planning never swaps, and there is
|
|
309
|
+
deliberately no `autoSwap` flag: converting one asset into another is a priced, irreversible act,
|
|
310
|
+
and a payment library should not do it on your behalf because it noticed you were short. A test
|
|
311
|
+
asserts the byte-identical default (STANDARDS §0), so "not using it costs you nothing" is machine
|
|
312
|
+
checked rather than promised in prose.
|
|
313
|
+
|
|
314
|
+
Ships in **two tiers, kept apart by the type system**. Tier 1 (`source.kind: 'protocol'`) is
|
|
315
|
+
Stellar (`PathPaymentStrictReceive` to your own account) and the XRP Ledger (a cross-currency
|
|
316
|
+
`Payment` to your own address, auto-bridged through XRP): the ledger itself swaps, so there is
|
|
317
|
+
**no third party, no API key, no extra dependency and no integrator fee**. Tier 2
|
|
318
|
+
(`source.kind: 'provider'`) is **Solana via Jupiter**, **9 live-probed EVM chains via KyberSwap**,
|
|
319
|
+
**Sui via Aftermath**, **NEAR via Ref Finance**, **Algorand via Vestige**, **Aptos via
|
|
320
|
+
Hyperion**, **TON via STON.fi** and **Tron via SunSwap V2**, because those chains have no
|
|
321
|
+
protocol-level swap. **Ten routes across 18 of the 30 chains.** On a chain with no route,
|
|
322
|
+
`quoteSwap()` answers `null` and `swap()` throws `UnsupportedNetworkError` naming every venue
|
|
323
|
+
that does exist, read from the registry rather than typed into the message.
|
|
324
|
+
|
|
325
|
+
NEAR uses **Ref Finance rather than NEAR Intents**, deliberately: Intents puts a solver in
|
|
326
|
+
possession of the funds mid-flight, which is the intermediary this project exists to remove. Ref
|
|
327
|
+
is one `ft_transfer_call` receipt chain inside a single transaction the user signs.
|
|
328
|
+
|
|
329
|
+
The **swap coverage map is data** (`SWAP_PROVIDERS`), shaped like `KNOWN_FACILITATORS` and
|
|
330
|
+
carrying the same admission rule. The website table is generated from it, a sync rule and a guard
|
|
331
|
+
test hold the logos, site data and docs to it, and a new `swaps` domain documents the update order.
|
|
332
|
+
|
|
333
|
+
**Providers had to earn their place.** Each was probed live from a plain keyless server request:
|
|
334
|
+
KyberSwap 200 ✅, Jupiter 200 with `platformFee: null` ✅, while 0x (401), 1inch (401), Odos (530),
|
|
335
|
+
OpenOcean (403), Squid (400), Rango (401) and thirdweb Bridge (401) all failed and were rejected.
|
|
336
|
+
PipRail never sets a platform or integrator fee field on any provider.
|
|
337
|
+
|
|
338
|
+
**Proven with 21 real mainnet swaps across 9 chains**, both tiers, both directions on every
|
|
339
|
+
chain that has two, each verified by reading the transaction back from a public node, and
|
|
340
|
+
re-verifiable any time with `npm run verify:proofs`. The full table with hashes is in the docs. One known failure is recorded rather than hidden: FDUSD→USDC
|
|
341
|
+
on BNB reverts with `TRANSFER_FROM_FAILED` despite confirmed balance and allowance, and is
|
|
342
|
+
documented as unexplained.
|
|
343
|
+
|
|
344
|
+
**Live testing found nine real bugs, all fixed**, every one of which passed a typechecker and a
|
|
345
|
+
unit suite first: some tokens revert on a non-zero to non-zero ERC-20 `approve` (the allowance is
|
|
346
|
+
now zeroed first); XRPL path steps carry fields xrpl.js cannot always encode (now stripped);
|
|
347
|
+
XRPL public path finding is intermittent, returning a route then nothing with no error (now
|
|
348
|
+
retried); XRPL float precision silently discarded valid routes, making a whole direction look
|
|
349
|
+
unsupported (now parsed with a ceiling); and a malformed Stellar issuer threw out of a
|
|
350
|
+
never-throw method while one unparseable Horizon candidate could poison a valid quote. Four more
|
|
351
|
+
came from the new families, and **four of the nine made an entire chain look unsupported when it
|
|
352
|
+
was not**: setting `accept-encoding` by hand defeated fetch's gzip handling and broke NEAR
|
|
353
|
+
outright; NEAR pool ids are strings in the indexer and numbers on the contract; Vestige returns
|
|
354
|
+
`amount_out: 0` for a dust probe rather than an error, so a fixed probe size reported no route on
|
|
355
|
+
a pair that routes fine; and Sui's public JSON-RPC is being deprecated.
|
|
356
|
+
|
|
357
|
+
**The no-fee claim is self-verifying.** Rather than hardcoding it, every quote inspects the
|
|
358
|
+
provider's own fee fields and the note SAYS SO if one ever appears, instead of repeating a claim
|
|
359
|
+
that has quietly become false.
|
|
360
|
+
|
|
361
|
+
🔴 **PipRail still runs no price oracle** (STANDARDS §7). A swap rate is a price, so every
|
|
362
|
+
`SwapQuote` carries a `source` naming who produced it: `kind: 'protocol'` when the ledger's own
|
|
363
|
+
order books priced it (Stellar, XRPL), `kind: 'provider'` when a named venue did. The distinction
|
|
364
|
+
lives in the type system, not in prose.
|
|
365
|
+
|
|
366
|
+
Slippage rides **on-chain** (`sendMax` / `SendMax`, `amountInMax`, `amount_in_max`, TON's
|
|
367
|
+
`min_ask_units` floor), so a market that moves past your tolerance fails the transaction rather
|
|
368
|
+
than overspending. Nothing is swapped in that case; chains that charge for a reverted
|
|
369
|
+
transaction (EVM, Aptos, Tron) still take the gas. Default 0.5%, ceiling 10%, integer maths
|
|
370
|
+
that rounds in the user's favour, and a malformed `slippageBps` throws `RangeError` before any
|
|
371
|
+
read happens rather than coming back as a silent `null`.
|
|
372
|
+
|
|
373
|
+
New exports: `SwapRequest`, `SwapQuote`, `SwapReceipt`, `SwapSide`, `SwapQuoteSource`,
|
|
374
|
+
`summarizeSwap`, `resolveSlippageBps`, `applySlippage`, `DEFAULT_SLIPPAGE_BPS`,
|
|
375
|
+
`MAX_SLIPPAGE_BPS`. Docs: [Swapping tokens](https://docs.piprail.com/making-payments/swapping/).
|
|
376
|
+
|
|
377
|
+
Surveyed and deliberately **not** bundled: thirdweb Bridge, LI.FI, Squid, Relay, deBridge,
|
|
378
|
+
Across, Rango. thirdweb Bridge as the worked example requires an API key at the type level,
|
|
379
|
+
charges a 0.30% protocol fee an integrator cannot disable, and is EVM-only for cross-chain
|
|
380
|
+
routing, so nine of PipRail's ten families cannot even be expressed in its API.
|
|
381
|
+
|
|
382
|
+
### Fixed
|
|
383
|
+
|
|
384
|
+
- **Tron could not have sold a token at all.** SunSwap V2's router moves the input with
|
|
385
|
+
`transferFrom` and the driver had no approve step, so native-TRX-in worked while every TRC-20
|
|
386
|
+
direction would have failed on allowance after the energy was burned. Nothing caught it
|
|
387
|
+
because the route ships without a mainnet proof. Adding the approve then exposed a second bug:
|
|
388
|
+
a fresh allowance slot measures **99,764 energy ≈ 9.98 TRX**, and the approve ceiling had been
|
|
389
|
+
set to 10 TRX because "an approve is cheap". Both paths are simulated against Tether's real
|
|
390
|
+
Tron contract and pinned by tests; a token-in swap now budgets roughly 33 TRX.
|
|
391
|
+
|
|
392
|
+
- **Both shipped TON proofs pointed at the gas refund, not the swap.** STON.fi returns unused
|
|
393
|
+
forward gas to the same wallet a second after the swap, and the driver reported whichever
|
|
394
|
+
wallet transaction was newest, so the recorded reference was an *incoming* `excesses` message
|
|
395
|
+
with no outgoing message at all. The swaps were real; the evidence pointed at the wrong leg. The
|
|
396
|
+
driver now reports the newest transaction the wallet itself signed (`external-in`), the registry
|
|
397
|
+
carries the corrected hashes, and `verify:proofs` rejects a TON reference of the wrong shape.
|
|
398
|
+
|
|
399
|
+
- **STON.fi's "reverse" simulation was not exact-output.** Asked for 50000 at 1% tolerance it
|
|
400
|
+
returns an on-chain floor of 49500, which the router will deliver, short-paying an invoice. The
|
|
401
|
+
request is now padded so the floor is at or above the invoice, the quote is refused when it is
|
|
402
|
+
not, and every number the swap later parses is validated in the never-throw quote.
|
|
403
|
+
|
|
404
|
+
- **Aptos paid the account that quoted, not the one that signed.** The recipient was captured at
|
|
405
|
+
quote time and reused at swap time; every other family derives it from the signing wallet.
|
|
406
|
+
|
|
407
|
+
- **`quoteSwap()` swallowed a malformed `slippageBps` into `null` on a read-only client** because
|
|
408
|
+
validation ran after the wallet check. It now runs first, so a caller's bug throws `RangeError`
|
|
409
|
+
regardless of what else is wrong.
|
|
410
|
+
|
|
411
|
+
- **A mined transaction is not a successful swap — `swap()` could report success for a swap that
|
|
412
|
+
failed.** On EVM, `waitForTransactionReceipt` resolves for a **reverted** transaction exactly as
|
|
413
|
+
it does for a mined one; the driver returned unconditionally, so a reverted swap handed back a
|
|
414
|
+
`SwapReceipt` carrying a real transaction hash while nothing had moved. On Solana the same class
|
|
415
|
+
of hole existed one step earlier: `sendRawTransaction` returns once the RPC accepts the bytes, so
|
|
416
|
+
a transaction that then failed on-chain was also reported as a completed swap. Both now check and
|
|
417
|
+
throw `InsufficientFundsError`. This is the worst failure shape available to a payments SDK — an
|
|
418
|
+
agent believes it holds the token and goes on to pay an invoice it cannot cover — and it affected
|
|
419
|
+
**every EVM chain and Solana**, not one chain. Found on Robinhood Chain, whose 100ms blocks stale
|
|
420
|
+
a route often enough to surface it; Sui and NEAR already checked their status correctly.
|
|
421
|
+
|
|
422
|
+
- **A strict x402 v2 facilitator refused every PipRail settlement, and we read it as a missing
|
|
423
|
+
chain.** PipRail sent a v2 body carrying only the v1 `paymentRequirements` key. A facilitator
|
|
424
|
+
that validates the v2 envelope needs `accepted` and `resource` at the request root, and answered
|
|
425
|
+
`400 data did not match any variant of untagged enum VerifyRequestEnvelope`. A v2 body now carries
|
|
426
|
+
both spellings, so the older facilitators keep reading `paymentRequirements` while a strict v2
|
|
427
|
+
facilitator gets what it requires. `resource` comes from the merchant's own gate config, never the
|
|
428
|
+
client's echo, and is always sent complete because the field is required rather than optional.
|
|
429
|
+
|
|
430
|
+
The cost of the bug was not the 400 itself. It was recorded in `facilitators.ts` for months as
|
|
431
|
+
"Ultravioleta DAO's NEAR listing is aspirational, not settle-capable" — a conclusion about
|
|
432
|
+
somebody else's implementation drawn from an error about our own request.
|
|
433
|
+
|
|
434
|
+
### Added
|
|
435
|
+
|
|
436
|
+
- **NEAR is live on a keyless facilitator, the first x402 facilitator settlement on `near:mainnet`.**
|
|
437
|
+
Ultravioleta DAO settles the NEP-141 USDC rail through a NEP-366 meta-transaction with the buyer
|
|
438
|
+
paying zero NEAR (tx `8pVe4oeykCT2WpCwToNbkxjdgSvwNNtJUrGezDCHKDda`), so `exact: true` is now
|
|
439
|
+
zero-config gasless on NEAR.
|
|
440
|
+
- **Ultravioleta DAO on Avalanche**, buyer holding zero AVAX
|
|
441
|
+
(tx `0xe9a88e4fa978a16599b6188444d87ea63d43122e5fad16493568c2a596a79436`).
|
|
442
|
+
- NEAR receipts are now re-verifiable by the facilitator-probe tx checker and link to an explorer on
|
|
443
|
+
piprail.com. Both tools matched only EVM, Solana and Algorand hash shapes, so a NEAR receipt would
|
|
444
|
+
have shipped as unlinked, unchecked text.
|
|
445
|
+
|
|
446
|
+
### Notes
|
|
447
|
+
|
|
448
|
+
- A sweep of all 21 mainnets Ultravioleta DAO advertises found two more it does **not** settle:
|
|
449
|
+
Solana (`/verify` returns `isValid: true`, then `/settle` fails `contract_call_failed`) and
|
|
450
|
+
Algorand (`/verify` cannot deserialize an Algorand `exact` payload). Both are locked by tests so
|
|
451
|
+
the next sweep does not re-run them. Advertised is not settled — only a receipt is.
|
|
452
|
+
|
|
7
453
|
## [2.16.2] — 2026-09-06 — every facilitator now carries a verifiable receipt
|
|
8
454
|
|
|
9
455
|
### Changed
|
|
@@ -2196,6 +2642,8 @@ straight into your wallet. The API is small and self-contained.
|
|
|
2196
2642
|
[1.5.0]: https://www.npmjs.com/package/@piprail/sdk
|
|
2197
2643
|
[1.4.0]: https://www.npmjs.com/package/@piprail/sdk
|
|
2198
2644
|
[1.3.1]: https://www.npmjs.com/package/@piprail/sdk
|
|
2645
|
+
[3.1.0]: https://www.npmjs.com/package/@piprail/sdk
|
|
2646
|
+
[3.0.0]: https://www.npmjs.com/package/@piprail/sdk
|
|
2199
2647
|
[1.3.0]: https://www.npmjs.com/package/@piprail/sdk
|
|
2200
2648
|
[1.2.0]: https://www.npmjs.com/package/@piprail/sdk
|
|
2201
2649
|
[1.1.1]: https://www.npmjs.com/package/@piprail/sdk
|
package/README.md
CHANGED
|
@@ -78,6 +78,7 @@ The same app can **take** payments and **make** them. → [Making payments](http
|
|
|
78
78
|
| **[Getting started](https://docs.piprail.com/getting-started/introduction/)** | Install · quickstart · how it works |
|
|
79
79
|
| **[Accepting payments](https://docs.piprail.com/accepting-payments/require-payment-and-gate/)** | `requirePayment` · `createPaymentGate` · [presets](https://docs.piprail.com/accepting-payments/merchant-presets/) (`createPaywall` / `createTipJar`) · [framework adapters](https://docs.piprail.com/accepting-payments/framework-adapters/) · the `exact` rail · the `upto` metered rail |
|
|
80
80
|
| **[Making payments](https://docs.piprail.com/making-payments/piprail-client/)** | `PipRailClient` · `quote` · `estimateCost` · `planPayment` · auto-route · `MultiChainPayer` |
|
|
81
|
+
| **[Swapping tokens](https://docs.piprail.com/making-payments/swapping/)** | `quoteSwap` · `swap` — OPTIONAL, opt-in, never automatic. Keyless routes only, no fee, no API key |
|
|
81
82
|
| **[Verifiable receipts](https://docs.piprail.com/accepting-payments/verifiable-receipts/)** | Chain-grounded, anyone-verifiable receipts (no key) · optional EIP-712 attestation |
|
|
82
83
|
| **[Spend controls](https://docs.piprail.com/spend-controls/payment-policy/)** | Per-token + cross-token grand total · payment-count caps · time envelope · durable budget · the spend ledger |
|
|
83
84
|
| **[Agent toolkit](https://docs.piprail.com/agent-toolkit/payment-tools/)** | `paymentTools` · the agent guide · NL renderers |
|