@piprail/sdk 2.16.1 → 3.0.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +369 -0
  2. package/README.md +1 -0
  3. package/dist/{algorand-25FMBCT3.js → algorand-HL57PQHE.js} +157 -2
  4. package/dist/{algorand-Q3TUQLPK.cjs → algorand-W776EEM2.cjs} +205 -50
  5. package/dist/{aptos-VBJONBFY.cjs → aptos-5QBXJ6NV.cjs} +255 -39
  6. package/dist/{aptos-SBV6SGDP.js → aptos-H5VLH2QL.js} +217 -1
  7. package/dist/{chunk-QONQSZHJ.cjs → chunk-6XTNI2OQ.cjs} +44 -44
  8. package/dist/{chunk-OXEFPLZA.cjs → chunk-6ZRAIQXF.cjs} +4 -4
  9. package/dist/chunk-GVCGUSTE.js +30 -0
  10. package/dist/{chunk-V2IJ5HUW.cjs → chunk-MZAJQYM3.cjs} +1 -0
  11. package/dist/chunk-MZXVXC3C.cjs +30 -0
  12. package/dist/{chunk-2CX7XRZK.js → chunk-TZDVZCTC.js} +1 -0
  13. package/dist/{chunk-C52H5TYB.js → chunk-YXBQKBDH.js} +2 -2
  14. package/dist/index.cjs +2935 -1450
  15. package/dist/index.d.cts +625 -13
  16. package/dist/index.d.ts +625 -13
  17. package/dist/index.js +2510 -1025
  18. package/dist/{near-OLKCMTBI.js → near-5X2CQGML.js} +197 -1
  19. package/dist/{near-5LTTDU6G.cjs → near-XSQXEIHT.cjs} +242 -46
  20. package/dist/{solana-AI2G7V33.cjs → solana-EBV6PUCU.cjs} +211 -52
  21. package/dist/{solana-HTKDRTD3.js → solana-O6Q6QILH.js} +167 -8
  22. package/dist/{stellar-E2KWEV2E.cjs → stellar-5C7FQLPS.cjs} +191 -29
  23. package/dist/{stellar-EUFZLX6J.js → stellar-YF5LOJEM.js} +163 -1
  24. package/dist/{sui-KCIITCYH.js → sui-JFQSDNSZ.js} +165 -3
  25. package/dist/{sui-Q3NJOJZS.cjs → sui-WIZEAO7E.cjs} +185 -23
  26. package/dist/{ton-J7TQWRN4.cjs → ton-AGTKLQA5.cjs} +264 -22
  27. package/dist/{ton-WH2JVQOO.js → ton-OZKFNRLT.js} +244 -2
  28. package/dist/{tron-FXBXDNEY.js → tron-HG3IOOCG.js} +283 -1
  29. package/dist/{tron-HIPMOX7S.cjs → tron-S4WX7OHY.cjs} +323 -41
  30. package/dist/{xrpl-RUOB37QH.js → xrpl-GOVHMYYK.js} +222 -5
  31. package/dist/{xrpl-SVVS445B.cjs → xrpl-YS3IXPLV.cjs} +273 -56
  32. package/package.json +12 -2
  33. package/dist/{chunk-QU25LSVS.js → chunk-5GRBEMCA.js} +44 -44
