@piprail/sdk 1.23.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3493,6 +3493,33 @@ function rankOptions(options) {
3493
3493
  return 0;
3494
3494
  });
3495
3495
  }
3496
+ function rankAcross(plans) {
3497
+ const rank = { payable: 0, unknown: 1, blocked: 2 };
3498
+ return plans.flatMap((p) => p.options).sort((a, b) => rank[a.state] - rank[b.state]);
3499
+ }
3500
+ async function planEachClient(clients, url, init) {
3501
+ const settled = await Promise.allSettled(clients.map((c) => c.planPayment(url, init)));
3502
+ const live = [];
3503
+ let anyReached = false;
3504
+ let firstError;
3505
+ settled.forEach((s, i) => {
3506
+ if (s.status === "fulfilled") {
3507
+ anyReached = true;
3508
+ if (s.value != null) live.push({ client: clients[i], plan: s.value });
3509
+ } else if (firstError === void 0) {
3510
+ firstError = s.reason;
3511
+ }
3512
+ });
3513
+ if (live.length === 0 && !anyReached) {
3514
+ throw firstError ?? new Error("planAcross: every client failed to reach the resource.");
3515
+ }
3516
+ return live;
3517
+ }
3518
+ function mergeDeclineHint(plans) {
3519
+ const actionable = plans.filter((p) => p.options.length > 0 && p.fundingHint).map((p) => p.fundingHint);
3520
+ const chosen = actionable.length ? actionable : plans.map((p) => p.fundingHint).filter(Boolean);
3521
+ return chosen.length ? [...new Set(chosen)].join(" \xB7 ") : null;
3522
+ }
3496
3523
  function buildFundingHint(options, chainLabel) {
3497
3524
  if (options.length === 0) return null;
3498
3525
  const target = [...options].sort((a, b) => a.blockers.length - b.blockers.length)[0];
@@ -3519,10 +3546,10 @@ function buildFundingHint(options, chainLabel) {
3519
3546
  return parts.length ? `Can't settle on ${chainLabel}: ${parts.join(" and ")} (to pay ${target.quote.amountFormatted} ${sym}).` : `Can't settle on ${chainLabel} for ${target.quote.amountFormatted} ${sym}.`;
3520
3547
  }
3521
3548
  async function planAcross(clients, url, init) {
3522
- const plans = await Promise.all(clients.map((c) => c.planPayment(url, init).catch(() => null)));
3523
- const live = plans.filter((p) => p != null);
3549
+ if (clients.length === 0) return null;
3550
+ const live = (await planEachClient(clients, url, init)).map((p) => p.plan);
3524
3551
  if (live.length === 0) return null;
3525
- const options = rankOptions(live.flatMap((p) => p.options));
3552
+ const options = rankAcross(live);
3526
3553
  const best = options.find((o) => o.state === "payable") ?? null;
3527
3554
  const status = best ? "ready" : options.some((o) => o.state === "unknown") ? "unknown" : "blocked";
3528
3555
  return {
@@ -3532,10 +3559,25 @@ async function planAcross(clients, url, init) {
3532
3559
  payable: best !== null,
3533
3560
  best,
3534
3561
  options,
3535
- // First non-null hint across clients each already names its chain.
3536
- fundingHint: best ? null : live.map((p) => p.fundingHint).find(Boolean) ?? null
3562
+ // Merge EVERY funded chain's blocker into one clear sentence (not just the first) —
3563
+ // see mergeDeclineHint. `null` when a rail is payable.
3564
+ fundingHint: best ? null : mergeDeclineHint(live)
3537
3565
  };
3538
3566
  }
3567
+ async function fetchAcross(clients, url, init) {
3568
+ if (clients.length === 0) {
3569
+ throw new TypeError("fetchAcross needs at least one PipRailClient.");
3570
+ }
3571
+ const live = await planEachClient(clients, url, init);
3572
+ if (live.length === 0) return clients[0].fetch(url, init);
3573
+ const best = rankAcross(live.map((p) => p.plan)).find((o) => o.state === "payable");
3574
+ if (!best) {
3575
+ const hint = mergeDeclineHint(live.map((p) => p.plan));
3576
+ throw new PaymentDeclinedError(hint || "No funded chain can settle this payment right now.");
3577
+ }
3578
+ const owner = live.find((p) => p.plan.options.includes(best)).client;
3579
+ return owner.fetch(url, { ...init ?? {}, autoRoute: true });
3580
+ }
3539
3581
  function railOnNetwork(rail, matches) {
3540
3582
  const n = normalizeNetwork(rail.network);
3541
3583
  return !n.includes(":") || matches(n);
@@ -3601,6 +3643,187 @@ async function readInvalidReason(response) {
3601
3643
  return null;
3602
3644
  }
3603
3645
 
3646
+ // src/payer.ts
3647
+ var MultiChainPayer = class _MultiChainPayer {
3648
+ _clients;
3649
+ /**
3650
+ * Wrap an explicit, ordered set of single-chain clients — use this when a client
3651
+ * needs full control (e.g. a custom EVM chain configured by a viem `Chain`). The
3652
+ * ORDER is your chain preference: across chains the first that can settle wins. Pass
3653
+ * at MOST one client per chain — two clients on the SAME network would double-count in
3654
+ * `spent()`/`budget()` and waste a plan round-trip (`fromWallets` can't produce this).
3655
+ * For the common case, prefer {@link MultiChainPayer.fromWallets}.
3656
+ */
3657
+ constructor(clients) {
3658
+ if (clients.length === 0) {
3659
+ throw new TypeError("MultiChainPayer needs at least one PipRailClient.");
3660
+ }
3661
+ this._clients = [...clients];
3662
+ }
3663
+ /**
3664
+ * Build one client per funded chain from a `{ chain → wallet }` map — the
3665
+ * ergonomic path. The shared `policy`/`schemes`/`onBeforePay`/`onEvent` apply to
3666
+ * every client; `rpcUrls` are matched per chain. Iteration order of `wallets` is
3667
+ * the chain preference.
3668
+ *
3669
+ * ```ts
3670
+ * const payer = MultiChainPayer.fromWallets({
3671
+ * wallets: {
3672
+ * base: { privateKey: process.env.EVM_KEY! },
3673
+ * solana: { secretKey: process.env.SOLANA_SECRET! },
3674
+ * xrpl: { seed: process.env.XRPL_SEED! },
3675
+ * },
3676
+ * policy: { maxAmount: '1.00', maxTotal: '20.00', tokens: ['USDC', 'USDT'] },
3677
+ * })
3678
+ * const res = await payer.get('https://api.example.com/paid') // pays on the first funded chain that can settle
3679
+ * ```
3680
+ */
3681
+ static fromWallets(opts) {
3682
+ const entries = Object.entries(opts.wallets);
3683
+ if (entries.length === 0) {
3684
+ throw new TypeError("MultiChainPayer.fromWallets needs at least one wallet.");
3685
+ }
3686
+ const clients = entries.map(
3687
+ ([chain, wallet]) => new PipRailClient({
3688
+ chain,
3689
+ wallet,
3690
+ ...opts.policy ? { policy: opts.policy } : {},
3691
+ ...opts.schemes ? { schemes: opts.schemes } : {},
3692
+ ...opts.rpcUrls?.[chain] ? { rpcUrl: opts.rpcUrls[chain] } : {},
3693
+ ...opts.onBeforePay ? { onBeforePay: opts.onBeforePay } : {},
3694
+ ...opts.onEvent ? { onEvent: opts.onEvent } : {},
3695
+ ...opts.maxPaymentRetries != null ? { maxPaymentRetries: opts.maxPaymentRetries } : {},
3696
+ ...opts.retryTimeoutMs != null ? { retryTimeoutMs: opts.retryTimeoutMs } : {}
3697
+ })
3698
+ );
3699
+ return new _MultiChainPayer(clients);
3700
+ }
3701
+ /** The underlying single-chain clients, in preference order. Reach for one of
3702
+ * these for chain-specific reads (`estimateCost`, `discoverySigner`, per-chain
3703
+ * `budget()`) that don't make sense merged. */
3704
+ get clients() {
3705
+ return this._clients;
3706
+ }
3707
+ /** Plan a 402 across every funded chain — merged + ranked payable-first. `null`
3708
+ * when the URL needs no payment. (Delegates to {@link planAcross}.) */
3709
+ planPayment(url, init) {
3710
+ return planAcross(this._clients, url, init);
3711
+ }
3712
+ /** Can ANY funded chain settle this URL right now? (A free resource is trivially
3713
+ * "affordable".) No funds move. */
3714
+ async canAfford(url, init) {
3715
+ const plan = await this.planPayment(url, init);
3716
+ return plan == null ? true : plan.payable;
3717
+ }
3718
+ /** Price a gated URL across funded chains — the chosen rail's quote (the first
3719
+ * funded chain that can settle), else the first offered rail's. `null` when the URL
3720
+ * needs no payment. When it IS
3721
+ * gated but none of your chains are offered, surfaces the same informative
3722
+ * `NoCompatibleAcceptError` a single client would (it names the chains the 402 is
3723
+ * payable on) rather than a misleading `null`. No funds move. */
3724
+ async quote(url, init) {
3725
+ const plan = await this.planPayment(url, init);
3726
+ if (plan == null) return null;
3727
+ const opt = plan.best ?? plan.options[0];
3728
+ if (opt) return opt.quote;
3729
+ return this._clients[0].quote(url, init);
3730
+ }
3731
+ /** Pay the first funded chain (in your listed order) that can settle this URL.
3732
+ * Delegates to {@link fetchAcross} — full policy / approval / retry / replay path on
3733
+ * the owning client. The owner re-reads balances at pay time, so the rail paid is the
3734
+ * surfaced `best` on a best-effort basis (it can pick another rail on the SAME chain,
3735
+ * or decline, if balances shift between plan and pay). PROBES the URL with `init`
3736
+ * (method + body) per client — prefer GET / idempotent requests. */
3737
+ fetch(url, init) {
3738
+ return fetchAcross(this._clients, url, init);
3739
+ }
3740
+ /** GET that auto-pays across chains. */
3741
+ get(url, init) {
3742
+ return this.fetch(url, { ...init ?? {}, method: "GET" });
3743
+ }
3744
+ /**
3745
+ * POST that auto-pays across chains. `body` is a string/FormData/URLSearchParams/
3746
+ * ArrayBuffer/Blob (sent as-is) or a plain object (serialised as JSON) — mirrors
3747
+ * {@link PipRailClient.post}.
3748
+ */
3749
+ post(url, body, init) {
3750
+ const headers = new Headers(init?.headers);
3751
+ let payload;
3752
+ if (body === void 0 || body === null) {
3753
+ payload = void 0;
3754
+ } else if (isBodyInit(body)) {
3755
+ payload = body;
3756
+ } else if (typeof body === "object") {
3757
+ payload = JSON.stringify(body);
3758
+ if (!headers.has("content-type")) headers.set("content-type", "application/json");
3759
+ } else {
3760
+ payload = String(body);
3761
+ }
3762
+ return this.fetch(url, { ...init ?? {}, method: "POST", headers, body: payload });
3763
+ }
3764
+ /**
3765
+ * Find payable resources across every funded chain. With the default
3766
+ * `network: 'self'`, each chain's own results are merged + deduped by URL (so
3767
+ * "self" means "any chain I can pay"). A network-scoped query (a CAIP-2 id or
3768
+ * `'any'`) is chain-independent, so one client answers it. Never throws for a
3769
+ * read problem; moves no funds.
3770
+ */
3771
+ async discover(opts = {}) {
3772
+ if (opts.network && opts.network !== "self") {
3773
+ return this._clients[0].discover(opts);
3774
+ }
3775
+ const perChain = await Promise.all(
3776
+ this._clients.map((c) => c.discover({ ...opts, network: "self" }).catch(() => []))
3777
+ );
3778
+ const seen = /* @__PURE__ */ new Set();
3779
+ const merged = [];
3780
+ for (const r of perChain.flat()) {
3781
+ if (seen.has(r.resource)) continue;
3782
+ seen.add(r.resource);
3783
+ merged.push(r);
3784
+ }
3785
+ return merged;
3786
+ }
3787
+ /** List a resource YOU run on the open indexes. Registration is a merchant action
3788
+ * independent of which chain you pay FROM, so it goes through your first chain's
3789
+ * client; pass `opts.network` to advertise a specific chain. Moves no funds. */
3790
+ register(url, opts = {}) {
3791
+ return this._clients[0].register(url, opts);
3792
+ }
3793
+ /** Aggregate spend across every chain — counts summed; per-(network,asset) rows
3794
+ * and records concatenated (no cross-chain collisions, never a cross-token sum). */
3795
+ spent() {
3796
+ const summaries = this._clients.map((c) => c.spent());
3797
+ return {
3798
+ count: summaries.reduce((n, s) => n + s.count, 0),
3799
+ byAsset: summaries.flatMap((s) => s.byAsset),
3800
+ records: summaries.flatMap((s) => s.records)
3801
+ };
3802
+ }
3803
+ /** A merged budget view: every chain's per-(network,asset) remaining rows, plus the
3804
+ * MOST-RESTRICTIVE session time envelope across chains (the soonest deadline wins).
3805
+ * Mirrors {@link PipRailClient.budget}'s shape so the agent toolkit reads it
3806
+ * unchanged; per-chain session detail is on each `clients[i].budget()`. */
3807
+ budget() {
3808
+ const budgets = this._clients.map((c) => c.budget());
3809
+ const session = budgets.map((b) => b.session).reduce((soonest, s) => {
3810
+ if (soonest.secondsRemaining == null) return s;
3811
+ if (s.secondsRemaining == null) return soonest;
3812
+ return s.secondsRemaining < soonest.secondsRemaining ? s : soonest;
3813
+ });
3814
+ return { session, byAsset: budgets.flatMap((b) => b.byAsset) };
3815
+ }
3816
+ };
3817
+ function isBodyInit(value) {
3818
+ if (typeof value === "string") return true;
3819
+ if (value instanceof ArrayBuffer) return true;
3820
+ if (ArrayBuffer.isView(value)) return true;
3821
+ if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) return true;
3822
+ if (typeof FormData !== "undefined" && value instanceof FormData) return true;
3823
+ if (typeof Blob !== "undefined" && value instanceof Blob) return true;
3824
+ return false;
3825
+ }
3826
+
3604
3827
  // src/selfdescribe.ts
3605
3828
  var BRAND = {
3606
3829
  name: "PipRail",
@@ -4003,7 +4226,12 @@ function paymentTools(client) {
4003
4226
  url: { type: "string", description: "Full URL of the resource to list." },
4004
4227
  name: { type: "string", description: "Display name (defaults to the host)." },
4005
4228
  description: { type: "string", description: "What the resource offers." },
4006
- priceUsd: { type: "number", description: "Advertised price in USD (metadata)." }
4229
+ priceUsd: { type: "number", description: "Advertised price in USD (metadata)." },
4230
+ network: {
4231
+ type: "string",
4232
+ description: "Network slug to advertise, e.g. 'base' (defaults to the paying chain). Set it when registering from a multi-chain wallet so the listing names the right chain."
4233
+ },
4234
+ asset: { type: "string", description: "Payment asset symbol, e.g. 'USDC' (metadata)." }
4007
4235
  },
4008
4236
  required: ["url"],
4009
4237
  additionalProperties: false
@@ -4013,6 +4241,8 @@ function paymentTools(client) {
4013
4241
  if (typeof args.name === "string") opts.name = args.name;
4014
4242
  if (typeof args.description === "string") opts.description = args.description;
4015
4243
  if (typeof args.priceUsd === "number") opts.priceUsd = args.priceUsd;
4244
+ if (typeof args.network === "string") opts.network = args.network;
4245
+ if (typeof args.asset === "string") opts.asset = args.asset;
4016
4246
  const outcomes = await client.register(String(args.url), opts);
4017
4247
  return { outcomes };
4018
4248
  }
@@ -4808,25 +5038,49 @@ function normaliseHeader(value) {
4808
5038
 
4809
5039
  // src/facilitators.ts
4810
5040
  var KNOWN_FACILITATORS = {
4811
- // PayAI keyless (no API key), sponsors the gas. Verified 2026-06-14 against
4812
- // https://facilitator.payai.network/supported (exact · eip155:8453) + the live demo.
5041
+ // Base (eip155:8453). Every entry is keyless and LIVE-settled by us (a real EIP-3009
5042
+ // exact payment, buyer paid zero ETH) — not just a /supported read on the dated day.
4813
5043
  "eip155:8453": [
4814
5044
  {
4815
5045
  url: "https://facilitator.payai.network",
4816
5046
  keyless: true,
4817
5047
  schemes: ["exact"],
4818
5048
  settles: ["eip3009"],
4819
- note: "PayAI \u2014 keyless, sponsors gas (Base USDC EIP-3009)"
5049
+ note: "PayAI \u2014 keyless, sponsors gas (Base USDC EIP-3009). Verified 2026-06-14 (/supported + live demo)."
5050
+ },
5051
+ {
5052
+ url: "https://facilitator.xpay.sh",
5053
+ keyless: true,
5054
+ schemes: ["exact"],
5055
+ settles: ["eip3009"],
5056
+ note: "xpay \u2014 keyless, zero-fee, sponsors gas. LIVE-settled on Base 2026-06-15 (tx 0x2273d5\u2026)."
4820
5057
  }
4821
5058
  ],
4822
- // PayAI on Solana keyless fee-payer sponsor for the SVM exact rail. Verified 2026-06-14.
5059
+ // Solana (mainnet-beta). Keyless fee-payer sponsors for the SVM exact rail, each LIVE-settled
5060
+ // by us (a real SPL TransferChecked, buyer paid zero SOL) on the dated day — beyond a /supported
5061
+ // read. Daydreams + Questflow are intentionally ABSENT: their /supported is public but /verify
5062
+ // returns 401 (an API key is required), so they are not keyless for settlement.
4823
5063
  "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": [
4824
5064
  {
4825
5065
  url: "https://facilitator.payai.network",
4826
5066
  keyless: true,
4827
5067
  schemes: ["exact"],
4828
5068
  settles: ["svm"],
4829
- note: "PayAI \u2014 keyless fee-payer sponsor (Solana SPL SVM)"
5069
+ note: "PayAI \u2014 keyless fee-payer sponsor (Solana SPL SVM). LIVE-settled 2026-06-14 (tx 4dL8jRKH\u2026)."
5070
+ },
5071
+ {
5072
+ url: "https://pay.openfacilitator.io",
5073
+ keyless: true,
5074
+ schemes: ["exact"],
5075
+ settles: ["svm"],
5076
+ note: "OpenFacilitator \u2014 keyless (no signup), fee-payer sponsor. LIVE-settled on Solana 2026-06-15 (tx 5BabDtX\u2026)."
5077
+ },
5078
+ {
5079
+ url: "https://facilitator.corbits.dev",
5080
+ keyless: true,
5081
+ schemes: ["exact"],
5082
+ settles: ["svm"],
5083
+ note: "Corbits \u2014 keyless, Solana-first fee-payer sponsor. LIVE-settled on Solana 2026-06-15 (tx BCreYer\u2026)."
4830
5084
  }
4831
5085
  ]
4832
5086
  };
@@ -4960,6 +5214,7 @@ export {
4960
5214
  KNOWN_FACILITATORS,
4961
5215
  MaxRetriesExceededError,
4962
5216
  MissingDriverError,
5217
+ MultiChainPayer,
4963
5218
  NoCompatibleAcceptError,
4964
5219
  NonReplayableBodyError,
4965
5220
  PERMIT2_ADDRESS,
@@ -5006,6 +5261,7 @@ export {
5006
5261
  evaluatePolicy,
5007
5262
  explainDecline,
5008
5263
  facilitatorCoverage,
5264
+ fetchAcross,
5009
5265
  firstKeylessFacilitator,
5010
5266
  formatSpendReport,
5011
5267
  getDirectoryInfo,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piprail/sdk",
3
- "version": "1.23.0",
3
+ "version": "1.25.0",
4
4
  "description": "Accept x402 crypto payments across 29 chains — every major EVM chain plus Solana, TON, Tron, NEAR, Sui, Aptos, Algorand, Stellar & XRPL — in a couple of lines. No backend, no database, no fee; payments settle straight to your wallet.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",