@bolloon/bolloon-agent 0.5.2 → 0.5.4
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 +4 -4
- package/dist/agents/chain/transfer-classify.js +99 -0
- package/dist/agents/chain/transfer-index.js +354 -0
- package/dist/agents/identity/address-binding.js +613 -0
- package/dist/agents/network-pulse.js +706 -27
- package/dist/agents/pi-sdk-tools.js +67 -15
- package/dist/agents/pi-sdk.js +7 -1
- package/dist/agents/session-store.js +144 -3
- package/dist/agents/wallet-tools.js +63 -0
- package/dist/agents/x402/direct-payment.js +689 -0
- package/dist/agents/x402/order-identity.js +408 -0
- package/dist/agents/x402/paid-info-store.js +17 -0
- package/dist/agents/x402/seller-signing.js +614 -0
- package/dist/agents/x402/seller-summary.js +398 -0
- package/dist/cli/identity-command.js +327 -2
- package/dist/cli/ink-app.js +79 -27
- package/dist/cli/input-history.js +119 -0
- package/dist/cli/loading-tui.js +66 -6
- package/dist/cli/log-gate.js +39 -1
- package/dist/cli/protocol-envelope.js +4 -0
- package/dist/cli/session-summary.js +129 -0
- package/dist/cli/session-tree.js +141 -0
- package/dist/cli/status-segments.js +70 -0
- package/dist/cli/stores.js +16 -0
- package/dist/cli/update-commands.js +41 -25
- package/dist/cli/wallet-store.js +159 -0
- package/dist/cli/wallet-table.js +46 -0
- package/dist/cli/x402-buyer-command.js +463 -0
- package/dist/cli/x402-seller-command.js +289 -0
- package/dist/cli-entry.js +43 -5
- package/dist/electron-build/agents/chain/transfer-classify.js +106 -0
- package/dist/electron-build/agents/chain/transfer-classify.js.map +1 -0
- package/dist/electron-build/agents/chain/transfer-index.js +400 -0
- package/dist/electron-build/agents/chain/transfer-index.js.map +1 -0
- package/dist/electron-build/agents/identity/address-binding.js +672 -0
- package/dist/electron-build/agents/identity/address-binding.js.map +1 -0
- package/dist/electron-build/agents/network-pulse.js +714 -28
- package/dist/electron-build/agents/network-pulse.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk-tools.js +68 -15
- package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk.js +7 -1
- package/dist/electron-build/agents/pi-sdk.js.map +1 -1
- package/dist/electron-build/agents/session-store.js +144 -3
- package/dist/electron-build/agents/session-store.js.map +1 -1
- package/dist/electron-build/agents/wallet-tools.js +102 -0
- package/dist/electron-build/agents/wallet-tools.js.map +1 -0
- package/dist/electron-build/agents/x402/direct-payment.js +744 -0
- package/dist/electron-build/agents/x402/direct-payment.js.map +1 -0
- package/dist/electron-build/agents/x402/order-identity.js +458 -0
- package/dist/electron-build/agents/x402/order-identity.js.map +1 -0
- package/dist/electron-build/agents/x402/paid-info-protocol.js.map +1 -1
- package/dist/electron-build/agents/x402/paid-info-store.js +18 -0
- package/dist/electron-build/agents/x402/paid-info-store.js.map +1 -1
- package/dist/electron-build/agents/x402/seller-signing.js +682 -0
- package/dist/electron-build/agents/x402/seller-signing.js.map +1 -0
- package/dist/electron-build/cli/wallet-store.js +200 -0
- package/dist/electron-build/cli/wallet-store.js.map +1 -0
- package/dist/electron-build/llm/pi-ai.js +25 -0
- package/dist/electron-build/llm/pi-ai.js.map +1 -1
- package/dist/electron-build/utils/auto-update.js +2 -2
- package/dist/electron-build/utils/auto-update.js.map +1 -1
- package/dist/electron-build/utils/runtime-bootstrap.js +1 -1
- package/dist/electron-build/utils/runtime-bootstrap.js.map +1 -1
- package/dist/electron-build/utils/update-manager.js +3 -3
- package/dist/electron-build/utils/update-manager.js.map +1 -1
- package/dist/electron-build/utils/version-identity.js +1 -1
- package/dist/electron-build/utils/version-identity.js.map +1 -1
- package/dist/electron-build/web/routes-x402-info.js +28 -1
- package/dist/electron-build/web/routes-x402-info.js.map +1 -1
- package/dist/index.js +829 -104
- package/dist/llm/pi-ai.js +23 -0
- package/dist/utils/auto-update.js +2 -2
- package/dist/utils/runtime-bootstrap.js +1 -1
- package/dist/utils/update-health.js +2 -2
- package/dist/utils/update-manager.js +3 -3
- package/dist/utils/version-identity.js +1 -1
- package/dist/web/bolloon-web.json +3 -3
- package/dist/web/routes-x402-info.js +28 -1
- package/dist/web/sw.js +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402-buyer-command.ts — 买方侧: `bolloon x402 pay <endpoint> [--with-memo]`
|
|
3
|
+
*
|
|
4
|
+
* 一句话: **买方用自己的钱包, 自己签、自己发**一笔 EIP-3009 `transferWithAuthorization`,
|
|
5
|
+
* 把 USDC 直接打到卖方的 `accepts.payTo`, 然后把 txHash 交回卖方端点。
|
|
6
|
+
* 全程**没有任何 facilitator / 平台 / 托管**; 付款方(买方)自己付 gas。
|
|
7
|
+
*
|
|
8
|
+
* `--with-memo` 是本命令与"随便转一笔"的全部区别:
|
|
9
|
+
* 把**订单身份**按约定 v1 编进 EIP-3009 的 `nonce` (见 `src/agents/x402/order-identity.ts`),
|
|
10
|
+
* 于是"谁付 · 多少 · 给谁 · **买的是哪件**"落在**同一笔交易**里, 卖方与任何第三方
|
|
11
|
+
* 都能从链上日志 (AuthorizationUsed) 读出并复算。不带 `--with-memo` 时用随机 nonce ——
|
|
12
|
+
* 付得成, 但那笔交易在链上**没有订单身份** (卖方会如实报「直转(无订单标识)」)。
|
|
13
|
+
*
|
|
14
|
+
* 红线 (写进代码, 不靠自觉):
|
|
15
|
+
* · 私钥**只从文件读** (`--key-file <path>` 或 `BOLLOON_X402_BUYER_KEY_FILE`), 绝不进命令行/日志/回显;
|
|
16
|
+
* 只有付款方自己的地址会打印出来。
|
|
17
|
+
* · **不在一个交易里自付自收**: payTo == 买方地址 → 直接拒。
|
|
18
|
+
* · 金额上限 `--max-payment` (缺省 20000 原子 = 0.02 USDC) —— 超过就拒, 不"问一下再发"。
|
|
19
|
+
* · EIP-712 域**不写死**: 先读链上 `DOMAIN_SEPARATOR()` 再拿候选 (name/version) 对账,
|
|
20
|
+
* 对不上就**拒签并说清试过哪些** (USDC 的 EIP-712 name 是 "USD Coin" 不是 "USDC" ——
|
|
21
|
+
* 用错 name 签出来链上必 revert, 这不是"可能不兼容")。
|
|
22
|
+
* · 发之前: 两条 RPC 交叉核 `chainId`/USDC 余额/ETH 余额/gasPrice + `estimateGas` 成功 + 本地验签回买方地址。
|
|
23
|
+
*/
|
|
24
|
+
import * as fs from 'fs/promises';
|
|
25
|
+
// 链/网络口径的事实源留在 direct-payment.ts (不在这里另抄一份 chainId 表 / RPC 表)
|
|
26
|
+
import { CHAIN_ID_BY_NETWORK, DEFAULT_DIRECT_RPCS } from '../agents/x402/direct-payment.js';
|
|
27
|
+
import { computeOrderNonce, decodeOrderNonce, orderIdentityFromLogs, eip712DomainSeparator, USDC_EIP712_NAME_CANDIDATES, USDC_EIP712_VERSION_CANDIDATES, SELECTOR_DOMAIN_SEPARATOR, ORDER_NONCE_TAG_ASCII, ORDER_IDENTITY_PROTOCOL, ORDER_NONCE_TAG, } from '../agents/x402/order-identity.js';
|
|
28
|
+
const RESET = '\x1b[0m';
|
|
29
|
+
const BOLD = '\x1b[1m';
|
|
30
|
+
const GREEN = '\x1b[32m';
|
|
31
|
+
const YELLOW = '\x1b[33m';
|
|
32
|
+
const RED = '\x1b[31m';
|
|
33
|
+
const DIM = '\x1b[2m';
|
|
34
|
+
const DEFAULT_MAX_PAYMENT_ATOMIC = '20000'; // 0.02 USDC —— 与"单品 ≤0.02 USDC"授权口径一致
|
|
35
|
+
const USDC_ABI = [
|
|
36
|
+
'function transferWithAuthorization(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, uint8 v, bytes32 r, bytes32 s)',
|
|
37
|
+
'function balanceOf(address) view returns (uint256)',
|
|
38
|
+
'function DOMAIN_SEPARATOR() view returns (bytes32)',
|
|
39
|
+
'function version() view returns (string)',
|
|
40
|
+
'function name() view returns (string)',
|
|
41
|
+
];
|
|
42
|
+
function usage() {
|
|
43
|
+
console.log(`${BOLD}bolloon x402 pay <endpoint> [选项]${RESET}`);
|
|
44
|
+
console.log(' 买方**自己**发一笔 EIP-3009 transferWithAuthorization 到卖方 accepts.payTo, 再把 txHash 交回卖方端点');
|
|
45
|
+
console.log('');
|
|
46
|
+
console.log(`${BOLD}选项:${RESET}`);
|
|
47
|
+
console.log(` --with-memo 把订单身份按约定 v1 编进 nonce (标签 ${ORDER_NONCE_TAG_ASCII} ${ORDER_NONCE_TAG}) —— 买方侧自证`);
|
|
48
|
+
console.log(' --key-file <path> 买方钱包文件 (JSON {privateKey} 或裸 0x…); 也可用 BOLLOON_X402_BUYER_KEY_FILE ★ 私钥只从文件读');
|
|
49
|
+
console.log(' --rpc <url[,url]> RPC (缺省按 network 取 2 条; 余额/gasPrice 交叉核对)');
|
|
50
|
+
console.log(' --seq <n> 订单序号 (缺省 0; 同一买家重复买同一件必须换一个, 链上 (payer,nonce) 只能用一次)');
|
|
51
|
+
console.log(' --valid-seconds <n> 授权有效期 (缺省 900s; validAfter = 现在-60)');
|
|
52
|
+
console.log(` --max-payment <atomic> 付款上限 (原子单位, 缺省 ${DEFAULT_MAX_PAYMENT_ATOMIC} = 0.02 USDC) —— 超了就拒`);
|
|
53
|
+
console.log(' --confirmations <n> 发出后等几个确认再交回卖方 (卖方门槛 2)');
|
|
54
|
+
console.log(' --dry-run 只算/只签, 不发交易');
|
|
55
|
+
console.log(' --json 只输出 JSON');
|
|
56
|
+
}
|
|
57
|
+
function parseArgs(args) {
|
|
58
|
+
const out = {
|
|
59
|
+
rpcs: [], seq: 0, validSeconds: 900, maxPayment: DEFAULT_MAX_PAYMENT_ATOMIC,
|
|
60
|
+
confirmations: 2, withMemo: false, json: false, dryRun: false, timeoutMs: 60_000,
|
|
61
|
+
};
|
|
62
|
+
const rest = [];
|
|
63
|
+
for (let i = 0; i < args.length; i++) {
|
|
64
|
+
const a = args[i];
|
|
65
|
+
const next = () => { const v = args[++i]; if (v === undefined)
|
|
66
|
+
throw new Error(`${a} 缺参数`); return v; };
|
|
67
|
+
if (a === '--with-memo' || a === '--memo')
|
|
68
|
+
out.withMemo = true;
|
|
69
|
+
else if (a === '--json')
|
|
70
|
+
out.json = true;
|
|
71
|
+
else if (a === '--dry-run')
|
|
72
|
+
out.dryRun = true;
|
|
73
|
+
else if (a === '--key-file')
|
|
74
|
+
out.keyFile = next();
|
|
75
|
+
else if (a === '--rpc' || a === '--rpc-url')
|
|
76
|
+
out.rpcs.push(...next().split(',').map((s) => s.trim()).filter(Boolean));
|
|
77
|
+
else if (a === '--seq')
|
|
78
|
+
out.seq = Number(next());
|
|
79
|
+
else if (a === '--valid-seconds')
|
|
80
|
+
out.validSeconds = Number(next());
|
|
81
|
+
else if (a === '--max-payment')
|
|
82
|
+
out.maxPayment = String(next());
|
|
83
|
+
else if (a === '--confirmations')
|
|
84
|
+
out.confirmations = Number(next());
|
|
85
|
+
else if (a === '--timeout-sec')
|
|
86
|
+
out.timeoutMs = Number(next()) * 1000;
|
|
87
|
+
else if (a.startsWith('--'))
|
|
88
|
+
throw new Error(`未知选项 ${a}`);
|
|
89
|
+
else
|
|
90
|
+
rest.push(a);
|
|
91
|
+
}
|
|
92
|
+
out.endpoint = rest[0];
|
|
93
|
+
out.keyFile = out.keyFile || process.env.BOLLOON_X402_BUYER_KEY_FILE || undefined;
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
async function rpcCall(url, method, params, timeoutMs = 15_000) {
|
|
97
|
+
const ac = new AbortController();
|
|
98
|
+
const t = setTimeout(() => ac.abort(), timeoutMs);
|
|
99
|
+
try {
|
|
100
|
+
const r = await fetch(url, {
|
|
101
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: ac.signal,
|
|
103
|
+
});
|
|
104
|
+
if (!r.ok)
|
|
105
|
+
throw new Error(`HTTP ${r.status}`);
|
|
106
|
+
const j = await r.json();
|
|
107
|
+
if (j?.error)
|
|
108
|
+
throw new Error(`${method}: ${String(j.error.message || 'error').slice(0, 140)}`);
|
|
109
|
+
return j.result;
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
clearTimeout(t);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function pad32(hexNo0x) { return hexNo0x.padStart(64, '0'); }
|
|
116
|
+
function addrWord(addr) { return pad32(String(addr).replace(/^0x/, '').toLowerCase()); }
|
|
117
|
+
/** 读一条 RPC 上的余额/gasPrice/chainId (交叉核对用) */
|
|
118
|
+
async function readFacts(url, buyer, asset) {
|
|
119
|
+
const [cid, bn, usdc, eth, gp, nonce] = await Promise.all([
|
|
120
|
+
rpcCall(url, 'eth_chainId', []),
|
|
121
|
+
rpcCall(url, 'eth_blockNumber', []),
|
|
122
|
+
rpcCall(url, 'eth_call', [{ to: asset, data: `0x70a08231${addrWord(buyer)}` }, 'latest']),
|
|
123
|
+
rpcCall(url, 'eth_getBalance', [buyer, 'latest']),
|
|
124
|
+
rpcCall(url, 'eth_gasPrice', []),
|
|
125
|
+
rpcCall(url, 'eth_getTransactionCount', [buyer, 'pending']),
|
|
126
|
+
]);
|
|
127
|
+
return {
|
|
128
|
+
rpc: new URL(url).host,
|
|
129
|
+
chainId: Number(BigInt(cid)),
|
|
130
|
+
blockNumber: Number(BigInt(bn)),
|
|
131
|
+
usdcBalance: String(BigInt(usdc)),
|
|
132
|
+
ethBalance: String(BigInt(eth)),
|
|
133
|
+
gasPrice: String(BigInt(gp)),
|
|
134
|
+
nonce: Number(BigInt(nonce)),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const fmtUsdc = (atomic) => (Number(BigInt(atomic)) / 1e6).toFixed(6).replace(/0+$/, '').replace(/\.$/, '');
|
|
138
|
+
const fmtEth = (wei) => (Number(BigInt(wei)) / 1e18).toFixed(12).replace(/0+$/, '').replace(/\.$/, '');
|
|
139
|
+
/** 从钱包文件里读私钥 (只在脚本内使用, 任何路径都不回显) */
|
|
140
|
+
async function loadBuyerKey(keyFile) {
|
|
141
|
+
const raw = (await fs.readFile(keyFile, 'utf-8')).trim();
|
|
142
|
+
let key = raw;
|
|
143
|
+
try {
|
|
144
|
+
const j = JSON.parse(raw);
|
|
145
|
+
key = String(j?.privateKey || j?.private_key || j?.key || '');
|
|
146
|
+
}
|
|
147
|
+
catch { /* 裸 hex 文件 */ }
|
|
148
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(key.trim())) {
|
|
149
|
+
throw new Error(`钱包文件里没找到 0x + 64 hex 的 privateKey (文件: ${keyFile}) —— 不打印内容, 请自行检查格式`);
|
|
150
|
+
}
|
|
151
|
+
return key.trim();
|
|
152
|
+
}
|
|
153
|
+
/** 从链上 DOMAIN_SEPARATOR 反查真正的 EIP-712 域 (name/version 不写死) */
|
|
154
|
+
async function resolveTokenDomain(rpcUrl, asset, chainId) {
|
|
155
|
+
const onchainSep = String(await rpcCall(rpcUrl, 'eth_call', [{ to: asset, data: SELECTOR_DOMAIN_SEPARATOR }, 'latest']));
|
|
156
|
+
const tried = [];
|
|
157
|
+
for (const name of USDC_EIP712_NAME_CANDIDATES) {
|
|
158
|
+
for (const version of USDC_EIP712_VERSION_CANDIDATES) {
|
|
159
|
+
const sep = eip712DomainSeparator({ name, version, chainId, verifyingContract: asset });
|
|
160
|
+
tried.push(`${name}/${version}=${sep.slice(0, 12)}…`);
|
|
161
|
+
if (sep.toLowerCase() === onchainSep.toLowerCase()) {
|
|
162
|
+
return { name, version, verifyingContract: asset, onchainSep, verified: true };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { error: '候选 EIP-712 域都对不上链上 DOMAIN_SEPARATOR()', onchainSep, tried };
|
|
167
|
+
}
|
|
168
|
+
/** 极简 ABI 解码 string (只处理 offset+len+data) */
|
|
169
|
+
function decodeAbiString(hex) {
|
|
170
|
+
const h = String(hex || '').replace(/^0x/, '');
|
|
171
|
+
if (h.length < 128)
|
|
172
|
+
return null;
|
|
173
|
+
try {
|
|
174
|
+
const off = Number(BigInt(`0x${h.slice(0, 64)}`)) * 2;
|
|
175
|
+
const len = Number(BigInt(`0x${h.slice(off, off + 64)}`));
|
|
176
|
+
return Buffer.from(h.slice(off + 64, off + 64 + len * 2), 'hex').toString('utf-8');
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* `bolloon x402 pay <endpoint> [--with-memo]` —— 返回退出码。
|
|
184
|
+
* 0 = 卖方端点 202/200 且 (带 memo 时) 订单自证成功; 1 = 失败 (原因写清); 2 = 用法/参数错
|
|
185
|
+
*/
|
|
186
|
+
export async function x402PayCommand(args) {
|
|
187
|
+
let opt;
|
|
188
|
+
try {
|
|
189
|
+
opt = parseArgs(args);
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
console.error(`${RED}参数错误:${RESET} ${e?.message}`);
|
|
193
|
+
usage();
|
|
194
|
+
return 2;
|
|
195
|
+
}
|
|
196
|
+
if (!opt.endpoint) {
|
|
197
|
+
usage();
|
|
198
|
+
return 2;
|
|
199
|
+
}
|
|
200
|
+
const log = (...a) => { if (!opt.json)
|
|
201
|
+
console.log(...a); };
|
|
202
|
+
try {
|
|
203
|
+
// ── 0. 买方钱包 (私钥只在内存里; 只打印地址)
|
|
204
|
+
if (!opt.keyFile) {
|
|
205
|
+
console.error(`${RED}缺 --key-file${RESET} (或 env BOLLOON_X402_BUYER_KEY_FILE) —— 私钥只从文件读, 不进命令行`);
|
|
206
|
+
return 2;
|
|
207
|
+
}
|
|
208
|
+
const privateKey = await loadBuyerKey(opt.keyFile);
|
|
209
|
+
// 动态 import: 只有走到这条路才需要 ethers (其余 x402 子命令不受影响)
|
|
210
|
+
const { Wallet, JsonRpcProvider, Contract, Signature, verifyTypedData, TypedDataEncoder } = await import('ethers');
|
|
211
|
+
const wallet = new Wallet(privateKey);
|
|
212
|
+
const buyer = wallet.address;
|
|
213
|
+
// ── 1. GET 拿 402 + accepts
|
|
214
|
+
const res = await fetch(opt.endpoint, { headers: { accept: 'application/json' } });
|
|
215
|
+
const bodyText = await res.text();
|
|
216
|
+
let body = null;
|
|
217
|
+
try {
|
|
218
|
+
body = JSON.parse(bodyText);
|
|
219
|
+
}
|
|
220
|
+
catch { /* 非 JSON */ }
|
|
221
|
+
let accepts = body?.accepts?.[0];
|
|
222
|
+
if (!accepts) {
|
|
223
|
+
const hdr = res.headers.get('x-payment-required') || res.headers.get('X-PAYMENT-REQUIRED');
|
|
224
|
+
if (hdr) {
|
|
225
|
+
try {
|
|
226
|
+
accepts = JSON.parse(hdr)?.[0];
|
|
227
|
+
}
|
|
228
|
+
catch { /* 忽略 */ }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (res.status !== 402 || !accepts) {
|
|
232
|
+
console.error(`${RED}预期 402 + accepts, 实际 HTTP ${res.status}${RESET} ${String(body?.error || bodyText).slice(0, 200)}`);
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
const network = String(accepts.network || '');
|
|
236
|
+
const chainId = CHAIN_ID_BY_NETWORK[network];
|
|
237
|
+
const asset = String(accepts.asset || '');
|
|
238
|
+
const payTo = String(accepts.payTo || '');
|
|
239
|
+
const amount = String(accepts.amount || '0');
|
|
240
|
+
const itemId = String(accepts.extra?.itemId || new URL(opt.endpoint).pathname.split('/').filter(Boolean).pop() || '');
|
|
241
|
+
if (!chainId) {
|
|
242
|
+
console.error(`${RED}不认识的 network=${network}${RESET}`);
|
|
243
|
+
return 1;
|
|
244
|
+
}
|
|
245
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(asset) || !/^0x[0-9a-fA-F]{40}$/.test(payTo)) {
|
|
246
|
+
console.error(`${RED}accepts 里的 asset/payTo 形状不对${RESET}`);
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
if (String(accepts.scheme || 'exact') !== 'exact') {
|
|
250
|
+
console.error(`${RED}不支持的 scheme=${accepts.scheme}${RESET}`);
|
|
251
|
+
return 1;
|
|
252
|
+
}
|
|
253
|
+
// ★ 不在一个交易里自付自收
|
|
254
|
+
if (payTo.toLowerCase() === buyer.toLowerCase()) {
|
|
255
|
+
console.error(`${RED}payTo 就是买方自己${RESET} (${payTo}) —— 自付自收不算交易, 拒发`);
|
|
256
|
+
return 1;
|
|
257
|
+
}
|
|
258
|
+
if (BigInt(amount) > BigInt(opt.maxPayment)) {
|
|
259
|
+
console.error(`${RED}金额 ${amount} 超过 --max-payment ${opt.maxPayment}${RESET} —— 拒发`);
|
|
260
|
+
return 1;
|
|
261
|
+
}
|
|
262
|
+
if (!itemId) {
|
|
263
|
+
console.error(`${RED}accepts.extra.itemId 与 URL 都没给出 itemId —— 无法编订单标识${RESET}`);
|
|
264
|
+
return 1;
|
|
265
|
+
}
|
|
266
|
+
const rpcs = opt.rpcs.length ? opt.rpcs : (DEFAULT_DIRECT_RPCS[network] || []);
|
|
267
|
+
if (!rpcs.length) {
|
|
268
|
+
console.error(`${RED}没有可用 RPC (--rpc 指定)${RESET}`);
|
|
269
|
+
return 1;
|
|
270
|
+
}
|
|
271
|
+
log(`${BOLD}卖方报价${RESET} (GET ${opt.endpoint} → 402)`);
|
|
272
|
+
log(` item=${itemId} 金额=${amount} 原子 (${fmtUsdc(amount)} USDC) payTo=${payTo}`);
|
|
273
|
+
log(` network=${network} chainId=${chainId} asset=${asset}`);
|
|
274
|
+
log(` 买方=${buyer} (gas 由买方自己出, 无 facilitator/托管)`);
|
|
275
|
+
// ── 2. 两条 RPC 交叉核余额/gasPrice
|
|
276
|
+
const facts = [];
|
|
277
|
+
for (const u of rpcs.slice(0, 3)) {
|
|
278
|
+
try {
|
|
279
|
+
facts.push(await readFacts(u, buyer, asset));
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
log(`${YELLOW}· RPC ${new URL(u).host} 读不到: ${String(e?.message || e).slice(0, 100)}${RESET}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (!facts.length) {
|
|
286
|
+
console.error(`${RED}所有 RPC 都读不到 —— 不发交易${RESET}`);
|
|
287
|
+
return 1;
|
|
288
|
+
}
|
|
289
|
+
for (const f of facts) {
|
|
290
|
+
if (f.chainId !== chainId) {
|
|
291
|
+
console.error(`${RED}RPC ${f.rpc} 在 chainId=${f.chainId}, 期望 ${chainId}${RESET}`);
|
|
292
|
+
return 1;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
log(`${BOLD}链上余额/费用交叉核对${RESET} (${facts.length} 条 RPC)`);
|
|
296
|
+
for (const f of facts) {
|
|
297
|
+
log(` ${f.rpc}: 块 ${f.blockNumber} · USDC ${fmtUsdc(f.usdcBalance)} · ETH ${fmtEth(f.ethBalance)} · gasPrice ${f.gasPrice} wei · txNonce ${f.nonce}`);
|
|
298
|
+
}
|
|
299
|
+
if (facts.length >= 2) {
|
|
300
|
+
const usdcSame = facts.every((f) => f.usdcBalance === facts[0].usdcBalance);
|
|
301
|
+
const ethSame = facts.every((f) => f.ethBalance === facts[0].ethBalance);
|
|
302
|
+
log(` ${usdcSame ? '✅' : `${YELLOW}⚠${RESET}`} USDC 余额两 RPC ${usdcSame ? '一致' : '不一致'} · ETH ${ethSame ? '一致' : '不一致'}`);
|
|
303
|
+
if (!usdcSame || !ethSame) {
|
|
304
|
+
console.error(`${RED}两个 RPC 对余额说法不一致 —— 不确定, 不发交易${RESET}`);
|
|
305
|
+
return 1;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const maxGasPrice = facts.reduce((a, f) => (BigInt(f.gasPrice) > BigInt(a) ? f.gasPrice : a), facts[0].gasPrice);
|
|
309
|
+
if (BigInt(facts[0].usdcBalance) < BigInt(amount)) {
|
|
310
|
+
console.error(`${RED}USDC 余额不足${RESET}: 有 ${fmtUsdc(facts[0].usdcBalance)}, 需要 ${fmtUsdc(amount)}`);
|
|
311
|
+
return 1;
|
|
312
|
+
}
|
|
313
|
+
const gasCeiling = BigInt(maxGasPrice) * 150000n; // 上界估算 (实际 ~60-80k)
|
|
314
|
+
if (BigInt(facts[0].ethBalance) < gasCeiling) {
|
|
315
|
+
console.error(`${RED}ETH 不够付 gas${RESET}: 有 ${fmtEth(facts[0].ethBalance)}, 上界需要 ${fmtEth(gasCeiling.toString())}`);
|
|
316
|
+
return 1;
|
|
317
|
+
}
|
|
318
|
+
// ── 3. EIP-712 域: 拿链上 DOMAIN_SEPARATOR 反查, 不写死
|
|
319
|
+
const t0 = facts[0].rpc;
|
|
320
|
+
const sendRpc = rpcs[0];
|
|
321
|
+
const dom = await resolveTokenDomain(sendRpc, asset, chainId);
|
|
322
|
+
if ('error' in dom) {
|
|
323
|
+
console.error(`${RED}EIP-712 域无法确定${RESET}: 链上 DOMAIN_SEPARATOR=${dom.onchainSep} 与所有候选都对不上 (试过: ${dom.tried.join(', ')}) —— 拒签`);
|
|
324
|
+
return 1;
|
|
325
|
+
}
|
|
326
|
+
log(`${BOLD}EIP-712 域${RESET} (链上 DOMAIN_SEPARATOR 反查通过, 不写死)`);
|
|
327
|
+
log(` name="${dom.name}" version="${dom.version}" chainId=${chainId} verifyingContract=${asset}`);
|
|
328
|
+
log(` DOMAIN_SEPARATOR=${dom.onchainSep} ${DIM}(候选里唯一命中的那个)${RESET}`);
|
|
329
|
+
// ── 4. 授权 + 订单标识 nonce
|
|
330
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
331
|
+
const validAfter = nowSec - 60;
|
|
332
|
+
const validBefore = nowSec + opt.validSeconds;
|
|
333
|
+
let nonce;
|
|
334
|
+
if (opt.withMemo) {
|
|
335
|
+
nonce = computeOrderNonce(itemId, opt.seq);
|
|
336
|
+
const d = decodeOrderNonce(nonce);
|
|
337
|
+
log(`${BOLD}订单标识 (约定 v1)${RESET}`);
|
|
338
|
+
log(` nonce=${nonce}`);
|
|
339
|
+
log(` 标签=${d.tag}(${ORDER_NONCE_TAG}) orderSeq=${d.orderSeq} itemIdHash=${d.itemIdHash}`);
|
|
340
|
+
log(` ${DIM}卖方会用本店 item 复算 keccak256(${itemId}‖${d.orderSeq})[0..23] 比对${RESET}`);
|
|
341
|
+
log(` ${DIM}★ 同一买家重复买同一件要换 --seq: 链上 (payer, nonce) 只能用一次${RESET}`);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
const { randomBytes } = await import('node:crypto');
|
|
345
|
+
nonce = `0x${randomBytes(32).toString('hex')}`;
|
|
346
|
+
log(`${YELLOW}未带 --with-memo: 用随机 nonce, 这笔交易在链上没有订单身份 (卖方会如实报「直转(无订单标识)」)${RESET}`);
|
|
347
|
+
}
|
|
348
|
+
log(`${BOLD}授权窗口${RESET} validAfter=${validAfter} validBefore=${validBefore} (${opt.validSeconds}s)`);
|
|
349
|
+
// ── 5. EIP-712 签名 + 本地验签
|
|
350
|
+
const domain = { name: dom.name, version: dom.version, chainId, verifyingContract: asset };
|
|
351
|
+
const types = {
|
|
352
|
+
TransferWithAuthorization: [
|
|
353
|
+
{ name: 'from', type: 'address' }, { name: 'to', type: 'address' },
|
|
354
|
+
{ name: 'value', type: 'uint256' }, { name: 'validAfter', type: 'uint256' },
|
|
355
|
+
{ name: 'validBefore', type: 'uint256' }, { name: 'nonce', type: 'bytes32' },
|
|
356
|
+
],
|
|
357
|
+
};
|
|
358
|
+
const value = { from: buyer, to: payTo, value: amount, validAfter, validBefore, nonce };
|
|
359
|
+
const signature = await wallet.signTypedData(domain, types, value);
|
|
360
|
+
const recovered = verifyTypedData(domain, types, value, signature);
|
|
361
|
+
if (recovered.toLowerCase() !== buyer.toLowerCase()) {
|
|
362
|
+
console.error(`${RED}本地验签回不到买方地址 (${recovered}) —— 不发出${RESET}`);
|
|
363
|
+
return 1;
|
|
364
|
+
}
|
|
365
|
+
const sig = Signature.from(signature);
|
|
366
|
+
log(`${GREEN}✅ EIP-712 签名完成并本地验签通过${RESET} (signer=${recovered})`);
|
|
367
|
+
log(` structHash=${TypedDataEncoder.hashStruct('TransferWithAuthorization', types, value)}`);
|
|
368
|
+
log(` v=${sig.v} r=${sig.r.slice(0, 18)}… s=${sig.s.slice(0, 18)}…`);
|
|
369
|
+
const plan = {
|
|
370
|
+
protocol: ORDER_IDENTITY_PROTOCOL,
|
|
371
|
+
endpoint: opt.endpoint, itemId, network, chainId, asset, payTo, amount, buyer,
|
|
372
|
+
nonce, withMemo: opt.withMemo,
|
|
373
|
+
validAfter, validBefore,
|
|
374
|
+
rpcs, t0, maxGasPriceWei: maxGasPrice,
|
|
375
|
+
balancesBefore: facts.map((f) => ({ rpc: f.rpc, usdc: f.usdcBalance, eth: f.ethBalance, txNonce: f.nonce })),
|
|
376
|
+
signature, domain,
|
|
377
|
+
};
|
|
378
|
+
// ── 6. estimateGas
|
|
379
|
+
const provider = new JsonRpcProvider(sendRpc, chainId, { cacheTimeout: -1 });
|
|
380
|
+
const contract = new Contract(asset, USDC_ABI, wallet.connect(provider));
|
|
381
|
+
let gasEstimate;
|
|
382
|
+
try {
|
|
383
|
+
gasEstimate = await contract.transferWithAuthorization.estimateGas(buyer, payTo, amount, validAfter, validBefore, nonce, sig.v, sig.r, sig.s);
|
|
384
|
+
}
|
|
385
|
+
catch (e) {
|
|
386
|
+
console.error(`${RED}estimateGas 失败${RESET}: ${String(e?.shortMessage || e?.message || e).slice(0, 300)}`);
|
|
387
|
+
console.error(' (链上预演没过就不发 —— 常见原因: EIP-712 域/参数不对、授权窗口过期、余额不足)');
|
|
388
|
+
return 1;
|
|
389
|
+
}
|
|
390
|
+
log(`${GREEN}✅ estimateGas 成功${RESET}: ${gasEstimate} gas (上界估算约 ${fmtEth((gasEstimate * BigInt(maxGasPrice)).toString())} ETH)`);
|
|
391
|
+
if (opt.dryRun) {
|
|
392
|
+
console.log(JSON.stringify({ ...plan, dryRun: true, gasEstimate: gasEstimate.toString() }, null, 2));
|
|
393
|
+
return 0;
|
|
394
|
+
}
|
|
395
|
+
// ── 7. 发交易 (买方自己发: 付款方付 gas)
|
|
396
|
+
const feeData = await provider.getFeeData();
|
|
397
|
+
const tx = await contract.transferWithAuthorization(buyer, payTo, amount, validAfter, validBefore, nonce, sig.v, sig.r, sig.s, { gasLimit: (gasEstimate * 13n) / 10n, maxFeePerGas: feeData.maxFeePerGas ?? undefined, maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? undefined });
|
|
398
|
+
log(`${BOLD}已发出${RESET} txHash=${tx.hash} (买方自己签、自己发, gas 从买方账户扣)`);
|
|
399
|
+
const receipt = await tx.wait(opt.confirmations);
|
|
400
|
+
if (!receipt || receipt.status !== 1) {
|
|
401
|
+
console.error(`${RED}交易失败/回滚${RESET} txHash=${tx.hash} status=${receipt?.status}`);
|
|
402
|
+
return 1;
|
|
403
|
+
}
|
|
404
|
+
const gasUsed = BigInt(receipt.gasUsed);
|
|
405
|
+
const gasPaid = BigInt(receipt.gasUsed) * BigInt(receipt.gasPrice ?? maxGasPrice);
|
|
406
|
+
log(`${GREEN}✅ 交易已确认${RESET} 块 ${receipt.blockNumber} · gasUsed ${gasUsed} · gasPrice ${receipt.gasPrice} wei · 实付 gas ${fmtEth(gasPaid.toString())} ETH · 确认数 ≥ ${opt.confirmations}`);
|
|
407
|
+
// ── 8. 链上自读复核 (不信卖方, 自己从日志里解一次订单标识)
|
|
408
|
+
const logs = (receipt.logs || []).map((l) => ({ address: l.address, topics: l.topics, data: l.data }));
|
|
409
|
+
const selfRead = orderIdentityFromLogs(logs, { itemIds: [itemId], asset });
|
|
410
|
+
log(`${BOLD}① 自己读链复核${RESET} (与卖方无关)`);
|
|
411
|
+
log(` mode=${selfRead.mode} nonce=${selfRead.nonce} orderSeq=${selfRead.orderSeq}`);
|
|
412
|
+
log(` ${selfRead.detail}`);
|
|
413
|
+
if (opt.withMemo && !selfRead.selfAttested) {
|
|
414
|
+
console.error(`${YELLOW}⚠ 自己读链也没能自证 (mode=${selfRead.mode}) —— 继续把 txHash 交给卖方, 但如实说明${RESET}`);
|
|
415
|
+
}
|
|
416
|
+
// ── 9. 交回卖方端点
|
|
417
|
+
const payUrl = `${opt.endpoint.replace(/\/+$/, '')}/payment`;
|
|
418
|
+
const post = await fetch(payUrl, {
|
|
419
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
420
|
+
body: JSON.stringify({ txHash: tx.hash }),
|
|
421
|
+
});
|
|
422
|
+
const postText = await post.text();
|
|
423
|
+
let postBody = null;
|
|
424
|
+
try {
|
|
425
|
+
postBody = JSON.parse(postText);
|
|
426
|
+
}
|
|
427
|
+
catch { /* 非 JSON */ }
|
|
428
|
+
log(`${BOLD}② 卖方端点回执${RESET} POST ${payUrl} → HTTP ${post.status}`);
|
|
429
|
+
log(` status=${postBody?.status || '-'} orderIdentityMode=${postBody?.payment?.orderIdentityMode || postBody?.orderIdentity?.mode || '-'}`);
|
|
430
|
+
log(` orderIdentitySelfAttested=${postBody?.payment?.orderIdentitySelfAttested ?? postBody?.orderIdentity?.selfAttested ?? '-'}`);
|
|
431
|
+
log(` ${postBody?.orderIdentity?.detail || ''}`);
|
|
432
|
+
const out = {
|
|
433
|
+
ok: post.status === 200 || post.status === 202,
|
|
434
|
+
httpStatus: post.status,
|
|
435
|
+
endpoint: opt.endpoint,
|
|
436
|
+
txHash: tx.hash,
|
|
437
|
+
explorer: `https://basescan.org/tx/${tx.hash}`,
|
|
438
|
+
itemId, network, chainId, amount, currency: 'USDC', payTo, payer: buyer,
|
|
439
|
+
gasUsed: gasUsed.toString(), gasPrice: String(receipt.gasPrice ?? maxGasPrice), gasPaidWei: gasPaid.toString(),
|
|
440
|
+
blockNumber: Number(receipt.blockNumber), withMemo: opt.withMemo, nonce,
|
|
441
|
+
orderIdentitySelfReadFromChain: selfRead,
|
|
442
|
+
sellerResponse: postBody ?? postText.slice(0, 2000),
|
|
443
|
+
};
|
|
444
|
+
if (opt.json) {
|
|
445
|
+
console.log(JSON.stringify(out, null, 2));
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
log(`${BOLD}③ 浏览器${RESET} https://basescan.org/tx/${tx.hash}`);
|
|
449
|
+
log(`${post.status === 202 || post.status === 200 ? GREEN + '✅' : RED + '❌'}${RESET} 最终 HTTP ${post.status}${postBody?.retrieval?.path ? ` · 取件 ${postBody.retrieval.path}` : ''}`);
|
|
450
|
+
}
|
|
451
|
+
if (post.status !== 200 && post.status !== 202)
|
|
452
|
+
return 1;
|
|
453
|
+
if (opt.withMemo && (postBody?.payment?.orderIdentitySelfAttested ?? postBody?.orderIdentity?.selfAttested) !== true)
|
|
454
|
+
return 1;
|
|
455
|
+
return 0;
|
|
456
|
+
}
|
|
457
|
+
catch (e) {
|
|
458
|
+
console.error(`${RED}失败:${RESET} ${String(e?.shortMessage || e?.message || e)}`);
|
|
459
|
+
return 1;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/** 给 `bolloon x402` 帮助文本用的一句说明 (与实现写在一起, 免得文档漂移) */
|
|
463
|
+
export const X402_PAY_HELP_LINE = 'bolloon x402 pay <endpoint> --with-memo # 买方自己发 EIP-3009 transferWithAuthorization (nonce 带订单标识 BOL1)';
|