@aifinpay/agent 0.3.0-alpha.5 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -42,6 +42,31 @@ const EVM_CHAIN_OBJECTS = {
42
42
  optimism,
43
43
  base,
44
44
  };
45
+ // Mainnet USDC SPL mint on Solana (Circle native, not Wormhole-wrapped USDCet)
46
+ const USDC_SOLANA_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
47
+ // Native Polygon USDC ERC20 (Circle-native, not bridged USDC.e)
48
+ const USDC_POLYGON_ERC20 = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359";
49
+ // Minimal ERC20 balanceOf ABI fragment
50
+ const ERC20_BALANCE_OF_ABI = [
51
+ {
52
+ type: "function",
53
+ name: "balanceOf",
54
+ stateMutability: "view",
55
+ inputs: [{ type: "address", name: "owner" }],
56
+ outputs: [{ type: "uint256" }],
57
+ },
58
+ ];
59
+ // B2BSplitter.treasury() view — used to route the ipCreator royalty slot
60
+ // when the bridge challenge doesn't name a recipient.
61
+ const SPLITTER_TREASURY_ABI = [
62
+ {
63
+ type: "function",
64
+ name: "treasury",
65
+ stateMutability: "view",
66
+ inputs: [],
67
+ outputs: [{ type: "address" }],
68
+ },
69
+ ];
45
70
  // ── Errors ─────────────────────────────────────────────────────────────────
46
71
  export class ProviderUnknownError extends AiFinPayError {
47
72
  }
@@ -131,9 +156,8 @@ export class AiFinPayAgent {
131
156
  ?? "https://api.mainnet-beta.solana.com";
132
157
  // Optional RPC overrides for non-Polygon EVM chains used in bridge flows.
133
158
  // Falls back to viem's chain.rpcUrls.default if not provided.
134
- const evmOpts = opts;
135
- if (evmOpts.evmRpcUrls)
136
- this.evmRpcUrls = evmOpts.evmRpcUrls;
159
+ if (opts.evmRpcUrls)
160
+ this.evmRpcUrls = opts.evmRpcUrls;
137
161
  if (this.polygonRpc)
138
162
  this.evmRpcUrls.polygon = this.polygonRpc;
139
163
  }
@@ -193,9 +217,11 @@ export class AiFinPayAgent {
193
217
  * chains.
194
218
  *
195
219
  * Solana key = nacl.sign.keyPair.fromSeed(seed)
196
- * EVM key = keccak256(seed)[:32] (independent path; not BIP-44
197
- * compatible — see design notes
198
- * in 23 - Unified SDK Design).
220
+ * EVM key = SHA-256("aifinpay:evm:v1\0" || seed)
221
+ * (domain-separated; independent path; not BIP-44 — see
222
+ * design notes in 23 - Unified SDK Design. This is the
223
+ * canonical derivation both SDKs implement — the Python
224
+ * SDK mirrors it byte-for-byte.)
199
225
  *
200
226
  * TODO(phase-1): replace with BIP-39/BIP-44 derivation
201
227
  * m/44'/501'/0'/0' (Solana) + m/44'/60'/0'/0/0 (EVM) once the
@@ -215,12 +241,21 @@ export class AiFinPayAgent {
215
241
  return new AiFinPayAgent(inner, evmAccount, opts);
216
242
  }
217
243
  /**
218
- * Legacy: load the Solana side from an existing keypair, then either
219
- * generate a fresh EVM key or import one with `attachEvmKey`.
244
+ * Legacy: load the Solana side from an existing keypair. The EVM key is
245
+ * derived deterministically from the Solana secret's 32-byte seed
246
+ * (domain-separated SHA-256, same path as `fromSeed`) unless an explicit
247
+ * `opts.evmPrivateKey` is provided.
248
+ *
249
+ * Why deterministic: a random key here would change the EVM address on
250
+ * every process restart — anything sent to the previous address becomes
251
+ * unrecoverable because the key was never persisted or printed. With
252
+ * derivation, the same AIFINPAY_AGENT_SECRET always reproduces the same
253
+ * EVM address (this is what the MCP server relies on).
220
254
  */
221
255
  static async fromSolanaSecret(secretB58, opts = {}) {
222
256
  const inner = Agent.fromSecretB58(secretB58, opts);
223
- const evmKey = opts.evmPrivateKey ?? generatePrivateKey();
257
+ const evmKey = opts.evmPrivateKey
258
+ ?? ("0x" + bytesToHex(crypto32(inner.secretKey.subarray(0, 32))));
224
259
  const evmAccount = privateKeyToAccount(evmKey);
225
260
  return new AiFinPayAgent(inner, evmAccount, opts);
226
261
  }
@@ -251,6 +286,194 @@ export class AiFinPayAgent {
251
286
  }
252
287
  return hit;
253
288
  }
