@piprail/sdk 3.0.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,134 @@ 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.1] — 2026-09-09 — XRPL payments work again
8
+
9
+ ### Fixed
10
+
11
+ - **A facilitator's payload rejection answered 5xx instead of 402.** Every non-200 from a
12
+ facilitator's `/verify` was treated as a transport failure, so a **400** — a forged or
13
+ malformed authorization — made the gate return a server error. That tells the buyer "our
14
+ fault, try again" about a payment that can never succeed, and shows in the merchant's metrics
15
+ as an outage they do not have. Now only `400`/`422` (the payload is bad, and only the buyer
16
+ can fix it) reject with the facilitator's own reason; `401`/`403` (our credentials),
17
+ `404` (our URL), `429` and `5xx` stay `SettlementError`, because a buyer can do nothing about
18
+ any of them. Found by tampering with a real authorization on Base.
19
+
20
+ - 🔴 **Every XRPL payment failed.** `ledger_current` is documented to return
21
+ `ledger_current_index`, and rippled does, but the public clusters in front of it answer with
22
+ `ledger_index`. Reading only the documented name returned `undefined`, `undefined + 20` became
23
+ `NaN`, and the transaction died in xrpl.js's serializer. Both names are now accepted, and a
24
+ response carrying neither is refused with a message that says nothing was signed.
25
+
26
+ Latent since XRPL shipped and intermittent by nature: it depends on which backend the cluster
27
+ routes to, which is why the same wallet settled all morning and then could not settle at all.
28
+ Caught by a post-release live sweep against the published package — the pre-flight guard added
29
+ in 3.1.0 is what turned an unreadable serializer error into a sentence naming the field.
30
+
31
+ ## [3.1.0] — 2026-09-09 — spendable is not held, and a swap refused for no reason
32
+
33
+ ### Fixed
34
+
35
+ - 🔴 **A spend cap could be breached by CONCURRENT payments.** A cap is read when a quote is
36
+ priced and written when the payment settles, and a whole network round trip sits between the
37
+ two. Six simultaneous `fetch()` calls against a `maxTotal` of `'2.50'` each priced against the
38
+ same "spent so far", each passed, and four settled: 4.00 spent against a 2.50 leash while every
39
+ individual check was correct. `maxPayments` and `maxTotalPerDenom` leaked the same way. It is
40
+ the same read-await-write shape as the replay race, on the buyer's side of the wire, and the
41
+ leash is the entire safety story for the default `budgeted` mode.
42
+
43
+ `authorize()` now RESERVES the budget synchronously before anything is signed or sent, so an
44
+ in-flight payment is visible to the next one's check. `recordSpend()` commits the reservation
45
+ as the real record lands, and every failure path releases it, so a refused or failed payment
46
+ never permanently consumes the leash. Sequential behaviour is unchanged: a 3.00 cap still
47
+ spends exactly 3.00, no more and no fewer.
48
+
49
+ - 🔴 **A custom `isUsed`/`markUsed` replay store could redeem ONE proof N times, concurrently.**
50
+ The gate's built-in set has always reserved a ref synchronously, which is what stops two
51
+ simultaneous requests carrying the same proof from both settling. The CUSTOM-store branch did
52
+ not: it `await`ed `isUsed(ref)` before verification and wrote only after it, so every concurrent
53
+ request read "unused" and every one settled. Five simultaneous requests redeemed one payment
54
+ five times. The gate now takes the same synchronous in-process reservation BEFORE consulting a
55
+ custom store, so a single instance is safe whichever store is configured. Across several
56
+ processes that set cannot help, so an atomic check-and-reserve (Redis `SET NX`) is still what
57
+ protects a multi-instance deployment, and the documented example now uses one: it previously
58
+ showed `exists()` then `set()`, which is the exact non-atomic pattern the surrounding text
59
+ warned against. Found by a concurrency sweep; the suite had no concurrent replay test at all.
60
+
61
+ - 🔴 **A sovereign agent could deliver N goods for ONE payment.** `piprail_sell` gives every offer
62
+ its own gate, and a gate's replay set is scoped to itself, so cross-offer replay was guarded by
63
+ a store-level `isUsed`/`markUsed` pair sharing the race above. Sequentially it refused correctly;
64
+ five concurrent `piprail_collect` calls against five offers all collected the same settlement,
65
+ which is the normal shape of a shop with more than one buyer. `collect` now reserves the proof
66
+ ref synchronously before any await, and releases it if the payment does not settle.
67
+
68
+ - **A chain id that is not a chain id is refused at config time.** `{ id: NaN }` (and negative,
69
+ fractional or unsafe-integer ids) resolved happily, and the gate went on to publish
70
+ `network: "eip155:NaN"` in a live 402 — an unparseable CAIP-2 that a standard x402 client cannot
71
+ read. It failed closed later, but surfaced as `tx_not_found` at payment time rather than as the
72
+ configuration error it is.
73
+
74
+ - **A gate must charge more than zero.** `amount: '0'` built a rail whose amount check any transfer
75
+ satisfies, so a paywall could read as configured and gate nothing. A metered `upto` SETTLE of
76
+ zero stays legitimate, because that is the settled amount and not the advertised one.
77
+ `piprail_sell` inherits the same floor, so a zero price can no longer mint an offer.
78
+
79
+ - **A facilitator's cold start no longer costs the buyer gas.** The lazy `/supported` probe that
80
+ discovers a facilitator's fee payer timed out at 8s. These are serverless hosts: measured
81
+ 2026-09-09, `x402.dexter.cash` answered in **8497ms cold** and ~310ms warm, so a cold facilitator
82
+ read as absent, the gasless `exact` rail was dropped, and the buyer paid gas instead. The probe
83
+ now allows 15s. It only runs for a family that cannot resolve `exact` without a fee payer
84
+ (Solana; EVM never reaches it), at most once per gate, and it still fails safe with the same
85
+ `skipReason` when the host really is down. Live re-probe after the change: **9/9 registry hosts
86
+ answering, 0 contradicted claims**.
87
+
88
+ - **Authority is sealed on the client instance.** `paymentTools()` picks a model's tool set from
89
+ `canAgentSell()` / `canAgentSwap()`, which read `mode()`. Those were plain prototype methods, so
90
+ any code holding the client could reassign one and turn a budgeted client's eight tools into
91
+ sovereign's fourteen. A MODEL could never do that — it sends JSON tool arguments and does not
92
+ hold the object — so this is defence in depth for a client passing through an agent framework,
93
+ a plugin, or middleware that wraps objects. All three are now non-writable and non-configurable:
94
+ authority is set once, by whoever provisioned the key.
95
+
96
+ - 🔴 **`planPayment` called a native payment affordable that the chain then refused, on FOUR
97
+ families.** Some chains require an account to retain a minimum it can never send, and
98
+ affordability was measured against the raw balance on every one of them:
99
+
100
+ | | retained minimum |
101
+ |---|---|
102
+ | Solana | the account's rent-exempt minimum |
103
+ | XRPL | a base reserve, plus an increment for each owned object (a trustline is one) |
104
+ | Stellar | `(2 + subentries) x` the base reserve, and a trustline is a subentry |
105
+ | Algorand | 0.1 ALGO, plus 0.1 for every ASA opted into |
106
+
107
+ A live wallet holding 0.0011 SOL was told it could send 0.0005 SOL; the transfer failed
108
+ simulation with a bare `SendTransactionError` after the agent had already signed, which is the
109
+ exact outcome the pre-flight check exists to prevent.
110
+
111
+ `WalletBalance.token` is now documented as the SPENDABLE figure (the reserve deducted) with
112
+ `native` staying the true balance for gas, all four drivers report it that way (reading the
113
+ chain's own figure where it offers one), and the client measures a native payment against it.
114
+ A family with no reserve reports the two as equal and is unchanged. The refusal names the
115
+ exact shortfall, and a payment comfortably inside the spendable balance still goes through.
116
+
117
+ - **XRPL: a throttled ledger read no longer surfaces as a serializer error.** `Sequence` and
118
+ `LastLedgerSequence` are UInt32 fields, so a failed or rate-limited pre-flight read left one
119
+ `undefined` (and `undefined + 20` is `NaN`), and xrpl.js rejected the transaction with
120
+ "Cannot construct UInt32 from given value": no field, no cause and no remedy, from a library
121
+ the caller never imported. Found under a batch of live mainnet payments, where the public
122
+ cluster throttles. Both reads are now checked before the transaction is built, and the refusal
123
+ says the thing that matters most on an ambiguous payment error: nothing was signed or
124
+ submitted, so retrying cannot double-pay.
125
+
126
+ ### Added
127
+
128
+ - **`releaseUsed` — the third replay hook.** Optional, and only meaningful when `isUsed` RESERVES
129
+ (the `SET NX` shape). Without it, making a custom store atomic traded a double-spend for a worse
130
+ failure: a transient RPC error left the reservation standing, so a buyer whose funds had already
131
+ moved could never redeem the proof. The built-in store has always released on failure; this is
132
+ how a custom store does the same. Supplying it without `isUsed`/`markUsed` throws, because it
133
+ would never fire.
134
+
7
135
  ## [3.0.0] — 2026-09-09 — agent modes: a wallet an agent can EARN with, not only spend from
8
136
 
9
137
  ### BREAKING
@@ -2538,6 +2666,8 @@ straight into your wallet. The API is small and self-contained.
2538
2666
  [1.5.0]: https://www.npmjs.com/package/@piprail/sdk
2539
2667
  [1.4.0]: https://www.npmjs.com/package/@piprail/sdk
2540
2668
  [1.3.1]: https://www.npmjs.com/package/@piprail/sdk
2669
+ [3.1.1]: https://www.npmjs.com/package/@piprail/sdk
2670
+ [3.1.0]: https://www.npmjs.com/package/@piprail/sdk
2541
2671
  [3.0.0]: https://www.npmjs.com/package/@piprail/sdk
2542
2672
  [1.3.0]: https://www.npmjs.com/package/@piprail/sdk
2543
2673
  [1.2.0]: https://www.npmjs.com/package/@piprail/sdk
@@ -550,6 +550,8 @@ function resolveAlgorandWallet(config) {
550
550
  }
551
551
 
552
552
  // src/drivers/algorand/index.ts
553
+ var ALGO_MIN_BALANCE = 100000n;
554
+ var ALGO_MIN_BALANCE_PER_ASSET = 100000n;
553
555
  var algorandDriver = {
554
556
  family: "algorand",
555
557
  resolve(opts) {
@@ -717,7 +719,11 @@ function makeAlgorandNetwork(preset, algodUrl) {
717
719
  return { token: null, native: null };
718
720
  }
719
721
  const native = info.amount != null ? BigInt(info.amount) : null;
720
- if (asset === "native") return { token: native, native };
722
+ if (asset === "native") {
723
+ if (native === null) return { token: null, native };
724
+ const min = info.minBalance != null ? BigInt(info.minBalance) : ALGO_MIN_BALANCE + ALGO_MIN_BALANCE_PER_ASSET * BigInt((_nullishCoalesce(info.assets, () => ( []))).length);
725
+ return { token: native > min ? native - min : 0n, native };
726
+ }
721
727
  const assetId = parseAlgorandAssetId(asset);
722
728
  const holding = (_nullishCoalesce(info.assets, () => ( []))).find((a) => Number(a.assetId) === assetId);
723
729
  return { token: holding ? BigInt(holding.amount) : 0n, native };
@@ -550,6 +550,8 @@ function resolveAlgorandWallet(config) {
550
550
  }
551
551
 
552
552
  // src/drivers/algorand/index.ts
553
+ var ALGO_MIN_BALANCE = 100000n;
554
+ var ALGO_MIN_BALANCE_PER_ASSET = 100000n;
553
555
  var algorandDriver = {
554
556
  family: "algorand",
555
557
  resolve(opts) {
@@ -717,7 +719,11 @@ function makeAlgorandNetwork(preset, algodUrl) {
717
719
  return { token: null, native: null };
718
720
  }
719
721
  const native = info.amount != null ? BigInt(info.amount) : null;
720
- if (asset === "native") return { token: native, native };
722
+ if (asset === "native") {
723
+ if (native === null) return { token: null, native };
724
+ const min = info.minBalance != null ? BigInt(info.minBalance) : ALGO_MIN_BALANCE + ALGO_MIN_BALANCE_PER_ASSET * BigInt((info.assets ?? []).length);
725
+ return { token: native > min ? native - min : 0n, native };
726
+ }
721
727
  const assetId = parseAlgorandAssetId(asset);
722
728
  const holding = (info.assets ?? []).find((a) => Number(a.assetId) === assetId);
723
729
  return { token: holding ? BigInt(holding.amount) : 0n, native };