@piprail/sdk 2.7.0 → 2.9.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 (35) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +5 -3
  3. package/dist/{algorand-WB6PBJU4.js → algorand-AA3WXKW4.js} +1 -1
  4. package/dist/{algorand-TMA62DN2.cjs → algorand-FCEECDG6.cjs} +32 -32
  5. package/dist/{aptos-JQMZNTMD.cjs → aptos-GHJPO6JJ.cjs} +31 -31
  6. package/dist/{aptos-QAAXIUY3.js → aptos-LY67Q6QF.js} +1 -1
  7. package/dist/{chunk-JG6KRAW6.cjs → chunk-MWBT7MCE.cjs} +13 -1
  8. package/dist/chunk-O4UQOZ4Z.cjs +14 -0
  9. package/dist/{chunk-7XK22JSQ.js → chunk-SC2ZYDHD.js} +12 -0
  10. package/dist/chunk-SK3CB7UA.js +14 -0
  11. package/dist/index.cjs +754 -238
  12. package/dist/index.d.cts +2386 -2577
  13. package/dist/index.d.ts +2386 -2577
  14. package/dist/index.js +595 -79
  15. package/dist/ledger-BtzrfO-3.d.cts +658 -0
  16. package/dist/ledger-BtzrfO-3.d.ts +658 -0
  17. package/dist/{near-H5AQ253I.cjs → near-6KAQVVG2.cjs} +26 -26
  18. package/dist/{near-OTPQD6BI.js → near-FZBUICCS.js} +1 -1
  19. package/dist/node.cjs +38 -0
  20. package/dist/node.d.cts +16 -0
  21. package/dist/node.d.ts +16 -0
  22. package/dist/node.js +38 -0
  23. package/dist/{solana-3FMCWSEE.js → solana-ELUWO6N5.js} +33 -2
  24. package/dist/{solana-M3VOHCMO.cjs → solana-MYF4HBO4.cjs} +64 -33
  25. package/dist/{stellar-U5NCRIOJ.js → stellar-BEMT7UYF.js} +1 -1
  26. package/dist/{stellar-FW6C6FBE.cjs → stellar-SUKASK4N.cjs} +20 -20
  27. package/dist/{sui-Y53M4GUM.js → sui-F5JQ2N6I.js} +1 -1
  28. package/dist/{sui-47C2KEZI.cjs → sui-VE5LT7BL.cjs} +16 -16
  29. package/dist/{ton-5ZPT5PSP.js → ton-7GKCTC5H.js} +11 -7
  30. package/dist/{ton-MMPKWT6N.cjs → ton-AOR3EURW.cjs} +25 -21
  31. package/dist/{tron-JOT4STIG.cjs → tron-EMFXDFHW.cjs} +24 -24
  32. package/dist/{tron-WYS4X2I5.js → tron-ZZZS3FNN.js} +1 -1
  33. package/dist/{xrpl-2MZEOIFY.js → xrpl-DD7TJL5L.js} +1 -1
  34. package/dist/{xrpl-PECT4IMX.cjs → xrpl-Y6SQNYLC.cjs} +20 -20
  35. package/package.json +11 -1
package/dist/index.js CHANGED
@@ -1,7 +1,11 @@
1
+ import {
2
+ memorySpendStore
3
+ } from "./chunk-SK3CB7UA.js";
1
4
  import {
2
5
  ConfirmationTimeoutError,
3
6
  InsufficientFundsError,
4
7
  InvalidEnvelopeError,
8
+ MAX_DECIMALS,
5
9
  MaxRetriesExceededError,
6
10
  MissingDriverError,
7
11
  NoCompatibleAcceptError,
@@ -24,7 +28,7 @@ import {
24
28
  parseUnits,
25
29
  rejectForeignToken,
26
30
  toInsufficientFundsError
27
- } from "./chunk-7XK22JSQ.js";
31
+ } from "./chunk-SC2ZYDHD.js";
28
32
 
29
33
  // src/drivers/registry.ts
30
34
  var byFamily = /* @__PURE__ */ new Map();