289
+ // ── Discovery + routing (Gateway) ───────────────────────────────────────────
290
+ //
291
+ // The vision: an agent asks AiFinPay for a *capability* ("search this",
292
+ // "voice this") and AiFinPay finds the best provider, pays, and returns the
293
+ // result. Provider selection is server-side (`/api/registry/best`) so the
294
+ // ranking is identical whether you call via the SDK, MCP, or raw HTTP. These
295
+ // methods are the convenience layer over those endpoints.
296
+ /**
297
+ * Browse the provider catalog. Filter by `category` (e.g. "search",
298
+ * "inference", "image", "speech") and/or free-text `q`. Each entry carries
299
+ * live connectivity (`status`, `latency_ms`) so you can rank client-side too.
300
+ *
301
+ * Note: this discovers *services* (the marketplace). To discover *agents*
302
+ * published on the public network, use `search()`.
303
+ */
304
+ async discover(opts = {}) {
305
+ const base = this.inner.baseUrl;
306
+ const params = new URLSearchParams();
307
+ if (opts.category)
308
+ params.set("category", opts.category);
309
+ if (opts.q)
310
+ params.set("q", opts.q);
311
+ const r = await fetch(`${base}/api/registry?${params.toString()}`);
312
+ if (!r.ok)
313
+ throw new AiFinPayError(`discover ${base} → ${r.status}`);
314
+ const j = (await r.json());
315
+ const list = j.providers ?? [];
316
+ return opts.limit != null ? list.slice(0, Math.max(0, opts.limit)) : list;
317
+ }
318
+ /**
319
+ * Automatic provider selection. Asks the gateway for the single best
320
+ * available provider in a category (ranked by price, latency, liveness,
321
+ * on-chain trust). Throws `ProviderUnknownError` when nothing payable
322
+ * matches. Pass `maxPriceUsd` to cap the candidate set.
323
+ */
324
+ async pickProvider(category, opts = {}) {
325
+ const base = this.inner.baseUrl;
326
+ const params = new URLSearchParams();
327
+ if (category)
328
+ params.set("category", category);
329
+ if (opts.q)
330
+ params.set("q", opts.q);
331
+ if (opts.maxPriceUsd != null)
332
+ params.set("max_price_usd", String(opts.maxPriceUsd));
333
+ const r = await fetch(`${base}/api/registry/best?${params.toString()}`);
334
+ if (r.status === 404) {
335
+ throw new ProviderUnknownError(`No available provider for category "${category}"${opts.q ? ` matching "${opts.q}"` : ""}`);
336
+ }
337
+ if (!r.ok)
338
+ throw new AiFinPayError(`pickProvider ${base} → ${r.status}`);
339
+ const j = (await r.json());
340
+ if (!j.best)
341
+ throw new ProviderUnknownError(`No best provider returned for "${category}"`);
342
+ return j.best;
343
+ }
344
+ /**
345
+ * Capability-first paid call: pick the best provider for `category`, pay it,
346
+ * and return the upstream response — the "agent talks only to AiFinPay" flow.
347
+ * Pass `opts.provider` to skip routing and force a specific provider.
348
+ * Resolves to `null` only when a budget cap is hit in "skip" mode (see call()).
349
+ */
350
+ async capability(category, body, opts = {}) {
351
+ let providerName = opts.provider;
352
+ if (!providerName) {
353
+ const chosen = await this.pickProvider(category, { q: opts.q, maxPriceUsd: opts.maxPriceUsd });
354
+ providerName = chosen.name || chosen.slug;
355
+ }
356
+ return this.call({
357
+ provider: providerName,
358
+ body,
359
+ cost: opts.cost,
360
+ chain: opts.chain,
361
+ method: opts.method,
362
+ timeoutMs: opts.timeoutMs,
363
+ signal: opts.signal,
364
+ });
365
+ }
366
+ /**
367
+ * Web search via the best available search provider.
368
+ *
369
+ * Named `webSearch` (not `search`) on purpose: `search()` already searches
370
+ * the public *agent* network directory and is part of the published 1.0 API.
371
+ * `webSearch()` is the marketplace capability the vision calls `search()`.
372
+ */
373
+ async webSearch(query, opts = {}) {
374
+ const body = typeof query === "string" ? { query } : query;
375
+ return this.capability("search", body, opts);
376
+ }
377
+ /** Image generation via the best available image provider. */
378
+ async image(prompt, opts = {}) {
379
+ const body = typeof prompt === "string" ? { prompt } : prompt;
380
+ return this.capability("image", body, opts);
381
+ }
382
+ /** Text-to-speech via the best available speech provider. */
383
+ async voice(text, opts = {}) {
384
+ const body = typeof text === "string" ? { input: text } : text;
385
+ return this.capability("speech", body, opts);
386
+ }
387
+ /** LLM inference via the best available inference/compute provider. */
388
+ async infer(body, opts = {}) {
389
+ return this.capability("inference", body, opts);
390
+ }
391
+ // ── Network directory ─────────────────────────────────────────────────────
392
+ /**
393
+ * Publish this agent to the public AiFinPay network so other agents can
394
+ * discover and call it. Self-sovereign: proves control of the EVM address by
395
+ * signing a one-time nonce with the agent's own key (EIP-191) — no partner
396
+ * account needed.
397
+ */
398
+ async register(opts) {
399
+ const base = this.inner.baseUrl;
400
+ const addr = this.evmAddress.toLowerCase();
401
+ const nonce = await this.networkNonce();
402
+ const message = `AiFinPay-network-publish:polygon:${addr}:${nonce}`;
403
+ const signature = await this.evmAccount.signMessage({ message });
404
+ const r = await fetch(`${base}/api/network/agents/${addr}/publish`, {
405
+ method: "POST",
406
+ headers: { "content-type": "application/json" },
407
+ body: JSON.stringify({
408
+ name: opts.name,
409
+ description: opts.description ?? null,
410
+ endpoint: opts.endpoint,
411
+ capabilities: opts.capabilities ?? [],
412
+ pricing: opts.pricing
413
+ ? { per_call: opts.pricing.perCall, currency: opts.pricing.currency ?? "USDC" }
414
+ : null,
415
+ nonce,
416
+ signature,
417
+ }),
418
+ });
419
+ const j = (await r.json().catch(() => ({})));
420
+ if (!r.ok || !j.ok || !j.agent) {
421
+ throw new AiFinPayError(`network publish failed: ${j.error ?? r.status}`);
422
+ }
423
+ return j.agent;
424
+ }
425
+ /** Remove this agent from the public network directory (same signature proof). */
426
+ async unregister() {
427
+ const base = this.inner.baseUrl;
428
+ const addr = this.evmAddress.toLowerCase();
429
+ const nonce = await this.networkNonce();
430
+ const message = `AiFinPay-network-unpublish:polygon:${addr}:${nonce}`;
431
+ const signature = await this.evmAccount.signMessage({ message });
432
+ const r = await fetch(`${base}/api/network/agents/${addr}/unpublish`, {
433
+ method: "POST",
434
+ headers: { "content-type": "application/json" },
435
+ body: JSON.stringify({ nonce, signature }),
436
+ });
437
+ if (!r.ok) {
438
+ const j = (await r.json().catch(() => ({})));
439
+ throw new AiFinPayError(`network unpublish failed: ${j.error ?? r.status}`);
440
+ }
441
+ }
442
+ /**
443
+ * Search the public network for agents advertising a capability or matching a
444
+ * free-text query. Pass a bare string to search by capability
445
+ * (`agent.search("weather")`) or an object for finer control. Returns the
446
+ * directory entries; invoking one (pay + call) lands in a later release.
447
+ */
448
+ async search(query) {
449
+ const base = this.inner.baseUrl;
450
+ const params = new URLSearchParams();
451
+ if (typeof query === "string") {
452
+ params.set("capability", query);
453
+ }
454
+ else {
455
+ if (query.capability)
456
+ params.set("capability", query.capability);
457
+ if (query.q)
458
+ params.set("q", query.q);
459
+ if (query.limit)
460
+ params.set("limit", String(query.limit));
461
+ }
462
+ const r = await fetch(`${base}/api/network/agents?${params.toString()}`);
463
+ if (!r.ok)
464
+ throw new AiFinPayError(`network search ${base} → ${r.status}`);
465
+ const j = (await r.json());
466
+ return j.agents ?? [];
467
+ }
468
+ async networkNonce() {
469
+ const r = await fetch(`${this.inner.baseUrl}/api/network/nonce`);
470
+ if (!r.ok)
471
+ throw new AiFinPayError(`network nonce → ${r.status}`);
472
+ const { nonce } = (await r.json());
473
+ if (!nonce)
474
+ throw new AiFinPayError("network nonce: empty response");
475
+ return nonce;
476
+ }
254
477
  // ── Budget caps ─────────────────────────────────────────────────────────
