@piprail/sdk 1.4.0 → 1.5.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,42 @@ 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
+ ## [1.5.0] — 2026-06-04
8
+
9
+ **The killer agent feature — `client.planPayment(url)`.** A read-only call that surveys a 402
10
+ across every rail it offers *on your chain* against your wallet's OWN holdings — **token balance +
11
+ native gas + recipient-readiness** (trustline / ATA / storage_deposit / ASA opt-in / activation) —
12
+ and tells you, crystal-clear, whether it's settleable, on which rail, and if not, exactly what to
13
+ top up. It completes the trio the SDK already ships: **`quote()` (what it costs) → `estimateCost()`
14
+ (the gas) → `planPayment()` (can I actually settle, and where).** Fully backward-compatible and
15
+ opt-in; defaults are unchanged. The official x402 client picks `accepts[0]` blind; PipRail is the
16
+ only backendless SDK that can answer "can I actually pay this?" across 28 chains with pure RPC
17
+ reads, no oracle/facilitator/bridge. Live-proven on Algorand mainnet (ready / recipient-not-ready /
18
+ insufficient / multi-rail-rank, 4/4).
19
+
20
+ ### Added
21
+ - **`client.planPayment(url, init?)` → `PaymentPlan | null`.** Never throws for a read problem (a
22
+ transient/RPC failure surfaces as a rail in `state: 'unknown'` + a warning, never a false
23
+ "unaffordable"); returns `null` when the URL isn't 402-gated; and when the 402 offers no rail on
24
+ your chain it EXPLAINS that (status `blocked` + a hint) instead of throwing. The plan carries:
25
+ `payable` + `best` (the cheapest settleable rail), `options[]` (every rail with typed `blockers`
26
+ — `INSUFFICIENT_TOKEN`/`INSUFFICIENT_GAS`/`RECIPIENT_NOT_READY`/`OUTSIDE_POLICY` — plus soft
27
+ `warnings`, a `shortfall`, live `balance`, and `recipient.fix`), and a one-sentence `fundingHint`.
28
+ - **`client.canAfford(url)` → `boolean`** — convenience over the above.
29
+ - **`fetch(url, { autoRoute: true })` / `new PipRailClient({ autoRoute: true })`** — opt-in:
30
+ `fetch` pays the cheapest rail the wallet can ACTUALLY settle (not the first policy-passing one),
31
+ or throws `PaymentDeclinedError` carrying the funding hint before any send. **Default off** —
32
+ the zero-config path is byte-identical.
33
+ - **`planAcross(clients, url)`** — the cross-chain brain: give it one client per chain you fund and
34
+ it merges their plans, payable-first (no oracle, so the cross-coin tiebreak is your client order).
35
+ - **`piprail_plan_payment`** agent tool (budget-bound; `paymentTools(client)` now returns 3 tools).
36
+ - **Driver contract:** `balanceOf(wallet, asset)` + `recipientReady(payTo, asset)` on every family
37
+ (10/10), RPC-read-only and NEVER-throw (transient ⇒ `null`/`'unknown'`, per ERRORS.md §5). Real
38
+ receive-prerequisite probes on NEAR (`storage_balance_of`), Stellar/XRPL (trustline presence),
39
+ Algorand (ASA opt-in); truthful `'n/a'` on EVM/Solana/TON/Tron/Sui/Aptos (no prerequisite).
40
+ - New exported types: `PaymentPlan`, `PayOption`, `PayBlocker`, `PayWarning`, `RecipientReason`,
41
+ `WalletBalance` (and the previously-missing `AptosToken`/`AlgorandToken`).
42
+
7
43
  ## [1.4.0] — 2026-06-04
8
44
 
9
45
  A new chain **family** — **Algorand** — the **10th driver family**, bringing the built-in count to
package/ERRORS.md CHANGED
@@ -149,6 +149,17 @@ Every `PaymentDriver` / `ResolvedNetwork` method has a fixed error behaviour:
149
149
  | `send(wallet, accept)` | wrap the broadcast; map **sender** affordability → `InsufficientFundsError` (§6) and **recipient** setup → `RecipientNotReadyError` (§6.1); **rethrow everything else unchanged** (never swallow). Every mapped throw carries `{ cause }` = the raw chain error. |
