@piprail/sdk 2.8.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 +62 -0
  2. package/README.md +5 -3
  3. package/dist/{algorand-WJL6VRXW.js → algorand-AA3WXKW4.js} +1 -1
  4. package/dist/{algorand-EKD34T4Q.cjs → algorand-FCEECDG6.cjs} +32 -32
  5. package/dist/{aptos-CVHLVMAF.cjs → aptos-GHJPO6JJ.cjs} +31 -31
  6. package/dist/{aptos-WMPFEASY.js → aptos-LY67Q6QF.js} +1 -1
  7. package/dist/{chunk-VPENSVOI.cjs → chunk-MWBT7MCE.cjs} +7 -3
  8. package/dist/chunk-O4UQOZ4Z.cjs +14 -0
  9. package/dist/{chunk-W3HOMXOF.js → chunk-SC2ZYDHD.js} +6 -2
  10. package/dist/chunk-SK3CB7UA.js +14 -0
  11. package/dist/index.cjs +748 -237
  12. package/dist/index.d.cts +2384 -2577
  13. package/dist/index.d.ts +2384 -2577
  14. package/dist/index.js +589 -78
  15. package/dist/ledger-BtzrfO-3.d.cts +658 -0
  16. package/dist/ledger-BtzrfO-3.d.ts +658 -0
  17. package/dist/{near-NYFMX2KA.cjs → near-6KAQVVG2.cjs} +26 -26
  18. package/dist/{near-QV4IOZNX.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-Z34OWXMO.js → solana-ELUWO6N5.js} +1 -1
  24. package/dist/{solana-L342QCSV.cjs → solana-MYF4HBO4.cjs} +32 -32
  25. package/dist/{stellar-PWC4LETJ.js → stellar-BEMT7UYF.js} +1 -1
  26. package/dist/{stellar-YEXRVR3W.cjs → stellar-SUKASK4N.cjs} +20 -20
  27. package/dist/{sui-VEOGHBYE.js → sui-F5JQ2N6I.js} +1 -1
  28. package/dist/{sui-EFRSCDWN.cjs → sui-VE5LT7BL.cjs} +16 -16
  29. package/dist/{ton-N7KSPSAR.js → ton-7GKCTC5H.js} +1 -1
  30. package/dist/{ton-LZKJWYHV.cjs → ton-AOR3EURW.cjs} +16 -16
  31. package/dist/{tron-DQU4WTGQ.cjs → tron-EMFXDFHW.cjs} +24 -24
  32. package/dist/{tron-WKDUQVZR.js → tron-ZZZS3FNN.js} +1 -1
  33. package/dist/{xrpl-6B4IYRWI.js → xrpl-DD7TJL5L.js} +1 -1
  34. package/dist/{xrpl-K3U3SBF4.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-W3HOMXOF.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-Z34OWXMO.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-N7KSPSAR.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-PWC4LETJ.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-6B4IYRWI.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-WKDUQVZR.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-VEOGHBYE.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-QV4IOZNX.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-WMPFEASY.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-WJL6VRXW.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`,
@@ -2582,6 +2586,53 @@ function encodeBase642(str) {
2582
2586
  }
2583
2587
 
2584
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
+ }
2585
2636
  var ALLOW = { allowed: true };
2586
2637
  var deny = (code, reason) => ({
2587
2638
  allowed: false,
@@ -2665,6 +2716,34 @@ function evaluatePolicy(intent, policy, spentForAssetBase, ctx) {
2665
2716
  );
2666
2717
  }
2667
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
+ }
2668
2747
  if (ctx && policy.windowTotal !== void 0 && policy.windowSeconds !== void 0) {
2669
2748
  const cap = floorUnits(policy.windowTotal, intent.decimals);
2670
2749
  if (ctx.spentInWindowBase + intent.amountBase > cap) {
@@ -2674,6 +2753,14 @@ function evaluatePolicy(intent, policy, spentForAssetBase, ctx) {
2674
2753
  );
2675
2754
  }
2676
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
+ }
2677
2764
  return ALLOW;
2678
2765
  }
2679
2766
 
@@ -2682,37 +2769,135 @@ var keyFor = (network, asset) => `${network}|${asset}`;
2682
2769
  var SpendLedger = class {
2683
2770
  records = [];
2684
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;
2685
2781
  /**
2686
2782
  * Session clock origin (epoch-ms) — process/session start = ledger
2687
2783
  * construction. In-memory; a new process is a new session. The client reads it
2688
2784
  * to compute the `ttlSeconds` deadline and the rolling-window slice.
2689
2785
  */
2690
2786
  sessionStart = Date.now();
2691
- /** Record a settled payment. `decimals` is the TRUE token decimals (for the
2692
- * per-asset running total used by maxTotal + the formatted summary). */
2693
- record(r, decimals) {
2694
- this.records.push(r);
2695
- 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);
2696
2825
  const bucket = this.buckets.get(key);
2697
2826
  if (bucket) {
2698
- bucket.total += BigInt(r.amountBase);
2827
+ bucket.total += BigInt(rec.amountBase);
2699
2828
  bucket.count += 1;
2700
- if (!bucket.symbol && r.symbol) bucket.symbol = r.symbol;
2829
+ if (!bucket.symbol && rec.symbol) bucket.symbol = rec.symbol;
2701
2830
  } else {
2702
2831
  this.buckets.set(key, {
2703
- network: r.network,
2704
- asset: r.asset,
2705
- symbol: r.symbol,
2832
+ network: rec.network,
2833
+ asset: rec.asset,
2834
+ symbol: rec.symbol,
2706
2835
  decimals,
2707
- total: BigInt(r.amountBase),
2836
+ total: BigInt(rec.amountBase),
2708
2837
  count: 1
2709
2838
  });
2710
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
+ }
2711
2861
  }
2712
2862
  /** Running total (base units) already spent on this (network, asset). */
2713
2863
  totalFor(network, asset) {
2714
2864
  return this.buckets.get(keyFor(network, asset))?.total ?? 0n;
2715
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
+ }
2716
2901
  /**
2717
2902
  * Sum of base-unit amounts for (network, asset) whose record `at` (ISO
2718
2903
  * timestamp) is at or after `sinceMs` (epoch-ms). Backs the rolling window
@@ -2723,9 +2908,9 @@ var SpendLedger = class {
2723
2908
  totalSince(network, asset, sinceMs) {
2724
2909
  let sum = 0n;
2725
2910
  for (const r of this.records) {
2726
- if (r.network === network && r.asset === asset && Date.parse(r.at) >= sinceMs) {
2727
- sum += BigInt(r.amountBase);
2728
- }
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);
2729
2914
  }
2730
2915
  return sum;
2731
2916
  }
@@ -2745,6 +2930,14 @@ var SpendLedger = class {
2745
2930
  totalBase: b.total
2746
2931
  }));
2747
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
+ }
2748
2941
  /** An immutable snapshot of all spend so far. */
2749
2942
  summary() {
2750
2943
  return {
@@ -2758,6 +2951,12 @@ var SpendLedger = class {
2758
2951
  totalFormatted: formatUnits(b.total, b.decimals),
2759
2952
  count: b.count
2760
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
+ })),
2761
2960
  records: [...this.records]
2762
2961
  };
2763
2962
  }
@@ -2776,9 +2975,14 @@ var PipRailClient = class {
2776
2975
  maxRetries;
2777
2976
  retryTimeoutMs;
2778
2977
  onEvent;
2779
- // Per-asset tally of everything this client has paid (powers spent() and the
2780
- // policy's maxTotal cap).
2781
- 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.
2782
2986
  // Resolved lazily on first request — this is what lets Solana (and future
2783
2987
  // families) auto-mount with no setup call.
2784
2988
  bound;
@@ -2787,8 +2991,15 @@ var PipRailClient = class {
2787
2991
  this.maxRetries = Math.max(1, opts.maxPaymentRetries ?? 3);
2788
2992
  this.retryTimeoutMs = opts.retryTimeoutMs ?? 3e4;
2789
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);
2790
3000
  this.assertPolicyAmountCaps(opts.policy);
2791
3001
  this.assertPolicyTimeOptions(opts.policy);
3002
+ this.assertPolicySpendControls(opts.policy);
2792
3003
  }
2793
3004
  /**
2794
3005
  * Fail LOUDLY at construction on a malformed amount cap — a security boundary
@@ -2809,6 +3020,65 @@ var PipRailClient = class {
2809
3020
  );
2810
3021
  }
2811
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
+ }
2812
3082
  }
2813
3083
  /**
2814
3084
  * Fail LOUDLY at construction on a misconfigured time policy — a security
@@ -2823,11 +3093,17 @@ var PipRailClient = class {
2823
3093
  if (!policy) return;
2824
3094
  const hasWindowTotal = policy.windowTotal !== void 0;
2825
3095
  const hasWindowSeconds = policy.windowSeconds !== void 0;
2826
- if (hasWindowTotal !== hasWindowSeconds) {
3096
+ const hasWindowCount = policy.maxPaymentsPerWindow !== void 0;
3097
+ if (hasWindowTotal && !hasWindowSeconds) {
2827
3098
  throw new TypeError(
2828
3099
  "policy.windowTotal and policy.windowSeconds must be set together \u2014 a rolling-window cap can't be half-armed (set both, or neither)."
2829
3100
  );
2830
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
+ }
2831
3107
  if (hasWindowSeconds && !(Number.isSafeInteger(policy.windowSeconds) && policy.windowSeconds > 0)) {
2832
3108
  throw new TypeError("policy.windowSeconds must be a positive integer number of seconds.");
2833
3109
  }
@@ -2839,6 +3115,14 @@ var PipRailClient = class {
2839
3115
  );
2840
3116
  }
2841
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
+ }
2842
3126
  }
2843
3127
  /** Emit an observability event, never letting a throwing handler break the
2844
3128
  * payment flow (mirrors the server gate's `onPaid` isolation). */
@@ -2932,15 +3216,25 @@ var PipRailClient = class {
2932
3216
  return { quote, cost };
2933
3217
  }
2934
3218
  /** Aggregated snapshot of every payment this client has settled — total
2935
- * 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. */
2936
3221
  spent() {
2937
3222
  return this.ledger.summary();
2938
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
+ }
2939
3230
  /**
2940
3231
  * Read-only budget + time leash for a Mode-A (headless) agent — the policy IS
2941
3232
  * the consent, and this is how the agent SEES what's left of it before paying.
2942
- * Composes the in-memory ledger with the configured policy; never throws, moves
2943
- * 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}).
2944
3238
  */
2945
3239
  budget() {
2946
3240
  const view = this.sessionView();
@@ -2951,9 +3245,54 @@ var PipRailClient = class {
2951
3245
  expiresAt: view?.expiresAt != null ? new Date(view.expiresAt).toISOString() : null,
2952
3246
  secondsRemaining: view?.secondsRemaining ?? null
2953
3247
  },
2954
- byAsset: this.remaining()
3248
+ byAsset: this.remaining(),
3249
+ byDenom: this.denomRemaining(),
3250
+ counts: this.countStatus()
2955
3251
  };
2956
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
+ }
2957
3296
  /**
2958
3297
  * Per-(network, asset) remaining budget — ONE row per pair the ledger already
2959
3298
  * holds (decimals are known only after the first spend), so a fresh client with
@@ -3371,7 +3710,7 @@ var PipRailClient = class {
3371
3710
  const shortfall = {};
3372
3711
  if (!quote.withinPolicy) {
3373
3712
  blockers.push(
3374
- 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"
3375
3714
  );
3376
3715
  }
3377
3716
  if (quote.symbolMismatch) warnings.push("SYMBOL_MISMATCH");
@@ -3450,6 +3789,11 @@ var PipRailClient = class {
3450
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.`
3451
3790
  );