package/CHANGELOG.md CHANGED
@@ -4,6 +4,373 @@ 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.0.0] — 2026-09-09 — agent modes: a wallet an agent can EARN with, not only spend from
8
+
9
+ ### BREAKING
10
+
11
+ - **`ResolvedNetwork` gains a required `addressOf(wallet)`.** Only affects code that implements
12
+ a CUSTOM driver via `registerDriver`; every built-in family already has it. The migration is one
13
+ method: return the bound wallet's own address, derived from the key, with no RPC read. Nothing
14
+ else in the public API changed, and no existing call site needs touching.
15
+
16
+ ### Added
17
+
18
+ - **Agent modes: capability follows AUTHORITY, and `sovereign` gives an agent BOTH halves of a
19
+ wallet.** A new `mode` option on `PipRailClient` (`'supervised' | 'budgeted' | 'sovereign'`,
20
+ default `'budgeted'`) and `PIPRAIL_MODE` on the MCP server. **The default is unchanged**: omit
21
+ it and `paymentTools()` returns exactly the same eight tools, in the same order, so nothing
22
+ moves for anyone who does not opt in.
23
+
24
+ Before this, whether a model could swap depended on which PACKAGE it imported: a model driving
25
+ `@piprail/mcp` never could, while the same wallet driven through the SDK always could. That is
26
+ the wrong axis. `mode` moves the decision onto the only question that matters, which is who is
27
+ answerable for the wallet, and it is set by whoever provisions the key. A model can never set
28
+ it: `mode()` reads, nothing writes, and no tool takes a `mode` argument.
29
+
30
+ ⭐ **`'sovereign'` is the whole wallet, not a bigger allowance.** A budget only ever lets an
31
+ agent SPEND, however large the cap. Sovereign appends five tools: `piprail_quote_swap` and
32
+ `piprail_swap`, plus the seller tools **`piprail_sell`**, **`piprail_collect`** and
33
+ **`piprail_earnings`**, so an agent can be PAID as well as pay. `piprail_sell` prices something
34
+ and returns an x402 challenge; because a challenge is only data, the agent hands it to a buyer
35
+ over any channel it already has, with **no web server and no open port anywhere**.
36
+ `piprail_collect` verifies the returned proof against the chain, and only `paid: true` clears
37
+ delivery. Each proof is single-use, so a replay is never a second sale.
38
+
39
+ **Two properties make selling safe to grant a model where spending is not.** The receiving side
40
+ holds **no private key at all** (`payTo` is a public address), so the earning half cannot spend
41
+ and cannot be drained even if the host is taken. And `payTo` defaults to the agent's **own**
42
+ address, which it otherwise has no way to learn, so it is never told to trust one a buyer sent.
43
+
44
+ Swapping is bounded by a separate instrument, because the payment caps cannot bound it: a swap
45
+ moves the holder's own funds between denominations, so `maxAmount`, `maxTotal`, the count caps
46
+ and the session TTL all pass over it untouched. `swapPolicy` adds a ceiling per swap, a slippage
47
+ ceiling and an optional destination allowlist, and the ceiling binds on the quote's on-chain
48
+ `maxSpend`, never the estimate. The MCP server **refuses to boot** on
49
+ `PIPRAIL_MODE=sovereign` without `PIPRAIL_MAX_PER_SWAP`, so an operator finds out while reading
50
+ their own config rather than mid-spend. Selling needs no ceiling, because it takes money rather
51
+ than spending it.
52
+
53
+ - **`client.balanceOf(assets)` + the `piprail_wallet` tool: an agent can finally see its own
54
+ balance sheet.** A sovereign agent had thirteen tools and could not answer *"what do I have?"* —
55
+ `budget()` reports how much of an allowance is left, which is a different question, and
56
+ `planPayment()` answers it only for one URL at a time. Owning finances starts with seeing them.
57
+ An unavailable read reports `null`, **never `0`**: an agent told it holds nothing when the RPC
58
+ merely failed would fire-sale to recover from a loss that never happened. On a `MultiChainPayer`
59
+ the holdings span every chain, because reporting only the primary would call an agent broke
60
+ while it held funds one client along.
61
+
62
+ - **`ResolvedNetwork.addressOf(wallet)` and `client.address()`: a wallet can finally report where
63
+ it gets PAID.** Every driver already derived this internally for `balanceOf`; nothing exposed
64
+ it. Without it an agent handed a key it never chose has no way to learn its own address, so it
65
+ could pay for anything and be paid for nothing. Implemented across all ten families. Pure:
66
+ derived from the key, no RPC read, nothing moved. Also `client.chain()`, and `canAgentSell()`
67
+ alongside the existing `canAgentSwap()`. `MultiChainPayer` delegates all of them to the primary
68
+ client, without which sovereign mode would be unreachable through the MCP, which always wraps
69
+ its accounts in one.
70
+
71
+ ### Fixed
72
+
73
+ - **An agent paying twice in a row collided with itself on EVM.** Found live on Base: the first
74
+ payment settled and the next two were refused by the node with "nonce lower than the current
75
+ nonce of the account", because viem asks the RPC for the nonce on every send and two payments
76
+ issued back to back both read the same value before either was mined. It failed SAFELY (nothing
77
+ broadcast, nothing recorded as spent, `send` called exactly once with no retry), so it never
78
+ risked a double payment, but paying several times in quick succession is the ordinary behaviour
79
+ of an autonomous agent rather than an edge case. A raw `{ key }` is now bound through viem's
80
+ `nonceManager`, which assigns nonces locally per (chain, account) and reconciles with the chain.
81
+ A caller who brings their own `walletClient` keeps their own nonce policy, untouched. The same
82
+ three back-to-back Base payments now all settle.
83
+
84
+ - **TON reported a BOUNCEABLE address as "where I get paid".** `addressOf` was added to all ten
85
+ families at once, and checking each against the address recorded in its funded test wallet came
86
+ back 9/10: TON derived `EQ…` where the wallet file (and every TON wallet UI) says `UQ…`, the
87
+ same account in the other encoding. A bounceable address returns funds to the sender when the
88
+ destination contract is not yet initialised, and a wallet contract stays uninitialised until it
89
+ has sent its first transaction, so a freshly generated agent advertising `EQ…` would have
90
+ bounced the first payment it was ever sent. Verification was never affected (both forms parse to
91
+ one account); what was wrong is the address an agent hands out. Now 10/10 against ground truth.
92
+
93
+ - **🔴 One payment could collect TWO offers.** Found by attacking the seller tools. Each offer
94
+ owns its own gate, and a gate's replay set is scoped to itself, which is right for
95
+ `requirePayment` (one gate, one resource) and wrong for a store of offers: two offers priced the
96
+ same to the same address are indistinguishable to any driver, because a driver can only ask "did
97
+ this settlement move at least X to this address?". A buyer could pay for a haiku and collect the
98
+ expensive listing with the same money. Two bindings now close it, both mutation-proven and
99
+ re-verified on mainnet: `collect` refuses a proof minted for a different offer
100
+ (`code: 'wrong_offer'`) before the chain is consulted, and the used-proof set belongs to the
101
+ SELLER rather than to each gate, so one settlement is dead everywhere once redeemed.
102
+
103
+ - **`mode: 'supervised'` supervised nothing, and `PIPRAIL_MODE=supervised` wired nothing.** The
104
+ mode↔confirm inference ran ONE WAY: `PIPRAIL_CONFIRM=1` meant supervised, but naming the mode
105
+ did not turn confirmation on. An operator who asked for a human in the loop got an agent that
106
+ paid without asking anybody, with the banner, the docs and the mode name all agreeing it was
107
+ supervised. Both directions now agree; a config that says both things at once is refused rather
108
+ than quietly resolved; and the SDK refuses `mode: 'supervised'` with no `onBeforePay` to perform
109
+ it. `sovereign` + confirmation is not a contradiction and stays allowed.
110
+
111
+ - **A SWAP was invisible to the approver.** `onBeforePay` never sees a swap, because a swap is not
112
+ a payment, which is the whole reason `swapPolicy` bounds it instead. But an operator who wired
113
+ an approver did not mean "ask me before payments and let value move silently any other way", so
114
+ a supervised sovereign agent could swap its whole balance without one prompt. New
115
+ **`onBeforeSwap`**, same fail-safe contract, wired automatically by `@piprail/mcp` whenever
116
+ confirmation is on. Proven against the bug: a refusing approver was never called and the swap
117
+ executed.
118
+
119
+ - **The SDK let `'sovereign'` hand a model the swap tools with NO ceiling.** `@piprail/mcp`
120
+ already refused to boot in that state, so the safer surface could be sidestepped by importing
121
+ the other one. Proven before the fix: a 99,999-unit swap executed with nothing refusing it.
122
+ `mode: 'sovereign'` now requires `swapPolicy.maxPerSwap` in the SDK too.
123
+
124
+ - **The MCP banner under-reported what the agent could do.** It printed a hardcoded eight tools
125
+ even in sovereign mode, where the model actually holds thirteen. It is now handed the real list
126
+ at startup, so an operator can see the capability they granted.
127
+
128
+ - **Docs claimed the MCP has no swap tool "and there will not be one".** True when written and
129
+ false since modes shipped, in `mcp/README.md` and the tools reference. Both now describe the
130
+ mode-dependent contract, and a sync rule pins it so the claim cannot rot again.
131
+
132
+ - **Swapping reaches every family: Aptos, TON and Tron close the last three gaps.** Seven routes
133
+ became **ten, across 18 chains, with 21 mainnet proofs**. Nothing about the existing routes
134
+ changed, and swapping stays opt-in.
135
+
136
+ **Aptos via Hyperion** and **Tron via SunSwap V2** need **no API at all**: the quote is an
137
+ on-chain read and the swap is a contract call the user's own key signs. There is no host to go
138
+ down, no key to rotate and no vendor in the middle. That was deliberate on Tron, whose own front
139
+ end talks to an undocumented, obfuscated hostname a payments SDK should not depend on. **TON via
140
+ STON.fi** uses a keyless REST simulation and one message the wallet signs.
141
+
142
+ ⭐ **Three of them are exact-output natively**, which no earlier route was. Hyperion's
143
+ `exact_output_swap_entry`, SunSwap's `swapTokensForExactTokens` and STON.fi's `reverse_swap`
144
+ all take the invoice amount and cap the input **on-chain**, so an x402 invoice is priced from
145
+ its output rather than sized from a probe. Proven to the unit on Aptos: four swaps delivered
146
+ exactly 0.05 USDT, 0.04 USDC, 0.03 USDC and 0.02 APT. On TON the guarantee is a floor rather
147
+ than a point: STON.fi's `reverse_swap` fixes the ask side, and PipRail pads the request so the
148
+ router's on-chain `min_ask_units` is at or above the invoice. Slightly more can arrive; less is
149
+ refused by the router.
150
+
151
+ **Live-proven on mainnet**: four Aptos swaps (both stable directions plus native APT in and out)
152
+ and two TON swaps (native in, jetton in, which the simulation routed through two *different*
153
+ router versions). **Tron ships WITHOUT a proof and says so on the site and in the docs**: the
154
+ quote is verified live and the swap executes cleanly in simulation, but Tron charges about
155
+ 230,629 ENERGY per swap, which without staked energy is roughly 23 TRX (~$7.79) *regardless of
156
+ trade size*, and the test wallets hold 8.1 TRX. A new `unproven` field carries that reason, and
157
+ a guard test fails if a route ever ships with neither a proof nor a stated reason.
158
+
159
+ Adds two OPTIONAL peer dependencies for TON only (`@ston-fi/sdk`, `@ston-fi/api`, both MIT and
160
+ lazily imported); the lazy-chunk invariant still holds, so a pure-EVM install downloads neither.
161
+ Aptos and Tron needed no new dependency at all.
162
+
163
+ **Four more bugs found the same way as always, by spending real money.** Aptos aborted with
164
+ `ESQRT_PRICE_LIMIT_UNAVAILABLE` because a concentrated-liquidity swap needs a directional price
165
+ bound and `0` is not one; the bounds are now read off the chain rather than copied from another
166
+ DEX. The entry function's two amount slots are "what leaves, what arrives", **not** "the exact
167
+ one, the limit", and having them backwards aborts with `EINSUFFICIENT_BALANCE`, which reads like
168
+ a funding problem and is not one. A pool's `token0` field comes back as all-zeros for native-APT
169
+ pools, so the direction is now derived by comparing addresses. And pricing every fee tier blindly
170
+ burned the public node's anonymous compute quota (40k units per 300s), after which good routes
171
+ came back as "no route" — the quote now cheap-filters empty pools before paying to price them.
172
+
173
+ - **Robinhood Chain (`chain: 'robinhood'`, eip155:4663) — the 30th chain, and the first with a
174
+ Paxos-issued stablecoin.** The tokenized-equity Arbitrum Orbit L2, mainnet since 2026-07-01,
175
+ running 100ms blocks and settling to Ethereum. viem ships no preset and it is absent from
176
+ chainid.network, so the chain is defined inline from the values Robinhood publishes.
177
+
178
+ **One stablecoin on purpose: USDG**, the Paxos Global Dollar, which is what the chain's own
179
+ markets quote against. Its address was agreed by four independent sources before shipping —
180
+ Paxos's published list, our own on-chain read (`Global Dollar`, 6dp), and both facilitators'
181
+ `/supported`. **Circle issues no native USDC here** (checked against Circle's own contract
182
+ list), so no USDC or USDT preset ships: a bridged one would break the issuer-native token rule.
183
+ Native ETH is a payment asset as on every EVM chain.
184
+
185
+ **Gasless from day one, proven twice.** USDG is EIP-3009 **and** EIP-2612, verified by calling
186
+ the contract rather than by scanning bytecode — a bytecode scan said neither was present, and
187
+ was wrong. Its EIP-712 domain is `name: "Global Dollar", version: "1"`, which we confirmed by
188
+ recomputing the on-chain `DOMAIN_SEPARATOR`. Two keyless facilitators already settle there and
189
+ **both were live-settled by us**, with buyer *and* merchant paying zero ETH:
190
+ Ultravioleta DAO (tx `0x35361e57…`) and Dexter (tx `0x1ba66e3d…`, permit2, ~$0.0066 floor).
191
+ A self-settled EIP-3009 round trip also passed end to end (tx `0xe6d35460…`): 402 → sign →
192
+ settle → 200, replay rejected as `tx_already_used`, payer ETH delta exactly zero.
193
+
194
+ **Swapping works there too**, through the existing KyberSwap route — native ETH → 0.360518 USDG,
195
+ tx `0x424299e6…`. Worth knowing: at 100ms blocks a route goes stale fast, and the default
196
+ 0.5% slippage reverted before 3% cleared. Reaching the chain at all is a bridge; Relay moved
197
+ ETH from Base in about three seconds for roughly $0.04, and Across and LI.FI both list it.
198
+
199
+ - **`quoteSwap()` / `swap()`: an optional helper for when you hold the wrong token.** A 402 names a
200
+ token, a wallet holds what it holds, and when those disagree `planPayment()` reported
201
+ `INSUFFICIENT_TOKEN` and stopped. This turns that dead end into an option the caller may take or
202
+ ignore.
203
+
204
+ **It is opt-in and it stays that way.** Paying never swaps, planning never swaps, and there is
205
+ deliberately no `autoSwap` flag: converting one asset into another is a priced, irreversible act,
206
+ and a payment library should not do it on your behalf because it noticed you were short. A test
207
+ asserts the byte-identical default (STANDARDS §0), so "not using it costs you nothing" is machine
208
+ checked rather than promised in prose.
209
+
210
+ Ships in **two tiers, kept apart by the type system**. Tier 1 (`source.kind: 'protocol'`) is
211
+ Stellar (`PathPaymentStrictReceive` to your own account) and the XRP Ledger (a cross-currency
212
+ `Payment` to your own address, auto-bridged through XRP): the ledger itself swaps, so there is
213
+ **no third party, no API key, no extra dependency and no integrator fee**. Tier 2
214
+ (`source.kind: 'provider'`) is **Solana via Jupiter**, **9 live-probed EVM chains via KyberSwap**,
215
+ **Sui via Aftermath**, **NEAR via Ref Finance**, **Algorand via Vestige**, **Aptos via
216
+ Hyperion**, **TON via STON.fi** and **Tron via SunSwap V2**, because those chains have no
217
+ protocol-level swap. **Ten routes across 18 of the 30 chains.** On a chain with no route,
218
+ `quoteSwap()` answers `null` and `swap()` throws `UnsupportedNetworkError` naming every venue
219
+ that does exist, read from the registry rather than typed into the message.
220
+
221
+ NEAR uses **Ref Finance rather than NEAR Intents**, deliberately: Intents puts a solver in
222
+ possession of the funds mid-flight, which is the intermediary this project exists to remove. Ref
223
+ is one `ft_transfer_call` receipt chain inside a single transaction the user signs.
224
+
225
+ The **swap coverage map is data** (`SWAP_PROVIDERS`), shaped like `KNOWN_FACILITATORS` and
226
+ carrying the same admission rule. The website table is generated from it, a sync rule and a guard
227
+ test hold the logos, site data and docs to it, and a new `swaps` domain documents the update order.
228
+
229
+ **Providers had to earn their place.** Each was probed live from a plain keyless server request:
230
+ KyberSwap 200 ✅, Jupiter 200 with `platformFee: null` ✅, while 0x (401), 1inch (401), Odos (530),
231
+ OpenOcean (403), Squid (400), Rango (401) and thirdweb Bridge (401) all failed and were rejected.
232
+ PipRail never sets a platform or integrator fee field on any provider.
233
+
234
+ **Proven with 21 real mainnet swaps across 9 chains**, both tiers, both directions on every
235
+ chain that has two, each verified by reading the transaction back from a public node, and
236
+ 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
237
+ on BNB reverts with `TRANSFER_FROM_FAILED` despite confirmed balance and allowance, and is
238
+ documented as unexplained.
239
+
240
+ **Live testing found nine real bugs, all fixed**, every one of which passed a typechecker and a
241
+ unit suite first: some tokens revert on a non-zero to non-zero ERC-20 `approve` (the allowance is
242
+ now zeroed first); XRPL path steps carry fields xrpl.js cannot always encode (now stripped);
243
+ XRPL public path finding is intermittent, returning a route then nothing with no error (now
244
+ retried); XRPL float precision silently discarded valid routes, making a whole direction look
245
+ unsupported (now parsed with a ceiling); and a malformed Stellar issuer threw out of a
246
+ never-throw method while one unparseable Horizon candidate could poison a valid quote. Four more
247
+ came from the new families, and **four of the nine made an entire chain look unsupported when it
248
+ was not**: setting `accept-encoding` by hand defeated fetch's gzip handling and broke NEAR
249
+ outright; NEAR pool ids are strings in the indexer and numbers on the contract; Vestige returns
250
+ `amount_out: 0` for a dust probe rather than an error, so a fixed probe size reported no route on
251
+ a pair that routes fine; and Sui's public JSON-RPC is being deprecated.
252
+
253
+ **The no-fee claim is self-verifying.** Rather than hardcoding it, every quote inspects the
254
+ provider's own fee fields and the note SAYS SO if one ever appears, instead of repeating a claim
255
+ that has quietly become false.
256
+
257
+ 🔴 **PipRail still runs no price oracle** (STANDARDS §7). A swap rate is a price, so every
258
+ `SwapQuote` carries a `source` naming who produced it: `kind: 'protocol'` when the ledger's own
259
+ order books priced it (Stellar, XRPL), `kind: 'provider'` when a named venue did. The distinction
260
+ lives in the type system, not in prose.
261
+
262
+ Slippage rides **on-chain** (`sendMax` / `SendMax`, `amountInMax`, `amount_in_max`, TON's
263
+ `min_ask_units` floor), so a market that moves past your tolerance fails the transaction rather
264
+ than overspending. Nothing is swapped in that case; chains that charge for a reverted
265
+ transaction (EVM, Aptos, Tron) still take the gas. Default 0.5%, ceiling 10%, integer maths
266
+ that rounds in the user's favour, and a malformed `slippageBps` throws `RangeError` before any
267
+ read happens rather than coming back as a silent `null`.
268
+
269
+ New exports: `SwapRequest`, `SwapQuote`, `SwapReceipt`, `SwapSide`, `SwapQuoteSource`,
270
+ `summarizeSwap`, `resolveSlippageBps`, `applySlippage`, `DEFAULT_SLIPPAGE_BPS`,
271
+ `MAX_SLIPPAGE_BPS`. Docs: [Swapping tokens](https://docs.piprail.com/making-payments/swapping/).
272
+
273
+ Surveyed and deliberately **not** bundled: thirdweb Bridge, LI.FI, Squid, Relay, deBridge,
274
+ Across, Rango. thirdweb Bridge as the worked example requires an API key at the type level,
275
+ charges a 0.30% protocol fee an integrator cannot disable, and is EVM-only for cross-chain
276
+ routing, so nine of PipRail's ten families cannot even be expressed in its API.
277
+
278
+ ### Fixed
279
+
280
+ - **Tron could not have sold a token at all.** SunSwap V2's router moves the input with
281
+ `transferFrom` and the driver had no approve step, so native-TRX-in worked while every TRC-20
282
+ direction would have failed on allowance after the energy was burned. Nothing caught it
283
+ because the route ships without a mainnet proof. Adding the approve then exposed a second bug:
284
+ a fresh allowance slot measures **99,764 energy ≈ 9.98 TRX**, and the approve ceiling had been
285
+ set to 10 TRX because "an approve is cheap". Both paths are simulated against Tether's real
286
+ Tron contract and pinned by tests; a token-in swap now budgets roughly 33 TRX.
287
+
288
+ - **Both shipped TON proofs pointed at the gas refund, not the swap.** STON.fi returns unused
289
+ forward gas to the same wallet a second after the swap, and the driver reported whichever
290
+ wallet transaction was newest, so the recorded reference was an *incoming* `excesses` message
291
+ with no outgoing message at all. The swaps were real; the evidence pointed at the wrong leg. The
292
+ driver now reports the newest transaction the wallet itself signed (`external-in`), the registry
293
+ carries the corrected hashes, and `verify:proofs` rejects a TON reference of the wrong shape.
294
+
295
+ - **STON.fi's "reverse" simulation was not exact-output.** Asked for 50000 at 1% tolerance it
296
+ returns an on-chain floor of 49500, which the router will deliver, short-paying an invoice. The
297
+ request is now padded so the floor is at or above the invoice, the quote is refused when it is
298
+ not, and every number the swap later parses is validated in the never-throw quote.
299
+
300
+ - **Aptos paid the account that quoted, not the one that signed.** The recipient was captured at
301
+ quote time and reused at swap time; every other family derives it from the signing wallet.
302
+
303
+ - **`quoteSwap()` swallowed a malformed `slippageBps` into `null` on a read-only client** because
304
+ validation ran after the wallet check. It now runs first, so a caller's bug throws `RangeError`
305
+ regardless of what else is wrong.
306
+
307
+ - **A mined transaction is not a successful swap — `swap()` could report success for a swap that
308
+ failed.** On EVM, `waitForTransactionReceipt` resolves for a **reverted** transaction exactly as
309
+ it does for a mined one; the driver returned unconditionally, so a reverted swap handed back a
310
+ `SwapReceipt` carrying a real transaction hash while nothing had moved. On Solana the same class
311
+ of hole existed one step earlier: `sendRawTransaction` returns once the RPC accepts the bytes, so
312
+ a transaction that then failed on-chain was also reported as a completed swap. Both now check and
313
+ throw `InsufficientFundsError`. This is the worst failure shape available to a payments SDK — an
314
+ agent believes it holds the token and goes on to pay an invoice it cannot cover — and it affected
315
+ **every EVM chain and Solana**, not one chain. Found on Robinhood Chain, whose 100ms blocks stale
316
+ a route often enough to surface it; Sui and NEAR already checked their status correctly.
317
+
318
+ - **A strict x402 v2 facilitator refused every PipRail settlement, and we read it as a missing
319
+ chain.** PipRail sent a v2 body carrying only the v1 `paymentRequirements` key. A facilitator
320
+ that validates the v2 envelope needs `accepted` and `resource` at the request root, and answered
321
+ `400 data did not match any variant of untagged enum VerifyRequestEnvelope`. A v2 body now carries
322
+ both spellings, so the older facilitators keep reading `paymentRequirements` while a strict v2
323
+ facilitator gets what it requires. `resource` comes from the merchant's own gate config, never the
324
+ client's echo, and is always sent complete because the field is required rather than optional.
325
+
326
+ The cost of the bug was not the 400 itself. It was recorded in `facilitators.ts` for months as
327
+ "Ultravioleta DAO's NEAR listing is aspirational, not settle-capable" — a conclusion about
328
+ somebody else's implementation drawn from an error about our own request.
329
+
330
+ ### Added
331
+
332
+ - **NEAR is live on a keyless facilitator, the first x402 facilitator settlement on `near:mainnet`.**
333
+ Ultravioleta DAO settles the NEP-141 USDC rail through a NEP-366 meta-transaction with the buyer
334
+ paying zero NEAR (tx `8pVe4oeykCT2WpCwToNbkxjdgSvwNNtJUrGezDCHKDda`), so `exact: true` is now
335
+ zero-config gasless on NEAR.
336
+ - **Ultravioleta DAO on Avalanche**, buyer holding zero AVAX
337
+ (tx `0xe9a88e4fa978a16599b6188444d87ea63d43122e5fad16493568c2a596a79436`).
338
+ - NEAR receipts are now re-verifiable by the facilitator-probe tx checker and link to an explorer on
339
+ piprail.com. Both tools matched only EVM, Solana and Algorand hash shapes, so a NEAR receipt would
340
+ have shipped as unlinked, unchecked text.
341
+
342
+ ### Notes
343
+
344
+ - A sweep of all 21 mainnets Ultravioleta DAO advertises found two more it does **not** settle:
345
+ Solana (`/verify` returns `isValid: true`, then `/settle` fails `contract_call_failed`) and
346
+ Algorand (`/verify` cannot deserialize an Algorand `exact` payload). Both are locked by tests so
347
+ the next sweep does not re-run them. Advertised is not settled — only a receipt is.
348
+
349
+ ## [2.16.2] — 2026-09-06 — every facilitator now carries a verifiable receipt
350
+
351
+ ### Changed
352
+
353
+ - **All 29 facilitator entries now cite a full, machine-verifiable transaction hash.** Four did
354
+ not: three kept only a truncated prefix (`tx 4dL8jRKH…`) and one recorded a verification with no
355
+ hash at all. A prefix cannot be opened on an explorer and cannot be re-checked by
356
+ `verify-tx.mjs`, so those four entries were claims rather than proofs.
357
+
358
+ Rather than reconstruct old hashes, each was **re-proven with a fresh mainnet payment**: PayAI
359
+ and OpenFacilitator on Solana, PayAI and xpay on Base. Every run asserted the full round trip,
360
+ the merchant balance moving by exactly the amount, the buyer paying **zero** native, and a
361
+ replay being rejected.
362
+
363
+ `verify-tx.mjs` now reports **30 verified, 0 refuted, 0 skipped**. Previously 4 were skipped as
364
+ unverifiable.
365
+
366
+ ### Fixed
367
+
368
+ - **Solana proof hashes never rendered as explorer links on piprail.com.** The site generator
369
+ matched only EVM (`0x…64`) and Algorand (base32) hash shapes, so base58 Solana signatures fell
370
+ through to unlinked text while `solscan.io` sat configured and unused in the chain metadata.
371
+ Every Solana receipt is now a working link. This was invisible because the hash still displayed,
372
+ just without a link.
373
+
7
374
  ## [2.16.1] — 2026-09-06 — GoPlausible settles Solana, live-proven
8
375
 
9
376
  ### Added
@@ -2130,6 +2497,7 @@ straight into your wallet. The API is small and self-contained.
2130
2497
  to your wallet; PipRail never holds funds.
2131
2498
  - `viem ^2.21` is a peer dependency. Node 20+ or a modern browser.
2132
2499
 
2500
+ [2.16.2]: https://www.npmjs.com/package/@piprail/sdk
2133
2501
  [2.16.1]: https://www.npmjs.com/package/@piprail/sdk
2134
2502
  [2.16.0]: https://www.npmjs.com/package/@piprail/sdk
2135
2503
  [2.15.1]: https://www.npmjs.com/package/@piprail/sdk
@@ -2170,6 +2538,7 @@ straight into your wallet. The API is small and self-contained.
2170
2538
  [1.5.0]: https://www.npmjs.com/package/@piprail/sdk
2171
2539
  [1.4.0]: https://www.npmjs.com/package/@piprail/sdk
2172
2540
  [1.3.1]: https://www.npmjs.com/package/@piprail/sdk
2541
+ [3.0.0]: https://www.npmjs.com/package/@piprail/sdk
2173
2542
  [1.3.0]: https://www.npmjs.com/package/@piprail/sdk
2174
2543
  [1.2.0]: https://www.npmjs.com/package/@piprail/sdk
2175
2544
  [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 |
@@ -1,6 +1,9 @@
1
1
  import {
2
2
  ALGORAND_SPEC_CAIP2
3
- } from "./chunk-2CX7XRZK.js";
3
+ } from "./chunk-TZDVZCTC.js";
4
+ import {
5
+ applySlippage
6
+ } from "./chunk-GVCGUSTE.js";
4
7
  import {
5
8
  ConfirmationTimeoutError,
6
9
  InsufficientFundsError,
@@ -10,10 +13,11 @@ import {
10
13
  UnsupportedSchemeError,
11
14
  WrongFamilyError,
12
15
  assertNoLegacyWalletKey,
16
+ formatUnits,
13
17
  nativeCost,
14
18
  rejectForeignToken,
15
19
  toInsufficientFundsError
16
- } from "./chunk-QU25LSVS.js";
20
+ } from "./chunk-5GRBEMCA.js";
17
21
 
18
22
  // src/drivers/algorand/index.ts
19
23
  import algosdk3 from "algosdk";
@@ -86,6 +90,144 @@ function firstLine(message) {
86
90
  return message.split("\n")[0].slice(0, 160);
87
91
  }
88
92
 
93
+ // src/drivers/algorand/swap.ts
94
+ var VESTIGE_API = "https://api.vestigelabs.org";
95
+ var ALGO_ASA = 0;
96
+ function sourceFor(poolFee) {
97
+ const fee = typeof poolFee === "number" ? `${(poolFee * 100).toFixed(2)}%` : "the pool's own fee";
98
+ return {
99
+ kind: "provider",
100
+ name: "Vestige",
101
+ note: `Third-party Algorand DEX aggregator (api.vestigelabs.org), used keyless. It returns an UNSIGNED atomic transaction group in which every signer is you: nobody co-signs and nothing is delegated. Pool fee on this route: ${fee}. PipRail adds nothing.`
102
+ };
103
+ }
104
+ function asaId(t) {
105
+ if (t.asset === "native") return ALGO_ASA;
106
+ const n = Number(t.asset);
107
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
108
+ }
109
+ function side(t, amount) {
110
+ const decimals = t.asset === "native" ? ALGO_DECIMALS : t.decimals;
111
+ return {
112
+ asset: t.asset,
113
+ symbol: t.symbol ?? (t.asset === "native" ? "ALGO" : t.asset),
114
+ decimals,
115
+ amount: amount.toString(),
116
+ amountFormatted: formatUnits(amount, decimals)
117
+ };
118
+ }
119
+ async function req(url, init) {
120
+ const ctrl = new AbortController();
121
+ const timer = setTimeout(() => ctrl.abort(), 15e3);
122
+ try {
123
+ const res = await fetch(url, { ...init ?? {}, signal: ctrl.signal });
124
+ if (!res.ok) return null;
125
+ return await res.json();
126
+ } catch {
127
+ return null;
128
+ } finally {
129
+ clearTimeout(timer);
130
+ }
131
+ }
132
+ async function quoteAlgorandSwap(p) {
133
+ if (p.wantAmount <= 0n) return null;
134
+ const fromAsa = asaId(p.from);
135
+ const toAsa = asaId(p.to);
136
+ if (fromAsa === null || toAsa === null || fromAsa === toAsa) return null;
137
+ const quoteFor = (amountIn) => req(
138
+ `${VESTIGE_API}/swap/v4?from_asa=${fromAsa}&to_asa=${toAsa}&amount=${amountIn.toString()}&mode=sef&denominating_asset_id=0`
139
+ );
140
+ const fromDecimals = p.from.asset === "native" ? ALGO_DECIMALS : p.from.decimals;
141
+ let probeIn = 10n ** BigInt(Math.max(fromDecimals - 2, 1));
142
+ let probeOut = 0;
143
+ for (let step = 0; step < 4; step += 1) {
144
+ const probe = await quoteFor(probeIn);
145
+ const out = probe?.amount_out;
146
+ if (out && out > 0) {
147
+ probeOut = out;
148
+ break;
149
+ }
150
+ probeIn *= 10n;
151
+ }
152
+ if (!probeOut) return null;
153
+ let needIn = (probeIn * p.wantAmount + BigInt(probeOut) - 1n) / BigInt(probeOut);
154
+ needIn = applySlippage(needIn, p.slippageBps);
155
+ const real = await quoteFor(needIn);
156
+ const realOut = real?.amount_out;
157
+ if (!realOut) return null;
158
+ if (BigInt(Math.floor(realOut)) < p.wantAmount) return null;
159
+ const poolFee = real.single?.transactions?.[0]?.swaps?.[0]?.fee;
160
+ return {
161
+ source: sourceFor(poolFee),
162
+ network: p.network,
163
+ from: side(p.from, needIn),
164
+ to: side(p.to, BigInt(Math.floor(realOut))),
165
+ maxSpend: needIn.toString(),
166
+ maxSpendFormatted: formatUnits(needIn, fromDecimals),
167
+ slippageBps: p.slippageBps,
168
+ // The build endpoint wants the ENTIRE quote response back, unmodified.
169
+ route: { quote: real }
170
+ };
171
+ }
172
+ async function swapAlgorand(p) {
173
+ const route = p.quote.route;
174
+ if (!route?.quote) {
175
+ throw new Error("Algorand: swap quote is missing its Vestige routing data \u2014 re-quote before swapping.");
176
+ }
177
+ const slippage = p.quote.slippageBps / 1e4;
178
+ const unsigned = await req(
179
+ `${VESTIGE_API}/swap/v4/transactions?sender=${p.signer.addr}&slippage=${slippage}`,
180
+ {
181
+ method: "POST",
182
+ headers: { "content-type": "application/json" },
183
+ body: JSON.stringify(route.quote)
184
+ }
185
+ );
186
+ if (!unsigned?.length) {
187
+ throw new RecipientNotReadyError(
188
+ "Vestige could not build this swap. The usual cause is that your account is not opted in to the asset you are swapping INTO: opt in first, then re-quote. Nothing was spent."
189
+ );
190
+ }
191
+ for (const t of unsigned) {
192
+ if (t.signers && t.signers.some((s) => s !== p.signer.addr)) {
193
+ throw new Error(
194
+ "Algorand swap refused: the built group asks a third party to co-sign, which this rail must never do. Nothing was signed."
195
+ );
196
+ }
197
+ }
198
+ try {
199
+ const signed = unsigned.map((t) => {
200
+ const decoded = p.algosdk.decodeUnsignedTransaction(Buffer.from(t.txn, "base64"));
201
+ return decoded.signTxn(p.signer.sk);
202
+ });
203
+ const res = await p.algod.sendRawTransaction(signed).do();
204
+ const txid = res.txid ?? res.txId;
205
+ if (!txid) throw new Error("Algorand: sendRawTransaction returned no transaction id.");
206
+ return {
207
+ transaction: txid,
208
+ network: p.quote.network,
209
+ source: p.quote.source,
210
+ from: p.quote.from,
211
+ to: p.quote.to
212
+ };
213
+ } catch (err) {
214
+ const msg = String(err?.message ?? err);
215
+ if (/asset .* missing|not opted in|receiver error/i.test(msg)) {
216
+ throw new RecipientNotReadyError(
217
+ `Algorand swap refused: your account must opt in to the asset you are swapping INTO first. (${msg.slice(0, 140)})`,
218
+ { cause: err }
219
+ );
220
+ }
221
+ if (/overspend|insufficient|below min/i.test(msg)) {
222
+ throw new InsufficientFundsError(
223
+ `Algorand swap failed: the account can't cover it (balance, the 0.1 ALGO minimum, or group fees). (${msg.slice(0, 140)})`,
224
+ { cause: err }
225
+ );
226
+ }
227
+ throw err;
228
+ }
229
+ }
230
+
89
231
  // src/drivers/algorand/exact.ts
90
232
  import algosdk from "algosdk";
91
233
  var ZERO_ADDRESS = algosdk.Address.zeroAddress().toString();
@@ -556,6 +698,11 @@ function makeAlgorandNetwork(preset, algodUrl) {
556
698
  detail: "min fee 1000 \xB5Algos (1 transaction)"
557
699
  });
558
700
  },
701
+ /** The bound wallet's own address — where THIS wallet gets paid. Derived from the key
702
+ * material only: no RPC, nothing moved. See {@link ResolvedNetwork.addressOf}. */
703
+ async addressOf(wallet) {
704
+ return String(resolveAlgorandWallet(wallet._native).addr);
705
+ },
559
706
  async balanceOf(wallet, asset) {
560
707
  let owner;
561
708
  try {
@@ -590,6 +737,14 @@ function makeAlgorandNetwork(preset, algodUrl) {
590
737
  return { ready: "unknown" };
591
738
  }
592
739
  },
740
+ /* ---- swap (OPTIONAL, opt-in): via Vestige, keyless + no integrator fee. See ./swap.ts ---- */
741
+ async quoteSwap({ from, to, wantAmount, slippageBps }) {
742
+ return quoteAlgorandSwap({ network, from, to, wantAmount, slippageBps });
743
+ },
744
+ async swap(wallet, quote) {
745
+ const signer = resolveAlgorandWallet(wallet._native);
746
+ return swapAlgorand({ algosdk: algosdk3, algod, signer, quote });
747
+ },
593
748
  async verify(_ref, accept) {
594
749
  return verifyAlgorand({ reader, accept });
595
750
  },