@piprail/sdk 3.0.0 → 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 CHANGED
@@ -4,6 +4,110 @@ 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
+
7
111
  ## [3.0.0] — 2026-09-09 — agent modes: a wallet an agent can EARN with, not only spend from
8
112
 
9
113
  ### BREAKING
@@ -2538,6 +2642,7 @@ straight into your wallet. The API is small and self-contained.
2538
2642
  [1.5.0]: https://www.npmjs.com/package/@piprail/sdk
2539
2643
  [1.4.0]: https://www.npmjs.com/package/@piprail/sdk
2540
2644
  [1.3.1]: https://www.npmjs.com/package/@piprail/sdk
2645
+ [3.1.0]: https://www.npmjs.com/package/@piprail/sdk
2541
2646
  [3.0.0]: https://www.npmjs.com/package/@piprail/sdk
2542
2647
  [1.3.0]: https://www.npmjs.com/package/@piprail/sdk
2543
2648
  [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 };