255
478
  setBudget(caps) {
256
479
  this.budgetCaps = caps;
@@ -285,6 +508,38 @@ export class AiFinPayAgent {
285
508
  getSpend24h() {
286
509
  return this.spend24h.total24h();
287
510
  }
511
+ /**
512
+ * Sanity-check the USD estimate of an on-chain challenge amount against
513
+ * the declared cost and the per-call cap BEFORE signing the tx.
514
+ *
515
+ * The estimate uses env-priced MATIC/SOL (AIFINPAY_MATIC_USD /
516
+ * AIFINPAY_SOL_USD — same stopgap as balance()), so it is approximate.
517
+ * A 2x + $0.05 tolerance absorbs oracle drift while still catching the
518
+ * dangerous case: a bridge that quotes $0.01 in the registry and then
519
+ * demands 100x in the 402 challenge.
520
+ *
521
+ * Returns false (skip) instead of throwing when on_limit_exceeded="skip".
522
+ */
523
+ guardChallengeAmount(estUsd, declaredCost, providerName) {
524
+ if (!Number.isFinite(estUsd) || estUsd <= 0)
525
+ return true; // can't estimate — don't block
526
+ const limits = [];
527
+ if (declaredCost > 0)
528
+ limits.push(Math.max(declaredCost * 2, declaredCost + 0.05));
529
+ if (this.budgetCaps.per_call_usd !== undefined)
530
+ limits.push(this.budgetCaps.per_call_usd);
531
+ if (!limits.length)
532
+ return true; // no cap declared anywhere — caller opted out
533
+ const limit = Math.min(...limits);
534
+ if (estUsd <= limit)
535
+ return true;
536
+ const err = new BudgetCapExceededError("per_call", `bridge ${providerName} challenge demands ≈$${estUsd.toFixed(4)} on-chain, ` +
537
+ `above the allowed $${limit.toFixed(4)} (declared cost/per-call cap). ` +
538
+ `Set AIFINPAY_MATIC_USD / AIFINPAY_SOL_USD for a tighter estimate.`);
539
+ if ((this.budgetCaps.on_limit_exceeded ?? "throw") === "skip")
540
+ return false;
541
+ throw err;
542
+ }
288
543
  // ── Routing — chain selection ───────────────────────────────────────────
289
544
  /**
290
545
  * Chain selection heuristic — see Obsidian/23 - Unified SDK Design §Routing.
@@ -419,7 +674,10 @@ export class AiFinPayAgent {
419
674
  async call(opts) {
420
675
  const provider = await this.resolveProvider(opts.provider);
421
676
  const chain = this.pickChain(provider, opts);
422
- const cost = opts.cost ?? provider.price_usd;
677
+ // Registry may carry price_usd: null — normalise to a finite number so a
678
+ // null/NaN cost can't poison the SpendTracker and silently disable caps.
679
+ const rawCost = opts.cost ?? provider.price_usd;
680
+ const cost = typeof rawCost === "number" && Number.isFinite(rawCost) ? rawCost : 0;
423
681
  const withinBudget = this.checkBudget(cost);
424
682
  if (!withinBudget)
425
683
  return null;
@@ -439,20 +697,26 @@ export class AiFinPayAgent {
439
697
  return "/run";
440
698
  if (provider.service_type === "analytics")
441
699
  return "/query";
700
+ if (provider.service_type === "image")
701
+ return "/images/generations";
702
+ if (provider.service_type === "speech")
703
+ return "/audio/speech";
442
704
  return "/";
443
705
  })();
444
706
  const buildInit = (extraHeaders = {}) => ({
445
707
  method: opts.method ?? "POST",
446
708
  headers: { "content-type": "application/json", ...extraHeaders },
447
709
  body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
448
- signal: opts.signal,
710
+ // CallOptions.timeoutMs was previously declared but never applied.
711
+ signal: opts.signal
712
+ ?? (opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined),
449
713
  });
450
714
  // 1. Initial unauthenticated POST → expect 402 challenge from the bridge.
451
715
  const fullUrl = url.replace(/\/$/, "") + path;
452
716
  const initialResp = await this.inner.fetchImpl(fullUrl, buildInit());
453
717
  if (initialResp.status !== 402) {
454
718
  // Bridge didn't ask for payment — pass response through unchanged.
455
- this.spend24h.add(cost);
719
+ // Nothing was paid, so the call does NOT count against budget caps.
456
720
  if (this.telemetry)
457
721
  this.reportTelemetry({ kind: "call", provider: provider.name, chain, cost, free: true });
458
722
  return initialResp;
@@ -472,6 +736,17 @@ export class AiFinPayAgent {
472
736
  `BRIDGE_MERCHANT_SOLANA on the bridge service.`);
473
737
  }
474
738
  const ps = challenge.pay_solana;
739
+ // Guard: the bridge controls the challenge — verify the demanded
740
+ // amount is in the same ballpark as the declared cost / caps before
741
+ // signing anything. Stops a misquoting bridge from draining the agent.
742
+ {
743
+ const lamports = Number(ps.total_lamports ?? ps.merchant_amount_lamports);
744
+ const solUsd = parseFloat(process.env.AIFINPAY_SOL_USD ?? "200");
745
+ const estUsd = (Number.isFinite(lamports) ? lamports / 1e9 : 0) * solUsd;
746
+ const guarded = this.guardChallengeAmount(estUsd, cost, provider.name);
747
+ if (!guarded)
748
+ return null;
749
+ }
475
750
  const solTxSig = await this.submitSolanaB2BPayWithSplit(ps);
476
751
  const paidResp = await this.inner.fetchImpl(fullUrl, buildInit({
477
752
  "x-solana-tx": solTxSig,
@@ -484,6 +759,12 @@ export class AiFinPayAgent {
484
759
  this.spend24h.add(cost);
485
760
  if (this.telemetry)
486
761
  this.reportTelemetry({ kind: "call", provider: provider.name, chain, cost, tx: solTxSig });
762
+ // @internal — attach settlement metadata for consumers (MCP, telemetry
763
+ // dashboards) that need to surface the tx hash without an extra RPC
764
+ // call. Response headers are read-only after construction so we use
765
+ // an instance property + cast on the read side.
766
+ paidResp.aifinpayTx = solTxSig;
767
+ paidResp.aifinpayChain = "solana";
487
768
  return paidResp;
488
769
  }
489
770
  // ── Polygon branch (B2BSplitter.payMatic atomic split, legacy default) ──
@@ -491,7 +772,24 @@ export class AiFinPayAgent {
491
772
  throw new X402Error(`bridge ${provider.name} returned 402 but no pay_matic block — only legacy AiFinPay/Coinbase facilitators not yet wired into AiFinPayAgent.call()`);
492
773
  }
493
774
  const pm = challenge.pay_matic;
775
+ // Guard: same ballpark check as the Solana branch — never sign for an
776
+ // amount wildly above the declared cost / per-call cap.
777
+ {
778
+ const wei = Number(pm.total_wei);
779
+ const maticUsd = parseFloat(process.env.AIFINPAY_MATIC_USD ?? "0.70");
780
+ const estUsd = (Number.isFinite(wei) ? wei / 1e18 : 0) * maticUsd;
781
+ const guarded = this.guardChallengeAmount(estUsd, cost, provider.name);
782
+ if (!guarded)
783
+ return null;
784
+ }
494
785
  // 2. Submit B2BSplitter.payMatic on Polygon mainnet.
786
+ // ipCreator routing: prefer the challenge's explicit ip_creator; else
787
+ // route the royalty slot to the splitter's treasury (mirrors the Solana
788
+ // branch). Passing address(0) would skip the transfer and permanently
789
+ // strand the 1bp inside B2BSplitter — the contract has no sweep function.
790
+ const ipCreator = pm.ip_creator
791
+ ?? await this.splitterTreasury(pm.splitter)
792
+ ?? "0x0000000000000000000000000000000000000000";
495
793
  const { publicClient, walletClient } = this.polygonClients();
496
794
  const txHash = await walletClient.writeContract({
497
795
  address: pm.splitter,
@@ -499,7 +797,7 @@ export class AiFinPayAgent {
499
797
  functionName: "payMatic",
500
798
  args: [
501
799
  pm.merchant_wallet,
502
- "0x0000000000000000000000000000000000000000",
800
+ ipCreator,
503
801
  pm.order_id,
504
802
  ],
505
803
  value: BigInt(pm.total_wei),
@@ -523,6 +821,9 @@ export class AiFinPayAgent {
523
821
  this.spend24h.add(cost);
524
822
  if (this.telemetry)
525
823
  this.reportTelemetry({ kind: "call", provider: provider.name, chain, cost, tx: txHash });
824
+ // @internal — see Solana branch above for rationale on property-attach.
825
+ paidResp.aifinpayTx = txHash;
826
+ paidResp.aifinpayChain = "polygon";
526
827
  return paidResp;
527
828
  }
528
829
  // ── Solana b2b_pay_with_split — build, sign, send via @solana/web3.js ───
@@ -606,13 +907,17 @@ export class AiFinPayAgent {
606
907
  const solUsd = parseFloat(process.env.AIFINPAY_SOL_USD ?? "200");
607
908
  const polygon = await this.fetchPolygonNative().catch(() => 0);
608
909
  const solana = await this.fetchSolanaNative().catch(() => 0);
910
+ const solana_usdc = await this.fetchSolanaUsdc().catch(() => 0);
911
+ const polygon_usdc = await this.fetchPolygonUsdc().catch(() => 0);
609
912
  const polygonUsd = polygon * maticUsd;
610
913
  const solanaUsd = solana * solUsd;
914
+ const solanaUsdcUsd = solana_usdc; // USDC ≈ $1
915
+ const polygonUsdcUsd = polygon_usdc; // USDC ≈ $1
611
916
  return {
612
- agent_balance_usd: polygonUsd + solanaUsd,
917
+ agent_balance_usd: polygonUsd + solanaUsd + solanaUsdcUsd + polygonUsdcUsd,
613
918
  chains: {
614
- solana: { sol: solana, usdc: 0, msecco_balance: 0 },
615
- polygon: { matic: polygon, usdc: 0, msecco_balance: 0 },
919
+ solana: { sol: solana, usdc: solana_usdc, msecco_balance: 0 },
920
+ polygon: { matic: polygon, usdc: polygon_usdc, msecco_balance: 0 },
616
921
  },
617
922
  spend_24h_usd: this.spend24h.total24h(),
618
923
  budget_caps: this.budgetCaps,
@@ -627,8 +932,9 @@ export class AiFinPayAgent {
627
932
  return Number(wei) / 1e18;
628
933
  }
629
934
  async fetchSolanaNative() {
630
- // Use raw JSON-RPC to avoid pulling @solana/web3.js as a dep.
631
- const rpc = process.env.AIFINPAY_SOLANA_RPC || "https://api.mainnet.solana.com";
935
+ // Raw JSON-RPC; honours the constructor's solanaRpc option (previously
936
+ // re-read env here with a different default endpoint).
937
+ const rpc = this.solanaRpc;
632
938
  const r = await this.inner.fetchImpl(rpc, {
633
939
  method: "POST",
634
940
  headers: { "content-type": "application/json" },
@@ -645,6 +951,91 @@ export class AiFinPayAgent {
645
951
  const lamports = j.result?.value ?? 0;
646
952
  return lamports / 1e9;
647
953
  }
954
+ /**
955
+ * Sum SPL USDC balances across all token accounts owned by the agent's
956
+ * Solana pubkey. Uses raw JSON-RPC `getTokenAccountsByOwner` so we don't
957
+ * pull `@solana/spl-token` as a dep. Returns 0 (never throws) when the
958
+ * RPC is unreachable or returns nothing — `balance()` must remain a
959
+ * non-blocking introspection call.
960
+ */
961
+ async fetchSolanaUsdc() {
962
+ const rpc = this.solanaRpc;
963
+ try {
964
+ const r = await this.inner.fetchImpl(rpc, {
965
+ method: "POST",
966
+ headers: { "content-type": "application/json" },
967
+ body: JSON.stringify({
968
+ jsonrpc: "2.0",
969
+ id: 1,
970
+ method: "getTokenAccountsByOwner",
971
+ params: [
972
+ this.solanaAddress,
973
+ { mint: USDC_SOLANA_MINT },
974
+ { encoding: "jsonParsed" },
975
+ ],
976
+ }),
977
+ });
978
+ if (!r.ok)
979
+ return 0;
980
+ const j = (await r.json());
981
+ // Sum all USDC token accounts (usually just one, but be safe)
982
+ const accounts = j.result?.value ?? [];
983
+ let total = 0;
984
+ for (const acc of accounts) {
985
+ const ui = acc.account?.data?.parsed?.info?.tokenAmount?.uiAmount;
986
+ if (typeof ui === "number")
987
+ total += ui;
988
+ }
989
+ return total;
990
+ }
991
+ catch {
992
+ return 0; // never block balance() on Solana RPC issues
993
+ }
994
+ }
995
+ // Cache: splitter address → treasury address (constant per deployment).
996
+ splitterTreasuryCache = new Map();
997
+ /** Read + cache B2BSplitter.treasury(). Returns null on RPC failure. */
998
+ async splitterTreasury(splitter) {
999
+ const cached = this.splitterTreasuryCache.get(splitter.toLowerCase());
1000
+ if (cached)
1001
+ return cached;
1002
+ try {
1003
+ const { publicClient } = this.polygonClients();
1004
+ const treasury = await publicClient.readContract({
1005
+ address: splitter,
1006
+ abi: SPLITTER_TREASURY_ABI,
1007
+ functionName: "treasury",
1008
+ });
1009
+ if (!treasury || treasury === "0x0000000000000000000000000000000000000000")
1010
+ return null;
1011
+ this.splitterTreasuryCache.set(splitter.toLowerCase(), treasury);
1012
+ return treasury;
1013
+ }
1014
+ catch {
1015
+ return null;
1016
+ }
1017
+ }
1018
+ /**
1019
+ * Read the native Polygon USDC (Circle-issued, 6 decimals) balance for the
1020
+ * agent's EVM address via the existing viem publicClient. Returns 0
1021
+ * (never throws) on RPC failure — see fetchSolanaUsdc rationale.
1022
+ */
1023
+ async fetchPolygonUsdc() {
1024
+ try {
1025
+ const { publicClient } = this.polygonClients();
1026
+ const raw = await publicClient.readContract({
1027
+ address: USDC_POLYGON_ERC20,
1028
+ abi: ERC20_BALANCE_OF_ABI,
1029
+ functionName: "balanceOf",
1030
+ args: [this.evmAccount.address],
1031
+ });
1032
+ // USDC has 6 decimals on Polygon
1033
+ return Number(raw) / 1e6;
1034
+ }
1035
+ catch {
1036
+ return 0;
1037
+ }
1038
+ }
648
1039
  // ── Reputation ──────────────────────────────────────────────────────────
649
1040
  /**
650
1041
  * TODO(phase-4 / phase-5): query operator backend reputation engine.
@@ -695,15 +1086,10 @@ function bytesToHex(b) {
695
1086
  * before audit-grade derivation lands.
696
1087
  */
697
1088
  function crypto32(seed) {
698
- // Lazy require to avoid pulling node:crypto into bundlers that don't need it.
699
- const c = globalThis.crypto;
700
- if (c?.subtle) {
701
- // Browser / modern Node — but subtle is async, and we're sync here.
702
- // Fall through to the node:crypto path.
703
- }
704
- // eslint-disable-next-line @typescript-eslint/no-require-imports
705
- const nodeCrypto = require("node:crypto");
706
- const h = nodeCrypto.createHash("sha256");
1089
+ // Must stay sync (constructors call it). Uses the createHash imported at
1090
+ // the top of this file — a bare `require()` is a ReferenceError here
1091
+ // because this package ships as ESM ("type": "module").
1092
+ const h = createHash("sha256");
707
1093
  h.update("aifinpay:evm:v1\0");
708
1094
  h.update(seed);
709
1095
  return new Uint8Array(h.digest());