3452
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
+ }
3453
3797
  const symbol = described?.symbol ?? accept.extra?.symbol;
3454
3798
  const amountFormatted = formatUnits(amountBase, decimals);
3455
3799
  const intent = {
@@ -3464,9 +3808,14 @@ var PipRailClient = class {
3464
3808
  };
3465
3809
  const policy = this.opts.policy;
3466
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;
3467
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;
3468
3817
  const now = Date.now();
3469
- const ctx = hasTimePolicy ? {
3818
+ const ctx = needsCtx ? {
3470
3819
  now,
3471
3820
  sessionStart: this.ledger.sessionStart,
3472
3821
  // Window slice ONLY when BOTH fields are set — never a `?? 0` width.
@@ -3474,7 +3823,12 @@ var PipRailClient = class {
3474
3823
  accept.network,
3475
3824
  accept.asset,
3476
3825
  now - policy.windowSeconds * 1e3
3477
- ) : 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
3478
3832
  } : void 0;
3479
3833
  const decision = evaluatePolicy(
3480
3834
  intent,
@@ -3510,9 +3864,11 @@ var PipRailClient = class {
3510
3864
  async authorize(quote) {
3511
3865
  if (!quote.withinPolicy) {
3512
3866
  const reason = `Payment refused by policy: ${quote.policyReason ?? "not allowed"}`;
3513
- const reasonCode = reasonCodeForPolicy(quote.policyCode);
3514
- this.safeEmit({ kind: "payment-failed", reason, code: reasonCode });
3515
- throw new PaymentDeclinedError(reason, { reasonCode });
3867
+ this.refuse(reason, {
3868
+ reasonCode: reasonCodeForPolicy(quote.policyCode),
3869
+ policyCode: quote.policyCode,
3870
+ quote
3871
+ });
3516
3872
  }
3517
3873
  const hook = this.opts.onBeforePay;
3518
3874
  if (!hook) return;
@@ -3520,32 +3876,125 @@ var PipRailClient = class {
3520
3876
  try {
3521
3877
  approved = await hook(quote);
3522
3878
  } catch (err) {
3523
- const reason = "onBeforePay threw \u2014 refusing to pay.";
3524
- this.safeEmit({ kind: "payment-failed", reason, code: "APPROVAL" });
3525
- throw new PaymentDeclinedError(reason, { cause: err, reasonCode: "APPROVAL" });
3879
+ this.refuse("onBeforePay threw \u2014 refusing to pay.", { reasonCode: "APPROVAL", quote, cause: err });
3526
3880
  }
3527
3881
  if (!approved) {
3528
3882
  const reason = `onBeforePay declined ${quote.amountFormatted} ${quote.symbol ?? ""}`.trimEnd() + ` on ${quote.network}.`;
3529
- this.safeEmit({ kind: "payment-failed", reason, code: "APPROVAL" });
3530
- throw new PaymentDeclinedError(reason, { reasonCode: "APPROVAL" });
3883
+ this.refuse(reason, { reasonCode: "APPROVAL", quote });
3531
3884
  }
3532
3885
  }
3533
- /** 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. */
3534
3913
  recordSpend(quote, ref) {
3535
- this.ledger.record(
3536
- {
3537
- url: quote.url,
3538
- host: hostOf2(quote.url),
3539
- network: quote.network,
3540
- asset: quote.asset,
3541
- amountBase: quote.amount,
3542
- amountFormatted: quote.amountFormatted,
3543
- ...quote.symbol ? { symbol: quote.symbol } : {},
3544
- ref,
3545
- at: (/* @__PURE__ */ new Date()).toISOString()
3546
- },
3547
- quote.decimals
3548
- );
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
+ }
3549
3998
  }
3550
3999
  async payAndConfirm(net, wallet, accept) {
3551
4000
  if (!this.supportsNetwork(net, accept.network)) {
@@ -3843,8 +4292,11 @@ function reasonCodeForPolicy(code) {
3843
4292
  case "SESSION_EXPIRED":
3844
4293
  return "SESSION_EXPIRED";
3845
4294
  case "WINDOW_TOTAL":
4295
+ case "WINDOW_COUNT":
3846
4296
  return "OUTSIDE_WINDOW";
3847
4297
  case "MAX_TOTAL":
4298
+ case "MAX_TOTAL_DENOM":
4299
+ case "MAX_PAYMENTS":
3848
4300
  return "BUDGET";
3849
4301
  case void 0:
3850
4302
  return void 0;
@@ -3902,6 +4354,10 @@ async function readInvalidReason(response) {
3902
4354
  // src/payer.ts
3903
4355
  var MultiChainPayer = class _MultiChainPayer {
3904
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;
3905
4361
  /**
3906
4362
  * Wrap an explicit, ordered set of single-chain clients — use this when a client
3907
4363
  * needs full control (e.g. a custom EVM chain configured by a viem `Chain`). The
@@ -3909,12 +4365,18 @@ var MultiChainPayer = class _MultiChainPayer {
3909
4365
  * at MOST one client per chain — two clients on the SAME network would double-count in
3910
4366
  * `spent()`/`budget()` and waste a plan round-trip (`fromWallets` can't produce this).
3911
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.
3912
4373
  */
3913
- constructor(clients) {
4374
+ constructor(clients, opts = {}) {
3914
4375
  if (clients.length === 0) {
3915
4376
  throw new TypeError("MultiChainPayer needs at least one PipRailClient.");
3916
4377
  }
3917
4378
  this._clients = [...clients];
4379
+ this._shared = opts.shared ?? false;
3918
4380
  }
3919
4381
  /**
3920
4382
  * Build one client per funded chain from a `{ chain → wallet }` map — the
@@ -3929,20 +4391,26 @@ var MultiChainPayer = class _MultiChainPayer {
3929
4391
  * solana: { key: process.env.SOLANA_SECRET! },
3930
4392
  * xrpl: { key: process.env.XRPL_SEED! },
3931
4393
  * },
3932
- * 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 },
3933
4396
  * })
3934
4397
  * const res = await payer.get('https://api.example.com/paid') // pays on the first funded chain that can settle
3935
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.
3936
4402
  */
3937
4403
  static fromWallets(opts) {
3938
4404
  const entries = Object.entries(opts.wallets);
3939
4405
  if (entries.length === 0) {
3940
4406
  throw new TypeError("MultiChainPayer.fromWallets needs at least one wallet.");
3941
4407
  }
4408
+ const ledger = new SpendLedger(opts.spendStore);
3942
4409
  const clients = entries.map(
3943
4410
  ([chain, wallet]) => new PipRailClient({
3944
4411
  chain,
3945
4412
  wallet,
4413
+ ledger,
3946
4414
  ...opts.policy ? { policy: opts.policy } : {},
3947
4415
  ...opts.schemes ? { schemes: opts.schemes } : {},
3948
4416
  ...opts.rpcUrls?.[chain] ? { rpcUrl: opts.rpcUrls[chain] } : {},
@@ -3952,7 +4420,7 @@ var MultiChainPayer = class _MultiChainPayer {
3952
4420
  ...opts.retryTimeoutMs != null ? { retryTimeoutMs: opts.retryTimeoutMs } : {}
3953
4421
  })
3954
4422
  );
3955
- return new _MultiChainPayer(clients);
4423
+ return new _MultiChainPayer(clients, { shared: true });
3956
4424
  }
3957
4425
  /** The underlying single-chain clients, in preference order. Reach for one of
3958
4426
  * these for chain-specific reads (`estimateCost`, `discoverySigner`, per-chain
@@ -4046,28 +4514,50 @@ var MultiChainPayer = class _MultiChainPayer {
4046
4514
  register(url, opts = {}) {
4047
4515
  return this._clients[0].register(url, opts);
4048
4516
  }
4049
- /** Aggregate spend across every chain — counts summed; per-(network,asset) rows
4050
- * 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
+ */
4051
4524
  spent() {
4525
+ if (this._shared) return this._clients[0].spent();
4052
4526
  const summaries = this._clients.map((c) => c.spent());
4053
4527
  return {
4054
4528
  count: summaries.reduce((n, s) => n + s.count, 0),
4055
4529
  byAsset: summaries.flatMap((s) => s.byAsset),
4530
+ byDenom: summaries.flatMap((s) => s.byDenom),
4056
4531
  records: summaries.flatMap((s) => s.records)
4057
4532
  };
4058
4533
  }
4059
- /** A merged budget view: every chain's per-(network,asset) remaining rows, plus the
4060
- * MOST-RESTRICTIVE session time envelope across chains (the soonest deadline wins).
4061
- * Mirrors {@link PipRailClient.budget}'s shape so the agent toolkit reads it
4062
- * 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
+ */
4063
4542
  budget() {
4543
+ if (this._shared) return this._clients[0].budget();
4064
4544
  const budgets = this._clients.map((c) => c.budget());
4065
4545
  const session = budgets.map((b) => b.session).reduce((soonest, s) => {
4066
4546
  if (soonest.secondsRemaining == null) return s;
4067
4547
  if (s.secondsRemaining == null) return soonest;
4068
4548
  return s.secondsRemaining < soonest.secondsRemaining ? s : soonest;
4069
4549
  });
4070
- 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();
4071
4561
  }
4072
4562
  };
4073
4563
  function isBodyInit(value) {
@@ -4172,9 +4662,12 @@ function explainDecline(err) {
4172
4662
  }
4173
4663
  function formatSpendReport(summary) {
4174
4664
  if (summary.count === 0) return "No payments yet.";
4175
- return summary.byAsset.map(
4665
+ const perAsset = summary.byAsset.map(
4176
4666
  (a) => `${a.totalFormatted} ${a.symbol ?? a.asset} on ${a.network} (${a.count} payment${a.count === 1 ? "" : "s"})`
4177
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}`;
4178
4671
  }
4179
4672
  function describeChallenge(challenge) {
4180
4673
  const first = challenge.accepts[0];
@@ -4253,9 +4746,11 @@ Branch on \`code\` (always reliable). Key cases:
4253
4746
  once (enable the exact scheme); report it, don't retry the same call blindly.
4254
4747
 
4255
4748
  ## Knowing your leash \u2014 call piprail_budget
4256
- piprail_budget tells you how much budget and time you have left, per
4257
- (network, asset), plus your spend so far. Read-only; moves no funds. Use it in
4258
- 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.
4259
4754
 
4260
4755
  ## Two modes
4261
4756
  - Mode A (headless, default): you run FREE inside a pre-set budget + time
@@ -4266,10 +4761,15 @@ Mode A to self-check before paying.
4266
4761
  do NOT retry it as if it were a transient error.
4267
4762
 
4268
4763
  ## Hard facts
4269
- - Spend caps are PER (network, asset). There is no single cross-token dollar cap \u2014
4270
- budgets aren't summed across tokens (no price oracle).
4271
- - Spend totals and the time envelope live IN-MEMORY for THIS process; they reset on restart
4272
- (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.
4273
4773
  `;
4274
4774
  function agentGuide() {
4275
4775
  return PIPRAIL_AGENT_GUIDE;
@@ -4584,11 +5084,11 @@ function paymentTools(client) {
4584
5084
  },
4585
5085
  {
4586
5086
  name: "piprail_budget",
4587
- 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.",
4588
5088
  annotations: {
4589
5089
  title: "Check remaining budget",
4590
5090
  readOnlyHint: true,
4591
- // reads the in-memory ledger + policy; never pays
5091
+ // reads the ledger + policy; never pays
4592
5092
  idempotentHint: true
4593
5093
  // a pure read
4594
5094
  },
@@ -4601,7 +5101,13 @@ function paymentTools(client) {
4601
5101
  return {
4602
5102
  spent,
4603
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)
4604
5108
  session: budget.session,
5109
+ policy: client.policy() ?? null,
5110
+ // the configured leash, read back
4605
5111
  report: formatSpendReport(spent)
4606
5112
  };
4607
5113
  } catch (err) {
@@ -5906,8 +6412,10 @@ async function deliverReceipt(receipt, options) {
5906
6412
  }
5907
6413
  export {
5908
6414
  BRAND,
6415
+ BUILTIN_DENOMS,
5909
6416
  CHAINS,
5910
6417
  ConfirmationTimeoutError,
6418
+ DENOM_PRECISION,
5911
6419
  DIRECTORY_INFO,
5912
6420
  EIP3009_TYPES,
5913
6421
  EXACT_NETWORK_SLUGS,
@@ -5937,6 +6445,7 @@ export {
5937
6445
  REGISTER_ATTRIBUTION,
5938
6446
  RecipientNotReadyError,
5939
6447
  SettlementError,
6448
+ SpendLedger,
5940
6449
  UnknownTokenError,
5941
6450
  UnsupportedNetworkError,
5942
6451
  UnsupportedSchemeError,
@@ -5964,6 +6473,7 @@ export {
5964
6473
  createPaymentGate,
5965
6474
  decorateOutcome,
5966
6475
  deliverReceipt,
6476
+ denomOf,
5967
6477
  describeChallenge,
5968
6478
  discoveryHeaders,
5969
6479
  eip3009Abi,
@@ -5977,6 +6487,7 @@ export {
5977
6487
  getDirectoryInfo,
5978
6488
  isPermit2ProxyChain,
5979
6489
  knownFacilitatorsFor,
6490
+ memorySpendStore,
5980
6491
  normalizeNetwork,
5981
6492
  parseChallenge,
5982
6493
  parseExactPaymentHeader,