150
150
  | `verify(ref, accept)` | **return** a `VerifyResult` with a canonical `VerifyErrorCode`. **Guard every RPC read** so a transient failure returns `tx_not_found` — `verify()` must not throw for an RPC hiccup. Re-derive the watched account from the trusted `accept`, never the client ref. |
151
151
  | `confirm(ref, n)` | broadcast-but-not-confirmed / timeout → `ConfirmationTimeoutError`. |
152
+ | `estimateCost(accept, opts?)` | **never throw** — guard the RPC read and fall back to a `'heuristic'` constant; always return a valid `CostEstimate`. |
153
+ | `balanceOf(wallet, asset)` | **never throw** — RPC-read-only. A field whose read was unavailable (transient/rate-limit) returns `null`, NOT `0` (a false 0 reads as "broke"). For `asset==='native'`, `token === native`. |
154
+ | `recipientReady(payTo, asset)` | **never throw** — report the receive prerequisite: `{ ready:'n/a' }` (no prerequisite on this family/native), `{ ready:true }`, `{ ready:false, reason }` (a `RecipientReason`), or `{ ready:'unknown' }` on a transient read. `'n/a'` must be TRUTHFUL — never a stand-in for "didn't check". |
155
+
156
+ > **`planPayment` is a RETURN-channel feature.** The client's `planPayment`/`canAfford` compose
157
+ > `balanceOf` + `recipientReady` + `estimateCost` + the policy verdict into a `PaymentPlan` — and,
158
+ > like `verify()`, they **return** the outcome rather than throwing: a transient read becomes a rail
159
+ > in `state:'unknown'` (+ a warning), an unsettleable rail carries typed `blockers`, and a 402 with
160
+ > no rail on the client's chain is *explained* in the plan. The only throw is `InvalidEnvelopeError`
161
+ > on an unparseable challenge. (`fetch({ autoRoute:true })` is the one place a plan turns into a
162
+ > THROWN `PaymentDeclinedError` — refusing before any send when nothing is settleable.)
152
163
 
153
164
  ### 6. Affordability converges on one error, by two mechanisms
154
165
 
package/README.md CHANGED
@@ -77,13 +77,41 @@ client.spent() // → { count, byAsset: [{ symbol:'USDC', totalFormatted:'0.05',
77
77
 
78
78
  **Know the gas before you pay.** `client.estimateCost(url)` returns the quote **and** a `CostEstimate` — the network fee in the chain's **native coin** (you pay in USDC but burn ETH / SOL / TON / XLM / XRP / TRX on gas, a separate balance the agent must keep topped up). It's best-effort and labelled (`cost.basis`): a live-RPC read where cheap (`'estimated'` — EVM gas price, XRPL fee), a typical-cost constant otherwise (`'heuristic'`), and it never throws. Most valuable on **Tron**, where a USD₮ transfer can cost real TRX. So an agent can budget the *total* — payment **+** gas — before any funds move. Every driver implements it; the math is extracted per-chain and shaped uniformly by one shared `nativeCost()` helper.
79
79
 
