@piprail/sdk 1.14.0 → 1.15.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +27 -754
  3. package/dist/{algorand-OIHGJN5S.cjs → algorand-EJ3S2V7E.cjs} +17 -17
  4. package/dist/{algorand-7EUZYL2Z.js → algorand-F3OYB534.js} +1 -1
  5. package/dist/{aptos-WDWZOU25.cjs → aptos-GJGIZHNI.cjs} +16 -16
  6. package/dist/{aptos-CDEYDDM5.js → aptos-SUXOVP7B.js} +1 -1
  7. package/dist/{chunk-H3A4KWLJ.js → chunk-ILPABTI2.js} +6 -0
  8. package/dist/{chunk-FTKVCP6K.cjs → chunk-PA6YD3HL.cjs} +17 -11
  9. package/dist/index.cjs +493 -115
  10. package/dist/index.d.cts +264 -12
  11. package/dist/index.d.ts +264 -12
  12. package/dist/index.js +403 -25
  13. package/dist/{near-DT6LRIKB.js → near-LM7S3WUD.js} +1 -1
  14. package/dist/{near-FUH3VAXT.cjs → near-ZJLZE26R.cjs} +19 -19
  15. package/dist/{solana-QUVXPKBZ.cjs → solana-MPPE6K24.cjs} +14 -14
  16. package/dist/{solana-3TRYD4QB.js → solana-WDKWWF33.js} +1 -1
  17. package/dist/{stellar-IK3UML6O.js → stellar-FIJPQZVW.js} +1 -1
  18. package/dist/{stellar-APZEBFAD.cjs → stellar-XHLLNHQP.cjs} +21 -21
  19. package/dist/{sui-L7BQNJWO.cjs → sui-6CVLEXLA.cjs} +17 -17
  20. package/dist/{sui-VSE63WQM.js → sui-B7AVN7NK.js} +1 -1
  21. package/dist/{ton-QHGQLJX2.js → ton-CHJ26BVA.js} +1 -1
  22. package/dist/{ton-5DLKKOFE.cjs → ton-RNEFN25G.cjs} +14 -14
  23. package/dist/{tron-2N2GA62O.js → tron-DD3JDROV.js} +1 -1
  24. package/dist/{tron-HHIT6WKY.cjs → tron-TKJHNFGM.cjs} +24 -24
  25. package/dist/{xrpl-2GZMDYW5.js → xrpl-GTUPP6SK.js} +1 -1
  26. package/dist/{xrpl-USEG4AHX.cjs → xrpl-XN2NBNGI.cjs} +21 -21
  27. package/package.json +1 -5
  28. package/CHAINS.md +0 -179
  29. package/DISCOVERY.md +0 -420
  30. package/ERRORS.md +0 -269
  31. package/STANDARDS.md +0 -128
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  parseUnits,
23
23
  rejectForeignToken,
24
24
  toInsufficientFundsError
25
- } from "./chunk-H3A4KWLJ.js";
25
+ } from "./chunk-ILPABTI2.js";
26
26
 
27
27
  // src/drivers/registry.ts
28
28
  var byFamily = /* @__PURE__ */ new Map();
