@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.
- package/README.md +6 -6
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +4 -2
- package/dist/agent.js.map +1 -1
- package/dist/crossChain.d.ts +77 -0
- package/dist/crossChain.d.ts.map +1 -0
- package/dist/crossChain.js +143 -0
- package/dist/crossChain.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/unifiedAgent.d.ts +138 -5
- package/dist/unifiedAgent.d.ts.map +1 -1
- package/dist/unifiedAgent.js +406 -25
- package/dist/unifiedAgent.js.map +1 -1
- package/package.json +1 -1
package/dist/unifiedAgent.js
CHANGED
|
@@ -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 =
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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
|
|
176
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
514
|
-
polygon: { matic: polygon, usdc:
|
|
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
|
-
//
|
|
530
|
-
|
|
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
|
-
//
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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());
|