80
+ ### Plan before you pay — `planPayment()` (never fumble a payment)
81
+
82
+ `quote()` tells you the price and `estimateCost()` the gas — **`planPayment(url)`** closes the loop: **one read-only call** that checks, against your wallet's *own* holdings, whether a 402 will actually go through — and if not, exactly what to fix. No funds move.
83
+
84
+ ```ts
85
+ const plan = await client.planPayment(url)
86
+
87
+ if (plan?.payable) {
88
+ await client.fetch(url, { autoRoute: true }) // pays plan.best — the cheapest rail you can settle
89
+ } else {
90
+ console.log(plan?.fundingHint)
91
+ // "Have the USDC, but need ~0.000021 ETH for gas on base (have 0)."
92
+ // "Recipient 2OT6…GC5E4 can't receive on algorand yet — must opt into the USDC ASA."
93
+ // "Top up 0.04 USDC on base (have 0.01)."
94
+ }
95
+ ```
96
+
97
+ For **every rail the 402 offers on your chain**, the plan reads **token balance + native-coin gas + recipient-readiness** (trustline / ATA / `storage_deposit` / ASA opt-in / activation) and returns:
98
+ - **`payable`** + **`best`** — the cheapest rail you can actually settle (recipient confirmed able to receive);
99
+ - **`options[]`** — each rail with typed **`blockers`** (`INSUFFICIENT_TOKEN` · `INSUFFICIENT_GAS` · `RECIPIENT_NOT_READY` · `OUTSIDE_POLICY`), soft **`warnings`** (`SYMBOL_MISMATCH`, `THIN_GAS_MARGIN`, `BALANCE_UNREADABLE`, …), a **`shortfall`**, the live **`balance`**, and **`recipient.fix`**;
100
+ - **`fundingHint`** — one human sentence on exactly what to top up.
101
+
102
+ **Why it's the agent unlock.** The official x402 client picks `accepts[0]` blind and learns it can't pay only when the broadcast reverts (no token, no gas) or the transfer silently strands (recipient not set up to receive). `planPayment` turns those runtime failures into a pre-checked decision — and on the no-facilitator path *you* pay your own gas, so "I hold USDC but no ETH" is a first-class answer, not a crash. It **never throws for a read hiccup** (a throttled RPC surfaces as `state: 'unknown'` + a warning, never a false "broke"), returns `null` when the URL isn't gated, and *explains* "this is offered on solana, base — you're on xrpl" instead of erroring. `client.canAfford(url)` is the one-boolean convenience.
103
+
104
+ **Auto-route (opt-in).** `new PipRailClient({ autoRoute: true })` (or `fetch(url, { autoRoute: true })`) makes `fetch` pay the cheapest *settleable* rail instead of the first policy-passing one — refusing with `PaymentDeclinedError` + the funding hint before any send. **Default off; the zero-config path is unchanged.**
105
+
106
+ **Across chains.** A client is bound to one chain; **`planAcross([baseClient, solanaClient, …], url)`** runs each plan in parallel and merges them payable-first, so an agent holding funds on several chains learns which to use. (No price oracle — cross-coin ties break on the order you list the clients.)
107
+
80
108
  ### Hand an LLM a budget-bound wallet
81
109
 
82
110
  `paymentTools(client)` returns framework-agnostic tool descriptors (name + description + JSON Schema + `invoke`) — drop them into MCP, the Vercel AI SDK, OpenAI/Anthropic function-calling, or LangChain in a couple of lines. The budget rides on the client, so the model can't overspend.
83
111
 
84
112
  ```ts
85
113
  import { paymentTools } from '@piprail/sdk'
86
- const tools = paymentTools(client) // → [piprail_quote_payment, piprail_pay_request]
114
+ const tools = paymentTools(client) // → [piprail_quote_payment, piprail_plan_payment, piprail_pay_request]
87
115
  ```
88
116
 
89
117
  See [`examples/agent-tools.mjs`](../examples/agent-tools.mjs) for MCP / AI-SDK wiring.
@@ -477,7 +505,7 @@ The SDK is browser-clean (no Node-only globals in the protocol layer), so a plai
477
505
  Two layers, one contract. Worth knowing if you're extending the SDK or auditing it.
478
506
 
479
507
  - **The protocol layer is chain-agnostic.** `server.ts` (`requirePayment`/`createPaymentGate`), `client.ts` (`PipRailClient`), `x402.ts` (wire envelopes), `policy.ts`, `ledger.ts`, and `agent.ts` depend **only** on the `PaymentDriver` contract in `drivers/types.ts` — zero `viem`, zero `@solana/web3.js`, zero chain SDK. The chain is data the caller passes, not an allowlist the SDK ships.
