@aifinpay/agent 0.3.0-alpha.4 → 1.0.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.
@@ -14,10 +14,11 @@ import bs58 from "bs58";
14
14
  import { createHash } from "node:crypto";
15
15
  import { createPublicClient, createWalletClient, http, } from "viem";
16
16
  import { privateKeyToAccount, generatePrivateKey, } from "viem/accounts";
17
- import { polygon } from "viem/chains";
17
+ import { polygon, base, arbitrum, optimism, bsc, mainnet } from "viem/chains";
18
18
  import { Connection, Keypair, PublicKey, SystemProgram, Transaction, TransactionInstruction, sendAndConfirmTransaction, } from "@solana/web3.js";
19
19
  import { AiFinPayError, X402Error, } from "./errors.js";
20
20
  import { Agent } from "./agent.js";
21
+ import { bridgeQuote as crossChainQuote, bridgeExecute as crossChainExecute, bridgeWaitForArrival, USDC_NATIVE, } from "./crossChain.js";
21
22
  // ── B2BSplitter contract (deployed at 0xE34Fc0…8440 on Polygon mainnet) ──
22
23
  const SPLITTER_PAY_MATIC_ABI = [
23
24
  {
@@ -32,6 +33,40 @@ const SPLITTER_PAY_MATIC_ABI = [
32
33
  outputs: [],
33
34
  },
34
35
  ];
36
+ // ── EVM chain object lookup — viem chains keyed by our EvmChainName ─────
37
+ const EVM_CHAIN_OBJECTS = {
38
+ ethereum: mainnet,
39
+ polygon,
40
+ bsc,
41
+ arbitrum,
42
+ optimism,
43
+ base,
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
+ ];
35
70
  // ── Errors ─────────────────────────────────────────────────────────────────
36
71
  export class ProviderUnknownError extends AiFinPayError {
37
72
  }
@@ -104,6 +139,10 @@ export class AiFinPayAgent {
104
139
  telemetry;
105
140
  _polygonPublic;
106
141
  _polygonWallet;
142
+ // Multi-chain client cache for cross-chain orchestration (bridge flows).
143
+ // Keyed by EVM chain name (see crossChain.ts EVM_CHAINS).
144
+ _evmClients = new Map();
145
+ evmRpcUrls = {};
107
146
  constructor(inner, evmAccount, opts = {}) {
108
147
  this.inner = inner;
109
148
  this.evmAccount = evmAccount;
@@ -115,8 +154,16 @@ export class AiFinPayAgent {
115
154
  this.solanaRpc = opts.solanaRpc
116
155
  ?? process.env.AIFINPAY_SOLANA_RPC
117
156
  ?? "https://api.mainnet-beta.solana.com";
157
+ // Optional RPC overrides for non-Polygon EVM chains used in bridge flows.
158
+ // Falls back to viem's chain.rpcUrls.default if not provided.
159
+ if (opts.evmRpcUrls)
160
+ this.evmRpcUrls = opts.evmRpcUrls;
161
+ if (this.polygonRpc)
162
+ this.evmRpcUrls.polygon = this.polygonRpc;
118
163
  }
119
164
  // Lazy viem clients — only spun up when a Polygon flow runs.
165
+ // Kept as-is for the legacy call() Polygon path; cross-chain flows use
166
+ // evmClients() below which is generalised across all supported EVM chains.
120
167
  polygonClients() {
121
168
  if (!this._polygonPublic) {
122
169
  this._polygonPublic = createPublicClient({
@@ -133,6 +180,26 @@ export class AiFinPayAgent {
133
180
  }
134
181
  return { publicClient: this._polygonPublic, walletClient: this._polygonWallet };
135
182
  }
183
+ // Multi-EVM-chain client cache. Used by bridge orchestration to sign source
184
+ // and (optionally) dest transactions on chains other than Polygon. Picks
185
+ // viem's built-in chain definitions; an optional RPC override per chain
186
+ // can be supplied via opts.evmRpcUrls.
187
+ evmClients(name) {
188
+ const cached = this._evmClients.get(name);
189
+ if (cached)
190
+ return cached;
191
+ const chain = EVM_CHAIN_OBJECTS[name];
192
+ if (!chain) {
193
+ throw new AiFinPayError(`evmClients: unsupported EVM chain "${name}"`);
194
+ }
195
+ const rpcUrl = this.evmRpcUrls[name];
196
+ const transport = rpcUrl ? http(rpcUrl) : http();
197
+ const publicClient = createPublicClient({ chain, transport });
198
+ const walletClient = createWalletClient({ chain, transport, account: this.evmAccount });
199
+ const pair = { publicClient, walletClient };
200
+ this._evmClients.set(name, pair);
201
+ return pair;
202
+ }
136
203
  // ── Constructors ─────────────────────────────────────────────────────────
137
204
  /**
138
205
  * Generate a fresh agent — new Solana keypair + new EVM keypair.
@@ -150,9 +217,11 @@ export class AiFinPayAgent {
150
217
  * chains.
151
218
  *
152
219
  * Solana key = nacl.sign.keyPair.fromSeed(seed)
153
- * EVM key = keccak256(seed)[:32] (independent path; not BIP-44
154
- * compatible — see design notes
155
- * 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.)
156
225
  *
157
226
  * TODO(phase-1): replace with BIP-39/BIP-44 derivation
158
227
  * m/44'/501'/0'/0' (Solana) + m/44'/60'/0'/0/0 (EVM) once the
@@ -172,12 +241,21 @@ export class AiFinPayAgent {
172
241
  return new AiFinPayAgent(inner, evmAccount, opts);
173
242
  }
174
243
  /**
175
- * Legacy: load the Solana side from an existing keypair, then either
176
- * 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).
177
254
  */
178
255
  static async fromSolanaSecret(secretB58, opts = {}) {
179
256
  const inner = Agent.fromSecretB58(secretB58, opts);
180
- const evmKey = opts.evmPrivateKey ?? generatePrivateKey();
257
+ const evmKey = opts.evmPrivateKey
258
+ ?? ("0x" + bytesToHex(crypto32(inner.secretKey.subarray(0, 32))));
181
259
  const evmAccount = privateKeyToAccount(evmKey);
182
260
  return new AiFinPayAgent(inner, evmAccount, opts);
183
261
  }
@@ -208,6 +286,92 @@ export class AiFinPayAgent {
208
286
  }
209
287
  return hit;
210
288
  }
289
+ // ── Network directory ─────────────────────────────────────────────────────
290
+ /**
291
+ * Publish this agent to the public AiFinPay network so other agents can
292
+ * discover and call it. Self-sovereign: proves control of the EVM address by
293
+ * signing a one-time nonce with the agent's own key (EIP-191) — no partner
294
+ * account needed.
295
+ */
296
+ async register(opts) {
297
+ const base = this.inner.baseUrl;
298
+ const addr = this.evmAddress.toLowerCase();
299
+ const nonce = await this.networkNonce();
300
+ const message = `AiFinPay-network-publish:polygon:${addr}:${nonce}`;
301
+ const signature = await this.evmAccount.signMessage({ message });
302
+ const r = await fetch(`${base}/api/network/agents/${addr}/publish`, {
303
+ method: "POST",
304
+ headers: { "content-type": "application/json" },
305
+ body: JSON.stringify({
306
+ name: opts.name,
307
+ description: opts.description ?? null,
308
+ endpoint: opts.endpoint,
309
+ capabilities: opts.capabilities ?? [],
310
+ pricing: opts.pricing
311
+ ? { per_call: opts.pricing.perCall, currency: opts.pricing.currency ?? "USDC" }
312
+ : null,
313
+ nonce,
314
+ signature,
315
+ }),
316
+ });
317
+ const j = (await r.json().catch(() => ({})));
318
+ if (!r.ok || !j.ok || !j.agent) {
319
+ throw new AiFinPayError(`network publish failed: ${j.error ?? r.status}`);
320
+ }
321
+ return j.agent;
322
+ }
323
+ /** Remove this agent from the public network directory (same signature proof). */
324
+ async unregister() {
325
+ const base = this.inner.baseUrl;
326
+ const addr = this.evmAddress.toLowerCase();
327
+ const nonce = await this.networkNonce();
328
+ const message = `AiFinPay-network-unpublish:polygon:${addr}:${nonce}`;
329
+ const signature = await this.evmAccount.signMessage({ message });
330
+ const r = await fetch(`${base}/api/network/agents/${addr}/unpublish`, {
331
+ method: "POST",
332
+ headers: { "content-type": "application/json" },
333
+ body: JSON.stringify({ nonce, signature }),
334
+ });
335
+ if (!r.ok) {
336
+ const j = (await r.json().catch(() => ({})));
337
+ throw new AiFinPayError(`network unpublish failed: ${j.error ?? r.status}`);
338
+ }
339
+ }
340
+ /**
341
+ * Search the public network for agents advertising a capability or matching a
342
+ * free-text query. Pass a bare string to search by capability
343
+ * (`agent.search("weather")`) or an object for finer control. Returns the
344
+ * directory entries; invoking one (pay + call) lands in a later release.
345
+ */
346
+ async search(query) {
347
+ const base = this.inner.baseUrl;
348
+ const params = new URLSearchParams();
349
+ if (typeof query === "string") {
350
+ params.set("capability", query);
351
+ }
352
+ else {
353
+ if (query.capability)
354
+ params.set("capability", query.capability);
355
+ if (query.q)
356
+ params.set("q", query.q);
357
+ if (query.limit)
358
+ params.set("limit", String(query.limit));
359
+ }
360
+ const r = await fetch(`${base}/api/network/agents?${params.toString()}`);
361
+ if (!r.ok)
362
+ throw new AiFinPayError(`network search ${base} → ${r.status}`);
363
+ const j = (await r.json());
364
+ return j.agents ?? [];
365
+ }
366
+ async networkNonce() {
367
+ const r = await fetch(`${this.inner.baseUrl}/api/network/nonce`);
368
+ if (!r.ok)
369
+ throw new AiFinPayError(`network nonce → ${r.status}`);
370
+ const { nonce } = (await r.json());
371
+ if (!nonce)
372
+ throw new AiFinPayError("network nonce: empty response");
373
+ return nonce;
374
+ }
211
375
  // ── Budget caps ─────────────────────────────────────────────────────────
212
376
  setBudget(caps) {
213
377
  this.budgetCaps = caps;
@@ -242,6 +406,38 @@ export class AiFinPayAgent {
242
406
  getSpend24h() {
243
407
  return this.spend24h.total24h();
244
408
  }
409
+ /**
410
+ * Sanity-check the USD estimate of an on-chain challenge amount against
411
+ * the declared cost and the per-call cap BEFORE signing the tx.
412
+ *
413
+ * The estimate uses env-priced MATIC/SOL (AIFINPAY_MATIC_USD /
414
+ * AIFINPAY_SOL_USD — same stopgap as balance()), so it is approximate.
415
+ * A 2x + $0.05 tolerance absorbs oracle drift while still catching the
416
+ * dangerous case: a bridge that quotes $0.01 in the registry and then
417
+ * demands 100x in the 402 challenge.
418
+ *
419
+ * Returns false (skip) instead of throwing when on_limit_exceeded="skip".
420
+ */
421
+ guardChallengeAmount(estUsd, declaredCost, providerName) {
422
+ if (!Number.isFinite(estUsd) || estUsd <= 0)
423
+ return true; // can't estimate — don't block
424
+ const limits = [];
425
+ if (declaredCost > 0)
426
+ limits.push(Math.max(declaredCost * 2, declaredCost + 0.05));
427
+ if (this.budgetCaps.per_call_usd !== undefined)
428
+ limits.push(this.budgetCaps.per_call_usd);
429
+ if (!limits.length)
430
+ return true; // no cap declared anywhere — caller opted out
431
+ const limit = Math.min(...limits);
432
+ if (estUsd <= limit)
433
+ return true;
434
+ const err = new BudgetCapExceededError("per_call", `bridge ${providerName} challenge demands ≈$${estUsd.toFixed(4)} on-chain, ` +
435
+ `above the allowed $${limit.toFixed(4)} (declared cost/per-call cap). ` +
436
+ `Set AIFINPAY_MATIC_USD / AIFINPAY_SOL_USD for a tighter estimate.`);
437
+ if ((this.budgetCaps.on_limit_exceeded ?? "throw") === "skip")
438
+ return false;
439
+ throw err;
440
+ }
245
441
  // ── Routing — chain selection ───────────────────────────────────────────
246
442
  /**
247
443
  * Chain selection heuristic — see Obsidian/23 - Unified SDK Design §Routing.
@@ -302,6 +498,64 @@ export class AiFinPayAgent {
302
498
  async deposit(_usd, _opts) {
303
499
  throw new AiFinPayError("deposit() not implemented yet — see migration phase 1.5");
304
500
  }
501
+ // ── Cross-chain orchestration (Phase 1.5a: EVM↔EVM via LiFi) ────────────
502
+ //
503
+ // We orchestrate; we do not custody. The agent signs every step.
504
+ // See Obsidian/21 - Unified Agent Economy non-goals.
505
+ //
506
+ // Typical flow for "agent has USDC on Base, merchant wants USDC on Polygon":
507
+ // const quote = await agent.bridgeQuote({ fromChain: "base", toChain: "polygon", amount_usdc: 1.0 });
508
+ // const receipt = await agent.bridgeExecute(quote); // sign source tx
509
+ // const arrival = await agent.bridgeWaitForArrival(receipt.source_tx); // wait for dest
510
+ // // ...then proceed with agent.call({ provider, chain: "polygon" })
511
+ /**
512
+ * Quote a USDC-denominated cross-chain transfer via LiFi.
513
+ *
514
+ * The convenience overload takes a USD amount and the chain names; the
515
+ * full overload accepts arbitrary token addresses for non-USDC corridors.
516
+ * The agent's EVM address is used as both `fromAddress` and `toAddress`
517
+ * unless overridden — bridging to a different recipient is rare for agents.
518
+ */
519
+ async bridgeQuote(opts) {
520
+ const fromToken = opts.fromToken ?? USDC_NATIVE[opts.fromChain];
521
+ const toToken = opts.toToken ?? USDC_NATIVE[opts.toChain];
522
+ const fromAmount = opts.fromAmount
523
+ ?? (opts.amount_usdc !== undefined
524
+ ? Math.round(opts.amount_usdc * 1e6).toString() // USDC has 6 decimals
525
+ : undefined);
526
+ if (!fromAmount) {
527
+ throw new AiFinPayError(`bridgeQuote: provide either amount_usdc OR fromAmount (in base units)`);
528
+ }
529
+ const quoteOpts = {
530
+ fromChain: opts.fromChain,
531
+ toChain: opts.toChain,
532
+ fromToken,
533
+ toToken,
534
+ fromAmount,
535
+ fromAddress: this.evmAccount.address,
536
+ toAddress: opts.toAddress ?? this.evmAccount.address,
537
+ slippage: opts.slippage,
538
+ };
539
+ return crossChainQuote(quoteOpts);
540
+ }
541
+ /**
542
+ * Execute a previously-fetched bridge quote. Signs and submits the source
543
+ * transaction with the agent's EVM key on the SOURCE chain. Returns once
544
+ * source-side inclusion is confirmed; dest-side arrival is async — call
545
+ * `bridgeWaitForArrival(receipt.source_tx)` if you need to block on it.
546
+ */
547
+ async bridgeExecute(quote) {
548
+ const { publicClient, walletClient } = this.evmClients(quote.from.chain);
549
+ return crossChainExecute(quote, walletClient, publicClient);
550
+ }
551
+ /**
552
+ * Wait for the bridge to deliver on the destination chain. Wraps LiFi's
553
+ * /status polling; timeout default is 30 minutes (Circle CCTP can take
554
+ * 15-25 min on Polygon side). Throws AiFinPayError on timeout.
555
+ */
556
+ async bridgeWaitForArrival(sourceTxHash, opts = {}) {
557
+ return bridgeWaitForArrival(sourceTxHash, opts);
558
+ }
305
559
  // ── Call ────────────────────────────────────────────────────────────────
306
560
  /**
307
561
  * High-level paid call. Resolves the provider, picks a chain, builds the
@@ -318,7 +572,10 @@ export class AiFinPayAgent {
318
572
  async call(opts) {
319
573
  const provider = await this.resolveProvider(opts.provider);
320
574
  const chain = this.pickChain(provider, opts);
321
- const cost = opts.cost ?? provider.price_usd;
575
+ // Registry may carry price_usd: null — normalise to a finite number so a
576
+ // null/NaN cost can't poison the SpendTracker and silently disable caps.
577
+ const rawCost = opts.cost ?? provider.price_usd;
578
+ const cost = typeof rawCost === "number" && Number.isFinite(rawCost) ? rawCost : 0;
322
579
  const withinBudget = this.checkBudget(cost);
323
580
  if (!withinBudget)
324
581
  return null;
@@ -344,14 +601,16 @@ export class AiFinPayAgent {
344
601
  method: opts.method ?? "POST",
345
602
  headers: { "content-type": "application/json", ...extraHeaders },
346
603
  body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
347
- signal: opts.signal,
604
+ // CallOptions.timeoutMs was previously declared but never applied.
605
+ signal: opts.signal
606
+ ?? (opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined),
348
607
  });
349
608
  // 1. Initial unauthenticated POST → expect 402 challenge from the bridge.
350
609
  const fullUrl = url.replace(/\/$/, "") + path;
351
610
  const initialResp = await this.inner.fetchImpl(fullUrl, buildInit());
352
611
  if (initialResp.status !== 402) {
353
612
  // Bridge didn't ask for payment — pass response through unchanged.
354
- this.spend24h.add(cost);
613
+ // Nothing was paid, so the call does NOT count against budget caps.
355
614
  if (this.telemetry)
356
615
  this.reportTelemetry({ kind: "call", provider: provider.name, chain, cost, free: true });
357
616
  return initialResp;
@@ -371,6 +630,17 @@ export class AiFinPayAgent {
371
630
  `BRIDGE_MERCHANT_SOLANA on the bridge service.`);
372
631
  }
373
632
  const ps = challenge.pay_solana;
633
+ // Guard: the bridge controls the challenge — verify the demanded
634
+ // amount is in the same ballpark as the declared cost / caps before
635
+ // signing anything. Stops a misquoting bridge from draining the agent.
636
+ {
637
+ const lamports = Number(ps.total_lamports ?? ps.merchant_amount_lamports);
638
+ const solUsd = parseFloat(process.env.AIFINPAY_SOL_USD ?? "200");
639
+ const estUsd = (Number.isFinite(lamports) ? lamports / 1e9 : 0) * solUsd;
640
+ const guarded = this.guardChallengeAmount(estUsd, cost, provider.name);
641
+ if (!guarded)
642
+ return null;
643
+ }
374
644
  const solTxSig = await this.submitSolanaB2BPayWithSplit(ps);
375
645
  const paidResp = await this.inner.fetchImpl(fullUrl, buildInit({
376
646
  "x-solana-tx": solTxSig,
@@ -383,6 +653,12 @@ export class AiFinPayAgent {
383
653
  this.spend24h.add(cost);
384
654
  if (this.telemetry)
385
655
  this.reportTelemetry({ kind: "call", provider: provider.name, chain, cost, tx: solTxSig });
656
+ // @internal — attach settlement metadata for consumers (MCP, telemetry
657
+ // dashboards) that need to surface the tx hash without an extra RPC
658
+ // call. Response headers are read-only after construction so we use
659
+ // an instance property + cast on the read side.
660
+ paidResp.aifinpayTx = solTxSig;
661
+ paidResp.aifinpayChain = "solana";
386
662
  return paidResp;
387
663
  }
388
664
  // ── Polygon branch (B2BSplitter.payMatic atomic split, legacy default) ──
@@ -390,7 +666,24 @@ export class AiFinPayAgent {
390
666
  throw new X402Error(`bridge ${provider.name} returned 402 but no pay_matic block — only legacy AiFinPay/Coinbase facilitators not yet wired into AiFinPayAgent.call()`);
391
667
  }
392
668
  const pm = challenge.pay_matic;
669
+ // Guard: same ballpark check as the Solana branch — never sign for an
670
+ // amount wildly above the declared cost / per-call cap.
671
+ {
672
+ const wei = Number(pm.total_wei);
673
+ const maticUsd = parseFloat(process.env.AIFINPAY_MATIC_USD ?? "0.70");
674
+ const estUsd = (Number.isFinite(wei) ? wei / 1e18 : 0) * maticUsd;
675
+ const guarded = this.guardChallengeAmount(estUsd, cost, provider.name);
676
+ if (!guarded)
677
+ return null;
678
+ }
393
679
  // 2. Submit B2BSplitter.payMatic on Polygon mainnet.
680
+ // ipCreator routing: prefer the challenge's explicit ip_creator; else
681
+ // route the royalty slot to the splitter's treasury (mirrors the Solana
682
+ // branch). Passing address(0) would skip the transfer and permanently
683
+ // strand the 1bp inside B2BSplitter — the contract has no sweep function.
684
+ const ipCreator = pm.ip_creator
685
+ ?? await this.splitterTreasury(pm.splitter)
686
+ ?? "0x0000000000000000000000000000000000000000";
394
687
  const { publicClient, walletClient } = this.polygonClients();
395
688
  const txHash = await walletClient.writeContract({
396
689
  address: pm.splitter,
@@ -398,7 +691,7 @@ export class AiFinPayAgent {
398
691
  functionName: "payMatic",
399
692
  args: [
400
693
  pm.merchant_wallet,
401
- "0x0000000000000000000000000000000000000000",
694
+ ipCreator,
402
695
  pm.order_id,
403
696
  ],
404
697
  value: BigInt(pm.total_wei),
@@ -422,6 +715,9 @@ export class AiFinPayAgent {
422
715
  this.spend24h.add(cost);
423
716
  if (this.telemetry)
424
717
  this.reportTelemetry({ kind: "call", provider: provider.name, chain, cost, tx: txHash });
718
+ // @internal — see Solana branch above for rationale on property-attach.
719
+ paidResp.aifinpayTx = txHash;
720
+ paidResp.aifinpayChain = "polygon";
425
721
  return paidResp;
426
722
  }
427
723
  // ── Solana b2b_pay_with_split — build, sign, send via @solana/web3.js ───
@@ -505,13 +801,17 @@ export class AiFinPayAgent {
505
801
  const solUsd = parseFloat(process.env.AIFINPAY_SOL_USD ?? "200");
506
802
  const polygon = await this.fetchPolygonNative().catch(() => 0);
507
803
  const solana = await this.fetchSolanaNative().catch(() => 0);
804
+ const solana_usdc = await this.fetchSolanaUsdc().catch(() => 0);
805
+ const polygon_usdc = await this.fetchPolygonUsdc().catch(() => 0);
508
806
  const polygonUsd = polygon * maticUsd;
509
807
  const solanaUsd = solana * solUsd;
808
+ const solanaUsdcUsd = solana_usdc; // USDC ≈ $1
809
+ const polygonUsdcUsd = polygon_usdc; // USDC ≈ $1
510
810
  return {
511
- agent_balance_usd: polygonUsd + solanaUsd,
811
+ agent_balance_usd: polygonUsd + solanaUsd + solanaUsdcUsd + polygonUsdcUsd,
512
812
  chains: {
513
- solana: { sol: solana, usdc: 0, msecco_balance: 0 },
514
- polygon: { matic: polygon, usdc: 0, msecco_balance: 0 },
813
+ solana: { sol: solana, usdc: solana_usdc, msecco_balance: 0 },
814
+ polygon: { matic: polygon, usdc: polygon_usdc, msecco_balance: 0 },
515
815
  },
516
816
  spend_24h_usd: this.spend24h.total24h(),
517
817
  budget_caps: this.budgetCaps,
@@ -526,8 +826,9 @@ export class AiFinPayAgent {
526
826
  return Number(wei) / 1e18;
527
827
  }
528
828
  async fetchSolanaNative() {
529
- // Use raw JSON-RPC to avoid pulling @solana/web3.js as a dep.
530
- const rpc = process.env.AIFINPAY_SOLANA_RPC || "https://api.mainnet.solana.com";
829
+ // Raw JSON-RPC; honours the constructor's solanaRpc option (previously
830
+ // re-read env here with a different default endpoint).
831
+ const rpc = this.solanaRpc;
531
832
  const r = await this.inner.fetchImpl(rpc, {
532
833
  method: "POST",
533
834
  headers: { "content-type": "application/json" },
@@ -544,6 +845,91 @@ export class AiFinPayAgent {
544
845
  const lamports = j.result?.value ?? 0;
545
846
  return lamports / 1e9;
546
847
  }
848
+ /**
849
+ * Sum SPL USDC balances across all token accounts owned by the agent's
850
+ * Solana pubkey. Uses raw JSON-RPC `getTokenAccountsByOwner` so we don't
851
+ * pull `@solana/spl-token` as a dep. Returns 0 (never throws) when the
852
+ * RPC is unreachable or returns nothing — `balance()` must remain a
853
+ * non-blocking introspection call.
854
+ */
855
+ async fetchSolanaUsdc() {
856
+ const rpc = this.solanaRpc;
857
+ try {
858
+ const r = await this.inner.fetchImpl(rpc, {
859
+ method: "POST",
860
+ headers: { "content-type": "application/json" },
861
+ body: JSON.stringify({
862
+ jsonrpc: "2.0",
863
+ id: 1,
864
+ method: "getTokenAccountsByOwner",
865
+ params: [
866
+ this.solanaAddress,
867
+ { mint: USDC_SOLANA_MINT },
868
+ { encoding: "jsonParsed" },
869
+ ],
870
+ }),
871
+ });
872
+ if (!r.ok)
873
+ return 0;
874
+ const j = (await r.json());
875
+ // Sum all USDC token accounts (usually just one, but be safe)
876
+ const accounts = j.result?.value ?? [];
877
+ let total = 0;
878
+ for (const acc of accounts) {
879
+ const ui = acc.account?.data?.parsed?.info?.tokenAmount?.uiAmount;
880
+ if (typeof ui === "number")
881
+ total += ui;
882
+ }
883
+ return total;
884
+ }
885
+ catch {
886
+ return 0; // never block balance() on Solana RPC issues
887
+ }
888
+ }
889
+ // Cache: splitter address → treasury address (constant per deployment).
890
+ splitterTreasuryCache = new Map();
891
+ /** Read + cache B2BSplitter.treasury(). Returns null on RPC failure. */
892
+ async splitterTreasury(splitter) {
893
+ const cached = this.splitterTreasuryCache.get(splitter.toLowerCase());
894
+ if (cached)
895
+ return cached;
896
+ try {
897
+ const { publicClient } = this.polygonClients();
898
+ const treasury = await publicClient.readContract({
899
+ address: splitter,
900
+ abi: SPLITTER_TREASURY_ABI,
901
+ functionName: "treasury",
902
+ });
903
+ if (!treasury || treasury === "0x0000000000000000000000000000000000000000")
904
+ return null;
905
+ this.splitterTreasuryCache.set(splitter.toLowerCase(), treasury);
906
+ return treasury;
907
+ }
908
+ catch {
909
+ return null;
910
+ }
911
+ }
912
+ /**
913
+ * Read the native Polygon USDC (Circle-issued, 6 decimals) balance for the
914
+ * agent's EVM address via the existing viem publicClient. Returns 0
915
+ * (never throws) on RPC failure — see fetchSolanaUsdc rationale.
916
+ */
917
+ async fetchPolygonUsdc() {
918
+ try {
919
+ const { publicClient } = this.polygonClients();
920
+ const raw = await publicClient.readContract({
921
+ address: USDC_POLYGON_ERC20,
922
+ abi: ERC20_BALANCE_OF_ABI,
923
+ functionName: "balanceOf",
924
+ args: [this.evmAccount.address],
925
+ });
926
+ // USDC has 6 decimals on Polygon
927
+ return Number(raw) / 1e6;
928
+ }
929
+ catch {
930
+ return 0;
931
+ }
932
+ }
547
933
  // ── Reputation ──────────────────────────────────────────────────────────
548
934
  /**
549
935
  * TODO(phase-4 / phase-5): query operator backend reputation engine.
@@ -594,15 +980,10 @@ function bytesToHex(b) {
594
980
  * before audit-grade derivation lands.
595
981
  */
596
982
  function crypto32(seed) {
597
- // Lazy require to avoid pulling node:crypto into bundlers that don't need it.
598
- const c = globalThis.crypto;
599
- if (c?.subtle) {
600
- // Browser / modern Node — but subtle is async, and we're sync here.
601
- // Fall through to the node:crypto path.
602
- }
603
- // eslint-disable-next-line @typescript-eslint/no-require-imports
604
- const nodeCrypto = require("node:crypto");
605
- const h = nodeCrypto.createHash("sha256");
983
+ // Must stay sync (constructors call it). Uses the createHash imported at
984
+ // the top of this file — a bare `require()` is a ReferenceError here
985
+ // because this package ships as ESM ("type": "module").
986
+ const h = createHash("sha256");
606
987
  h.update("aifinpay:evm:v1\0");
607
988
  h.update(seed);
608
989
  return new Uint8Array(h.digest());