agentwallet-mcp 1.10.9 → 1.12.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 +28 -10
- package/build/index.js +316 -112
- package/build/local-wallet.d.ts +26 -0
- package/build/local-wallet.js +43 -0
- package/build/token-risk.d.ts +19 -0
- package/build/token-risk.js +127 -0
- package/build/x402-eip3009.d.ts +171 -0
- package/build/x402-eip3009.js +207 -0
- package/build/x402-permit2.d.ts +124 -0
- package/build/x402-permit2.js +93 -0
- package/package.json +2 -2
package/build/local-wallet.js
CHANGED
|
@@ -18,6 +18,8 @@ import { readFileSync } from 'node:fs';
|
|
|
18
18
|
import { createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, formatUnits, } from 'viem';
|
|
19
19
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
20
20
|
import { lookupTrustedDecimals } from './x402-payment.js';
|
|
21
|
+
import { typedDataFor } from './x402-eip3009.js';
|
|
22
|
+
import { uptoTypedData } from './x402-permit2.js';
|
|
21
23
|
/* ── Key loading ─────────────────────────────────────────────────── */
|
|
22
24
|
/**
|
|
23
25
|
* Read the key once at startup. Kept module-private: nothing exports the key
|
|
@@ -306,3 +308,44 @@ export function localWalletRecord() {
|
|
|
306
308
|
note: 'Signed in-process. The private key is never sent to AgentWallet servers.',
|
|
307
309
|
};
|
|
308
310
|
}
|
|
311
|
+
/* ── x402 "exact": EIP-3009 authorization signed in-process ──────── */
|
|
312
|
+
/**
|
|
313
|
+
* Sign a TransferWithAuthorization for an x402 payment. Nothing is broadcast;
|
|
314
|
+
* the resource server's facilitator settles it on-chain. The amount is put
|
|
315
|
+
* through the same AGENTWALLET_MAX_TX_TOKEN guard as a transfer, because that
|
|
316
|
+
* is exactly what the authorization lets someone else execute.
|
|
317
|
+
*/
|
|
318
|
+
export async function localSignAuthorization(chainId, asset, domain, auth) {
|
|
319
|
+
if (!/^0x[a-fA-F0-9]{40}$/.test(asset))
|
|
320
|
+
throw new Error(`Local mode needs an EVM token address for x402, got "${asset}".`);
|
|
321
|
+
if (!/^0x[a-fA-F0-9]{40}$/.test(auth.to))
|
|
322
|
+
throw new Error(`Local mode needs an EVM payTo address for x402, got "${auth.to}".`);
|
|
323
|
+
if (!/^\d+$/.test(auth.value))
|
|
324
|
+
throw new Error(`x402 authorization value must be integer base units, got "${auth.value}".`);
|
|
325
|
+
const calldata = ('0xa9059cbb'
|
|
326
|
+
+ auth.to.slice(2).toLowerCase().padStart(64, '0')
|
|
327
|
+
+ BigInt(auth.value).toString(16).padStart(64, '0'));
|
|
328
|
+
await assertWithinTokenCap(chainId, asset, calldata);
|
|
329
|
+
const from = getLocalAddress();
|
|
330
|
+
const signature = await getLocalAccount().signTypedData(typedDataFor(chainId, asset, domain.name, domain.version, { ...auth, from }));
|
|
331
|
+
return { signature, from, mode: 'local' };
|
|
332
|
+
}
|
|
333
|
+
/** Read-only eth_call against the local RPC, same shape as the hosted /eth-call route. */
|
|
334
|
+
export async function localEthCall(chainId, to, data) {
|
|
335
|
+
const client = createPublicClient({ transport: http(resolveRpcUrl(chainId)) });
|
|
336
|
+
const r = await client.call({ to, data });
|
|
337
|
+
return { result: r.data ?? '0x' };
|
|
338
|
+
}
|
|
339
|
+
/** x402 "upto": sign a Permit2 max-authorization in-process. The maximum goes through the token cap like a transfer would. */
|
|
340
|
+
export async function localSignPermit2Upto(chainId, auth) {
|
|
341
|
+
const token = auth.permitted.token;
|
|
342
|
+
if (!/^0x[a-fA-F0-9]{40}$/.test(token))
|
|
343
|
+
throw new Error(`Local mode needs an EVM token address for x402 upto, got "${token}".`);
|
|
344
|
+
if (!/^\d+$/.test(auth.permitted.amount))
|
|
345
|
+
throw new Error(`x402 upto amount must be integer base units, got "${auth.permitted.amount}".`);
|
|
346
|
+
const calldata = ('0xa9059cbb' + auth.witness.to.slice(2).toLowerCase().padStart(64, '0') + BigInt(auth.permitted.amount).toString(16).padStart(64, '0'));
|
|
347
|
+
await assertWithinTokenCap(chainId, token, calldata);
|
|
348
|
+
const from = getLocalAddress();
|
|
349
|
+
const signature = await getLocalAccount().signTypedData(uptoTypedData(chainId, { ...auth, from }));
|
|
350
|
+
return { signature, from, mode: 'local' };
|
|
351
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type RiskLevel = 'low' | 'medium' | 'high' | 'unknown';
|
|
2
|
+
export interface TokenRisk {
|
|
3
|
+
level: RiskLevel;
|
|
4
|
+
score: number;
|
|
5
|
+
flags: string[];
|
|
6
|
+
notes: string[];
|
|
7
|
+
source: 'goplus' | 'onchain' | 'none';
|
|
8
|
+
holder_count?: number;
|
|
9
|
+
top_holder_percent?: number;
|
|
10
|
+
buy_tax?: number;
|
|
11
|
+
sell_tax?: number;
|
|
12
|
+
}
|
|
13
|
+
/** Turn a GoPlus token_security record into a verdict. Exported so it can be unit-tested offline. */
|
|
14
|
+
export declare function assessGoPlus(r: Record<string, unknown>): TokenRisk;
|
|
15
|
+
/**
|
|
16
|
+
* Assess a token. `ethCall` runs a read-only call and returns the hex result
|
|
17
|
+
* (used for the on-chain fallback: does the contract answer name() and decimals()?).
|
|
18
|
+
*/
|
|
19
|
+
export declare function assessTokenRisk(chainId: number, token: string, ethCall?: (to: string, data: string) => Promise<string>): Promise<TokenRisk>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset risk before an approval or a swap. The wallet layer knows the amount;
|
|
3
|
+
* it did not know the asset. This asks GoPlus Security's free token_security
|
|
4
|
+
* endpoint (no key) and, when that is unavailable, falls back to what the
|
|
5
|
+
* chain itself can tell us. Always a WARNING, never a block: the caller
|
|
6
|
+
* decides. Requested in agentwallet-mcp issue #13.
|
|
7
|
+
*/
|
|
8
|
+
import { safeFetch } from './ssrf-guard.js';
|
|
9
|
+
const GOPLUS_CHAINS = new Set([1, 56, 137, 8453, 42161, 10, 43114, 324, 59144, 534352, 25, 250, 100, 1101, 5000, 81457, 204]);
|
|
10
|
+
function num(v) {
|
|
11
|
+
if (v === undefined || v === null || v === '')
|
|
12
|
+
return null;
|
|
13
|
+
const n = Number(v);
|
|
14
|
+
return Number.isFinite(n) ? n : null;
|
|
15
|
+
}
|
|
16
|
+
function flag(v) { return v === '1' || v === 1 || v === true; }
|
|
17
|
+
/** Turn a GoPlus token_security record into a verdict. Exported so it can be unit-tested offline. */
|
|
18
|
+
export function assessGoPlus(r) {
|
|
19
|
+
const flags = [];
|
|
20
|
+
const notes = [];
|
|
21
|
+
let score = 0;
|
|
22
|
+
const add = (cond, name, pts, note) => { if (cond) {
|
|
23
|
+
flags.push(name);
|
|
24
|
+
score += pts;
|
|
25
|
+
if (note)
|
|
26
|
+
notes.push(note);
|
|
27
|
+
} };
|
|
28
|
+
add(flag(r.is_honeypot), 'honeypot', 100, 'GoPlus marks this token as a honeypot: it can be bought but not sold.');
|
|
29
|
+
add(flag(r.fake_token), 'fake_token', 100, 'Flagged as an imitation of a known token.');
|
|
30
|
+
add(flag(r.is_airdrop_scam), 'airdrop_scam', 80);
|
|
31
|
+
add(flag(r.cannot_sell_all), 'cannot_sell_all', 60);
|
|
32
|
+
add(flag(r.transfer_pausable), 'transfer_pausable', 30, 'The owner can pause transfers.');
|
|
33
|
+
add(flag(r.is_blacklisted), 'blacklist', 30, 'The contract has a blacklist function.');
|
|
34
|
+
add(flag(r.owner_change_balance), 'owner_change_balance', 80, 'The owner can change any balance.');
|
|
35
|
+
add(flag(r.hidden_owner), 'hidden_owner', 40);
|
|
36
|
+
add(flag(r.selfdestruct), 'selfdestruct', 40);
|
|
37
|
+
add(flag(r.is_mintable), 'mintable', 15);
|
|
38
|
+
add(flag(r.slippage_modifiable), 'tax_modifiable', 25, 'Buy/sell tax can be changed by the owner.');
|
|
39
|
+
add(flag(r.personal_slippage_modifiable), 'personal_tax', 40, 'The owner can set a different tax for specific addresses.');
|
|
40
|
+
add(flag(r.trading_cooldown), 'trading_cooldown', 10);
|
|
41
|
+
add(flag(r.external_call), 'external_call', 15);
|
|
42
|
+
add(r.is_open_source === '0', 'closed_source', 35, 'Contract source is not verified.');
|
|
43
|
+
add(flag(r.is_proxy), 'proxy', 10, 'Upgradeable proxy: the logic can change.');
|
|
44
|
+
const buy = num(r.buy_tax), sell = num(r.sell_tax);
|
|
45
|
+
if (buy !== null && buy > 0.1) {
|
|
46
|
+
flags.push('buy_tax_high');
|
|
47
|
+
score += 30;
|
|
48
|
+
}
|
|
49
|
+
if (sell !== null && sell > 0.1) {
|
|
50
|
+
flags.push('sell_tax_high');
|
|
51
|
+
score += 40;
|
|
52
|
+
}
|
|
53
|
+
const holders = num(r.holder_count);
|
|
54
|
+
if (holders !== null && holders < 50) {
|
|
55
|
+
flags.push('few_holders');
|
|
56
|
+
score += 25;
|
|
57
|
+
}
|
|
58
|
+
let top = 0;
|
|
59
|
+
const list = Array.isArray(r.holders) ? r.holders : [];
|
|
60
|
+
for (const h of list) {
|
|
61
|
+
const p = num(h.percent);
|
|
62
|
+
if (p !== null && !flag(h.is_contract) && !flag(h.is_locked) && p > top)
|
|
63
|
+
top = p;
|
|
64
|
+
}
|
|
65
|
+
if (top > 0.5) {
|
|
66
|
+
flags.push('top_holder_over_50pct');
|
|
67
|
+
score += 40;
|
|
68
|
+
}
|
|
69
|
+
else if (top > 0.2) {
|
|
70
|
+
flags.push('top_holder_over_20pct');
|
|
71
|
+
score += 15;
|
|
72
|
+
}
|
|
73
|
+
if (r.is_in_dex === '0') {
|
|
74
|
+
flags.push('no_dex_liquidity');
|
|
75
|
+
score += 20;
|
|
76
|
+
}
|
|
77
|
+
if (flag(r.trust_list)) {
|
|
78
|
+
notes.push('On GoPlus trust list (well-known token).');
|
|
79
|
+
score = Math.min(score, 5);
|
|
80
|
+
}
|
|
81
|
+
score = Math.min(100, score);
|
|
82
|
+
const level = score >= 60 ? 'high' : score >= 25 ? 'medium' : 'low';
|
|
83
|
+
const out = { level, score, flags, notes, source: 'goplus' };
|
|
84
|
+
if (holders !== null)
|
|
85
|
+
out.holder_count = holders;
|
|
86
|
+
if (top)
|
|
87
|
+
out.top_holder_percent = Math.round(top * 10000) / 100;
|
|
88
|
+
if (buy !== null)
|
|
89
|
+
out.buy_tax = buy;
|
|
90
|
+
if (sell !== null)
|
|
91
|
+
out.sell_tax = sell;
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Assess a token. `ethCall` runs a read-only call and returns the hex result
|
|
96
|
+
* (used for the on-chain fallback: does the contract answer name() and decimals()?).
|
|
97
|
+
*/
|
|
98
|
+
export async function assessTokenRisk(chainId, token, ethCall) {
|
|
99
|
+
const addr = token.toLowerCase();
|
|
100
|
+
if (GOPLUS_CHAINS.has(chainId)) {
|
|
101
|
+
try {
|
|
102
|
+
const res = await safeFetch(`https://api.gopluslabs.io/api/v1/token_security/${chainId}?contract_addresses=${addr}`, {
|
|
103
|
+
method: 'GET', headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(12_000),
|
|
104
|
+
});
|
|
105
|
+
const j = (await res.json());
|
|
106
|
+
const rec = j?.result?.[addr];
|
|
107
|
+
if (rec && Object.keys(rec).length > 0)
|
|
108
|
+
return assessGoPlus(rec);
|
|
109
|
+
}
|
|
110
|
+
catch { /* fall through to on-chain */ }
|
|
111
|
+
}
|
|
112
|
+
if (ethCall) {
|
|
113
|
+
try {
|
|
114
|
+
const name = await ethCall(token, '0x06fdde03');
|
|
115
|
+
const decimals = await ethCall(token, '0x313ce567');
|
|
116
|
+
const answers = Boolean(name && name !== '0x') && Boolean(decimals && decimals !== '0x');
|
|
117
|
+
return {
|
|
118
|
+
level: answers ? 'unknown' : 'high', score: answers ? 50 : 90,
|
|
119
|
+
flags: answers ? ['no_security_data'] : ['not_an_erc20'],
|
|
120
|
+
notes: [answers ? 'No security data available for this chain or token; only basic ERC-20 shape was confirmed.' : 'Contract did not answer name() and decimals(); it may not be an ERC-20 token at all.'],
|
|
121
|
+
source: 'onchain',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
catch { /* fall through */ }
|
|
125
|
+
}
|
|
126
|
+
return { level: 'unknown', score: 50, flags: ['no_security_data'], notes: ['No risk data could be fetched.'], source: 'none' };
|
|
127
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
export declare const TRANSFER_WITH_AUTHORIZATION_TYPES: {
|
|
2
|
+
readonly TransferWithAuthorization: readonly [{
|
|
3
|
+
readonly name: "from";
|
|
4
|
+
readonly type: "address";
|
|
5
|
+
}, {
|
|
6
|
+
readonly name: "to";
|
|
7
|
+
readonly type: "address";
|
|
8
|
+
}, {
|
|
9
|
+
readonly name: "value";
|
|
10
|
+
readonly type: "uint256";
|
|
11
|
+
}, {
|
|
12
|
+
readonly name: "validAfter";
|
|
13
|
+
readonly type: "uint256";
|
|
14
|
+
}, {
|
|
15
|
+
readonly name: "validBefore";
|
|
16
|
+
readonly type: "uint256";
|
|
17
|
+
}, {
|
|
18
|
+
readonly name: "nonce";
|
|
19
|
+
readonly type: "bytes32";
|
|
20
|
+
}];
|
|
21
|
+
};
|
|
22
|
+
/** One entry of a 402 "accepts" array, v1 or v2 (and AgentWallet's own legacy shape). */
|
|
23
|
+
export interface X402Requirement {
|
|
24
|
+
scheme: string;
|
|
25
|
+
network: string;
|
|
26
|
+
payTo: string;
|
|
27
|
+
asset?: string;
|
|
28
|
+
amount?: string;
|
|
29
|
+
maxAmountRequired?: string;
|
|
30
|
+
maxTimeoutSeconds?: number;
|
|
31
|
+
resource?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
mimeType?: string;
|
|
34
|
+
outputSchema?: Record<string, unknown>;
|
|
35
|
+
requiredDecimals?: number;
|
|
36
|
+
extra?: {
|
|
37
|
+
name?: string;
|
|
38
|
+
version?: string;
|
|
39
|
+
token?: string;
|
|
40
|
+
[k: string]: unknown;
|
|
41
|
+
};
|
|
42
|
+
[k: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
export interface X402PaymentRequired {
|
|
45
|
+
x402Version: number;
|
|
46
|
+
error?: string;
|
|
47
|
+
resource?: {
|
|
48
|
+
url: string;
|
|
49
|
+
description?: string;
|
|
50
|
+
mimeType?: string;
|
|
51
|
+
};
|
|
52
|
+
accepts: X402Requirement[];
|
|
53
|
+
extensions?: Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
export interface Eip3009Authorization {
|
|
56
|
+
from: `0x${string}`;
|
|
57
|
+
to: `0x${string}`;
|
|
58
|
+
value: string;
|
|
59
|
+
validAfter: string;
|
|
60
|
+
validBefore: string;
|
|
61
|
+
nonce: `0x${string}`;
|
|
62
|
+
}
|
|
63
|
+
export interface X402Settlement {
|
|
64
|
+
success?: boolean;
|
|
65
|
+
transaction?: string;
|
|
66
|
+
network?: string;
|
|
67
|
+
payer?: string;
|
|
68
|
+
amount?: string;
|
|
69
|
+
errorReason?: string;
|
|
70
|
+
errorMessage?: string;
|
|
71
|
+
[k: string]: unknown;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* AgentWallet's own paywalls (hifriendbot.com /x402/access/{id}) predate the
|
|
75
|
+
* spec's field names: no `asset`, token under `extra.token`, `requiredDecimals`
|
|
76
|
+
* present, and they verify a broadcast transfer by receipt. Anything with an
|
|
77
|
+
* `asset` is treated as standard x402.
|
|
78
|
+
*/
|
|
79
|
+
export declare function isLegacyAgentWalletAccept(a: X402Requirement): boolean;
|
|
80
|
+
/** Required amount in base units, whichever spelling the server used. */
|
|
81
|
+
export declare function requiredAmount(a: X402Requirement): string;
|
|
82
|
+
export declare function createNonce(): `0x${string}`;
|
|
83
|
+
/** Authorization window mirrors the reference client: valid from 10 minutes ago until maxTimeoutSeconds from now. */
|
|
84
|
+
export declare function buildAuthorization(from: string, req: X402Requirement, nowSeconds?: number, nonce?: `0x${string}`): Eip3009Authorization;
|
|
85
|
+
/** EIP-712 typed data for the authorization. `name`/`version` are the token's own domain values. */
|
|
86
|
+
export declare function typedDataFor(chainId: number, asset: string, name: string, version: string, auth: Eip3009Authorization): {
|
|
87
|
+
domain: {
|
|
88
|
+
name: string;
|
|
89
|
+
version: string;
|
|
90
|
+
chainId: number;
|
|
91
|
+
verifyingContract: `0x${string}`;
|
|
92
|
+
};
|
|
93
|
+
types: {
|
|
94
|
+
readonly TransferWithAuthorization: readonly [{
|
|
95
|
+
readonly name: "from";
|
|
96
|
+
readonly type: "address";
|
|
97
|
+
}, {
|
|
98
|
+
readonly name: "to";
|
|
99
|
+
readonly type: "address";
|
|
100
|
+
}, {
|
|
101
|
+
readonly name: "value";
|
|
102
|
+
readonly type: "uint256";
|
|
103
|
+
}, {
|
|
104
|
+
readonly name: "validAfter";
|
|
105
|
+
readonly type: "uint256";
|
|
106
|
+
}, {
|
|
107
|
+
readonly name: "validBefore";
|
|
108
|
+
readonly type: "uint256";
|
|
109
|
+
}, {
|
|
110
|
+
readonly name: "nonce";
|
|
111
|
+
readonly type: "bytes32";
|
|
112
|
+
}];
|
|
113
|
+
};
|
|
114
|
+
primaryType: "TransferWithAuthorization";
|
|
115
|
+
message: {
|
|
116
|
+
from: `0x${string}`;
|
|
117
|
+
to: `0x${string}`;
|
|
118
|
+
value: bigint;
|
|
119
|
+
validAfter: bigint;
|
|
120
|
+
validBefore: bigint;
|
|
121
|
+
nonce: `0x${string}`;
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Token EIP-712 domains we know without asking the chain. USDC's domain name
|
|
126
|
+
* differs by deployment ("USD Coin" on most mainnets, "USDC" on Base Sepolia),
|
|
127
|
+
* which is exactly the kind of thing that silently produces an unrecoverable
|
|
128
|
+
* signature, so only well-known deployments are listed; anything else is read
|
|
129
|
+
* from the contract's name()/version().
|
|
130
|
+
*/
|
|
131
|
+
export declare const KNOWN_TOKEN_DOMAINS: Record<string, {
|
|
132
|
+
name: string;
|
|
133
|
+
version: string;
|
|
134
|
+
}>;
|
|
135
|
+
export declare function knownTokenDomain(chainId: number, asset: string): {
|
|
136
|
+
name: string;
|
|
137
|
+
version: string;
|
|
138
|
+
} | null;
|
|
139
|
+
/** Decode a single ABI-encoded string return value (name()/version()). */
|
|
140
|
+
export declare function decodeAbiString(hex: string): string;
|
|
141
|
+
/** Build the payment payload the resource server expects for this x402 version. */
|
|
142
|
+
export declare function buildPaymentPayload(x402Version: number, req: X402Requirement, auth: Eip3009Authorization, signature: string, resource?: X402PaymentRequired['resource'], extensions?: Record<string, unknown>): Record<string, unknown>;
|
|
143
|
+
/** Same envelope for any scheme payload (EIP-3009 authorization, Permit2 authorization, tx hash). */
|
|
144
|
+
export declare function buildPaymentPayloadRaw(x402Version: number, req: X402Requirement, payload: Record<string, unknown>, resource?: X402PaymentRequired['resource'], extensions?: Record<string, unknown>): Record<string, unknown>;
|
|
145
|
+
/** 16-128 chars of [A-Za-z0-9_-], the shape the payment-identifier extension accepts. */
|
|
146
|
+
export declare function generatePaymentId(): string;
|
|
147
|
+
/**
|
|
148
|
+
* x402 v2 "payment-identifier" extension: the server may ask for a client-chosen
|
|
149
|
+
* id so it can de-duplicate retries. The 402's extensions are echoed back with
|
|
150
|
+
* info.id filled in. Other extensions pass through untouched.
|
|
151
|
+
*/
|
|
152
|
+
export declare function withPaymentIdentifier(extensions?: Record<string, unknown>, id?: string): Record<string, unknown> | null;
|
|
153
|
+
export declare function paymentHeaderFor(x402Version: number, payload: Record<string, unknown>): {
|
|
154
|
+
name: string;
|
|
155
|
+
value: string;
|
|
156
|
+
};
|
|
157
|
+
/** v2 servers put the requirements in PAYMENT-REQUIRED; v1 servers put them in the JSON body. */
|
|
158
|
+
export declare function parsePaymentRequired(getHeader: (name: string) => string | null | undefined, body: unknown): X402PaymentRequired | null;
|
|
159
|
+
export declare function parseSettlement(getHeader: (name: string) => string | null | undefined): X402Settlement | null;
|
|
160
|
+
/**
|
|
161
|
+
* Choose which accepts[] entry to pay. `exact` (EIP-3009, gasless for the
|
|
162
|
+
* payer) is preferred. `upto` is accepted when it carries the facilitator
|
|
163
|
+
* address the Permit2 witness must be bound to; an upto option without it is
|
|
164
|
+
* refused rather than approximated with an upfront transfer (issue #6).
|
|
165
|
+
* Returns the reason when nothing is payable.
|
|
166
|
+
*/
|
|
167
|
+
export declare function pickOption(accepts: X402Requirement[], resolveChainId: (network: string) => number | null, preferChain?: number): {
|
|
168
|
+
option: X402Requirement | null;
|
|
169
|
+
chainId: number | null;
|
|
170
|
+
reason?: string;
|
|
171
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402 "exact" scheme on EVM, done the way the spec says: an EIP-3009
|
|
3
|
+
* TransferWithAuthorization signed off-chain and handed to the resource server
|
|
4
|
+
* in the payment header. The facilitator settles it; the payer broadcasts
|
|
5
|
+
* nothing and pays no gas.
|
|
6
|
+
*
|
|
7
|
+
* Before v1.11.0 pay_x402 broadcast a plain ERC-20 transfer() and put the tx
|
|
8
|
+
* hash in the header. That only ever worked against AgentWallet's own paywalls
|
|
9
|
+
* (which verify by receipt); every standard x402 server rejected it (issue #7).
|
|
10
|
+
* The receipt-style flow is kept for those paywalls and detected by their
|
|
11
|
+
* shape, see isLegacyAgentWalletAccept().
|
|
12
|
+
*
|
|
13
|
+
* Wire formats, both supported (issue #7 endpoint speaks v1, newer servers v2):
|
|
14
|
+
* v1: 402 body {x402Version:1, accepts:[{maxAmountRequired, asset, payTo, network:"base", ...}]}
|
|
15
|
+
* request header X-PAYMENT: base64({x402Version:1, scheme, network, payload:{signature, authorization}})
|
|
16
|
+
* response header X-PAYMENT-RESPONSE
|
|
17
|
+
* v2: 402 header PAYMENT-REQUIRED: base64({x402Version:2, resource, accepts:[{amount, asset, payTo, network:"eip155:8453", ...}]})
|
|
18
|
+
* request header PAYMENT-SIGNATURE: base64({x402Version:2, resource, accepted, payload:{signature, authorization}})
|
|
19
|
+
* response header PAYMENT-RESPONSE
|
|
20
|
+
*/
|
|
21
|
+
import { randomBytes } from 'node:crypto';
|
|
22
|
+
import { isUptoPayable } from './x402-permit2.js';
|
|
23
|
+
export const TRANSFER_WITH_AUTHORIZATION_TYPES = {
|
|
24
|
+
TransferWithAuthorization: [
|
|
25
|
+
{ name: 'from', type: 'address' },
|
|
26
|
+
{ name: 'to', type: 'address' },
|
|
27
|
+
{ name: 'value', type: 'uint256' },
|
|
28
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
29
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
30
|
+
{ name: 'nonce', type: 'bytes32' },
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* AgentWallet's own paywalls (hifriendbot.com /x402/access/{id}) predate the
|
|
35
|
+
* spec's field names: no `asset`, token under `extra.token`, `requiredDecimals`
|
|
36
|
+
* present, and they verify a broadcast transfer by receipt. Anything with an
|
|
37
|
+
* `asset` is treated as standard x402.
|
|
38
|
+
*/
|
|
39
|
+
export function isLegacyAgentWalletAccept(a) {
|
|
40
|
+
if (a.asset)
|
|
41
|
+
return false;
|
|
42
|
+
return Boolean(a.extra?.token) || typeof a.requiredDecimals === 'number';
|
|
43
|
+
}
|
|
44
|
+
/** Required amount in base units, whichever spelling the server used. */
|
|
45
|
+
export function requiredAmount(a) {
|
|
46
|
+
const v = a.amount ?? a.maxAmountRequired ?? '';
|
|
47
|
+
return String(v);
|
|
48
|
+
}
|
|
49
|
+
export function createNonce() {
|
|
50
|
+
return ('0x' + randomBytes(32).toString('hex'));
|
|
51
|
+
}
|
|
52
|
+
const DEFAULT_TIMEOUT_SECONDS = 300;
|
|
53
|
+
/** Authorization window mirrors the reference client: valid from 10 minutes ago until maxTimeoutSeconds from now. */
|
|
54
|
+
export function buildAuthorization(from, req, nowSeconds = Math.floor(Date.now() / 1000), nonce = createNonce()) {
|
|
55
|
+
const value = requiredAmount(req);
|
|
56
|
+
if (!/^\d+$/.test(value))
|
|
57
|
+
throw new Error(`x402: invalid required amount "${value}" (expected integer base units).`);
|
|
58
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(req.payTo))
|
|
59
|
+
throw new Error(`x402: payTo is not an EVM address: "${req.payTo}".`);
|
|
60
|
+
const timeout = Number.isFinite(req.maxTimeoutSeconds) && req.maxTimeoutSeconds > 0
|
|
61
|
+
? Math.floor(req.maxTimeoutSeconds) : DEFAULT_TIMEOUT_SECONDS;
|
|
62
|
+
return {
|
|
63
|
+
from: from,
|
|
64
|
+
to: req.payTo,
|
|
65
|
+
value,
|
|
66
|
+
validAfter: String(nowSeconds - 600),
|
|
67
|
+
validBefore: String(nowSeconds + timeout),
|
|
68
|
+
nonce,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** EIP-712 typed data for the authorization. `name`/`version` are the token's own domain values. */
|
|
72
|
+
export function typedDataFor(chainId, asset, name, version, auth) {
|
|
73
|
+
return {
|
|
74
|
+
domain: { name, version, chainId, verifyingContract: asset },
|
|
75
|
+
types: TRANSFER_WITH_AUTHORIZATION_TYPES,
|
|
76
|
+
primaryType: 'TransferWithAuthorization',
|
|
77
|
+
message: {
|
|
78
|
+
from: auth.from,
|
|
79
|
+
to: auth.to,
|
|
80
|
+
value: BigInt(auth.value),
|
|
81
|
+
validAfter: BigInt(auth.validAfter),
|
|
82
|
+
validBefore: BigInt(auth.validBefore),
|
|
83
|
+
nonce: auth.nonce,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Token EIP-712 domains we know without asking the chain. USDC's domain name
|
|
89
|
+
* differs by deployment ("USD Coin" on most mainnets, "USDC" on Base Sepolia),
|
|
90
|
+
* which is exactly the kind of thing that silently produces an unrecoverable
|
|
91
|
+
* signature, so only well-known deployments are listed; anything else is read
|
|
92
|
+
* from the contract's name()/version().
|
|
93
|
+
*/
|
|
94
|
+
export const KNOWN_TOKEN_DOMAINS = {
|
|
95
|
+
'8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': { name: 'USD Coin', version: '2' }, // USDC Base
|
|
96
|
+
'84532:0x036cbd53842c5426634e7929541ec2318f3dcf7e': { name: 'USDC', version: '2' }, // USDC Base Sepolia
|
|
97
|
+
'1:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { name: 'USD Coin', version: '2' }, // USDC Ethereum
|
|
98
|
+
'137:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': { name: 'USD Coin', version: '2' }, // USDC Polygon (native)
|
|
99
|
+
'42161:0xaf88d065e77c8cc2239327c5edb3a432268e5831': { name: 'USD Coin', version: '2' }, // USDC Arbitrum
|
|
100
|
+
'10:0x0b2c639c533813f4aa9d7837caf62653d097ff85': { name: 'USD Coin', version: '2' }, // USDC Optimism
|
|
101
|
+
'43114:0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e': { name: 'USD Coin', version: '2' }, // USDC Avalanche
|
|
102
|
+
};
|
|
103
|
+
export function knownTokenDomain(chainId, asset) {
|
|
104
|
+
return KNOWN_TOKEN_DOMAINS[`${chainId}:${asset.toLowerCase()}`] ?? null;
|
|
105
|
+
}
|
|
106
|
+
/** Decode a single ABI-encoded string return value (name()/version()). */
|
|
107
|
+
export function decodeAbiString(hex) {
|
|
108
|
+
const h = (hex || '').replace(/^0x/, '');
|
|
109
|
+
if (h.length < 128)
|
|
110
|
+
return '';
|
|
111
|
+
const len = parseInt(h.slice(64, 128), 16);
|
|
112
|
+
if (!Number.isFinite(len) || len <= 0 || len > 256)
|
|
113
|
+
return '';
|
|
114
|
+
const bytes = Buffer.from(h.slice(128, 128 + len * 2), 'hex');
|
|
115
|
+
return bytes.toString('utf8');
|
|
116
|
+
}
|
|
117
|
+
/** Build the payment payload the resource server expects for this x402 version. */
|
|
118
|
+
export function buildPaymentPayload(x402Version, req, auth, signature, resource, extensions) {
|
|
119
|
+
return buildPaymentPayloadRaw(x402Version, req, { signature, authorization: auth }, resource, extensions);
|
|
120
|
+
}
|
|
121
|
+
/** Same envelope for any scheme payload (EIP-3009 authorization, Permit2 authorization, tx hash). */
|
|
122
|
+
export function buildPaymentPayloadRaw(x402Version, req, payload, resource, extensions) {
|
|
123
|
+
if (x402Version >= 2) {
|
|
124
|
+
const out = { x402Version, accepted: req, payload };
|
|
125
|
+
if (resource)
|
|
126
|
+
out.resource = resource;
|
|
127
|
+
const ext = withPaymentIdentifier(extensions);
|
|
128
|
+
if (ext)
|
|
129
|
+
out.extensions = ext;
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
return { x402Version: 1, scheme: req.scheme, network: req.network, payload };
|
|
133
|
+
}
|
|
134
|
+
/** 16-128 chars of [A-Za-z0-9_-], the shape the payment-identifier extension accepts. */
|
|
135
|
+
export function generatePaymentId() {
|
|
136
|
+
return 'agw_' + randomBytes(16).toString('hex');
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* x402 v2 "payment-identifier" extension: the server may ask for a client-chosen
|
|
140
|
+
* id so it can de-duplicate retries. The 402's extensions are echoed back with
|
|
141
|
+
* info.id filled in. Other extensions pass through untouched.
|
|
142
|
+
*/
|
|
143
|
+
export function withPaymentIdentifier(extensions, id) {
|
|
144
|
+
if (!extensions || typeof extensions !== 'object')
|
|
145
|
+
return null;
|
|
146
|
+
const out = { ...extensions };
|
|
147
|
+
const pid = out['payment-identifier'];
|
|
148
|
+
if (pid && typeof pid === 'object' && pid.info && typeof pid.info === 'object') {
|
|
149
|
+
out['payment-identifier'] = { ...pid, info: { ...pid.info, id: pid.info.id || id || generatePaymentId() } };
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
export function paymentHeaderFor(x402Version, payload) {
|
|
154
|
+
const value = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
|
|
155
|
+
return { name: x402Version >= 2 ? 'PAYMENT-SIGNATURE' : 'X-PAYMENT', value };
|
|
156
|
+
}
|
|
157
|
+
function decodeBase64Json(v) {
|
|
158
|
+
if (!v)
|
|
159
|
+
return null;
|
|
160
|
+
try {
|
|
161
|
+
const parsed = JSON.parse(Buffer.from(v.trim(), 'base64').toString('utf8'));
|
|
162
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** v2 servers put the requirements in PAYMENT-REQUIRED; v1 servers put them in the JSON body. */
|
|
169
|
+
export function parsePaymentRequired(getHeader, body) {
|
|
170
|
+
const fromHeader = decodeBase64Json(getHeader('PAYMENT-REQUIRED') ?? getHeader('payment-required'));
|
|
171
|
+
if (fromHeader && Array.isArray(fromHeader.accepts))
|
|
172
|
+
return fromHeader;
|
|
173
|
+
if (body && typeof body === 'object' && Array.isArray(body.accepts)) {
|
|
174
|
+
return body;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
export function parseSettlement(getHeader) {
|
|
179
|
+
return (decodeBase64Json(getHeader('PAYMENT-RESPONSE') ?? getHeader('payment-response'))
|
|
180
|
+
?? decodeBase64Json(getHeader('X-PAYMENT-RESPONSE') ?? getHeader('x-payment-response')));
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Choose which accepts[] entry to pay. `exact` (EIP-3009, gasless for the
|
|
184
|
+
* payer) is preferred. `upto` is accepted when it carries the facilitator
|
|
185
|
+
* address the Permit2 witness must be bound to; an upto option without it is
|
|
186
|
+
* refused rather than approximated with an upfront transfer (issue #6).
|
|
187
|
+
* Returns the reason when nothing is payable.
|
|
188
|
+
*/
|
|
189
|
+
export function pickOption(accepts, resolveChainId, preferChain) {
|
|
190
|
+
const onChain = accepts.filter(a => resolveChainId(a.network) !== null);
|
|
191
|
+
const exact = onChain.filter(a => a.scheme === 'exact');
|
|
192
|
+
const upto = onChain.filter(a => isUptoPayable(a));
|
|
193
|
+
const payable = [...exact, ...upto];
|
|
194
|
+
if (payable.length === 0) {
|
|
195
|
+
const schemes = Array.from(new Set(accepts.map(a => a.scheme))).join(', ') || 'none';
|
|
196
|
+
const reason = accepts.some(a => a.scheme === 'upto')
|
|
197
|
+
? 'This endpoint offers the x402 "upto" scheme without an extra.facilitatorAddress (or without an asset), so the ' +
|
|
198
|
+
'Permit2 authorization cannot be bound to a facilitator. AgentWallet will not approximate it with an upfront transfer. ' +
|
|
199
|
+
'Ask the endpoint operator to publish facilitatorAddress, or an "exact" option.'
|
|
200
|
+
: `No payable option: schemes offered were ${schemes}.`;
|
|
201
|
+
return { option: null, chainId: null, reason };
|
|
202
|
+
}
|
|
203
|
+
let option = payable[0];
|
|
204
|
+
if (preferChain)
|
|
205
|
+
option = payable.find(a => resolveChainId(a.network) === preferChain) ?? option;
|
|
206
|
+
return { option, chainId: resolveChainId(option.network) };
|
|
207
|
+
}
|