480
- - **The `PaymentDriver` contract.** `resolve(chain)` → a bound `ResolvedNetwork` exposing `resolveToken` · `describeAsset` · `assertValidPayTo` · `bindWallet` · `send` · `confirm` · `estimateCost` · `verify`. That's the entire boundary every family implements and the protocol layer ever sees.
508
+ - **The `PaymentDriver` contract.** `resolve(chain)` → a bound `ResolvedNetwork` exposing `resolveToken` · `describeAsset` · `assertValidPayTo` · `bindWallet` · `send` · `confirm` · `estimateCost` · `balanceOf` · `recipientReady` · `verify`. That's the entire boundary every family implements and the protocol layer ever sees.
481
509
  - **Families mirror each other file-for-file.** Each lives in `drivers/<family>/` as `chains` · `wallet` · `pay` · `verify` · `index`, with family-suffixed functions (`payEvm`/`paySui`/…, `verifyEvm`/`verifyNear`/…). Ten today: `evm`, `solana`, `ton`, `stellar`, `xrpl`, `tron`, `near`, `sui`, `aptos`, `algorand`. Adding one = copy the five files, implement the contract, `registerDriver` — the protocol layer never changes.
482
510
  - **Routing + lazy auto-mount.** `registry.ts` maps a `chain` value to its family synchronously (`familyForChain`). EVM is always present (viem is a hard peer); every non-EVM family **loads itself on first use** via one dynamic `import()`, so a pure-EVM install never downloads `@solana`/`@ton`/`@stellar`/`xrpl`/`tronweb`/`near-api-js`/`@mysten/sui`/`@aptos-labs/ts-sdk`/`algosdk`. A build-time invariant asserts the main bundle has **zero** static imports of those libs — only per-family lazy chunks.
483
511
  - **Two verification templates.** *Template A (memo-bound)* — Stellar, XRPL, TON, NEAR, Algorand — carries the challenge nonce inside the transfer (memo / tag / comment / note), so the proof is cryptographically bound to its challenge. *Template B (digest-bound)* — EVM, Solana, Tron, Sui, Aptos — binds via a single-use proof set + recipient + amount + a tight recency window (use a persistent `isUsed`/`markUsed` store in production).