@@ -1840,7 +1844,7 @@ var loaders = {
1840
1844
  solana: async () => {
1841
1845
  let mod;
1842
1846
  try {
1843
- mod = await import("./solana-3FMCWSEE.js");
1847
+ mod = await import("./solana-ELUWO6N5.js");
1844
1848
  } catch (cause) {
1845
1849
  throw new MissingDriverError(
1846
1850
  `Solana selected, but its packages aren't installed. Run: npm install @solana/web3.js @solana/spl-token bs58`,
@@ -1852,7 +1856,7 @@ var loaders = {
1852
1856
  ton: async () => {
1853
1857
  let mod;
1854
1858
  try {
1855
- mod = await import("./ton-5ZPT5PSP.js");
1859
+ mod = await import("./ton-7GKCTC5H.js");
1856
1860
  } catch (cause) {
1857
1861
  throw new MissingDriverError(
1858
1862
  `TON selected, but its packages aren't installed. Run: npm install @ton/ton @ton/core @ton/crypto`,
@@ -1864,7 +1868,7 @@ var loaders = {
1864
1868
  stellar: async () => {
1865
1869
  let mod;
1866
1870
  try {
1867
- mod = await import("./stellar-U5NCRIOJ.js");
1871
+ mod = await import("./stellar-BEMT7UYF.js");
1868
1872
  } catch (cause) {
1869
1873
  throw new MissingDriverError(
1870
1874
  `Stellar selected, but its package isn't installed. Run: npm install @stellar/stellar-sdk`,
@@ -1876,7 +1880,7 @@ var loaders = {
1876
1880
  xrpl: async () => {
1877
1881
  let mod;
1878
1882
  try {
1879
- mod = await import("./xrpl-2MZEOIFY.js");
1883
+ mod = await import("./xrpl-DD7TJL5L.js");
1880
1884
  } catch (cause) {
1881
1885
  throw new MissingDriverError(
1882
1886
  `XRPL selected, but its package isn't installed. Run: npm install xrpl`,
@@ -1888,7 +1892,7 @@ var loaders = {
1888
1892
  tron: async () => {
1889
1893
  let mod;
1890
1894
  try {
1891
- mod = await import("./tron-WYS4X2I5.js");
1895
+ mod = await import("./tron-ZZZS3FNN.js");
1892
1896
  } catch (cause) {
1893
1897
  throw new MissingDriverError(
1894
1898
  `Tron selected, but its package isn't installed. Run: npm install tronweb`,
@@ -1900,7 +1904,7 @@ var loaders = {
1900
1904
  sui: async () => {
1901
1905
  let mod;
1902
1906
  try {
1903
- mod = await import("./sui-Y53M4GUM.js");
1907
+ mod = await import("./sui-F5JQ2N6I.js");
1904
1908
  } catch (cause) {
1905
1909
  throw new MissingDriverError(
1906
1910
  `Sui selected, but its package isn't installed. Run: npm install @mysten/sui`,
@@ -1912,7 +1916,7 @@ var loaders = {
1912
1916
  near: async () => {
1913
1917
  let mod;
1914
1918
  try {
1915
- mod = await import("./near-OTPQD6BI.js");
1919
+ mod = await import("./near-FZBUICCS.js");
1916
1920
  } catch (cause) {
1917
1921
  throw new MissingDriverError(
1918
1922
  `NEAR selected, but its package isn't installed. Run: npm install near-api-js`,
@@ -1924,7 +1928,7 @@ var loaders = {
1924
1928
  aptos: async () => {
1925
1929
  let mod;
1926
1930
  try {
1927
- mod = await import("./aptos-QAAXIUY3.js");
1931
+ mod = await import("./aptos-LY67Q6QF.js");
1928
1932
  } catch (cause) {
1929
1933
  throw new MissingDriverError(
1930
1934
  `Aptos selected, but its package isn't installed. Run: npm install @aptos-labs/ts-sdk`,
@@ -1936,7 +1940,7 @@ var loaders = {
1936
1940
  algorand: async () => {
1937
1941
  let mod;
1938
1942
  try {
1939
- mod = await import("./algorand-WB6PBJU4.js");
1943
+ mod = await import("./algorand-AA3WXKW4.js");
1940
1944
  } catch (cause) {
1941
1945
  throw new MissingDriverError(
1942
1946
  `Algorand selected, but its package isn't installed. Run: npm install algosdk`,
@@ -2027,7 +2031,7 @@ var SLUG_TO_CAIP2 = {
2027
2031
  bsc: "eip155:56",
2028
2032
  // non-EVM families — values mirror each driver's bound caip2 exactly
2029
2033
  solana: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
2030
- ton: "ton:-239",
2034
+ ton: "tvm:-239",
2031
2035
  tron: "tron:mainnet",
2032
2036
  near: "near:mainnet",
2033
2037
  sui: "sui:mainnet",
@@ -2036,7 +2040,12 @@ var SLUG_TO_CAIP2 = {
2036
2040
  stellar: "stellar:pubnet",
2037
2041
  xrpl: "xrpl:0"
2038
2042
  };
2043
+ var LEGACY_CAIP2_ALIAS = {
2044
+ "ton:-239": "tvm:-239"
2045
+ };
2039
2046
  function normalizeNetwork(network) {
2047
+ const legacy = LEGACY_CAIP2_ALIAS[network];
2048
+ if (legacy) return legacy;
2040
2049
  if (network.includes(":")) return network;
2041
2050
  return SLUG_TO_CAIP2[network.toLowerCase()] ?? network;
2042
2051
  }
@@ -2577,6 +2586,53 @@ function encodeBase642(str) {
2577
2586
  }
2578
2587
 
2579
2588
  // src/policy.ts
2589
+ var DENOM_PRECISION = MAX_DECIMALS;
2590
+ var BUILTIN_DENOMS = {
2591
+ USDC: "USD",
2592
+ USDT: "USD",
2593
+ USD1: "USD",
2594
+ FDUSD: "USD",
2595
+ RLUSD: "USD",
2596
+ EURC: "EUR"
2597
+ };
2598
+ function denomOf(symbol, asset, policy) {
2599
+ if (asset === "native") return void 0;
2600
+ const norm = (v) => typeof v === "string" && v.trim() !== "" ? v.trim().toUpperCase() : void 0;
2601
+ const override = policy?.denomFor;
2602
+ if (override && typeof override === "object") {
2603
+ if (asset && asset in override) {
2604
+ const d = norm(override[asset]);
2605
+ if (d) return d;
2606
+ }
2607
+ if (symbol) {
2608
+ const hit = Object.entries(override).find(([k]) => k.trim().toUpperCase() === symbol.trim().toUpperCase());
2609
+ if (hit) {
2610
+ const d = norm(hit[1]);
2611
+ if (d) return d;
2612
+ }
2613
+ }
2614
+ }
2615
+ if (symbol) {
2616
+ const builtin = BUILTIN_DENOMS[symbol.trim().toUpperCase()];
2617
+ if (builtin) return builtin;
2618
+ }
2619
+ return void 0;
2620
+ }
2621
+ function scaleToDenom(amountBase, decimals) {
2622
+ if (!Number.isSafeInteger(decimals) || decimals < 0 || decimals > DENOM_PRECISION) return null;
2623
+ return amountBase * 10n ** BigInt(DENOM_PRECISION - decimals);
2624
+ }
2625
+ function capForDenom(caps, denom) {
2626
+ if (!caps || typeof caps !== "object") return void 0;
2627
+ const target = denom.trim().toUpperCase();
2628
+ let best;
2629
+ for (const [k, v] of Object.entries(caps)) {
2630
+ if (typeof k !== "string" || typeof v !== "string") continue;
2631
+ if (k.trim().toUpperCase() !== target) continue;
2632
+ if (best === void 0 || Number(v) < Number(best)) best = v;
2633
+ }
2634
+ return best;
2635
+ }
2580
2636
  var ALLOW = { allowed: true };
2581
2637
  var deny = (code, reason) => ({
2582
2638
  allowed: false,
@@ -2660,6 +2716,34 @@ function evaluatePolicy(intent, policy, spentForAssetBase, ctx) {
2660
2716
  );
2661
2717
  }
2662
2718
  }
2719
+ if (ctx && policy.maxTotalPerDenom) {
2720
+ const denom = denomOf(intent.symbol, intent.asset, policy);
2721
+ const capStr = denom ? capForDenom(policy.maxTotalPerDenom, denom) : void 0;
2722
+ if (denom && capStr !== void 0) {
2723
+ const thisScaled = scaleToDenom(intent.amountBase, intent.decimals);
2724
+ if (thisScaled === null) {
2725
+ return deny(
2726
+ "MAX_TOTAL_DENOM",
2727
+ `token for ${denom} has more than ${DENOM_PRECISION} decimals \u2014 refusing to count it toward policy.maxTotalPerDenom.${denom} on trust (it can't be summed safely).`
2728
+ );
2729
+ }
2730
+ const capScaled = floorUnits(capStr, DENOM_PRECISION);
2731
+ if ((ctx.spentInDenomScaled ?? 0n) + thisScaled > capScaled) {
2732
+ return deny(
2733
+ "MAX_TOTAL_DENOM",
2734
+ `this payment would push total ${denom} spend past policy.maxTotalPerDenom.${denom} (${capStr}) \u2014 summed across every ${denom} token and chain.`
2735
+ );
2736
+ }
2737
+ }
2738
+ }
2739
+ if (ctx && policy.maxPayments !== void 0) {
2740
+ if ((ctx.paymentCount ?? 0) + 1 > policy.maxPayments) {
2741
+ return deny(
2742
+ "MAX_PAYMENTS",
2743
+ `this payment would exceed policy.maxPayments (${policy.maxPayments}); already settled ${ctx.paymentCount ?? 0}.`
2744
+ );
2745
+ }
2746
+ }
2663
2747
  if (ctx && policy.windowTotal !== void 0 && policy.windowSeconds !== void 0) {
2664
2748
  const cap = floorUnits(policy.windowTotal, intent.decimals);
2665
2749
  if (ctx.spentInWindowBase + intent.amountBase > cap) {
@@ -2669,6 +2753,14 @@ function evaluatePolicy(intent, policy, spentForAssetBase, ctx) {
2669
2753
  );
2670
2754
  }
2671
2755
  }
2756
+ if (ctx && policy.maxPaymentsPerWindow !== void 0 && policy.windowSeconds !== void 0) {
2757
+ if ((ctx.paymentCountInWindow ?? 0) + 1 > policy.maxPaymentsPerWindow) {
2758
+ return deny(
2759
+ "WINDOW_COUNT",
2760
+ `this payment would exceed policy.maxPaymentsPerWindow (${policy.maxPaymentsPerWindow}) within the last ${policy.windowSeconds}s.`
2761
+ );
2762
+ }
2763
+ }
2672
2764
  return ALLOW;
2673
2765
  }
2674
2766
 
@@ -2677,37 +2769,135 @@ var keyFor = (network, asset) => `${network}|${asset}`;
2677
2769
  var SpendLedger = class {
2678
2770
  records = [];
2679
2771
  buckets = /* @__PURE__ */ new Map();
2772
+ /** Per-denomination running total, scaled to {@link DENOM_PRECISION}. Keyed by the
2773
+ * UPPERCASE denomination so lookups are case-insensitive. */
2774
+ denomTotals = /* @__PURE__ */ new Map();
2775
+ /** Threshold keys already warned (`warnAtFraction` fires once per crossing per cap).
2776
+ * Lives on the LEDGER — not the client — so clients SHARING one (a cross-chain
2777
+ * MultiChainPayer) dedupe together: a denomination/count threshold fires once across the
2778
+ * whole shared budget, not once per chain. */
2779
+ warned = /* @__PURE__ */ new Set();
2780
+ store;
2680
2781
  /**
2681
2782
  * Session clock origin (epoch-ms) — process/session start = ledger
2682
2783
  * construction. In-memory; a new process is a new session. The client reads it
2683
2784
  * to compute the `ttlSeconds` deadline and the rolling-window slice.
2684
2785
  */
2685
2786
  sessionStart = Date.now();
2686
- /** Record a settled payment. `decimals` is the TRUE token decimals (for the
2687
- * per-asset running total used by maxTotal + the formatted summary). */
2688
- record(r, decimals) {
2689
- this.records.push(r);
2690
- const key = keyFor(r.network, r.asset);
2787
+ /**
2788
+ * @param store Optional durable {@link SpendStore}. When supplied, the ledger
2789
+ * HYDRATES from `store.load()` here (so prior spend resumes after a restart) and
2790
+ * `append()`s every settled payment. A throwing/absent store fails SAFE to an
2791
+ * empty in-memory ledger — it never blocks construction (ERRORS.md: never throw).
2792
+ */
2793
+ constructor(store) {
2794
+ this.store = store;
2795
+ if (store) {
2796
+ let seed = [];
2797
+ try {
2798
+ seed = store.load() ?? [];
2799
+ } catch {
2800
+ seed = [];
2801
+ }
2802
+ for (const r of seed) this.ingest(r, r?.decimals ?? 0, r?.denom);
2803
+ }
2804
+ }
2805
+ /**
2806
+ * A record is safe to tally iff its `amountBase` is a non-negative integer STRING and its
2807
+ * `decimals` is an integer in `[0, MAX_DECIMALS]`. The live `record()` path always passes
2808
+ * (the client validated the quote), but a hydrated record comes from an UNTRUSTED store
2809
+ * (a tampered/corrupt/future-version JSONL line), so we gate it here — a bad record is
2810
+ * dropped rather than allowed to throw `BigInt(...)`/`formatUnits(...)` later and brick a
2811
+ * read or the constructor.
2812
+ */
2813
+ isTallyable(r, decimals) {
2814
+ return !!r && typeof r.amountBase === "string" && /^\d+$/.test(r.amountBase) && Number.isInteger(decimals) && decimals >= 0 && decimals <= MAX_DECIMALS;
2815
+ }
2816
+ /** Apply a record to the in-memory tallies (records + per-asset bucket + denom total).
2817
+ * Shared by {@link record} and constructor hydration; does NOT persist. Returns the
2818
+ * stored record, or `null` when the record is corrupt and was skipped. */
2819
+ ingest(r, decimals, denom) {
2820
+ if (!this.isTallyable(r, decimals)) return null;
2821
+ const denomStr = typeof denom === "string" && denom.trim() !== "" ? denom : void 0;
2822
+ const rec = { ...r, decimals, ...denomStr ? { denom: denomStr } : {} };
2823
+ this.records.push(rec);
2824
+ const key = keyFor(rec.network, rec.asset);
2691
2825
  const bucket = this.buckets.get(key);
2692
2826
  if (bucket) {
2693
- bucket.total += BigInt(r.amountBase);
2827
+ bucket.total += BigInt(rec.amountBase);
2694
2828
  bucket.count += 1;
2695
- if (!bucket.symbol && r.symbol) bucket.symbol = r.symbol;
2829
+ if (!bucket.symbol && rec.symbol) bucket.symbol = rec.symbol;
2696
2830
  } else {
2697
2831
  this.buckets.set(key, {
2698
- network: r.network,
2699
- asset: r.asset,
2700
- symbol: r.symbol,
2832
+ network: rec.network,
2833
+ asset: rec.asset,
2834
+ symbol: rec.symbol,
2701
2835
  decimals,
2702
- total: BigInt(r.amountBase),
2836
+ total: BigInt(rec.amountBase),
2703
2837
  count: 1
2704
2838
  });
2705
2839
  }
2840
+ if (denomStr) {
2841
+ const scaled = scaleToDenom(BigInt(rec.amountBase), decimals);
2842
+ if (scaled !== null) {
2843
+ const k = denomStr.toUpperCase();
2844
+ this.denomTotals.set(k, (this.denomTotals.get(k) ?? 0n) + scaled);
2845
+ }
2846
+ }
2847
+ return rec;
2848
+ }
2849
+ /** Record a settled payment. `decimals` is the TRUE token decimals (for the
2850
+ * per-asset running total + the formatted summary). `denom` is the unit-of-account
2851
+ * the payment counts toward in the grand total (or omit for none). Persists to the
2852
+ * {@link SpendStore} when one is configured (a failed append never throws). */
2853
+ record(r, decimals, denom) {
2854
+ const rec = this.ingest(r, decimals, denom);
2855
+ if (rec && this.store) {
2856
+ try {
2857
+ this.store.append(rec);
2858
+ } catch {
2859
+ }
2860
+ }
2706
2861
  }
2707
2862
  /** Running total (base units) already spent on this (network, asset). */
2708
2863
  totalFor(network, asset) {
2709
2864
  return this.buckets.get(keyFor(network, asset))?.total ?? 0n;
2710
2865
  }
2866
+ /**
2867
+ * Running grand total for a DENOMINATION, scaled to {@link DENOM_PRECISION} (so
2868
+ * tokens of different decimals add up exactly). Summed across every token of that
2869
+ * denomination and every chain this ledger has seen. Powers `maxTotalPerDenom`.
2870
+ * `0n` for a denomination never spent on. Case-insensitive.
2871
+ */
2872
+ totalForDenom(denom) {
2873
+ return this.denomTotals.get(denom.toUpperCase()) ?? 0n;
2874
+ }
2875
+ /** Total number of settled payments (across every chain + token). Powers `maxPayments`. */
2876
+ count() {
2877
+ return this.records.length;
2878
+ }
2879
+ /** Mark a `warnAtFraction` threshold key as fired; returns `true` the FIRST time (so the
2880
+ * caller emits the `budget-threshold` event once) and `false` thereafter. Shared across
2881
+ * every client on this ledger, so a cross-chain threshold fires once for the whole budget. */
2882
+ markWarned(key) {
2883
+ if (this.warned.has(key)) return false;
2884
+ this.warned.add(key);
2885
+ return true;
2886
+ }
2887
+ /**
2888
+ * Number of settled payments whose `at` (ISO) is at or after `sinceMs` (epoch-ms),
2889
+ * across every chain + token. Backs the rolling payment-count cap
2890
+ * (`maxPaymentsPerWindow`, `sinceMs = now - windowSeconds*1000`). Linear scan —
2891
+ * negligible at agent-session cardinality and only when a window count cap is set.
2892
+ */
2893
+ countSince(sinceMs) {
2894
+ let n = 0;
2895
+ for (const r of this.records) {
2896
+ const t = Date.parse(r.at);
2897
+ if (Number.isNaN(t) || t >= sinceMs) n += 1;
2898
+ }
2899
+ return n;
2900
+ }
2711
2901
  /**
2712
2902
  * Sum of base-unit amounts for (network, asset) whose record `at` (ISO
2713
2903
  * timestamp) is at or after `sinceMs` (epoch-ms). Backs the rolling window
@@ -2718,9 +2908,9 @@ var SpendLedger = class {
2718
2908
  totalSince(network, asset, sinceMs) {
2719
2909
  let sum = 0n;
2720
2910
  for (const r of this.records) {
2721
- if (r.network === network && r.asset === asset && Date.parse(r.at) >= sinceMs) {
2722
- sum += BigInt(r.amountBase);
2723
- }
2911
+ if (r.network !== network || r.asset !== asset) continue;
2912
+ const t = Date.parse(r.at);
2913
+ if (Number.isNaN(t) || t >= sinceMs) sum += BigInt(r.amountBase);
2724
2914
  }
2725
2915
  return sum;
2726
2916
  }
@@ -2740,6 +2930,14 @@ var SpendLedger = class {
2740
2930
  totalBase: b.total
2741
2931
  }));
2742
2932
  }
2933
+ /**
2934
+ * The per-denomination grand totals, as read-only tuples — `denom` and the running
2935
+ * `totalScaled` (at {@link DENOM_PRECISION}). Lets the client compose the grand-total
2936
+ * budget view; the cap math lives in the client. A never-spent denomination is absent.
2937
+ */
2938
+ denomBuckets() {
2939
+ return [...this.denomTotals.entries()].map(([denom, totalScaled]) => ({ denom, totalScaled }));
2940
+ }
2743
2941
  /** An immutable snapshot of all spend so far. */
2744
2942
  summary() {
2745
2943
  return {
@@ -2753,6 +2951,12 @@ var SpendLedger = class {
2753
2951
  totalFormatted: formatUnits(b.total, b.decimals),
2754
2952
  count: b.count
2755
2953
  })),
2954
+ byDenom: [...this.denomTotals.entries()].map(([denom, scaled]) => ({
2955
+ denom,
2956
+ totalScaled: scaled.toString(),
2957
+ totalFormatted: formatUnits(scaled, DENOM_PRECISION),
2958
+ count: this.records.filter((r) => r.denom?.toUpperCase() === denom).length
2959
+ })),
2756
2960
  records: [...this.records]
2757
2961
  };
2758
2962
  }
@@ -2771,9 +2975,14 @@ var PipRailClient = class {
2771
2975
  maxRetries;
2772
2976
  retryTimeoutMs;
2773
2977
  onEvent;
2774
- // Per-asset tally of everything this client has paid (powers spent() and the
2775
- // policy's maxTotal cap).
2776
- ledger = new SpendLedger();
2978
+ // Per-asset (+ per-denomination + count) tally of everything this client has paid
2979
+ // powers spent()/budget() and the maxTotal/maxTotalPerDenom/maxPayments caps. Either
2980
+ // its own (optionally store-backed) ledger, or a SHARED one injected so the grand
2981
+ // total + count caps span several chains (MultiChainPayer.fromWallets).
2982
+ ledger;
2983
+ // `warnAtFraction` threshold dedup lives on the LEDGER (`ledger.markWarned`), so clients
2984
+ // sharing one ledger (a cross-chain MultiChainPayer) fire each threshold once for the whole
2985
+ // shared budget — not once per chain.
2777
2986
  // Resolved lazily on first request — this is what lets Solana (and future
2778
2987
  // families) auto-mount with no setup call.
2779
2988
  bound;
@@ -2782,8 +2991,15 @@ var PipRailClient = class {
2782
2991
  this.maxRetries = Math.max(1, opts.maxPaymentRetries ?? 3);
2783
2992
  this.retryTimeoutMs = opts.retryTimeoutMs ?? 3e4;
2784
2993
  this.onEvent = opts.onEvent ?? (() => void 0);
2994
+ if (opts.ledger && opts.spendStore) {
2995
+ throw new TypeError(
2996
+ "Pass either `ledger` (a shared SpendLedger) or `spendStore`, not both \u2014 a shared ledger already owns its store."
2997
+ );
2998
+ }
2999
+ this.ledger = opts.ledger ?? new SpendLedger(opts.spendStore);
2785
3000
  this.assertPolicyAmountCaps(opts.policy);
2786
3001
  this.assertPolicyTimeOptions(opts.policy);
3002
+ this.assertPolicySpendControls(opts.policy);
2787
3003
  }
2788
3004
  /**
2789
3005
  * Fail LOUDLY at construction on a malformed amount cap — a security boundary
@@ -2804,6 +3020,65 @@ var PipRailClient = class {
2804
3020
  );
2805
3021
  }
2806
3022
  }
3023
+ if (policy.maxTotalPerDenom !== void 0) {
3024
+ const m = policy.maxTotalPerDenom;
3025
+ const proto = m && typeof m === "object" ? Object.getPrototypeOf(m) : false;
3026
+ if (!m || typeof m !== "object" || Array.isArray(m) || proto !== Object.prototype && proto !== null) {
3027
+ throw new TypeError(
3028
+ `policy.maxTotalPerDenom must be a plain { DENOM: amount } object (e.g. { USD: '20.00' }); got ${JSON.stringify(policy.maxTotalPerDenom)}.`
3029
+ );
3030
+ }
3031
+ for (const [denom, v] of Object.entries(policy.maxTotalPerDenom)) {
3032
+ if (denom.trim() === "") {
3033
+ throw new TypeError("policy.maxTotalPerDenom has a blank denomination key.");
3034
+ }
3035
+ if (typeof v !== "string" || !/^\d+(\.\d+)?$/.test(v)) {
3036
+ throw new TypeError(
3037
+ `policy.maxTotalPerDenom.${denom} must be a non-negative decimal string (e.g. '20.00'); got ${JSON.stringify(v)}.`
3038
+ );
3039
+ }
3040
+ }
3041
+ }
3042
+ if (policy.denomFor !== void 0) {
3043
+ const m = policy.denomFor;
3044
+ if (!m || typeof m !== "object" || Array.isArray(m)) {
3045
+ throw new TypeError(`policy.denomFor must be a { token: DENOM } object; got ${JSON.stringify(m)}.`);
3046
+ }
3047
+ for (const [k, v] of Object.entries(policy.denomFor)) {
3048
+ if (typeof v !== "string" || v.trim() === "") {
3049
+ throw new TypeError(`policy.denomFor.${k} must be a non-empty denomination string; got ${JSON.stringify(v)}.`);
3050
+ }
3051
+ }
3052
+ }
3053
+ }
3054
+ /**
3055
+ * Fail LOUDLY at construction on a misconfigured count / threshold control (a
3056
+ * programmer error → `TypeError`): payment counts must be positive safe integers,
3057
+ * `maxPaymentsPerWindow` needs `windowSeconds` (it shares the window), and
3058
+ * `warnAtFraction` must be in (0, 1].
3059
+ */
3060
+ assertPolicySpendControls(policy) {
3061
+ if (!policy) return;
3062
+ for (const field of ["maxPayments", "maxPaymentsPerWindow"]) {
3063
+ const v = policy[field];
3064
+ if (v === void 0) continue;
3065
+ if (!Number.isSafeInteger(v) || v <= 0) {
3066
+ throw new TypeError(`policy.${field} must be a positive integer; got ${JSON.stringify(v)}.`);
3067
+ }
3068
+ }
3069
+ if (policy.maxPaymentsPerWindow !== void 0 && policy.windowSeconds === void 0) {
3070
+ throw new TypeError(
3071
+ "policy.maxPaymentsPerWindow needs policy.windowSeconds (the rolling window it counts within) \u2014 set both, or neither."
3072
+ );
3073
+ }
3074
+ if (policy.warnAtFraction !== void 0) {
3075
+ const f = policy.warnAtFraction;
3076
+ if (typeof f !== "number" || !(f > 0 && f <= 1)) {
3077
+ throw new TypeError(
3078
+ `policy.warnAtFraction must be a number in (0, 1] (e.g. 0.8); got ${JSON.stringify(f)}.`
3079
+ );
3080
+ }
3081
+ }
2807
3082
  }
2808
3083
  /**
2809
3084
  * Fail LOUDLY at construction on a misconfigured time policy — a security
@@ -2818,11 +3093,17 @@ var PipRailClient = class {
2818
3093
  if (!policy) return;
2819
3094
  const hasWindowTotal = policy.windowTotal !== void 0;
2820
3095
  const hasWindowSeconds = policy.windowSeconds !== void 0;
2821
- if (hasWindowTotal !== hasWindowSeconds) {
3096
+ const hasWindowCount = policy.maxPaymentsPerWindow !== void 0;
3097
+ if (hasWindowTotal && !hasWindowSeconds) {
2822
3098
  throw new TypeError(
2823
3099
  "policy.windowTotal and policy.windowSeconds must be set together \u2014 a rolling-window cap can't be half-armed (set both, or neither)."
2824
3100
  );
2825
3101
  }
3102
+ if (hasWindowSeconds && !hasWindowTotal && !hasWindowCount) {
3103
+ throw new TypeError(
3104
+ "policy.windowSeconds must be set together with policy.windowTotal and/or policy.maxPaymentsPerWindow \u2014 a window width alone bounds nothing."
3105
+ );
3106
+ }
2826
3107
  if (hasWindowSeconds && !(Number.isSafeInteger(policy.windowSeconds) && policy.windowSeconds > 0)) {
2827
3108
  throw new TypeError("policy.windowSeconds must be a positive integer number of seconds.");
2828
3109
  }
@@ -2834,6 +3115,14 @@ var PipRailClient = class {
2834
3115
  );
2835
3116
  }
2836
3117
  }
3118
+ if (policy.expiresAt !== void 0) {
3119
+ const at = policy.expiresAt;
3120
+ if (!Number.isSafeInteger(at) || Math.abs(at) > 864e13) {
3121
+ throw new TypeError(
3122
+ `policy.expiresAt must be an absolute epoch-MILLISECONDS integer (like Date.now()); got ${JSON.stringify(at)}.`
3123
+ );
3124
+ }
3125
+ }
2837
3126
  }
2838
3127
  /** Emit an observability event, never letting a throwing handler break the
2839
3128
  * payment flow (mirrors the server gate's `onPaid` isolation). */
@@ -2927,15 +3216,25 @@ var PipRailClient = class {
2927
3216
  return { quote, cost };
2928
3217
  }
2929
3218
  /** Aggregated snapshot of every payment this client has settled — total
2930
- * count, cumulative spend per token, and the individual records. */
3219
+ * count, cumulative spend per token, cumulative spend per denomination (the
3220
+ * cross-token grand total), and the individual records. */
2931
3221
  spent() {
2932
3222
  return this.ledger.summary();
2933
3223
  }
3224
+ /** The CONFIGURED spend policy, read back unchanged — so an agent can self-check
3225
+ * its WHOLE leash (caps, allowlists, time, denom + count limits) without hitting a
3226
+ * decline. `undefined` when no policy is set. Pure; never throws. */
3227
+ policy() {
3228
+ return this.opts.policy;
3229
+ }
2934
3230
  /**
2935
3231
  * Read-only budget + time leash for a Mode-A (headless) agent — the policy IS
2936
3232
  * the consent, and this is how the agent SEES what's left of it before paying.
2937
- * Composes the in-memory ledger with the configured policy; never throws, moves
2938
- * no funds. PROCESS-SCOPED every figure resets on restart (see {@link SessionBudget}).
3233
+ * Composes the ledger with the configured policy: the per-asset money leash
3234
+ * (`byAsset`), the cross-token GRAND TOTAL per denomination (`byDenom`, present
3235
+ * from the start), and the payment-COUNT leash (`counts`). Never throws, moves no
3236
+ * funds. The money/count figures persist if a `spendStore` is set; the time
3237
+ * envelope is process-scoped (see {@link SessionBudget}).
2939
3238
  */
2940
3239
  budget() {
2941
3240
  const view = this.sessionView();
@@ -2946,9 +3245,54 @@ var PipRailClient = class {
2946
3245
  expiresAt: view?.expiresAt != null ? new Date(view.expiresAt).toISOString() : null,
2947
3246
  secondsRemaining: view?.secondsRemaining ?? null
2948
3247
  },
2949
- byAsset: this.remaining()
3248
+ byAsset: this.remaining(),
3249
+ byDenom: this.denomRemaining(),
3250
+ counts: this.countStatus()
2950
3251
  };
2951
3252
  }
3253
+ /**
3254
+ * The cross-token GRAND-TOTAL leash — one row per denomination capped by
3255
+ * `policy.maxTotalPerDenom`. Unlike `remaining()`, rows exist from the START
3256
+ * (the cap is a single declared number, so headroom is previewable before any
3257
+ * spend). `[]` when no `maxTotalPerDenom` is set. Pure; never throws; never a
3258
+ * price-converted figure (tokens grouped as one unit, each 1:1).
3259
+ */
3260
+ denomRemaining() {
3261
+ const caps = this.opts.policy?.maxTotalPerDenom;
3262
+ if (!caps) return [];
3263
+ return Object.entries(caps).map(([rawDenom, capStr]) => {
3264
+ const denom = rawDenom.toUpperCase();
3265
+ const spentScaled = this.ledger.totalForDenom(denom);
3266
+ const capScaled = floorUnits(capStr, DENOM_PRECISION);
3267
+ const remainingScaled = capScaled > spentScaled ? capScaled - spentScaled : 0n;
3268
+ const fraction = capScaled === 0n ? spentScaled > 0n ? 1 : 0 : Number(spentScaled * 1000000n / capScaled) / 1e6;
3269
+ return {
3270
+ denom,
3271
+ spentFormatted: formatUnits(spentScaled, DENOM_PRECISION),
3272
+ capFormatted: formatUnits(capScaled, DENOM_PRECISION),
3273
+ remainingFormatted: formatUnits(remainingScaled, DENOM_PRECISION),
3274
+ fraction
3275
+ };
3276
+ });
3277
+ }
3278
+ /** The payment-COUNT leash — settled count so far + the configured lifetime/window
3279
+ * caps and what's left. Pure; never throws. */
3280
+ countStatus() {
3281
+ const policy = this.opts.policy;
3282
+ const settled = this.ledger.count();
3283
+ const out = { settled };
3284
+ if (policy?.maxPayments !== void 0) {
3285
+ out.lifetimeCap = policy.maxPayments;
3286
+ out.lifetimeRemaining = Math.max(0, policy.maxPayments - settled);
3287
+ }
3288
+ if (policy?.maxPaymentsPerWindow !== void 0 && policy.windowSeconds !== void 0) {
3289
+ const windowSettled = this.ledger.countSince(Date.now() - policy.windowSeconds * 1e3);
3290
+ out.windowCap = policy.maxPaymentsPerWindow;
3291
+ out.windowSettled = windowSettled;
3292
+ out.windowRemaining = Math.max(0, policy.maxPaymentsPerWindow - windowSettled);
3293
+ }
3294
+ return out;
3295
+ }
2952
3296
  /**
2953
3297
  * Per-(network, asset) remaining budget — ONE row per pair the ledger already
2954
3298
  * holds (decimals are known only after the first spend), so a fresh client with
@@ -3366,7 +3710,7 @@ var PipRailClient = class {
3366
3710
  const shortfall = {};
3367
3711
  if (!quote.withinPolicy) {
3368
3712
  blockers.push(
3369
- quote.policyCode === "SESSION_EXPIRED" || quote.policyCode === "WINDOW_TOTAL" ? "OUTSIDE_WINDOW" : "OUTSIDE_POLICY"
3713
+ quote.policyCode === "SESSION_EXPIRED" || quote.policyCode === "WINDOW_TOTAL" || quote.policyCode === "WINDOW_COUNT" ? "OUTSIDE_WINDOW" : "OUTSIDE_POLICY"
3370
3714
  );
3371
3715
  }
3372
3716
  if (quote.symbolMismatch) warnings.push("SYMBOL_MISMATCH");
@@ -3445,6 +3789,11 @@ var PipRailClient = class {
3445
3789
  `challenge for ${accept.asset} on ${accept.network} states no valid decimals and the SDK doesn't recognise the token \u2014 refusing to price it.`
3446
3790
  );
3447
3791
  }
3792
+ if (decimals > MAX_DECIMALS) {
3793
+ throw new InvalidEnvelopeError(
3794
+ `challenge for ${accept.asset} on ${accept.network} states ${decimals} decimals (> ${MAX_DECIMALS}) \u2014 refusing to price it (no real token is that deep).`
3795
+ );
3796
+ }
3448
3797
  const symbol = described?.symbol ?? accept.extra?.symbol;
3449
3798
  const amountFormatted = formatUnits(amountBase, decimals);
3450
3799
  const intent = {
@@ -3459,9 +3808,14 @@ var PipRailClient = class {
3459
3808
  };
3460
3809
  const policy = this.opts.policy;
3461
3810
  const hasWindow = !!policy && policy.windowTotal != null && policy.windowSeconds != null;
3811
+ const hasWindowCount = !!policy && policy.maxPaymentsPerWindow != null && policy.windowSeconds != null;
3812
+ const hasDenomCap = !!policy && policy.maxTotalPerDenom != null && Object.keys(policy.maxTotalPerDenom).length > 0;
3813
+ const hasCountCap = !!policy && policy.maxPayments != null;
3462
3814
  const hasTimePolicy = !!policy && (policy.ttlSeconds != null || policy.expiresAt != null || hasWindow);
3815
+ const needsCtx = hasTimePolicy || hasWindowCount || hasDenomCap || hasCountCap;
3816
+ const denom = hasDenomCap ? denomOf(intent.symbol, intent.asset, policy) : void 0;
3463
3817
  const now = Date.now();
3464
- const ctx = hasTimePolicy ? {
3818
+ const ctx = needsCtx ? {
3465
3819
  now,
3466
3820
  sessionStart: this.ledger.sessionStart,
3467
3821
  // Window slice ONLY when BOTH fields are set — never a `?? 0` width.
@@ -3469,7 +3823,12 @@ var PipRailClient = class {
3469
3823
  accept.network,
3470
3824
  accept.asset,
3471
3825
  now - policy.windowSeconds * 1e3
3472
- ) : 0n
3826
+ ) : 0n,
3827
+ // Running grand total on this intent's denomination, across the (shared) ledger.
3828
+ spentInDenomScaled: denom ? this.ledger.totalForDenom(denom) : 0n,
3829
+ // Settled-payment counts (lifetime + window) for the count caps.
3830
+ paymentCount: hasCountCap ? this.ledger.count() : 0,
3831
+ paymentCountInWindow: hasWindowCount ? this.ledger.countSince(now - policy.windowSeconds * 1e3) : 0
3473
3832
  } : void 0;
3474
3833
  const decision = evaluatePolicy(
3475
3834
  intent,
@@ -3505,9 +3864,11 @@ var PipRailClient = class {
3505
3864
  async authorize(quote) {
3506
3865
  if (!quote.withinPolicy) {
3507
3866
  const reason = `Payment refused by policy: ${quote.policyReason ?? "not allowed"}`;
3508
- const reasonCode = reasonCodeForPolicy(quote.policyCode);
3509
- this.safeEmit({ kind: "payment-failed", reason, code: reasonCode });
3510
- throw new PaymentDeclinedError(reason, { reasonCode });
3867
+ this.refuse(reason, {
3868
+ reasonCode: reasonCodeForPolicy(quote.policyCode),
3869
+ policyCode: quote.policyCode,
3870
+ quote
3871
+ });
3511
3872
  }
3512
3873
  const hook = this.opts.onBeforePay;
3513
3874
  if (!hook) return;
@@ -3515,32 +3876,125 @@ var PipRailClient = class {
3515
3876
  try {
3516
3877
  approved = await hook(quote);
3517
3878
  } catch (err) {
3518
- const reason = "onBeforePay threw \u2014 refusing to pay.";
3519
- this.safeEmit({ kind: "payment-failed", reason, code: "APPROVAL" });
3520
- throw new PaymentDeclinedError(reason, { cause: err, reasonCode: "APPROVAL" });
3879
+ this.refuse("onBeforePay threw \u2014 refusing to pay.", { reasonCode: "APPROVAL", quote, cause: err });
3521
3880
  }
3522
3881
  if (!approved) {
3523
3882
  const reason = `onBeforePay declined ${quote.amountFormatted} ${quote.symbol ?? ""}`.trimEnd() + ` on ${quote.network}.`;
3524
- this.safeEmit({ kind: "payment-failed", reason, code: "APPROVAL" });
3525
- throw new PaymentDeclinedError(reason, { reasonCode: "APPROVAL" });
3883
+ this.refuse(reason, { reasonCode: "APPROVAL", quote });
3526
3884
  }
3527
3885
  }
3528
- /** Record a settled payment in the ledger (true decimals for the running total). */
3886
+ /**
3887
+ * Refuse a payment BEFORE any send: emit BOTH the legacy `payment-failed` (so existing
3888
+ * `onEvent` consumers are unaffected) AND the richer, dedicated `payment-declined`
3889
+ * (typed reasonCode + fine PolicyDenyCode + the quote + a budget snapshot), then throw
3890
+ * the typed {@link PaymentDeclinedError}. Returns `never` so callers' control flow is
3891
+ * exhaustive.
3892
+ */
3893
+ refuse(reason, opts) {
3894
+ this.safeEmit({ kind: "payment-failed", reason, ...opts.reasonCode ? { code: opts.reasonCode } : {} });
3895
+ this.safeEmit({
3896
+ kind: "payment-declined",
3897
+ reason,
3898
+ ...opts.reasonCode ? { reasonCode: opts.reasonCode } : {},
3899
+ ...opts.policyCode ? { code: opts.policyCode } : {},
3900
+ ...opts.quote ? { quote: opts.quote } : {},
3901
+ budget: this.budget()
3902
+ });
3903
+ throw new PaymentDeclinedError(reason, {
3904
+ ...opts.reasonCode ? { reasonCode: opts.reasonCode } : {},
3905
+ ...opts.cause !== void 0 ? { cause: opts.cause } : {}
3906
+ });
3907
+ }
3908
+ /** Record a settled payment in the ledger (TRUE decimals for the running total + the
3909
+ * denomination it counts toward in the grand total). Then fire the `onSpend` callback
3910
+ * with the record + the post-payment budget, and emit any `warnAtFraction` thresholds
3911
+ * this payment just crossed. All observability is isolated — a throwing hook never
3912
+ * affects the (already-settled) payment. */
3529
3913
  recordSpend(quote, ref) {
3530
- this.ledger.record(
3531
- {
3532
- url: quote.url,
3533
- host: hostOf2(quote.url),
3534
- network: quote.network,
3535
- asset: quote.asset,
3536
- amountBase: quote.amount,
3537
- amountFormatted: quote.amountFormatted,
3538
- ...quote.symbol ? { symbol: quote.symbol } : {},
3539
- ref,
3540
- at: (/* @__PURE__ */ new Date()).toISOString()
3541
- },
3542
- quote.decimals
3543
- );
3914
+ const denom = denomOf(quote.symbol, quote.asset, this.opts.policy);
3915
+ const record = {
3916
+ url: quote.url,
3917
+ host: hostOf2(quote.url),
3918
+ network: quote.network,
3919
+ asset: quote.asset,
3920
+ amountBase: quote.amount,
3921
+ amountFormatted: quote.amountFormatted,
3922
+ ...quote.symbol ? { symbol: quote.symbol } : {},
3923
+ decimals: quote.decimals,
3924
+ ...denom ? { denom } : {},
3925
+ ref,
3926
+ at: (/* @__PURE__ */ new Date()).toISOString()
3927
+ };
3928
+ this.ledger.record(record, quote.decimals, denom);
3929
+ const budget = this.budget();
3930
+ if (this.opts.onSpend) {
3931
+ try {
3932
+ this.opts.onSpend(record, budget);
3933
+ } catch {
3934
+ }
3935
+ }
3936
+ this.emitThresholds(budget);
3937
+ }
3938
+ /**
3939
+ * Emit a `budget-threshold` event for each cap whose used-fraction just reached
3940
+ * `policy.warnAtFraction` — the early warning before a hard decline. Fires ONCE per
3941
+ * crossing per cap (deduped on the shared ledger via `markWarned`, so a cross-chain
3942
+ * threshold fires once for the whole budget, not once per chain). No-op when no
3943
+ * `warnAtFraction` is set. Reads the just-computed {@link SessionBudget}; isolated (safeEmit).
3944
+ */
3945
+ emitThresholds(budget) {
3946
+ const frac = this.opts.policy?.warnAtFraction;
3947
+ if (frac === void 0) return;
3948
+ const fire = (scope, label, spentFormatted, capFormatted, fraction) => {
3949
+ if (fraction < frac) return;
3950
+ if (!this.ledger.markWarned(`${scope}:${label}`)) return;
3951
+ this.safeEmit({ kind: "budget-threshold", scope, label, spentFormatted, capFormatted, fraction });
3952
+ };
3953
+ for (const r of budget.byAsset) {
3954
+ if (r.capBase === void 0) continue;
3955
+ const cap = BigInt(r.capBase);
3956
+ const spent = BigInt(r.spentBase);
3957
+ const fraction = cap === 0n ? spent > 0n ? 1 : 0 : Number(spent * 1000000n / cap) / 1e6;
3958
+ fire(
3959
+ "asset",
3960
+ `${r.symbol ?? r.asset} on ${r.network}`,
3961
+ formatUnits(spent, r.decimals),
3962
+ formatUnits(cap, r.decimals),
3963
+ fraction
3964
+ );
3965
+ }
3966
+ for (const d of budget.byDenom) {
3967
+ fire("denom", d.denom, d.spentFormatted, d.capFormatted, d.fraction);
3968
+ }
3969
+ const policy = this.opts.policy;
3970
+ if (policy?.windowTotal !== void 0 && policy.windowSeconds !== void 0) {
3971
+ const since = Date.now() - policy.windowSeconds * 1e3;
3972
+ for (const r of budget.byAsset) {
3973
+ const cap = floorUnits(policy.windowTotal, r.decimals);
3974
+ const spent = this.ledger.totalSince(r.network, r.asset, since);
3975
+ const fraction = cap === 0n ? spent > 0n ? 1 : 0 : Number(spent * 1000000n / cap) / 1e6;
3976
+ fire(
3977
+ "window",
3978
+ `${r.symbol ?? r.asset} on ${r.network}`,
3979
+ formatUnits(spent, r.decimals),
3980
+ formatUnits(cap, r.decimals),
3981
+ fraction
3982
+ );
3983
+ }
3984
+ }
3985
+ const c = budget.counts;
3986
+ if (c.lifetimeCap !== void 0 && c.lifetimeCap > 0) {
3987
+ fire("count", "maxPayments", String(c.settled), String(c.lifetimeCap), c.settled / c.lifetimeCap);
3988
+ }
3989
+ if (c.windowCap !== void 0 && c.windowCap > 0 && c.windowSettled !== void 0) {
3990
+ fire(
3991
+ "window-count",
3992
+ "maxPaymentsPerWindow",
3993
+ String(c.windowSettled),
3994
+ String(c.windowCap),
3995
+ c.windowSettled / c.windowCap
3996
+ );
3997
+ }
3544
3998
  }
3545
3999
  async payAndConfirm(net, wallet, accept) {
3546
4000
  if (!this.supportsNetwork(net, accept.network)) {
@@ -3838,8 +4292,11 @@ function reasonCodeForPolicy(code) {
3838
4292
  case "SESSION_EXPIRED":
3839
4293
  return "SESSION_EXPIRED";
3840
4294
  case "WINDOW_TOTAL":
4295
+ case "WINDOW_COUNT":
3841
4296
  return "OUTSIDE_WINDOW";
3842
4297
  case "MAX_TOTAL":
4298
+ case "MAX_TOTAL_DENOM":
4299
+ case "MAX_PAYMENTS":
3843
4300
  return "BUDGET";
3844
4301
  case void 0:
3845
4302
  return void 0;
@@ -3897,6 +4354,10 @@ async function readInvalidReason(response) {
3897
4354
  // src/payer.ts
3898
4355
  var MultiChainPayer = class _MultiChainPayer {
3899
4356
  _clients;
4357
+ /** True when every client shares ONE ledger (the `fromWallets` path) — so spend +
4358
+ * budget are read from that single source, never concatenated (which would
4359
+ * double-count a shared ledger). */
4360
+ _shared;
3900
4361
  /**
3901
4362
  * Wrap an explicit, ordered set of single-chain clients — use this when a client
3902
4363
  * needs full control (e.g. a custom EVM chain configured by a viem `Chain`). The
@@ -3904,12 +4365,18 @@ var MultiChainPayer = class _MultiChainPayer {
3904
4365
  * at MOST one client per chain — two clients on the SAME network would double-count in
3905
4366
  * `spent()`/`budget()` and waste a plan round-trip (`fromWallets` can't produce this).
3906
4367
  * For the common case, prefer {@link MultiChainPayer.fromWallets}.
4368
+ *
4369
+ * For a cross-chain grand total / count cap via this path, build the clients with a
4370
+ * SHARED `ledger` (`new SpendLedger(store?)` passed to each client's `ledger` option)
4371
+ * and set `opts.shared = true` so reads come from that one ledger. `fromWallets` does
4372
+ * this for you.
3907
4373
  */
3908
- constructor(clients) {
4374
+ constructor(clients, opts = {}) {
3909
4375
  if (clients.length === 0) {
3910
4376
  throw new TypeError("MultiChainPayer needs at least one PipRailClient.");
3911
4377
  }
3912
4378
  this._clients = [...clients];
4379
+ this._shared = opts.shared ?? false;
3913
4380
  }
3914
4381
  /**
3915
4382
  * Build one client per funded chain from a `{ chain → wallet }` map — the
@@ -3924,20 +4391,26 @@ var MultiChainPayer = class _MultiChainPayer {
3924
4391
  * solana: { key: process.env.SOLANA_SECRET! },
3925
4392
  * xrpl: { key: process.env.XRPL_SEED! },
3926
4393
  * },
3927
- * policy: { maxAmount: '1.00', maxTotal: '20.00', tokens: ['USDC', 'USDT'] },
4394
+ * // ONE budget across all three chains: $20 total + at most 100 payments.
4395
+ * policy: { maxAmount: '1.00', maxTotalPerDenom: { USD: '20.00' }, maxPayments: 100 },
3928
4396
  * })
3929
4397
  * const res = await payer.get('https://api.example.com/paid') // pays on the first funded chain that can settle
3930
4398
  * ```
4399
+ *
4400
+ * Every client shares ONE {@link SpendLedger} (optionally backed by `spendStore`), so
4401
+ * the cross-token grand total + the count caps span all chains as a single budget.
3931
4402
  */
3932
4403
  static fromWallets(opts) {
3933
4404
  const entries = Object.entries(opts.wallets);
3934
4405
  if (entries.length === 0) {
3935
4406
  throw new TypeError("MultiChainPayer.fromWallets needs at least one wallet.");
3936
4407
  }
4408
+ const ledger = new SpendLedger(opts.spendStore);
3937
4409
  const clients = entries.map(
3938
4410
  ([chain, wallet]) => new PipRailClient({
3939
4411
  chain,
3940
4412
  wallet,
4413
+ ledger,
3941
4414
  ...opts.policy ? { policy: opts.policy } : {},
3942
4415
  ...opts.schemes ? { schemes: opts.schemes } : {},
3943
4416
  ...opts.rpcUrls?.[chain] ? { rpcUrl: opts.rpcUrls[chain] } : {},
@@ -3947,7 +4420,7 @@ var MultiChainPayer = class _MultiChainPayer {
3947
4420
  ...opts.retryTimeoutMs != null ? { retryTimeoutMs: opts.retryTimeoutMs } : {}
3948
4421
  })
3949
4422
  );
3950
- return new _MultiChainPayer(clients);
4423
+ return new _MultiChainPayer(clients, { shared: true });
3951
4424
  }
3952
4425
  /** The underlying single-chain clients, in preference order. Reach for one of
3953
4426
  * these for chain-specific reads (`estimateCost`, `discoverySigner`, per-chain
@@ -4041,28 +4514,50 @@ var MultiChainPayer = class _MultiChainPayer {
4041
4514
  register(url, opts = {}) {
4042
4515
  return this._clients[0].register(url, opts);
4043
4516
  }
4044
- /** Aggregate spend across every chain — counts summed; per-(network,asset) rows
4045
- * and records concatenated (no cross-chain collisions, never a cross-token sum). */
4517
+ /**
4518
+ * Aggregate spend across every chain. With a SHARED ledger (`fromWallets`), this reads
4519
+ * that single ledger ONCE (its records already span every chain) — so the count and the
4520
+ * cross-token `byDenom` grand total are correct and never double-counted. With
4521
+ * independent clients (the explicit constructor), it concatenates: counts summed,
4522
+ * per-(network,asset) `byAsset` + `byDenom` + records concatenated.
4523
+ */
4046
4524
  spent() {
4525
+ if (this._shared) return this._clients[0].spent();
4047
4526
  const summaries = this._clients.map((c) => c.spent());
4048
4527
  return {
4049
4528
  count: summaries.reduce((n, s) => n + s.count, 0),
4050
4529
  byAsset: summaries.flatMap((s) => s.byAsset),
4530
+ byDenom: summaries.flatMap((s) => s.byDenom),
4051
4531
  records: summaries.flatMap((s) => s.records)
4052
4532
  };
4053
4533
  }
4054
- /** A merged budget view: every chain's per-(network,asset) remaining rows, plus the
4055
- * MOST-RESTRICTIVE session time envelope across chains (the soonest deadline wins).
4056
- * Mirrors {@link PipRailClient.budget}'s shape so the agent toolkit reads it
4057
- * unchanged; per-chain session detail is on each `clients[i].budget()`. */
4534
+ /**
4535
+ * A merged budget view. With a SHARED ledger (`fromWallets`) it's the single source of
4536
+ * truth `byAsset`/`byDenom`/`counts` already span every chain, so the grand-total and
4537
+ * count leashes read as ONE budget. With independent clients it merges: per-(network,
4538
+ * asset) rows + per-denom rows concatenated, counts summed, and the MOST-RESTRICTIVE
4539
+ * session time envelope (the soonest deadline wins). Per-chain detail is on each
4540
+ * `clients[i].budget()`.
4541
+ */
4058
4542
  budget() {
4543
+ if (this._shared) return this._clients[0].budget();
4059
4544
  const budgets = this._clients.map((c) => c.budget());
4060
4545
  const session = budgets.map((b) => b.session).reduce((soonest, s) => {
4061
4546
  if (soonest.secondsRemaining == null) return s;
4062
4547
  if (s.secondsRemaining == null) return soonest;
4063
4548
  return s.secondsRemaining < soonest.secondsRemaining ? s : soonest;
4064
4549
  });
4065
- return { session, byAsset: budgets.flatMap((b) => b.byAsset) };
4550
+ return {
4551
+ session,
4552
+ byAsset: budgets.flatMap((b) => b.byAsset),
4553
+ byDenom: budgets.flatMap((b) => b.byDenom),
4554
+ counts: { settled: budgets.reduce((n, b) => n + b.counts.settled, 0) }
4555
+ };
4556
+ }
4557
+ /** The CONFIGURED spend policy (the shared policy `fromWallets` applies to every chain;
4558
+ * the first client's for the explicit constructor). `undefined` when none is set. */
4559
+ policy() {
4560
+ return this._clients[0].policy();
4066
4561
  }
4067
4562
  };
4068
4563
  function isBodyInit(value) {
@@ -4167,9 +4662,12 @@ function explainDecline(err) {
4167
4662
  }
4168
4663
  function formatSpendReport(summary) {
4169
4664
  if (summary.count === 0) return "No payments yet.";
4170
- return summary.byAsset.map(
4665
+ const perAsset = summary.byAsset.map(
4171
4666
  (a) => `${a.totalFormatted} ${a.symbol ?? a.asset} on ${a.network} (${a.count} payment${a.count === 1 ? "" : "s"})`
4172
4667
  ).join("; ");
4668
+ if (summary.byDenom.length === 0) return perAsset;
4669
+ const grand = summary.byDenom.map((d) => `${d.totalFormatted} ${d.denom} total`).join("; ");
4670
+ return `${perAsset} \u2014 grand total: ${grand}`;
4173
4671
  }
4174
4672
  function describeChallenge(challenge) {
4175
4673
  const first = challenge.accepts[0];
@@ -4248,9 +4746,11 @@ Branch on \`code\` (always reliable). Key cases:
4248
4746
  once (enable the exact scheme); report it, don't retry the same call blindly.
4249
4747
 
4250
4748
  ## Knowing your leash \u2014 call piprail_budget
4251
- piprail_budget tells you how much budget and time you have left, per
4252
- (network, asset), plus your spend so far. Read-only; moves no funds. Use it in
4253
- Mode A to self-check before paying.
4749
+ piprail_budget tells you how much budget and time you have left: per (network,
4750
+ asset) remaining, the cross-token GRAND TOTAL per denomination (e.g. how much
4751
+ USD you can still spend across every stablecoin and chain), the payment-count
4752
+ leash, the session time envelope, your spend so far, and the configured policy
4753
+ read back. Read-only; moves no funds. Use it in Mode A to self-check before paying.
4254
4754
 
4255
4755
  ## Two modes
4256
4756
  - Mode A (headless, default): you run FREE inside a pre-set budget + time
@@ -4261,10 +4761,15 @@ Mode A to self-check before paying.
4261
4761
  do NOT retry it as if it were a transient error.
4262
4762
 
4263
4763
  ## Hard facts
4264
- - Spend caps are PER (network, asset). There is no single cross-token dollar cap \u2014
4265
- budgets aren't summed across tokens (no price oracle).
4266
- - Spend totals and the time envelope live IN-MEMORY for THIS process; they reset on restart
4267
- (a convenience, not a durable ledger).
4764
+ - Per-payment + per-(network, asset) caps always apply. A cross-token GRAND TOTAL per
4765
+ denomination (maxTotalPerDenom, e.g. "$20 across every USD stablecoin + chain") is
4766
+ OPTIONAL \u2014 it sums tokens declared as one unit, each 1:1; it is NOT a price oracle and
4767
+ never prices a volatile native coin. Payment-COUNT caps (maxPayments / per-window) also
4768
+ span every chain + token.
4769
+ - The time envelope lives IN-MEMORY for THIS process (resets on restart). The money + count
4770
+ totals also reset on restart UNLESS a durable spend store is configured \u2014 then they resume.
4771
+ - A refusal arrives as declined:true with a reasonCode; 'BUDGET' covers the lifetime, denom,
4772
+ and count caps; 'OUTSIDE_WINDOW' covers both the rolling money and rolling count windows.
4268
4773
  `;
4269
4774
  function agentGuide() {
4270
4775
  return PIPRAIL_AGENT_GUIDE;
@@ -4579,11 +5084,11 @@ function paymentTools(client) {
4579
5084
  },
4580
5085
  {
4581
5086
  name: "piprail_budget",
4582
- 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.",
5087
+ description: "Read how much of your spend budget and time leash is left \u2014 per (network, asset) remaining, the cross-token GRAND TOTAL per denomination (e.g. how much USD you can still spend across every stablecoin and chain), the payment-count leash, 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: the time envelope is in-memory for THIS process; the money/count totals persist only if a spend store is configured.",
4583
5088
  annotations: {
4584
5089
  title: "Check remaining budget",
4585
5090
  readOnlyHint: true,
4586
- // reads the in-memory ledger + policy; never pays
5091
+ // reads the ledger + policy; never pays
4587
5092
  idempotentHint: true
4588
5093
  // a pure read
4589
5094
  },
@@ -4596,7 +5101,13 @@ function paymentTools(client) {
4596
5101
  return {
4597
5102
  spent,
4598
5103
  remaining: budget.byAsset,
5104
+ grandTotal: budget.byDenom,
5105
+ // cross-token spend cap per denomination (USD/EUR/…)
5106
+ counts: budget.counts,
5107
+ // payment-count leash (settled + lifetime/window caps)
4599
5108
  session: budget.session,
5109
+ policy: client.policy() ?? null,
5110
+ // the configured leash, read back
4600
5111
  report: formatSpendReport(spent)
4601
5112
  };
4602
5113
  } catch (err) {
@@ -5901,8 +6412,10 @@ async function deliverReceipt(receipt, options) {
5901
6412
  }
5902
6413
  export {
5903
6414
  BRAND,
6415
+ BUILTIN_DENOMS,
5904
6416
  CHAINS,
5905
6417
  ConfirmationTimeoutError,
6418
+ DENOM_PRECISION,
5906
6419
  DIRECTORY_INFO,
5907
6420
  EIP3009_TYPES,
5908
6421
  EXACT_NETWORK_SLUGS,
@@ -5932,6 +6445,7 @@ export {
5932
6445
  REGISTER_ATTRIBUTION,
5933
6446
  RecipientNotReadyError,
5934
6447
  SettlementError,
6448
+ SpendLedger,
5935
6449
  UnknownTokenError,
5936
6450
  UnsupportedNetworkError,
5937
6451
  UnsupportedSchemeError,
@@ -5959,6 +6473,7 @@ export {
5959
6473
  createPaymentGate,
5960
6474
  decorateOutcome,
5961
6475
  deliverReceipt,
6476
+ denomOf,
5962
6477
  describeChallenge,
5963
6478
  discoveryHeaders,
5964
6479
  eip3009Abi,
@@ -5972,6 +6487,7 @@ export {
5972
6487
  getDirectoryInfo,
5973
6488
  isPermit2ProxyChain,
5974
6489
  knownFacilitatorsFor,
6490
+ memorySpendStore,
5975
6491
  normalizeNetwork,
5976
6492
  parseChallenge,
5977
6493
  parseExactPaymentHeader,