@@ -1271,7 +1271,7 @@ var loaders = {
1271
1271
  solana: async () => {
1272
1272
  let mod;
1273
1273
  try {
1274
- mod = await import("./solana-3TRYD4QB.js");
1274
+ mod = await import("./solana-WDKWWF33.js");
1275
1275
  } catch (cause) {
1276
1276
  throw new MissingDriverError(
1277
1277
  `Solana selected, but its packages aren't installed. Run: npm install @solana/web3.js @solana/spl-token bs58`,
@@ -1283,7 +1283,7 @@ var loaders = {
1283
1283
  ton: async () => {
1284
1284
  let mod;
1285
1285
  try {
1286
- mod = await import("./ton-QHGQLJX2.js");
1286
+ mod = await import("./ton-CHJ26BVA.js");
1287
1287
  } catch (cause) {
1288
1288
  throw new MissingDriverError(
1289
1289
  `TON selected, but its packages aren't installed. Run: npm install @ton/ton @ton/core @ton/crypto`,
@@ -1295,7 +1295,7 @@ var loaders = {
1295
1295
  stellar: async () => {
1296
1296
  let mod;
1297
1297
  try {
1298
- mod = await import("./stellar-IK3UML6O.js");
1298
+ mod = await import("./stellar-FIJPQZVW.js");
1299
1299
  } catch (cause) {
1300
1300
  throw new MissingDriverError(
1301
1301
  `Stellar selected, but its package isn't installed. Run: npm install @stellar/stellar-sdk`,
@@ -1307,7 +1307,7 @@ var loaders = {
1307
1307
  xrpl: async () => {
1308
1308
  let mod;
1309
1309
  try {
1310
- mod = await import("./xrpl-2GZMDYW5.js");
1310
+ mod = await import("./xrpl-GTUPP6SK.js");
1311
1311
  } catch (cause) {
1312
1312
  throw new MissingDriverError(
1313
1313
  `XRPL selected, but its package isn't installed. Run: npm install xrpl`,
@@ -1319,7 +1319,7 @@ var loaders = {
1319
1319
  tron: async () => {
1320
1320
  let mod;
1321
1321
  try {
1322
- mod = await import("./tron-2N2GA62O.js");
1322
+ mod = await import("./tron-DD3JDROV.js");
1323
1323
  } catch (cause) {
1324
1324
  throw new MissingDriverError(
1325
1325
  `Tron selected, but its package isn't installed. Run: npm install tronweb`,
@@ -1331,7 +1331,7 @@ var loaders = {
1331
1331
  sui: async () => {
1332
1332
  let mod;
1333
1333
  try {
1334
- mod = await import("./sui-VSE63WQM.js");
1334
+ mod = await import("./sui-B7AVN7NK.js");
1335
1335
  } catch (cause) {
1336
1336
  throw new MissingDriverError(
1337
1337
  `Sui selected, but its package isn't installed. Run: npm install @mysten/sui`,
@@ -1343,7 +1343,7 @@ var loaders = {
1343
1343
  near: async () => {
1344
1344
  let mod;
1345
1345
  try {
1346
- mod = await import("./near-DT6LRIKB.js");
1346
+ mod = await import("./near-LM7S3WUD.js");
1347
1347
  } catch (cause) {
1348
1348
  throw new MissingDriverError(
1349
1349
  `NEAR selected, but its package isn't installed. Run: npm install near-api-js`,
@@ -1355,7 +1355,7 @@ var loaders = {
1355
1355
  aptos: async () => {
1356
1356
  let mod;
1357
1357
  try {
1358
- mod = await import("./aptos-CDEYDDM5.js");
1358
+ mod = await import("./aptos-SUXOVP7B.js");
1359
1359
  } catch (cause) {
1360
1360
  throw new MissingDriverError(
1361
1361
  `Aptos selected, but its package isn't installed. Run: npm install @aptos-labs/ts-sdk`,
@@ -1367,7 +1367,7 @@ var loaders = {
1367
1367
  algorand: async () => {
1368
1368
  let mod;
1369
1369
  try {
1370
- mod = await import("./algorand-7EUZYL2Z.js");
1370
+ mod = await import("./algorand-F3OYB534.js");
1371
1371
  } catch (cause) {
1372
1372
  throw new MissingDriverError(
1373
1373
  `Algorand selected, but its package isn't installed. Run: npm install algosdk`,
@@ -1842,7 +1842,18 @@ function encodeBase642(str) {
1842
1842
 
1843
1843
  // src/policy.ts
1844
1844
  var ALLOW = { allowed: true };
1845
- var deny = (reason) => ({ allowed: false, reason });
1845
+ var deny = (code, reason) => ({
1846
+ allowed: false,
1847
+ reason,
1848
+ code
1849
+ });
1850
+ function resolveDeadline(policy, sessionStart) {
1851
+ const fromTtl = policy.ttlSeconds != null && Number.isSafeInteger(sessionStart + policy.ttlSeconds * 1e3) ? sessionStart + policy.ttlSeconds * 1e3 : null;
1852
+ const fromAbs = policy.expiresAt != null ? policy.expiresAt : null;
1853
+ if (fromTtl == null) return fromAbs;
1854
+ if (fromAbs == null) return fromTtl;
1855
+ return Math.min(fromTtl, fromAbs);
1856
+ }
1846
1857
  function hostMatches(host, pattern) {
1847
1858
  if (pattern.startsWith("*.")) {
1848
1859
  const suffix = pattern.slice(1);
@@ -1858,18 +1869,26 @@ function chainMatches(intent, allowed) {
1858
1869
  const id = "id" in allowed ? allowed.id : void 0;
1859
1870
  return id !== void 0 && intent.network === `eip155:${id}`;
1860
1871
  }
1861
- function evaluatePolicy(intent, policy, spentForAssetBase) {
1872
+ function evaluatePolicy(intent, policy, spentForAssetBase, ctx) {
1862
1873
  if (!policy) return ALLOW;
1874
+ if (ctx) {
1875
+ const deadline = resolveDeadline(policy, ctx.sessionStart);
1876
+ if (deadline != null && ctx.now >= deadline) {
1877
+ return deny("SESSION_EXPIRED", "session expired (TTL elapsed) \u2014 refusing to pay.");
1878
+ }
1879
+ }
1863
1880
  if (policy.chains && !policy.chains.some((c) => chainMatches(intent, c))) {
1864
1881
  return deny(
1882
+ "CHAIN",
1865
1883
  `chain ${intent.network} is not in the allowed set (policy.chains).`
1866
1884
  );
1867
1885
  }
1868
1886
  if (policy.hosts && !policy.hosts.some((h) => hostMatches(intent.host, h))) {
1869
- return deny(`host ${intent.host} is not in the allowed set (policy.hosts).`);
1887
+ return deny("HOST", `host ${intent.host} is not in the allowed set (policy.hosts).`);
1870
1888
  }
1871
1889
  if (!intent.recognized && !policy.allowUnknownTokens) {
1872
1890
  return deny(
1891
+ "UNKNOWN_TOKEN",
1873
1892
  `asset ${intent.asset} on ${intent.network} isn't a token the SDK can price; refusing to pay it on trust. Set policy.allowUnknownTokens to override.`
1874
1893
  );
1875
1894
  }
@@ -1882,6 +1901,7 @@ function evaluatePolicy(intent, policy, spentForAssetBase) {
1882
1901
  });
1883
1902
  if (!matches) {
1884
1903
  return deny(
1904
+ "TOKEN",
1885
1905
  `token ${intent.symbol ?? intent.asset} is not in the allowed set (policy.tokens).`
1886
1906
  );
1887
1907
  }
@@ -1890,6 +1910,7 @@ function evaluatePolicy(intent, policy, spentForAssetBase) {
1890
1910
  const cap = floorUnits(policy.maxAmount, intent.decimals);
1891
1911
  if (intent.amountBase > cap) {
1892
1912
  return deny(
1913
+ "MAX_AMOUNT",
1893
1914
  `payment of ${intent.amountBase} base units exceeds policy.maxAmount ` + `(${policy.maxAmount} ${intent.symbol ?? ""}).`.trimEnd()
1894
1915
  );
1895
1916
  }
@@ -1898,10 +1919,20 @@ function evaluatePolicy(intent, policy, spentForAssetBase) {
1898
1919
  const cap = floorUnits(policy.maxTotal, intent.decimals);
1899
1920
  if (spentForAssetBase + intent.amountBase > cap) {
1900
1921
  return deny(
1922
+ "MAX_TOTAL",
1901
1923
  `this payment would push spend on ${intent.symbol ?? intent.asset} past policy.maxTotal (${policy.maxTotal}); already spent ${spentForAssetBase} base units.`
1902
1924
  );
1903
1925
  }
1904
1926
  }
1927
+ if (ctx && policy.windowTotal !== void 0 && policy.windowSeconds !== void 0) {
1928
+ const cap = floorUnits(policy.windowTotal, intent.decimals);
1929
+ if (ctx.spentInWindowBase + intent.amountBase > cap) {
1930
+ return deny(
1931
+ "WINDOW_TOTAL",
1932
+ `this payment would exceed policy.windowTotal (${policy.windowTotal}) within the last ${policy.windowSeconds}s on ${intent.symbol ?? intent.asset}.`
1933
+ );
1934
+ }
1935
+ }
1905
1936
  return ALLOW;
1906
1937
  }
1907
1938
 
@@ -1910,6 +1941,12 @@ var keyFor = (network, asset) => `${network}|${asset}`;
1910
1941
  var SpendLedger = class {
1911
1942
  records = [];
1912
1943
  buckets = /* @__PURE__ */ new Map();
1944
+ /**
1945
+ * Session clock origin (epoch-ms) — process/session start = ledger
1946
+ * construction. In-memory; a new process is a new session. The client reads it
1947
+ * to compute the `ttlSeconds` deadline and the rolling-window slice.
1948
+ */
1949
+ sessionStart = Date.now();
1913
1950
  /** Record a settled payment. `decimals` is the TRUE token decimals (for the
1914
1951
  * per-asset running total used by maxTotal + the formatted summary). */
1915
1952
  record(r, decimals) {
@@ -1935,6 +1972,38 @@ var SpendLedger = class {
1935
1972
  totalFor(network, asset) {
1936
1973
  return this.buckets.get(keyFor(network, asset))?.total ?? 0n;
1937
1974
  }
1975
+ /**
1976
+ * Sum of base-unit amounts for (network, asset) whose record `at` (ISO
1977
+ * timestamp) is at or after `sinceMs` (epoch-ms). Backs the rolling window
1978
+ * (`sinceMs = now - windowSeconds*1000`). A linear scan of `records` —
1979
+ * agent-session cardinality is small (tens), and it only runs when a window
1980
+ * policy is set, so it's negligible against the network round-trip.
1981
+ */
1982
+ totalSince(network, asset, sinceMs) {
1983
+ let sum = 0n;
1984
+ for (const r of this.records) {
1985
+ if (r.network === network && r.asset === asset && Date.parse(r.at) >= sinceMs) {
1986
+ sum += BigInt(r.amountBase);
1987
+ }
1988
+ }
1989
+ return sum;
1990
+ }
1991
+ /**
1992
+ * The per-(network, asset) buckets, as read-only tuples — `network`, `asset`,
1993
+ * `symbol`, the TRUE `decimals` (frozen from the first record), and the running
1994
+ * `totalBase`. Lets the client compose a budget view WITHOUT coupling the ledger
1995
+ * to the policy (the cap math lives in the client). Decimals only exist for a
1996
+ * pair once it's been spent on — a never-spent pair simply isn't a bucket.
1997
+ */
1998
+ assetBuckets() {
1999
+ return [...this.buckets.values()].map((b) => ({
2000
+ network: b.network,
2001
+ asset: b.asset,
2002
+ ...b.symbol ? { symbol: b.symbol } : {},
2003
+ decimals: b.decimals,
2004
+ totalBase: b.total
2005
+ }));
2006
+ }
1938
2007
  /** An immutable snapshot of all spend so far. */
1939
2008
  summary() {
1940
2009
  return {
@@ -1977,6 +2046,37 @@ var PipRailClient = class {
1977
2046
  this.maxRetries = Math.max(1, opts.maxPaymentRetries ?? 3);
1978
2047
  this.retryTimeoutMs = opts.retryTimeoutMs ?? 3e4;
1979
2048
  this.onEvent = opts.onEvent ?? (() => void 0);
2049
+ this.assertPolicyTimeOptions(opts.policy);
2050
+ }
2051
+ /**
2052
+ * Fail LOUDLY at construction on a misconfigured time policy — a security
2053
+ * boundary must never silently half-arm. Two invariants (a misconfiguration is
2054
+ * a programmer error → `TypeError`, no new SDK error code):
2055
+ * - the rolling window needs BOTH `windowTotal` and `windowSeconds`, or NEITHER
2056
+ * (one alone is a leash that silently doesn't bite);
2057
+ * - `ttlSeconds` must be a positive, safe integer whose `*1000` deadline stays
2058
+ * within `Number.MAX_SAFE_INTEGER` (else the arithmetic would lose precision).
2059
+ */
2060
+ assertPolicyTimeOptions(policy) {
2061
+ if (!policy) return;
2062
+ const hasWindowTotal = policy.windowTotal !== void 0;
2063
+ const hasWindowSeconds = policy.windowSeconds !== void 0;
2064
+ if (hasWindowTotal !== hasWindowSeconds) {
2065
+ throw new TypeError(
2066
+ "policy.windowTotal and policy.windowSeconds must be set together \u2014 a rolling-window cap can't be half-armed (set both, or neither)."
2067
+ );
2068
+ }
2069
+ if (hasWindowSeconds && !(Number.isSafeInteger(policy.windowSeconds) && policy.windowSeconds > 0)) {
2070
+ throw new TypeError("policy.windowSeconds must be a positive integer number of seconds.");
2071
+ }
2072
+ if (policy.ttlSeconds !== void 0) {
2073
+ const ttl = policy.ttlSeconds;
2074
+ if (!Number.isSafeInteger(ttl) || ttl <= 0 || !Number.isSafeInteger(this.ledger.sessionStart + ttl * 1e3)) {
2075
+ throw new TypeError(
2076
+ "policy.ttlSeconds must be a positive integer number of seconds small enough that the resulting deadline stays a safe integer."
2077
+ );
2078
+ }
2079
+ }
1980
2080
  }
1981
2081
  /** Emit an observability event, never letting a throwing handler break the
1982
2082
  * payment flow (mirrors the server gate's `onPaid` isolation). */
@@ -2074,6 +2174,64 @@ var PipRailClient = class {
2074
2174
  spent() {
2075
2175
  return this.ledger.summary();
2076
2176
  }
2177
+ /**
2178
+ * Read-only budget + time leash for a Mode-A (headless) agent — the policy IS
2179
+ * the consent, and this is how the agent SEES what's left of it before paying.
2180
+ * Composes the in-memory ledger with the configured policy; never throws, moves
2181
+ * no funds. PROCESS-SCOPED — every figure resets on restart (see {@link SessionBudget}).
2182
+ */
2183
+ budget() {
2184
+ const view = this.sessionView();
2185
+ const start = new Date(this.ledger.sessionStart).toISOString();
2186
+ return {
2187
+ session: {
2188
+ start,
2189
+ expiresAt: view?.expiresAt != null ? new Date(view.expiresAt).toISOString() : null,
2190
+ secondsRemaining: view?.secondsRemaining ?? null
2191
+ },
2192
+ byAsset: this.remaining()
2193
+ };
2194
+ }
2195
+ /**
2196
+ * Per-(network, asset) remaining budget — ONE row per pair the ledger already
2197
+ * holds (decimals are known only after the first spend), so a fresh client with
2198
+ * a `maxTotal` set returns `[]` until its first payment. `cap`/`remaining` are
2199
+ * `undefined` when no `maxTotal` is configured (unbounded). Pure + in-memory;
2200
+ * never throws, never sums across tokens (no price oracle). PROCESS-SCOPED.
2201
+ */
2202
+ remaining() {
2203
+ const maxTotal = this.opts.policy?.maxTotal;
2204
+ return this.ledger.assetBuckets().map((b) => {
2205
+ const base2 = {
2206
+ network: b.network,
2207
+ asset: b.asset,
2208
+ ...b.symbol ? { symbol: b.symbol } : {},
2209
+ decimals: b.decimals,
2210
+ spentBase: b.totalBase.toString()
2211
+ };
2212
+ if (maxTotal === void 0) return base2;
2213
+ const capBase = floorUnits(maxTotal, b.decimals);
2214
+ const remainingBase = capBase > b.totalBase ? capBase - b.totalBase : 0n;
2215
+ return {
2216
+ ...base2,
2217
+ capBase: capBase.toString(),
2218
+ remainingBase: remainingBase.toString(),
2219
+ remainingFormatted: formatUnits(remainingBase, b.decimals)
2220
+ };
2221
+ });
2222
+ }
2223
+ /** The read-only TIME envelope for the plan/budget surfaces, or `undefined`
2224
+ * when no session deadline (`ttlSeconds`/`expiresAt`) is set. `secondsRemaining`
2225
+ * is clamped ≥ 0 — a best-effort host wall-clock estimate. */
2226
+ sessionView(now = Date.now()) {
2227
+ const policy = this.opts.policy;
2228
+ if (!policy || policy.ttlSeconds == null && policy.expiresAt == null) return void 0;
2229
+ const deadline = resolveDeadline(policy, this.ledger.sessionStart);
2230
+ return {
2231
+ expiresAt: deadline,
2232
+ secondsRemaining: deadline == null ? null : Math.max(0, Math.floor((deadline - now) / 1e3))
2233
+ };
2234
+ }
2077
2235
  /**
2078
2236
  * Plan a payment for a gated URL — WITHOUT paying. The read-only completion of
2079
2237
  * the `quote()` → `estimateCost()` → **`planPayment()`** trio: it surveys every
@@ -2369,6 +2527,7 @@ var PipRailClient = class {
2369
2527
  * net/wallet. Shared by `planPayment` (read-only) and `fetch`'s autoRoute. */
2370
2528
  async planFromChallenge(net, wallet, challenge, url, schemes) {
2371
2529
  const chainLabel = typeof this.opts.chain === "string" ? this.opts.chain : net.network;
2530
+ const session = this.sessionView();
2372
2531
  const candidates = this.gatherCandidates(net, challenge, schemes);
2373
2532
  if (candidates.length === 0) {
2374
2533
  const offered = [...new Set(challenge.accepts.map((a) => a.network))].join(", ") || "none";
@@ -2379,7 +2538,8 @@ var PipRailClient = class {
2379
2538
  payable: false,
2380
2539
  best: null,
2381
2540
  options: [],
2382
- fundingHint: `This 402 isn't offered on your chain (${chainLabel}); it's payable on: ${offered}.`
2541
+ fundingHint: `This 402 isn't offered on your chain (${chainLabel}); it's payable on: ${offered}.`,
2542
+ ...session ? { session } : {}
2383
2543
  };
2384
2544
  }
2385
2545
  const analysed = await Promise.all(
@@ -2397,7 +2557,8 @@ var PipRailClient = class {
2397
2557
  payable: best !== null,
2398
2558
  best,
2399
2559
  options,
2400
- fundingHint: best ? null : buildFundingHint(options, chainLabel)
2560
+ fundingHint: best ? null : buildFundingHint(options, chainLabel),
2561
+ ...session ? { session } : {}
2401
2562
  };
2402
2563
  }
2403
2564
  /** Analyse ONE rail against the wallet's holdings — quote (existing) + gas
@@ -2414,7 +2575,11 @@ var PipRailClient = class {
2414
2575
  const blockers = [];
2415
2576
  const warnings = [];
2416
2577
  const shortfall = {};
2417
- if (!quote.withinPolicy) blockers.push("OUTSIDE_POLICY");
2578
+ if (!quote.withinPolicy) {
2579
+ blockers.push(
2580
+ quote.policyCode === "SESSION_EXPIRED" || quote.policyCode === "WINDOW_TOTAL" ? "OUTSIDE_WINDOW" : "OUTSIDE_POLICY"
2581
+ );
2582
+ }
2418
2583
  if (quote.symbolMismatch) warnings.push("SYMBOL_MISMATCH");
2419
2584
  if (!isExact && cost.basis === "heuristic") warnings.push("GAS_HEURISTIC");
2420
2585
  const tokenKnown = bal.token != null;
@@ -2498,10 +2663,25 @@ var PipRailClient = class {
2498
2663
  symbol,
2499
2664
  recognized: described != null
2500
2665
  };
2666
+ const policy = this.opts.policy;
2667
+ const hasWindow = !!policy && policy.windowTotal != null && policy.windowSeconds != null;
2668
+ const hasTimePolicy = !!policy && (policy.ttlSeconds != null || policy.expiresAt != null || hasWindow);
2669
+ const now = Date.now();
2670
+ const ctx = hasTimePolicy ? {
2671
+ now,
2672
+ sessionStart: this.ledger.sessionStart,
2673
+ // Window slice ONLY when BOTH fields are set — never a `?? 0` width.
2674
+ spentInWindowBase: hasWindow ? this.ledger.totalSince(
2675
+ accept.network,
2676
+ accept.asset,
2677
+ now - policy.windowSeconds * 1e3
2678
+ ) : 0n
2679
+ } : void 0;
2501
2680
  const decision = evaluatePolicy(
2502
2681
  intent,
2503
2682
  this.opts.policy,
2504
- this.ledger.totalFor(accept.network, accept.asset)
2683
+ this.ledger.totalFor(accept.network, accept.asset),
2684
+ ctx
2505
2685
  );
2506
2686
  const serverSymbol = accept.extra.symbol;
2507
2687
  const symbolMismatch = intent.recognized && !!serverSymbol && !!symbol && serverSymbol.toUpperCase() !== symbol.toUpperCase();
@@ -2520,15 +2700,19 @@ var PipRailClient = class {
2520
2700
  recognized: intent.recognized,
2521
2701
  symbolMismatch,
2522
2702
  withinPolicy: decision.allowed,
2523
- ...decision.reason ? { policyReason: decision.reason } : {}
2703
+ ...decision.reason ? { policyReason: decision.reason } : {},
2704
+ ...decision.code ? { policyCode: decision.code } : {}
2524
2705
  };
2525
2706
  }
2526
2707
  /** Enforce the spend policy and the onBeforePay hook — both refuse by
2527
- * throwing PaymentDeclinedError, before any funds move. */
2708
+ * throwing PaymentDeclinedError, before any funds move. Every refusal carries
2709
+ * a typed `reasonCode` so an agent can branch on the cause (and spot a
2710
+ * TERMINAL expiry/approval decline it must not retry) without parsing prose. */
2528
2711
  async authorize(quote) {
2529
2712
  if (!quote.withinPolicy) {
2530
2713
  throw new PaymentDeclinedError(
2531
- `Payment refused by policy: ${quote.policyReason ?? "not allowed"}`
2714
+ `Payment refused by policy: ${quote.policyReason ?? "not allowed"}`,
2715
+ { reasonCode: reasonCodeForPolicy(quote.policyCode) }
2532
2716
  );
2533
2717
  }
2534
2718
  const hook = this.opts.onBeforePay;
@@ -2538,12 +2722,14 @@ var PipRailClient = class {
2538
2722
  approved = await hook(quote);
2539
2723
  } catch (err) {
2540
2724
  throw new PaymentDeclinedError("onBeforePay threw \u2014 refusing to pay.", {
2541
- cause: err
2725
+ cause: err,
2726
+ reasonCode: "APPROVAL"
2542
2727
  });
2543
2728
  }
2544
2729
  if (!approved) {
2545
2730
  throw new PaymentDeclinedError(
2546
- `onBeforePay declined ${quote.amountFormatted} ${quote.symbol ?? ""}`.trimEnd() + ` on ${quote.network}.`
2731
+ `onBeforePay declined ${quote.amountFormatted} ${quote.symbol ?? ""}`.trimEnd() + ` on ${quote.network}.`,
2732
+ { reasonCode: "APPROVAL" }
2547
2733
  );
2548
2734
  }
2549
2735
  }
@@ -2771,6 +2957,9 @@ function buildFundingHint(options, chainLabel) {
2771
2957
  if (target.blockers.includes("RECIPIENT_NOT_READY")) {
2772
2958
  return `Recipient ${shortAddr(target.accept.payTo)} can't receive on ${chainLabel} yet \u2014 ${target.recipient.fix ?? "recipient not ready"}.`;
2773
2959
  }
2960
+ if (target.blockers.includes("OUTSIDE_WINDOW")) {
2961
+ return target.quote.policyCode === "SESSION_EXPIRED" ? `Session is over on ${chainLabel} \u2014 restart the process or extend the TTL; no retry will succeed.` : `Budget window exhausted on ${chainLabel} \u2014 wait for it to free, or raise policy.windowTotal.`;
2962
+ }
2774
2963
  if (target.blockers.includes("OUTSIDE_POLICY")) {
2775
2964
  return `Refused by spend policy: ${target.quote.policyReason ?? "not allowed"}.`;
2776
2965
  }
@@ -2808,6 +2997,20 @@ function railOnNetwork(rail, matches) {
2808
2997
  const n = normalizeNetwork(rail.network);
2809
2998
  return !n.includes(":") || matches(n);
2810
2999
  }
3000
+ function reasonCodeForPolicy(code) {
3001
+ switch (code) {
3002
+ case "SESSION_EXPIRED":
3003
+ return "SESSION_EXPIRED";
3004
+ case "WINDOW_TOTAL":
3005
+ return "OUTSIDE_WINDOW";
3006
+ case "MAX_TOTAL":
3007
+ return "BUDGET";
3008
+ case void 0:
3009
+ return void 0;
3010
+ default:
3011
+ return "POLICY";
3012
+ }
3013
+ }
2811
3014
  function hostOf2(url) {
2812
3015
  try {
2813
3016
  return new URL(url).hostname;
@@ -2855,7 +3058,111 @@ async function readInvalidReason(response) {
2855
3058
  return null;
2856
3059
  }
2857
3060
 
3061
+ // src/render.ts
3062
+ function summarizePlan(plan) {
3063
+ if (plan == null) return "No payment required \u2014 the URL is not payment-gated.";
3064
+ if (plan.payable && plan.best) {
3065
+ const q = plan.best.quote;
3066
+ const c = plan.best.cost;
3067
+ const otherRails = plan.options.length - 1;
3068
+ const note = otherRails > 0 ? ` ${otherRails} other rail(s) not settleable.` : "";
3069
+ return `Payable: ${q.amountFormatted} ${q.symbol ?? q.asset} on ${plan.best.accept.network} (gas ~${c.feeFormatted} ${c.feeSymbol}).${note}`;
3070
+ }
3071
+ return `NOT payable: ${plan.fundingHint ?? `no settleable rail on ${plan.network}`}`;
3072
+ }
3073
+ function explainDecline(err) {
3074
+ if (!(err instanceof PipRailError)) {
3075
+ return `Payment failed: ${err instanceof Error ? err.message : String(err)}`;
3076
+ }
3077
+ switch (err.code) {
3078
+ case "PAYMENT_DECLINED":
3079
+ return err.message;
3080
+ case "PAYMENT_TIMEOUT":
3081
+ case "MAX_RETRIES_EXCEEDED":
3082
+ case "CONFIRMATION_TIMEOUT":
3083
+ return `${err.message} Recover using the proof on .ref (re-verify or re-submit it); never re-pay \u2014 a fresh payment would double-spend.`;
3084
+ case "INSUFFICIENT_FUNDS":
3085
+ return "The wallet cannot cover the payment + gas \u2014 top up the payer (token and/or native gas) and retry.";
3086
+ case "RECIPIENT_NOT_READY":
3087
+ return `${err.message} The fix is on the RECIPIENT (trustline / registration / opt-in / activation), not your balance.`;
3088
+ case "NO_COMPATIBLE_ACCEPT":
3089
+ case "UNSUPPORTED_SCHEME":
3090
+ return err.message;
3091
+ default:
3092
+ return `Payment failed: ${err.message}`;
3093
+ }
3094
+ }
3095
+ function formatSpendReport(summary) {
3096
+ if (summary.count === 0) return "No payments yet.";
3097
+ return summary.byAsset.map(
3098
+ (a) => `${a.totalFormatted} ${a.symbol ?? a.asset} on ${a.network} (${a.count} payment${a.count === 1 ? "" : "s"})`
3099
+ ).join("; ");
3100
+ }
3101
+
3102
+ // src/agentGuide.ts
3103
+ var PIPRAIL_AGENT_GUIDE = `# Paying with PipRail \u2014 the agent contract
3104
+
3105
+ You can pay for x402 "402 Payment Required" resources autonomously. Money moves
3106
+ straight from your wallet to the server; PipRail custodies nothing. Follow this.
3107
+
3108
+ ## The loop: quote \u2192 plan \u2192 pay
3109
+ 1. piprail_quote_payment(url) \u2014 PRICE it. Returns the amount, token, chain, and
3110
+ whether it is within your spend policy. No funds move. Use it to decide if a
3111
+ resource is worth buying.
3112
+ 2. piprail_plan_payment(url) \u2014 can I afford it NOW? Reads your balance, native gas,
3113
+ and recipient-readiness across every rail, and returns { payable, best,
3114
+ fundingHint, session? }. If payable is false, do NOT attempt the payment \u2014
3115
+ fundingHint says exactly what to fix.
3116
+ 3. piprail_pay_request(url, method?, body?) \u2014 PAY (only if the plan was payable)
3117
+ and return the result.
3118
+ Always plan before you pay so you never commit to a payment you cannot finish.
3119
+
3120
+ ## Reading a refusal \u2014 never crash, never double-spend
3121
+ A failed pay returns a STRUCTURED object, never a thrown error you must catch:
3122
+ { ok:false, code, reason, explain, ref?, reasonCode?, declined? }
3123
+ Branch on \`code\` (always reliable). Key cases:
3124
+ - declined:true with reasonCode:'SESSION_EXPIRED' \u2014 your time budget is over. This
3125
+ is TERMINAL: STOP. Do not retry ANY payment this process; it cannot be undone
3126
+ without a restart / a longer TTL.
3127
+ - declined:true with reasonCode:'APPROVAL' \u2014 a human (or hook) declined this
3128
+ payment. Terminal for this pay: do NOT auto-retry \u2014 they said no, or no one
3129
+ answered.
3130
+ - declined:true with reasonCode:'OUTSIDE_WINDOW' \u2014 your rolling rate-limit is
3131
+ exhausted. Wait for it to free, then retry; do not raise the amount.
3132
+ - declined:true with reasonCode:'POLICY' or 'BUDGET' \u2014 a spend cap or allowlist
3133
+ refused it. Don't retry the same payment; pick a cheaper/allowed one.
3134
+ - code:'INSUFFICIENT_FUNDS' \u2014 top up the wallet (token and/or native gas), retry.
3135
+ - code:'PAYMENT_TIMEOUT' / 'MAX_RETRIES_EXCEEDED' / 'CONFIRMATION_TIMEOUT' \u2014 the
3136
+ payment may ALREADY be on-chain. Recover using the proof on \`.ref\` (re-verify
3137
+ or re-submit it); never re-pay \u2014 a fresh payment would double-spend.
3138
+ - code:'NO_COMPATIBLE_ACCEPT' / 'UNSUPPORTED_SCHEME' \u2014 the 402 isn't payable on
3139
+ your chain/scheme; \`explain\` says whether it's the wrong chain or a scheme to enable.
3140
+
3141
+ ## Knowing your leash \u2014 call piprail_budget
3142
+ piprail_budget tells you how much budget and time you have left, per
3143
+ (network, asset), plus your spend so far. Read-only; moves no funds. Use it in
3144
+ Mode A to self-check before paying.
3145
+
3146
+ ## Two modes
3147
+ - Mode A (headless, default): you run FREE inside a pre-set budget + time
3148
+ envelope. The policy IS the consent \u2014 there is no per-payment prompt. Stay
3149
+ inside it; piprail_budget shows what's left.
3150
+ - Mode B (supervised): the host may ask a human to approve each payment. A
3151
+ decline/cancel/timeout comes back as declined:true (reasonCode:'APPROVAL') \u2014
3152
+ do NOT retry it as if it were a transient error.
3153
+
3154
+ ## Hard facts
3155
+ - Spend caps are PER (network, asset). There is no single cross-token dollar cap \u2014
3156
+ budgets aren't summed across tokens (no price oracle).
3157
+ - Spend totals and the time envelope live IN-MEMORY for THIS process; they reset on restart
3158
+ (a convenience, not a durable ledger).
3159
+ `;
3160
+ function agentGuide() {
3161
+ return PIPRAIL_AGENT_GUIDE;
3162
+ }
3163
+
2858
3164
  // src/agent.ts
3165
+ var OPEN_OBJECT = { type: "object", additionalProperties: true };
2859
3166
  async function readBody(res) {
2860
3167
  const text = await res.text();
2861
3168
  if (!text) return null;
@@ -2928,6 +3235,7 @@ function paymentTools(client) {
2928
3235
  required: ["url"],
2929
3236
  additionalProperties: false
2930
3237
  },
3238
+ outputSchema: OPEN_OBJECT,
2931
3239
  invoke: async (args) => {
2932
3240
  const quote = await client.quote(String(args.url));
2933
3241
  return quote ? { gated: true, ...quote } : { gated: false, url: String(args.url) };
@@ -2951,6 +3259,7 @@ function paymentTools(client) {
2951
3259
  required: ["url"],
2952
3260
  additionalProperties: false
2953
3261
  },
3262
+ outputSchema: OPEN_OBJECT,
2954
3263
  invoke: async (args) => {
2955
3264
  const plan = await client.planPayment(String(args.url));
2956
3265
  if (plan == null) return { gated: false, url: String(args.url) };
@@ -2959,6 +3268,8 @@ function paymentTools(client) {
2959
3268
  payable: plan.payable,
2960
3269
  status: plan.status,
2961
3270
  fundingHint: plan.fundingHint,
3271
+ // One model-readable line distilling the whole plan.
3272
+ summary: summarizePlan(plan),
2962
3273
  best: plan.best ? {
2963
3274
  network: plan.best.accept.network,
2964
3275
  symbol: plan.best.quote.symbol,
@@ -2974,7 +3285,9 @@ function paymentTools(client) {
2974
3285
  blockers: o.blockers,
2975
3286
  warnings: o.warnings,
2976
3287
  recipientReady: o.recipient.ready
2977
- }))
3288
+ })),
3289
+ // The session's time leash, present only when a time policy is configured.
3290
+ ...plan.session ? { session: plan.session } : {}
2978
3291
  };
2979
3292
  }
2980
3293
  },
@@ -3032,8 +3345,20 @@ function paymentTools(client) {
3032
3345
  receipt: parseReceipt(res)
3033
3346
  };
3034
3347
  } catch (err) {
3035
- if (err instanceof PaymentDeclinedError) {
3036
- return { declined: true, reason: err.message };
3348
+ if (err instanceof PipRailError) {
3349
+ const out = {
3350
+ ok: false,
3351
+ code: err.code,
3352
+ reason: err.message,
3353
+ explain: explainDecline(err)
3354
+ };
3355
+ if (err instanceof PaymentDeclinedError) {
3356
+ out.declined = true;
3357
+ if (err.reasonCode) out.reasonCode = err.reasonCode;
3358
+ }
3359
+ const ref = err.ref;
3360
+ if (typeof ref === "string") out.ref = ref;
3361
+ return out;
3037
3362
  }
3038
3363
  throw err;
3039
3364
  }
@@ -3071,10 +3396,57 @@ function paymentTools(client) {
3071
3396
  const outcomes = await client.register(String(args.url), opts);
3072
3397
  return { outcomes };
3073
3398
  }
3399
+ },
3400
+ {
3401
+ name: "piprail_budget",
3402
+ description: "Read how much of your spend budget and time leash is left \u2014 per (network, asset) remaining, the session time envelope, and your spend so far. Use it in Mode A (headless) to self-check BEFORE paying, so you never discover the leash by hitting a decline. Read-only; moves no funds. NOTE: totals and the time envelope are in-memory for THIS process and reset on restart.",
3403
+ annotations: {
3404
+ title: "Check remaining budget",
3405
+ readOnlyHint: true,
3406
+ // reads the in-memory ledger + policy; never pays
3407
+ idempotentHint: true
3408
+ // a pure read
3409
+ },
3410
+ parameters: { type: "object", properties: {}, additionalProperties: false },
3411
+ outputSchema: OPEN_OBJECT,
3412
+ invoke: async () => {
3413
+ const spent = client.spent();
3414
+ const budget = client.budget();
3415
+ return {
3416
+ spent,
3417
+ remaining: budget.byAsset,
3418
+ session: budget.session,
3419
+ report: formatSpendReport(spent)
3420
+ };
3421
+ }
3422
+ },
3423
+ {
3424
+ name: "piprail_guide",
3425
+ description: "Read the PipRail agent contract \u2014 the quote \u2192 plan \u2192 pay loop, how to read a refusal (and which declines are TERMINAL), the never-re-pay rule for broadcast-but-unconfirmed payments, and Mode A (headless) vs Mode B (supervised). Read-only; call it once if unsure how to use these tools.",
3426
+ annotations: {
3427
+ title: "How to use PipRail",
3428
+ readOnlyHint: true,
3429
+ idempotentHint: true
3430
+ },
3431
+ parameters: { type: "object", properties: {}, additionalProperties: false },
3432
+ invoke: async () => ({ guide: PIPRAIL_AGENT_GUIDE })
3074
3433
  }
3075
3434
  ];
3076
3435
  }
3077
3436
 
3437
+ // src/classify.ts
3438
+ function classifyChallenge(challenge, opts) {
3439
+ const accepts = challenge.accepts ?? [];
3440
+ const offeredSchemes = [...new Set(accepts.map((a) => a.scheme))];
3441
+ const offeredNetworks = [...new Set(accepts.map((a) => a.network))];
3442
+ const onClientChain = accepts.some((a) => a.network === opts.network);
3443
+ const payableScheme = accepts.some(
3444
+ (a) => a.network === opts.network && opts.schemes.includes(a.scheme)
3445
+ );
3446
+ const verdict = accepts.length === 0 ? "NO_RAIL" : payableScheme ? "PAYABLE_RAIL" : onClientChain ? "UNPAYABLE_SCHEME" : "WRONG_CHAIN";
3447
+ return { onClientChain, payableScheme, offeredSchemes, offeredNetworks, verdict };
3448
+ }
3449
+
3078
3450
  // src/discovery.ts
3079
3451
  var GENERATOR = "@piprail/sdk \xB7 https://piprail.com";
3080
3452
  function buildBazaarExtension(descriptor = {}) {
@@ -3620,6 +3992,7 @@ export {
3620
3992
  MissingDriverError,
3621
3993
  NoCompatibleAcceptError,
3622
3994
  NonReplayableBodyError,
3995
+ PIPRAIL_AGENT_GUIDE,
3623
3996
  PaymentDeclinedError,
3624
3997
  PaymentTimeoutError,
3625
3998
  PipRailClient,
@@ -3631,6 +4004,7 @@ export {
3631
4004
  UnsupportedSchemeError,
3632
4005
  WrongChainError,
3633
4006
  WrongFamilyError,
4007
+ agentGuide,
3634
4008
  buildBazaarExtension,
3635
4009
  buildChallengeHeader,
3636
4010
  buildExactAuthorization,
@@ -3642,11 +4016,14 @@ export {
3642
4016
  buildX402DnsTxt,
3643
4017
  chainIdForExactNetwork,
3644
4018
  claim402IndexDomain,
4019
+ classifyChallenge,
3645
4020
  createPaymentGate,
3646
4021
  decorateOutcome,
3647
4022
  eip3009Abi,
3648
4023
  encodeXPaymentHeader,
3649
4024
  evaluatePolicy,
4025
+ explainDecline,
4026
+ formatSpendReport,
3650
4027
  getDirectoryInfo,
3651
4028
  normalizeNetwork,
3652
4029
  parseChallenge,
@@ -3666,6 +4043,7 @@ export {
3666
4043
  resolveChain,
3667
4044
  searchOpenIndexes,
3668
4045
  settleViaFacilitator,
4046
+ summarizePlan,
3669
4047
  toInsufficientFundsError,
3670
4048
  toInvalidBody,
3671
4049
  verify402IndexDomain