@@ -556,7 +584,7 @@ Provide **either** `chain` + `token` + `amount` (single) **or** a non-empty `acc
556
584
  | `retryTimeoutMs` | `30000` | Timeout for the retry leg after broadcast |
557
585
  | `onEvent` | — | `(event) => void` observability: `payment-required` · `payment-broadcast` · `payment-confirmed` · `payment-unconfirmed` (broadcast OK, local confirm timed out → deferring to server) · `payment-settled` · `payment-failed` |
558
586
 
559
- Methods: `fetch` · `get` · `post` (return the gated `Response` after settlement) · **`quote(url)`** (price without paying → `PipRailQuote \| null`) · **`estimateCost(url)`** (price **+** native-coin gas estimate → `PipRailCostQuote \| null`) · **`spent()`** (per-asset ledger snapshot).
587
+ Methods: `fetch` · `get` · `post` (return the gated `Response` after settlement) · **`quote(url)`** (price without paying → `PipRailQuote \| null`) · **`estimateCost(url)`** (price **+** native-coin gas estimate → `PipRailCostQuote \| null`) · **`planPayment(url)`** (affordability + recipient-readiness across the offered rails → `PaymentPlan \| null`) · **`canAfford(url)`** (→ `boolean`) · **`spent()`** (per-asset ledger snapshot). Pass `{ autoRoute: true }` to `fetch` (or set it on the client) to pay the cheapest *settleable* rail. Module-level **`planAcross(clients, url)`** plans across chains.
560
588
 
561
589
  **Wallets by family** — the `chain` selector routes; each driver validates its own key format (a mismatch throws `WrongFamilyError`):
562
590
 
@@ -314,6 +314,40 @@ function makeAlgorandNetwork(preset, algodUrl) {
314
314
  detail: "min fee 1000 \xB5Algos (1 transaction)"
315
315
  });
316
316
  },
317
+ async balanceOf(wallet, asset) {
318
+ let owner;
319
+ try {
320
+ owner = resolveAlgorandWallet(wallet._native).addr;
321
+ } catch {
322
+ return { token: null, native: null };
323
+ }
324
+ let info;
325
+ try {
326
+ info = await algod.accountInformation(owner).do();
327
+ } catch {
328
+ return { token: null, native: null };
329
+ }
330
+ const native = info.amount != null ? BigInt(info.amount) : null;
331
+ if (asset === "native") return { token: native, native };
332
+ const assetId = parseAlgorandAssetId(asset);
333
+ const holding = (info.assets ?? []).find((a) => Number(a.assetId) === assetId);
334
+ return { token: holding ? BigInt(holding.amount) : 0n, native };
335
+ },
336
+ async recipientReady(payTo, asset) {
337
+ if (asset === "native") return { ready: "n/a" };
338
+ const assetId = parseAlgorandAssetId(asset);
339
+ if (assetId == null) return { ready: "unknown" };
340
+ try {
341
+ const info = await algod.accountInformation(payTo).do();
342
+ const optedIn = (info.assets ?? []).some((a) => Number(a.assetId) === assetId);
343
+ return optedIn ? { ready: true } : { ready: false, reason: "NOT_OPTED_IN" };
344
+ } catch (e) {
345
+ if (/does not exist|no accounts found|404|account not found/i.test(String(e?.message ?? e))) {
346
+ return { ready: false, reason: "NOT_OPTED_IN" };
347
+ }
348
+ return { ready: "unknown" };
349
+ }
350
+ },
317
351
  async verify(_ref, accept) {
318
352
  return verifyAlgorand({ reader, accept });
319
353
  }
@@ -1,4 +1,4 @@
1
- "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } }
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
2
2
 
3
3
 
4
4
 
@@ -89,7 +89,7 @@ async function verifyAlgorand(params) {
89
89
  let txs;
90
90
  try {
91
91
  txs = await reader.transactionsForAccount(accept.payTo, 50);
92
- } catch (e) {
92
+ } catch (e2) {
93
93
  return rpcFailed(nonce);
94
94
  }
95
95
  const tx = txs.find((t) => typeof t.note === "string" && t.note === nonce);
@@ -117,7 +117,7 @@ async function verifyAlgorand(params) {
117
117
  let paid = 0n;
118
118
  try {
119
119
  paid = tx.amount ? BigInt(tx.amount) : 0n;
120
- } catch (e2) {
120
+ } catch (e3) {
121
121
  paid = 0n;
122
122
  }
123
123
  if (paid < required) {
@@ -314,6 +314,40 @@ function makeAlgorandNetwork(preset, algodUrl) {
314
314
  detail: "min fee 1000 \xB5Algos (1 transaction)"
315
315
  });
316
316
  },
317
+ async balanceOf(wallet, asset) {
318
+ let owner;
319
+ try {
320
+ owner = resolveAlgorandWallet(wallet._native).addr;
321
+ } catch (e4) {
322
+ return { token: null, native: null };
323
+ }
324
+ let info;
325
+ try {
326
+ info = await algod.accountInformation(owner).do();
327
+ } catch (e5) {
328
+ return { token: null, native: null };
329
+ }
330
+ const native = info.amount != null ? BigInt(info.amount) : null;
331
+ if (asset === "native") return { token: native, native };
332
+ const assetId = parseAlgorandAssetId(asset);
333
+ const holding = (_nullishCoalesce(info.assets, () => ( []))).find((a) => Number(a.assetId) === assetId);
334
+ return { token: holding ? BigInt(holding.amount) : 0n, native };
335
+ },
336
+ async recipientReady(payTo, asset) {
337
+ if (asset === "native") return { ready: "n/a" };
338
+ const assetId = parseAlgorandAssetId(asset);
339
+ if (assetId == null) return { ready: "unknown" };
340
+ try {
341
+ const info = await algod.accountInformation(payTo).do();
342
+ const optedIn = (_nullishCoalesce(info.assets, () => ( []))).some((a) => Number(a.assetId) === assetId);
343
+ return optedIn ? { ready: true } : { ready: false, reason: "NOT_OPTED_IN" };
344
+ } catch (e) {
345
+ if (/does not exist|no accounts found|404|account not found/i.test(String(_nullishCoalesce(_optionalChain([e, 'optionalAccess', _ => _.message]), () => ( e))))) {
346
+ return { ready: false, reason: "NOT_OPTED_IN" };
347
+ }
348
+ return { ready: "unknown" };
349
+ }
350
+ },
317
351
  async verify(_ref, accept) {
318
352
  return verifyAlgorand({ reader, accept });
319
353
  }
@@ -354,7 +388,7 @@ function adaptTxn(raw) {
354
388
  function decodeNote(bytes) {
355
389
  try {
356
390
  return new TextDecoder().decode(bytes);
357
- } catch (e3) {
391
+ } catch (e6) {
358
392
  return void 0;
359
393
  }
360
394
  }
@@ -322,6 +322,34 @@ function makeAptosNetwork(preset, rpcUrl) {
322
322
  detail: "\u22480.001 APT (a simple Fungible-Asset transfer; Aptos gas is sub-cent)"
323
323
  });
324
324
  },
325
+ async balanceOf(wallet, asset) {
326
+ let owner;
327
+ try {
328
+ owner = resolveAptosAccount(wallet._native).accountAddress.toString();
329
+ } catch (e7) {
330
+ return { token: null, native: null };
331
+ }
332
+ const native = await aptos.getAccountAPTAmount({ accountAddress: owner }).then((n) => BigInt(n)).catch(() => null);
333
+ if (asset === "native") return { token: native, native };
334
+ let token = null;
335
+ try {
336
+ const [bal] = await aptos.view({
337
+ payload: {
338
+ function: "0x1::primary_fungible_store::balance",
339
+ typeArguments: ["0x1::fungible_asset::Metadata"],
340
+ functionArguments: [owner, asset]
341
+ }
342
+ });
343
+ token = BigInt(String(bal));
344
+ } catch (e8) {
345
+ token = null;
346
+ }
347
+ return { token, native };
348
+ },
349
+ // No receive prerequisite — the recipient's primary FA store auto-creates on receipt.
350
+ async recipientReady() {
351
+ return { ready: "n/a" };
352
+ },
325
353
  async verify(ref, accept) {
326
354
  return verifyAptos({ reader, hash: ref, accept });
327
355
  }
@@ -322,6 +322,34 @@ function makeAptosNetwork(preset, rpcUrl) {
322
322
  detail: "\u22480.001 APT (a simple Fungible-Asset transfer; Aptos gas is sub-cent)"
323
323
  });
324
324
  },
325
+ async balanceOf(wallet, asset) {
326
+ let owner;
327
+ try {
328
+ owner = resolveAptosAccount(wallet._native).accountAddress.toString();
329
+ } catch {
330
+ return { token: null, native: null };
331
+ }
332
+ const native = await aptos.getAccountAPTAmount({ accountAddress: owner }).then((n) => BigInt(n)).catch(() => null);
333
+ if (asset === "native") return { token: native, native };
334
+ let token = null;
335
+ try {
336
+ const [bal] = await aptos.view({
337
+ payload: {
338
+ function: "0x1::primary_fungible_store::balance",
339
+ typeArguments: ["0x1::fungible_asset::Metadata"],
340
+ functionArguments: [owner, asset]
341
+ }
342
+ });
343
+ token = BigInt(String(bal));
344
+ } catch {
345
+ token = null;
346
+ }
347
+ return { token, native };
348
+ },
349
+ // No receive prerequisite — the recipient's primary FA store auto-creates on receipt.
350
+ async recipientReady() {
351
+ return { ready: "n/a" };
352
+ },
325
353
  async verify(ref, accept) {
326
354
  return verifyAptos({ reader, hash: ref, accept });
327
355
  }