@bolloon/bolloon-agent 0.4.31 → 0.4.33
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/dist/agents/chain/chain-config.js +504 -0
- package/dist/agents/chain/chain-index-query.js +135 -0
- package/dist/agents/chain/chain-indexer.js +1045 -0
- package/dist/agents/chain/chain-settlement.js +284 -0
- package/dist/agents/chain/chain-state-store.js +245 -0
- package/dist/agents/chain/chain-wallet.js +198 -0
- package/dist/agents/chain/escrow-client.js +433 -0
- package/dist/agents/chain/explorer.js +52 -0
- package/dist/agents/chain/index.js +22 -0
- package/dist/agents/chain/onchain-trade.js +705 -0
- package/dist/agents/network-pulse.js +676 -2
- package/dist/agents/task/task-onchain-runner.js +302 -0
- package/dist/agents/task/task-runner.js +27 -3
- package/dist/agents/task-board.js +702 -0
- package/dist/agents/task-group.js +567 -0
- package/dist/agents/x402/paid-info-store.js +75 -6
- package/dist/cli/commands/chain.js +926 -0
- package/dist/cli/commands/index.js +12 -2
- package/dist/cli/commands/tasks.js +627 -6
- package/dist/cli/mcp/server.js +4 -1
- package/dist/cli/mcp/tools.js +348 -5
- package/dist/cli/protocol-envelope.js +16 -0
- package/dist/cli-entry.js +6 -1
- package/dist/electron-build/agents/chain/chain-config.js +556 -0
- package/dist/electron-build/agents/chain/chain-config.js.map +1 -0
- package/dist/electron-build/agents/chain/chain-index-query.js +145 -0
- package/dist/electron-build/agents/chain/chain-index-query.js.map +1 -0
- package/dist/electron-build/agents/chain/chain-indexer.js +1095 -0
- package/dist/electron-build/agents/chain/chain-indexer.js.map +1 -0
- package/dist/electron-build/agents/chain/chain-settlement.js +291 -0
- package/dist/electron-build/agents/chain/chain-settlement.js.map +1 -0
- package/dist/electron-build/agents/chain/escrow-client.js +443 -0
- package/dist/electron-build/agents/chain/escrow-client.js.map +1 -0
- package/dist/electron-build/agents/chain/explorer.js +59 -0
- package/dist/electron-build/agents/chain/explorer.js.map +1 -0
- package/dist/electron-build/agents/local-signer.js +101 -0
- package/dist/electron-build/agents/local-signer.js.map +1 -0
- package/dist/electron-build/agents/network-pulse.js +693 -3
- package/dist/electron-build/agents/network-pulse.js.map +1 -1
- package/dist/electron-build/agents/task/task-runner.js +27 -3
- package/dist/electron-build/agents/task/task-runner.js.map +1 -1
- package/dist/electron-build/agents/task-board.js +761 -0
- package/dist/electron-build/agents/task-board.js.map +1 -0
- package/dist/electron-build/agents/task-contract.js +382 -0
- package/dist/electron-build/agents/task-contract.js.map +1 -0
- package/dist/electron-build/agents/x402/paid-info-store.js +76 -6
- package/dist/electron-build/agents/x402/paid-info-store.js.map +1 -1
- package/dist/web/chain-explorer.js +617 -0
- package/dist/web/explorer.html +163 -0
- package/dist/web/index.html +5 -0
- package/dist/web/routes-chain-index.js +108 -0
- package/dist/web/routes-onchain-trade.js +99 -0
- package/dist/web/server.js +13 -0
- package/package.json +2 -1
- package/scripts/build-web.ts +20 -1
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chain.ts — P6 `bolloon chain ...` (链命令组)
|
|
3
|
+
* =========================================================================
|
|
4
|
+
* 一个命令组, 五组能力, **全部复用 P3/P4/P5 的现成实现** (本文件不新写任何链上判定):
|
|
5
|
+
*
|
|
6
|
+
* bolloon chain status → chain-config + escrow-client (+ 余额/确认数口径)
|
|
7
|
+
* bolloon chain escrow show <taskKey> → escrow-client.getEscrow (链上 19 字段)
|
|
8
|
+
* bolloon chain timeline <taskKey> → chain-index-query.getEscrowTimeline (链上事件时间线)
|
|
9
|
+
* + onchain-trade.recoverOnchainTrade (本机视角, 纯读盘)
|
|
10
|
+
* bolloon chain index status|stats|sync → chain-index-query / chain-indexer.ChainIndexer.syncFrom
|
|
11
|
+
* bolloon chain trade create|submit-proof|release|recover
|
|
12
|
+
* → onchain-trade 的 createEscrowStep / submitProofStep /
|
|
13
|
+
* releaseStep / recoverOnchainTrade (不重写)
|
|
14
|
+
*
|
|
15
|
+
* 四条红线 (与 P3/P4 的冻结口径逐条一致):
|
|
16
|
+
* ① **绝不打印/落盘私钥** —— 本文件不读私钥、不造 signer; `chain status` 只报钱包**可用性 + 公开地址**;
|
|
17
|
+
* 链上写操作的签名只由 P3 `sendChainTxGuarded` → `authorizeWalletSignature` 唯一放行闸产生。
|
|
18
|
+
* ② **不确定一律 `CHAIN_UNCERTAIN`** (确认数不够 / receipt 读不到 / 事件对不上) —— 绝不报成功;
|
|
19
|
+
* 「读不到合约」与「合约里没有这条 escrow」是两个不同的码 (`CHAIN_UNAVAILABLE` vs `ESCROW_NOT_FOUND`)。
|
|
20
|
+
* ③ `local-dev` (chainId 31337) 只报链上事实, **永不产出 `fully_settled`** (本命令组根本不写结算事实)。
|
|
21
|
+
* ④ 失败一律走 §2 冻结信封 (`ok:false` + `code` + `next_action`); 被标可疑 (重组/事件消失) → `REORG_SUSPECTED`。
|
|
22
|
+
*
|
|
23
|
+
* P6 新增错误码 (append-only, 不改任何冻结码含义):
|
|
24
|
+
* `CHAIN_NOT_CONFIGURED` · `CHAIN_UNAVAILABLE` · `CHAIN_UNCERTAIN` · `CHAIN_TX_REVERTED`
|
|
25
|
+
* `ESCROW_NOT_FOUND` · `INSUFFICIENT_FUNDS` · `NOT_AUTHORIZED` · `REORG_SUSPECTED`
|
|
26
|
+
* `INDEX_IDENTITY_CHANGED` (2026-09-22 P5 修正: 换合约部署/换链实例 ⇒ 索引身份变了, **不是重组**)
|
|
27
|
+
*/
|
|
28
|
+
import * as os from 'os';
|
|
29
|
+
import { okEnvelope, failEnvelope, line, title, hint, plain, opt, } from '../protocol-envelope.js';
|
|
30
|
+
import { isPaymentMode, PAYMENT_MODES } from '../../agents/task-contract.js';
|
|
31
|
+
const home = () => os.homedir();
|
|
32
|
+
export const CHAIN_USAGE = `
|
|
33
|
+
${title('bolloon chain')}
|
|
34
|
+
bolloon chain status [--json]
|
|
35
|
+
链配置 (chainId / RPC / escrow / token / 确认数) + RPC 可达性 + 合约 bytecode + 钱包可用性 (地址公开, 私钥绝不打印)
|
|
36
|
+
|
|
37
|
+
bolloon chain escrow show <taskKey> [--json]
|
|
38
|
+
读链上 escrow (19 字段): state = ACTIVE|RELEASED|DISPUTED|REFUNDED
|
|
39
|
+
|
|
40
|
+
bolloon chain timeline <taskKey> [--json]
|
|
41
|
+
链上事件时间线 (索引里的真事件) + 本机视角 (chain-state.json 的 create/proof/release 记录)
|
|
42
|
+
|
|
43
|
+
bolloon chain index status|stats|sync|rebuild [--from-block <n>] [--json]
|
|
44
|
+
索引高度/最后同步 / 全量统计 / 增量同步 (从 deployment block 起扫, 分页+去重+重组回退) /
|
|
45
|
+
全量重建 (换过合约部署 → **干净重建**: 丢弃旧身份的记录, 采用当前身份, 报 identityChanged)
|
|
46
|
+
|
|
47
|
+
bolloon chain trade create --task-id <id> --agent <addr> --amount <USDC> [--asset <token>] \\
|
|
48
|
+
[--deadline <unix秒>] [--confirmation-window <秒>] [--proof-version <n>] [--gate confirmed|finalized]
|
|
49
|
+
bolloon chain trade submit-proof --task-id <id> --result <正文|sha256:hex> [--manifest-digest <正文|sha256:hex>]
|
|
50
|
+
bolloon chain trade release --task-id <id>
|
|
51
|
+
bolloon chain trade recover (--task-id <id> | --task-key <hex>)
|
|
52
|
+
|
|
53
|
+
选项: --json · --quiet · --request-id <id> · --timeout <ms> · --task-key <hex>
|
|
54
|
+
判据永远是信封里的 ok/code; 失败码见 skills/bolloon-network/SKILL.md「链上能力」一节。
|
|
55
|
+
链上写操作的签名走 P3 放行闸 (fail-closed): 未授权 → NOT_AUTHORIZED, 且**不发交易、不碰私钥**。
|
|
56
|
+
|
|
57
|
+
链上**写**操作 (create|submit-proof|release) 的**授权意图**:
|
|
58
|
+
--payment-mode <manual|policy|autonomous|agent-authorized> 声明"在何种支付模式下签这笔链上写"
|
|
59
|
+
--request-id <id> 显式幂等/授权键 (参与放行闸 requestId 的确定性派生)
|
|
60
|
+
两个都是**可选**(CLI 缺省沿用历史口径 agent-authorized / 确定性派生), 但 **MCP 写 tool 强制显式携带**;
|
|
61
|
+
声明本身**不授权** —— 最终还是看本机放行闸 (authorizeWalletSignature, fail-closed)。
|
|
62
|
+
--payment-mode manual|policy 会被放行闸直接拒 (modeIsAutonomous): 它只能**收紧**, 不可能放权。
|
|
63
|
+
chain trade expire 不存在 (仓库没实现) —— 需要它请先实现合约侧子命令再暴露, 不许在 MCP 层假装。
|
|
64
|
+
|
|
65
|
+
索引身份 (identity = chainId + escrowAddress + 部署块) 随索引一并落盘 (identity 字段):
|
|
66
|
+
换过合约部署 / anvil 重启换了链实例 ⇒ 索引文件属于**另一次部署** —— chain index sync 一律拒绝,
|
|
67
|
+
报 **INDEX_IDENTITY_CHANGED** (next_action=needs_human, 这次不扫不写), **不是** REORG_SUSPECTED;
|
|
68
|
+
修法只有一个: bolloon chain index rebuild (干净重建: 丢弃旧身份的记录, 采用当前身份, 如实报丢弃条数)。
|
|
69
|
+
chain index status 里的 escrow/部署块就是**这份索引**的身份 —— 它不该等于当前链配置时, 先 rebuild。
|
|
70
|
+
`;
|
|
71
|
+
let deps = null;
|
|
72
|
+
export function setChainCommandDepsForTesting(d) {
|
|
73
|
+
deps = d;
|
|
74
|
+
}
|
|
75
|
+
// ── 小工具 ──────────────────────────────────────────────────────────────────
|
|
76
|
+
const TASK_KEY_RE = /^0x[0-9a-fA-F]{64}$/;
|
|
77
|
+
const ADDR_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
78
|
+
/** 「钱不够」类真实 revert 文案 (余额/授权/原生币 gas) —— 别把 RPC 故障误判成钱不够 */
|
|
79
|
+
const INSUFFICIENT_RE = /insufficient|exceeds allowance|exceeds balance|transfer amount exceeds|ERC20Insufficient/i;
|
|
80
|
+
export function isHexTaskKey(v) {
|
|
81
|
+
return typeof v === 'string' && TASK_KEY_RE.test(v.trim());
|
|
82
|
+
}
|
|
83
|
+
/** 十进制 USDC 串 → 原子单位 (超精度**拒绝**, 不静默截断) */
|
|
84
|
+
export function toAtomicUnits(usdc, decimals) {
|
|
85
|
+
const s = String(usdc).trim();
|
|
86
|
+
if (!/^\d+(\.\d+)?$/.test(s))
|
|
87
|
+
return null;
|
|
88
|
+
const [int, frac = ''] = s.split('.');
|
|
89
|
+
if (frac.length > decimals)
|
|
90
|
+
return null;
|
|
91
|
+
return BigInt(int) * 10n ** BigInt(decimals) + BigInt((frac + '0'.repeat(decimals)).slice(0, decimals) || '0');
|
|
92
|
+
}
|
|
93
|
+
function optInt(flags, name, def) {
|
|
94
|
+
const v = opt(flags, name);
|
|
95
|
+
if (v === undefined || v === '')
|
|
96
|
+
return def;
|
|
97
|
+
const n = Number(v);
|
|
98
|
+
return Number.isFinite(n) ? Math.floor(n) : def;
|
|
99
|
+
}
|
|
100
|
+
/** 摘要口径: `sha256:` 前缀原样透传, 否则按正文算 sha256 (与 P4 sha256Digest 同一实现) */
|
|
101
|
+
function digestOf(raw, sha256Digest) {
|
|
102
|
+
return raw.startsWith('sha256:') ? raw : sha256Digest(raw);
|
|
103
|
+
}
|
|
104
|
+
function humanFail(head, env) {
|
|
105
|
+
const extra = env.data?.missing ? `\n 缺失: ${env.data.missing.join(', ')}` : '';
|
|
106
|
+
return `${title(head)}\n ✗ ${env.message}${extra}\n code: ${env.code} · 下一步: ${env.next_action ?? '无需额外动作'}`;
|
|
107
|
+
}
|
|
108
|
+
function taskKeyArg(flags) {
|
|
109
|
+
const v = flags.positionals[2] || opt(flags, '--task-key');
|
|
110
|
+
return v && TASK_KEY_RE.test(String(v).trim()) ? String(v).trim().toLowerCase() : null;
|
|
111
|
+
}
|
|
112
|
+
/** 链配置 (缺 → CHAIN_NOT_CONFIGURED, 列出缺哪些; 绝不猜地址) */
|
|
113
|
+
async function loadCfg(opts = {}) {
|
|
114
|
+
const CFG = await import('../../agents/chain/chain-config.js');
|
|
115
|
+
try {
|
|
116
|
+
// requireToken: 真写 (createEscrow) 必须知道**付哪个 token** —— 拿不到就给可操作的错
|
|
117
|
+
// (三层来源逐个点名 + 怎么修), 而不是让下游报含糊的"缺少参数"。
|
|
118
|
+
return { ok: true, cfg: CFG.loadChainConfig({ home: home(), requireToken: opts.requireToken === true }) };
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
const missing = Array.isArray(e?.missing) ? e.missing : [];
|
|
122
|
+
const tokenGap = missing.includes('tokenAddress');
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
envelope: failEnvelope('CHAIN_NOT_CONFIGURED', String(e?.message || e).slice(0, 400), {
|
|
126
|
+
missing,
|
|
127
|
+
home: home(),
|
|
128
|
+
configPath: CFG.chainConfigPath(home()),
|
|
129
|
+
deploymentsDir: CFG.deploymentsDir(process.env),
|
|
130
|
+
// ★ 缺"付款资产"时把修法一并放进信封 (机器可执行, 不用去猜) —— 见 chain-config.tokenAddressGuidance
|
|
131
|
+
...(tokenGap ? {
|
|
132
|
+
tokenAddress: null,
|
|
133
|
+
howToFix: [
|
|
134
|
+
'export BOLLOON_TOKEN_ADDRESS=0x… (真 USDC 或本地 MockERC20)',
|
|
135
|
+
`在 ${CFG.chainConfigPath(home())} 写 {"tokenAddress":"0x…","tokenDecimals":6}`,
|
|
136
|
+
'让部署 manifest 记 token 地址, 并给出能唯一定位它的锚 (BOLLOON_CHAIN_ID / BOLLOON_NETWORK_NAME)',
|
|
137
|
+
'本次显式传 --asset 0x… (只影响这一次调用)',
|
|
138
|
+
],
|
|
139
|
+
} : {}),
|
|
140
|
+
}, [], 'needs_human'),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function makeClient(cfg) {
|
|
145
|
+
if (deps?.client)
|
|
146
|
+
return deps.client(cfg);
|
|
147
|
+
const { EscrowClient } = await import('../../agents/chain/escrow-client.js');
|
|
148
|
+
return new EscrowClient({ config: cfg });
|
|
149
|
+
}
|
|
150
|
+
// ── 入口 ────────────────────────────────────────────────────────────────────
|
|
151
|
+
export async function chainCommand(flags) {
|
|
152
|
+
const sub = flags.positionals[0];
|
|
153
|
+
switch (sub) {
|
|
154
|
+
case 'status': return chainStatus(flags);
|
|
155
|
+
case 'escrow': return chainEscrow(flags);
|
|
156
|
+
case 'timeline': return chainTimeline(flags);
|
|
157
|
+
case 'index': return chainIndex(flags);
|
|
158
|
+
case 'trade': return chainTrade(flags);
|
|
159
|
+
default:
|
|
160
|
+
return {
|
|
161
|
+
envelope: failEnvelope('INVALID_ARGUMENT', sub ? `未知 chain 子命令: ${sub}` : '缺少 chain 子命令', { usage: plain(CHAIN_USAGE.trim()) }, [], 'needs_human'),
|
|
162
|
+
human: CHAIN_USAGE,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// ── chain status ────────────────────────────────────────────────────────────
|
|
167
|
+
async function chainStatus(flags) {
|
|
168
|
+
const head = 'bolloon chain status';
|
|
169
|
+
const loaded = await loadCfg();
|
|
170
|
+
if (!loaded.ok)
|
|
171
|
+
return { envelope: loaded.envelope, human: humanFail(head, loaded.envelope) };
|
|
172
|
+
const cfg = loaded.cfg;
|
|
173
|
+
const CFG = await import('../../agents/chain/chain-config.js');
|
|
174
|
+
const data = {
|
|
175
|
+
chainId: cfg.chainId,
|
|
176
|
+
networkName: cfg.networkName,
|
|
177
|
+
rpcUrl: cfg.rpcUrl,
|
|
178
|
+
escrowAddress: cfg.escrowAddress,
|
|
179
|
+
tokenAddress: cfg.tokenAddress,
|
|
180
|
+
tokenDecimals: cfg.tokenDecimals,
|
|
181
|
+
confirmations: { confirmed: cfg.confirmations.confirmed, finalized: cfg.confirmations.finalized },
|
|
182
|
+
confirmationsSemantics: {
|
|
183
|
+
observed: `确认数 < ${cfg.confirmations.confirmed} (只在链上被观测到)`,
|
|
184
|
+
confirmed: `确认数 ≥ ${cfg.confirmations.confirmed}`,
|
|
185
|
+
finalized: `确认数 ≥ ${cfg.confirmations.finalized}`,
|
|
186
|
+
},
|
|
187
|
+
localDev: cfg.chainId === 31337,
|
|
188
|
+
sources: cfg.sources,
|
|
189
|
+
configPath: CFG.chainConfigPath(home()),
|
|
190
|
+
walletPath: CFG.walletFilePath(home()),
|
|
191
|
+
rpcOk: false,
|
|
192
|
+
latestBlock: null,
|
|
193
|
+
escrowBytecode: null,
|
|
194
|
+
onChainContractVersion: null,
|
|
195
|
+
wallet: { available: false, source: 'none', reason: null, address: null, nativeBalanceEth: null, tokenBalanceUsdc: null },
|
|
196
|
+
privateKeyPrinted: false,
|
|
197
|
+
writesMoneyOn: null,
|
|
198
|
+
note: '钱包只报可用性与公开地址; 私钥永不出本命令; local-dev (chainId 31337) 永不产出 fully_settled',
|
|
199
|
+
};
|
|
200
|
+
let client = null;
|
|
201
|
+
try {
|
|
202
|
+
client = await makeClient(cfg);
|
|
203
|
+
data.latestBlock = await client.getLatestBlockNumber();
|
|
204
|
+
data.rpcOk = true;
|
|
205
|
+
try {
|
|
206
|
+
const code = await client.provider.getCode(cfg.escrowAddress);
|
|
207
|
+
data.escrowBytecode = typeof code === 'string' && code.length > 2;
|
|
208
|
+
}
|
|
209
|
+
catch { /* bytecode 读不到 → 保持 null (不假装有) */ }
|
|
210
|
+
try {
|
|
211
|
+
data.onChainContractVersion = await client.contractVersion();
|
|
212
|
+
}
|
|
213
|
+
catch { /* 可选 */ }
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
return {
|
|
217
|
+
envelope: failEnvelope('CHAIN_UNAVAILABLE', `连不上链 / 读不到 head: ${String(e?.shortMessage || e?.message || e).slice(0, 240)}`, { ...data, requestedBy: head }, [], 'needs_human'),
|
|
218
|
+
human: humanFail(head, { code: 'CHAIN_UNAVAILABLE', message: `连不上 ${cfg.rpcUrl}`, next_action: 'needs_human', data }),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
// 钱包: 只报可用性 + 公开地址 (私钥留在 walletAvailable 内部)
|
|
222
|
+
const w = CFG.walletAvailable({ home: home() });
|
|
223
|
+
const wallet = data.wallet;
|
|
224
|
+
wallet.available = w.available === true;
|
|
225
|
+
wallet.source = w.source;
|
|
226
|
+
wallet.reason = w.reason ?? null;
|
|
227
|
+
if (w.available === true) {
|
|
228
|
+
try {
|
|
229
|
+
const signer = client.localSigner({ home: home() });
|
|
230
|
+
wallet.address = await signer.getAddress();
|
|
231
|
+
const native = await client.provider.getBalance(wallet.address);
|
|
232
|
+
wallet.nativeBalanceEth = `${Number(native) / 1e18}`;
|
|
233
|
+
if (cfg.tokenAddress) {
|
|
234
|
+
const { Contract } = await import('ethers');
|
|
235
|
+
const erc20 = new Contract(cfg.tokenAddress, ['function balanceOf(address) view returns (uint256)'], client.provider);
|
|
236
|
+
const bal = await erc20.balanceOf(wallet.address);
|
|
237
|
+
wallet.tokenBalanceAtomic = bal.toString();
|
|
238
|
+
const { atomicToUsdc } = await import('../../agents/chain/onchain-trade.js');
|
|
239
|
+
wallet.tokenBalanceUsdc = atomicToUsdc(BigInt(bal), cfg.tokenDecimals);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch (e) {
|
|
243
|
+
wallet.balanceError = String(e?.shortMessage || e?.message || e).slice(0, 160);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const human = [
|
|
247
|
+
title(head),
|
|
248
|
+
line('chainId', `${data.chainId}${data.localDev ? ' (本地开发链 — local-dev 永不 fully_settled)' : ''}`),
|
|
249
|
+
line('network', String(data.networkName)),
|
|
250
|
+
line('RPC', `${data.rpcUrl} (可达: ${data.rpcOk ? '是' : '否'})`),
|
|
251
|
+
line('latestBlock', String(data.latestBlock ?? '(读不到)')),
|
|
252
|
+
line('escrow', `${data.escrowAddress} (bytecode: ${data.escrowBytecode === null ? '未读' : data.escrowBytecode ? '有' : '无'} · 链上版本 ${data.onChainContractVersion ?? '?'})`),
|
|
253
|
+
line('token', `${data.tokenAddress ?? '(未配置)'} · decimals=${data.tokenDecimals}`),
|
|
254
|
+
line('确认数', `confirmed ≥ ${cfg.confirmations.confirmed} · finalized ≥ ${cfg.confirmations.finalized}`),
|
|
255
|
+
line('钱包可用', `${w.available ? '是' : '否'} (${w.source})`),
|
|
256
|
+
...(wallet.address ? [
|
|
257
|
+
line('钱包地址', String(wallet.address)),
|
|
258
|
+
line('原生币', `${wallet.nativeBalanceEth ?? '?'}`),
|
|
259
|
+
...(wallet.tokenBalanceUsdc != null ? [line('token 余额', `${wallet.tokenBalanceUsdc} (${wallet.tokenBalanceAtomic} 原子)`)] : []),
|
|
260
|
+
] : []),
|
|
261
|
+
...(wallet.reason ? [line('钱包不可用', String(wallet.reason).slice(0, 120))] : []),
|
|
262
|
+
`\n ${hint('私钥永不打印/落盘; 链上写操作的签名走 (fail-closed) 放行闸')}`,
|
|
263
|
+
].join('\n');
|
|
264
|
+
return { envelope: okEnvelope('OK', `链 configuration 就绪: chainId=${data.chainId} · escrow=${data.escrowAddress}`, data, [String(data.escrowAddress), cfg.rpcUrl], null), human };
|
|
265
|
+
}
|
|
266
|
+
// ── chain escrow show ───────────────────────────────────────────────────────
|
|
267
|
+
async function chainEscrow(flags) {
|
|
268
|
+
const head = 'bolloon chain escrow show';
|
|
269
|
+
if (flags.positionals[1] !== 'show') {
|
|
270
|
+
return { envelope: failEnvelope('INVALID_ARGUMENT', '只实现了 `chain escrow show <taskKey>`', { usage: plain(CHAIN_USAGE.trim()) }, [], 'needs_human'), human: CHAIN_USAGE };
|
|
271
|
+
}
|
|
272
|
+
const raw = flags.positionals[2] || opt(flags, '--task-key') || '';
|
|
273
|
+
if (!isHexTaskKey(raw)) {
|
|
274
|
+
return {
|
|
275
|
+
envelope: failEnvelope('INVALID_ARGUMENT', `taskKey 必须是 0x + 64 hex (收到: ${String(raw).slice(0, 80) || '(空)'})`, { taskKey: String(raw).slice(0, 80) }, [], 'needs_human'),
|
|
276
|
+
human: `${title(head)}\n ✗ taskKey 不合法 (要 0x + 64 hex)`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
const taskKey = String(raw).trim().toLowerCase();
|
|
280
|
+
const loaded = await loadCfg();
|
|
281
|
+
if (!loaded.ok)
|
|
282
|
+
return { envelope: loaded.envelope, human: humanFail(head, loaded.envelope) };
|
|
283
|
+
let client;
|
|
284
|
+
let e;
|
|
285
|
+
try {
|
|
286
|
+
client = await makeClient(loaded.cfg);
|
|
287
|
+
e = await client.getEscrow(taskKey);
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
// ★ 读不到 ≠ 不存在: 读不到一律 CHAIN_UNAVAILABLE (绝不冒报 ESCROW_NOT_FOUND)
|
|
291
|
+
return {
|
|
292
|
+
envelope: failEnvelope('CHAIN_UNAVAILABLE', `读不到 escrow (RPC/合约调用失败, **不代表不存在**): ${String(err?.shortMessage || err?.message || err).slice(0, 240)}`, { taskKey, escrowAddress: loaded.cfg.escrowAddress }, [], 'reconcile'),
|
|
293
|
+
human: `${title(`${head} ${taskKey}`)}\n ✗ 读不到 (RPC 问题), 不代表不存在 — 稍后重试`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
if (!e) {
|
|
297
|
+
return {
|
|
298
|
+
envelope: failEnvelope('ESCROW_NOT_FOUND', `链上 ${loaded.cfg.escrowAddress} 里没有 taskKey=${taskKey} 对应的 escrow (buyer==0)`, { taskKey, escrowAddress: loaded.cfg.escrowAddress }, [taskKey], 'wait'),
|
|
299
|
+
human: `${title(`${head} ${taskKey}`)}\n 链上没有这条 escrow (buyer==0)\n ${hint('下一步: 先 create (bolloon chain trade create) 或确认 taskId/taskKey 对不对')}`,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const { atomicToUsdc } = await import('../../agents/chain/onchain-trade.js');
|
|
303
|
+
const data = {
|
|
304
|
+
taskKey,
|
|
305
|
+
taskKeyMatches: String(e.taskKey).toLowerCase() === taskKey,
|
|
306
|
+
escrowAddress: loaded.cfg.escrowAddress,
|
|
307
|
+
chainId: loaded.cfg.chainId,
|
|
308
|
+
chainIdOnChain: e.chainId.toString(),
|
|
309
|
+
buyer: e.buyer,
|
|
310
|
+
agent: e.agent,
|
|
311
|
+
amountAtomic: e.amount.toString(),
|
|
312
|
+
amountUsdc: atomicToUsdc(e.amount, loaded.cfg.tokenDecimals),
|
|
313
|
+
state: e.stateName,
|
|
314
|
+
stateCode: e.state,
|
|
315
|
+
paymentAsset: e.paymentAsset,
|
|
316
|
+
proofVersion: e.proofVersion,
|
|
317
|
+
proofHash: e.proofHash,
|
|
318
|
+
resultHash: e.resultHash,
|
|
319
|
+
termsHash: e.termsHash,
|
|
320
|
+
quoteHash: e.quoteHash,
|
|
321
|
+
inputHash: e.inputHash,
|
|
322
|
+
manifestHash: e.manifestHash,
|
|
323
|
+
deadline: e.deadline.toString(),
|
|
324
|
+
confirmationWindow: e.confirmationWindow,
|
|
325
|
+
createdAt: e.createdAt.toString(),
|
|
326
|
+
createdBlock: e.createdBlock,
|
|
327
|
+
contractVersion: e.contractVersion,
|
|
328
|
+
localDev: loaded.cfg.chainId === 31337,
|
|
329
|
+
note: '链上原文 (19 字段); 判据仍是 receipt/事件/确认数 —— 状态=RELEASED 不等于本机已 verified',
|
|
330
|
+
};
|
|
331
|
+
const human = [
|
|
332
|
+
title(`${head} ${taskKey}`),
|
|
333
|
+
line('状态', `${e.stateName} (${e.state})`),
|
|
334
|
+
line('buyer', String(e.buyer)),
|
|
335
|
+
line('agent', String(e.agent)),
|
|
336
|
+
line('金额', `${data.amountUsdc} (${data.amountAtomic} 原子)`),
|
|
337
|
+
line('资产', String(e.paymentAsset)),
|
|
338
|
+
line('deadline', String(e.deadline)),
|
|
339
|
+
line('确认窗口', String(e.confirmationWindow)),
|
|
340
|
+
line('proofVersion', String(e.proofVersion)),
|
|
341
|
+
line('resultHash', String(e.resultHash)),
|
|
342
|
+
line('escrow 合约', String(loaded.cfg.escrowAddress)),
|
|
343
|
+
].join('\n');
|
|
344
|
+
return { envelope: okEnvelope('OK', `escrow ${taskKey}: ${e.stateName} · ${data.amountUsdc} USDC`, data, [taskKey, String(e.buyer), String(e.agent), String(loaded.cfg.escrowAddress)], null), human };
|
|
345
|
+
}
|
|
346
|
+
// ── chain timeline ──────────────────────────────────────────────────────────
|
|
347
|
+
async function chainTimeline(flags) {
|
|
348
|
+
const head = 'bolloon chain timeline';
|
|
349
|
+
const raw = flags.positionals[1] || opt(flags, '--task-key') || '';
|
|
350
|
+
if (!isHexTaskKey(raw)) {
|
|
351
|
+
return { envelope: failEnvelope('INVALID_ARGUMENT', `taskKey 必须是 0x + 64 hex (收到: ${String(raw).slice(0, 80) || '(空)'})`, { taskKey: String(raw).slice(0, 80) }, [], 'needs_human'), human: `${title(head)}\n ✗ taskKey 不合法 (要 0x + 64 hex)` };
|
|
352
|
+
}
|
|
353
|
+
const taskKey = String(raw).trim().toLowerCase();
|
|
354
|
+
const IQ = await import('../../agents/chain/chain-index-query.js');
|
|
355
|
+
const OT = await import('../../agents/chain/onchain-trade.js');
|
|
356
|
+
const tl = IQ.getEscrowTimeline(taskKey, { home: home() });
|
|
357
|
+
const index = IQ.getIndexStatus({ home: home() });
|
|
358
|
+
let local = null;
|
|
359
|
+
try {
|
|
360
|
+
local = OT.recoverOnchainTrade({ home: home(), taskKey });
|
|
361
|
+
}
|
|
362
|
+
catch (e) {
|
|
363
|
+
local = { error: String(e?.message || e).slice(0, 200) };
|
|
364
|
+
}
|
|
365
|
+
if (tl.count === 0 && !(local && local.found)) {
|
|
366
|
+
return {
|
|
367
|
+
envelope: failEnvelope('ESCROW_NOT_FOUND', `索引里没有 taskKey=${taskKey} 的事件, 本机 chain-state.json 里也没有它的链上记录`, { taskKey, indexPath: index.indexPath, lastSyncedBlock: index.lastSyncedBlock, entries: index.entries }, [taskKey], 'wait'),
|
|
368
|
+
human: `${title(`${head} ${taskKey}`)}\n 索引与本机记录里都没有这条任务\n ${hint('下一步: bolloon chain index sync 先把链上事件同步下来')}`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
const suspect = tl.hasSuspect === true || (local?.records || []).some((r) => r.suspect === true);
|
|
372
|
+
const data = {
|
|
373
|
+
taskKey,
|
|
374
|
+
/** 只按事件推出来的状态 (不读链) */
|
|
375
|
+
state: tl.state,
|
|
376
|
+
count: tl.count,
|
|
377
|
+
events: tl.events,
|
|
378
|
+
finalityLegend: { observed: '只在链上被观测到 (确认数 < confirmed 门槛)', confirmed: '≥ confirmed 门槛', finalized: '≥ finalized 门槛' },
|
|
379
|
+
reconstructed: tl.events.map((e) => `${e.eventName}@${e.blockNumber}(${e.finality})`),
|
|
380
|
+
index: { indexPath: index.indexPath, lastSyncedBlock: index.lastSyncedBlock, lastSyncedAt: index.lastSyncedAt, entries: index.entries },
|
|
381
|
+
local: local ? {
|
|
382
|
+
found: local.found, nextAction: local.nextAction, mustNotRepay: local.mustNotRepay, verified: local.verified,
|
|
383
|
+
reason: local.reason, blockedBy: local.blockedBy, statePath: local.statePath,
|
|
384
|
+
records: (local.records || []).map((r) => ({ method: r.method, txHash: r.txHash, status: r.status, confirmations: r.confirmations, suspect: r.suspect })),
|
|
385
|
+
} : null,
|
|
386
|
+
hasSuspect: suspect,
|
|
387
|
+
chainSettled: local?.verified === true,
|
|
388
|
+
note: '链上事件时间线 (索引) + 本机视角 (纯读盘); verified 只认「链上释放确认 + 事件对上 + 合约 RELEASED」',
|
|
389
|
+
};
|
|
390
|
+
const evidence = Array.from(new Set([taskKey, ...tl.events.map((e) => e.txHash), ...((local?.records || []).map((r) => r.txHash))].filter(Boolean))).slice(0, 20);
|
|
391
|
+
if (suspect) {
|
|
392
|
+
return {
|
|
393
|
+
envelope: failEnvelope('REORG_SUSPECTED', `这条时间线里有被标可疑的记录 (重组/事件消失) → 一律不算已结算`, data, evidence, 'reconcile'),
|
|
394
|
+
human: `${title(`${head} ${taskKey}`)}\n ✗ 有被标可疑的记录 (重组) — 不算已结算\n code: REORG_SUSPECTED · 下一步: reconcile`,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
const human = [
|
|
398
|
+
title(`${head} ${taskKey}`),
|
|
399
|
+
line('事件数', `${tl.count} (链上索引)`),
|
|
400
|
+
line('推出状态', String(tl.state ?? '(未定)')),
|
|
401
|
+
line('本机 verified', local?.verified ? '是' : '否'),
|
|
402
|
+
line('本机 nextAction', String(local?.nextAction ?? '(无记录)')),
|
|
403
|
+
...tl.events.map((e) => ` ${String(e.blockNumber).padStart(7)} #${e.logIndex} ${e.eventName} (${e.finality}, ${e.confirmations} 确认) ${e.txHash.slice(0, 18)}…`),
|
|
404
|
+
...(local?.records?.length ? [`\n 本机链上记录 (chain-state.json):`, ...local.records.map((r) => ` ${r.method} ${r.status} conf=${r.confirmations} ${r.txHash.slice(0, 18)}…`)] : []),
|
|
405
|
+
].join('\n');
|
|
406
|
+
return { envelope: okEnvelope('OK', `时间线 ${tl.count} 条事件 · 状态 ${tl.state ?? '(未定)'}`, data, evidence, null), human };
|
|
407
|
+
}
|
|
408
|
+
// ── chain index status|stats|sync|rebuild ───────────────────────────────────
|
|
409
|
+
/** 索引身份 → 一行人话 (chainId · escrow · 部署块) —— 读的人一眼看到这份索引是谁的 */
|
|
410
|
+
function identityLine(i) {
|
|
411
|
+
if (!i)
|
|
412
|
+
return '(未知)';
|
|
413
|
+
return `chainId=${i.chainId} · escrow=${i.escrowAddress} · 部署块=${i.deploymentBlock}`;
|
|
414
|
+
}
|
|
415
|
+
/** 身份变更后唯一该做的事 (与 chain-indexer 的 INDEX_IDENTITY_REBUILD_COMMAND 同一句) */
|
|
416
|
+
const IDENTITY_FIX = 'bolloon chain index rebuild';
|
|
417
|
+
/** 造索引器 (测试可注入假链; 生产唯一构造入口仍是 new ChainIndexer({ config, home })) */
|
|
418
|
+
async function makeIndexer(cfg) {
|
|
419
|
+
if (deps?.indexer)
|
|
420
|
+
return deps.indexer(cfg, home());
|
|
421
|
+
const { ChainIndexer } = await import('../../agents/chain/chain-indexer.js');
|
|
422
|
+
return new ChainIndexer({ config: cfg, home: home() });
|
|
423
|
+
}
|
|
424
|
+
async function chainIndex(flags) {
|
|
425
|
+
const sub = flags.positionals[1];
|
|
426
|
+
const IQ = await import('../../agents/chain/chain-index-query.js');
|
|
427
|
+
if (sub === 'status') {
|
|
428
|
+
const s = IQ.getIndexStatus({ home: home() });
|
|
429
|
+
const never = s.lastSyncedAt == null;
|
|
430
|
+
const data = { ...s, synced: !never, note: never ? '索引从未同步过 (本机没有任何链上事件记录)' : '读本机索引文件 (不发 RPC)' };
|
|
431
|
+
return {
|
|
432
|
+
envelope: okEnvelope('OK', never ? `索引从未同步 (文件 ${s.indexPath})` : `索引高度 ${s.lastSyncedBlock} · ${s.entries} 条事件 (suspect ${s.suspects})`, data, [s.indexPath], null),
|
|
433
|
+
human: [title('bolloon chain index status'), line('索引文件', s.indexPath), line('已同步到', never ? '(从未)' : `${s.lastSyncedBlock}`), line('事件数', `${s.entries} (suspect ${s.suspects})`), line('最后同步', s.lastSyncedAt ? new Date(s.lastSyncedAt).toISOString() : '(从未)'), line('部署块', `${s.deploymentBlock} (${s.deploymentSource})`), line('索引身份', identityLine(s.identity)), line('确认数门槛', `confirmed ≥ ${s.confirmations.confirmed} · finalized ≥ ${s.confirmations.finalized}`)].join('\n'),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
if (sub === 'stats') {
|
|
437
|
+
const st = IQ.getIndexStats({ home: home() });
|
|
438
|
+
const data = { ...st, note: 'suspect (被回退) 的记录不计入业务计数, 单独报出' };
|
|
439
|
+
return {
|
|
440
|
+
envelope: okEnvelope('OK', `链上事件统计: ${st.tasks} 个任务 · created ${st.created} / proof ${st.proofSubmitted} / released ${st.released}`, data, [st.escrowAddress], null),
|
|
441
|
+
human: [title('bolloon chain index stats'), line('任务数', st.tasks), line('created', st.created), line('proof', st.proofSubmitted), line('released', st.released), line('refunded', st.refunded), line('disputed', st.disputed), line('expired', st.expired), line('分档', `observed ${st.byFinality.observed} · confirmed ${st.byFinality.confirmed} · finalized ${st.byFinality.finalized}`), line('suspect', st.suspects)].join('\n'),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
if (sub === 'sync') {
|
|
445
|
+
const head = 'bolloon chain index sync';
|
|
446
|
+
const loaded = await loadCfg();
|
|
447
|
+
if (!loaded.ok)
|
|
448
|
+
return { envelope: loaded.envelope, human: humanFail(head, loaded.envelope) };
|
|
449
|
+
const from = optInt(flags, '--from-block', undefined);
|
|
450
|
+
let idx;
|
|
451
|
+
try {
|
|
452
|
+
idx = await makeIndexer(loaded.cfg);
|
|
453
|
+
}
|
|
454
|
+
catch (e) {
|
|
455
|
+
// 索引起点解析不到 (部署 manifest 缺) → 配置类失败, 不猜 0
|
|
456
|
+
return {
|
|
457
|
+
envelope: failEnvelope('CHAIN_NOT_CONFIGURED', `索引起点解析失败 (部署 manifest / BOLLOON_ESCROW_DEPLOYMENT_BLOCK): ${String(e?.message || e).slice(0, 300)}`, { escrowAddress: loaded.cfg.escrowAddress, chainId: loaded.cfg.chainId }, [], 'needs_human'),
|
|
458
|
+
human: humanFail(head, { code: 'CHAIN_NOT_CONFIGURED', message: '索引起点解析失败', next_action: 'needs_human' }),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
let r;
|
|
462
|
+
try {
|
|
463
|
+
r = await idx.syncFrom(from);
|
|
464
|
+
}
|
|
465
|
+
catch (e) {
|
|
466
|
+
// ★ 身份变更 ≠ 重组 (2026-09-22 P5 修正): 索引文件属于另一次部署/另一条链实例时,
|
|
467
|
+
// indexer 在任何 RPC/写盘之前就拒了 —— 这里把它翻成一个**可操作**的新码,
|
|
468
|
+
// 绝不落进 CHAIN_UNAVAILABLE (看不到原因) 或 REORG_SUSPECTED (把人引去查分叉)。
|
|
469
|
+
if (e?.code === 'INDEX_IDENTITY_CHANGED' || e?.name === 'ChainIndexIdentityChangedError') {
|
|
470
|
+
const oldI = e?.oldIdentity ?? null;
|
|
471
|
+
const newI = e?.newIdentity ?? null;
|
|
472
|
+
return {
|
|
473
|
+
envelope: failEnvelope('INDEX_IDENTITY_CHANGED', `索引身份变了 (**这不是重组**): 索引文件属于 ${identityLine(oldI)}, 当前链是 ${identityLine(newI)} —— 旧索引里的事件不属于当前合约/链实例。这次**没扫、没写盘**。修法: ${IDENTITY_FIX}`, {
|
|
474
|
+
identityChanged: true, oldIdentity: oldI, newIdentity: newI,
|
|
475
|
+
indexPath: IQ.currentIndexPath(home()),
|
|
476
|
+
escrowAddress: loaded.cfg.escrowAddress, chainId: loaded.cfg.chainId,
|
|
477
|
+
scanned: false, wroteIndex: false,
|
|
478
|
+
suggestedCommand: IDENTITY_FIX,
|
|
479
|
+
howToFix: [
|
|
480
|
+
`${IDENTITY_FIX} --json (干净重建: 丢弃旧身份的记录, 采用当前身份)`,
|
|
481
|
+
'想留旧部署的索引 → 把 BOLLOON_* 指回旧部署/旧链, 或用 BOLLOON_INDEX_* 给它单独一个索引文件 (别覆盖)',
|
|
482
|
+
],
|
|
483
|
+
note: '换合约部署/anvil 重启会让索引文件属于另一次部署; 把两边的事件混在一个文件里会污染 status/stats/timeline 的每一处读数 —— 所以这里什么都不动',
|
|
484
|
+
}, [String(oldI?.escrowAddress ?? ''), String(newI?.escrowAddress ?? '')].filter(Boolean), 'needs_human'),
|
|
485
|
+
human: [
|
|
486
|
+
title(head),
|
|
487
|
+
' ✗ 索引身份变了 (**这不是重组**) — 这次没扫、没写盘',
|
|
488
|
+
line('旧索引', identityLine(oldI)),
|
|
489
|
+
line('当前链', identityLine(newI)),
|
|
490
|
+
hint(` 下一步: ${IDENTITY_FIX} (干净重建: 丢弃旧身份记录, 采用当前身份)`),
|
|
491
|
+
].join('\n'),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
return {
|
|
495
|
+
envelope: failEnvelope('CHAIN_UNAVAILABLE', `同步失败 (RPC/读链): ${String(e?.shortMessage || e?.message || e).slice(0, 260)}`, { fromBlock: from ?? null, escrowAddress: loaded.cfg.escrowAddress }, [], 'reconcile'),
|
|
496
|
+
human: humanFail(head, { code: 'CHAIN_UNAVAILABLE', message: '同步失败', next_action: 'reconcile' }),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
finally {
|
|
500
|
+
try {
|
|
501
|
+
idx.close();
|
|
502
|
+
}
|
|
503
|
+
catch { /* noop */ }
|
|
504
|
+
}
|
|
505
|
+
const data = {
|
|
506
|
+
mode: r.mode, requestedFrom: r.requestedFrom, scanFrom: r.scanFrom, scanTo: r.scanTo,
|
|
507
|
+
pages: r.pages, blocksScanned: r.blocksScanned, logsFound: r.logsFound,
|
|
508
|
+
inserted: r.inserted, deduped: r.deduped, restored: r.restored,
|
|
509
|
+
markedSuspect: r.markedSuspect, rewoundTo: r.rewoundTo,
|
|
510
|
+
headBlock: r.headBlock, headBlockHash: r.headBlockHash, lastSyncedBlock: r.lastSyncedBlock,
|
|
511
|
+
entries: r.entries, suspects: r.suspects, durationMs: r.durationMs,
|
|
512
|
+
/** 扫这些日志时**实际用的**身份 (与落盘身份同源) */
|
|
513
|
+
identity: r.identity ?? null,
|
|
514
|
+
indexPath: IQ.currentIndexPath(home()),
|
|
515
|
+
writesMoney: false, writesKeys: false,
|
|
516
|
+
note: '索引只是链上事件的本地缓存 (可删可重建); 它**不是**结算事实, 判据仍是 receipt/事件/确认数',
|
|
517
|
+
};
|
|
518
|
+
// 重组回退 → 有记录被标可疑 → 不报成功
|
|
519
|
+
if ((r.markedSuspect ?? 0) > 0 || r.rewoundTo != null) {
|
|
520
|
+
const idData = r.identity ?? {};
|
|
521
|
+
// 回退点 = 扫描下界 (部署块 - 1) 时: 整个已索引区间都对不上 —— 说清楚这不是"某一块分叉",
|
|
522
|
+
// 并且**绝不**把回退点报到部署块以下 (回退不到那里: 那些块从来不在本索引的扫描范围内)。
|
|
523
|
+
const deepRewind = r.markedSuspect > 0 && r.rewoundTo != null
|
|
524
|
+
&& r.rewoundTo <= Math.max(0, Number(idData.deploymentBlock ?? 0) - 1);
|
|
525
|
+
return {
|
|
526
|
+
envelope: failEnvelope('REORG_SUSPECTED', deepRewind
|
|
527
|
+
? `同步时检出**整段**对不上: 回退到扫描下界 ${r.rewoundTo} (部署块 ${idData.deploymentBlock} - 1, 再往下不属于本索引的扫描范围), 标可疑 ${r.markedSuspect} 条 (记录保留, 不当没发生)。若你最近换过合约部署/换过链, 那不是重组 → 先 \`${IDENTITY_FIX}\``
|
|
528
|
+
: `同步时检出重组: 回退到 ${r.rewoundTo}, 标可疑 ${r.markedSuspect} 条 (记录保留, 不当没发生)`, { ...data, deepRewind, rewoundToIsScanFloor: deepRewind, scanFloor: Math.max(0, Number(idData.deploymentBlock ?? 0) - 1) }, [String(r.headBlockHash)], 'reconcile'),
|
|
529
|
+
human: `${title(head)}\n ✗ ${deepRewind ? `整段对不上: 回退到扫描下界 ${r.rewoundTo}` : `检出重组: 回退到 ${r.rewoundTo}`}, 标 suspect ${r.markedSuspect} 条\n ${hint(deepRewind ? `若换过合约部署/换过链 → 先 ${IDENTITY_FIX} (身份变更不是重组)` : '下一步: bolloon chain index stats 看 suspect, 再决定是否 rebuild')}`,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
envelope: okEnvelope('OK', `同步完成: 新增 ${r.inserted} 条 (去重 ${r.deduped}), 共 ${r.entries} 条 · 扫到 ${r.scanTo}/${r.headBlock}`, data, [String(r.headBlockHash), String(r.headBlock)], null),
|
|
534
|
+
human: [title(head), line('扫描区间', `${r.scanFrom} → ${r.scanTo}`), line('本页/区块', `${r.pages} 页 / ${r.blocksScanned} 块`), line('新增/去重', `${r.inserted} / ${r.deduped}`), line('索引事件数', `${r.entries} (suspect ${r.suspects})`), line('已同步到', r.lastSyncedBlock), line('索引身份', identityLine(r.identity)), line('耗时', `${r.durationMs}ms`)].join('\n'),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
if (sub === 'rebuild') {
|
|
538
|
+
// ★ 薄包装 ChainIndexer.rebuild (本分支**不新写任何业务逻辑**): 全量重扫 → 身份变更时干净重建。
|
|
539
|
+
const head = 'bolloon chain index rebuild';
|
|
540
|
+
const loaded = await loadCfg();
|
|
541
|
+
if (!loaded.ok)
|
|
542
|
+
return { envelope: loaded.envelope, human: humanFail(head, loaded.envelope) };
|
|
543
|
+
let idx;
|
|
544
|
+
try {
|
|
545
|
+
idx = await makeIndexer(loaded.cfg);
|
|
546
|
+
}
|
|
547
|
+
catch (e) {
|
|
548
|
+
return {
|
|
549
|
+
envelope: failEnvelope('CHAIN_NOT_CONFIGURED', `索引起点解析失败 (部署 manifest / BOLLOON_ESCROW_DEPLOYMENT_BLOCK): ${String(e?.message || e).slice(0, 300)}`, { escrowAddress: loaded.cfg.escrowAddress, chainId: loaded.cfg.chainId }, [], 'needs_human'),
|
|
550
|
+
human: humanFail(head, { code: 'CHAIN_NOT_CONFIGURED', message: '索引起点解析失败', next_action: 'needs_human' }),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
let r;
|
|
554
|
+
try {
|
|
555
|
+
r = await idx.rebuild({ persist: true });
|
|
556
|
+
}
|
|
557
|
+
catch (e) {
|
|
558
|
+
return {
|
|
559
|
+
envelope: failEnvelope('CHAIN_UNAVAILABLE', `重建失败 (RPC/读链): ${String(e?.shortMessage || e?.message || e).slice(0, 260)}`, { escrowAddress: loaded.cfg.escrowAddress }, [], 'reconcile'),
|
|
560
|
+
human: humanFail(head, { code: 'CHAIN_UNAVAILABLE', message: '重建失败', next_action: 'reconcile' }),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
finally {
|
|
564
|
+
try {
|
|
565
|
+
idx.close();
|
|
566
|
+
}
|
|
567
|
+
catch { /* noop */ }
|
|
568
|
+
}
|
|
569
|
+
const data = {
|
|
570
|
+
mode: r.mode, identity: r.identity, identityChanged: r.identityChanged === true,
|
|
571
|
+
oldIdentity: r.oldIdentity ?? null, newIdentity: r.newIdentity ?? null,
|
|
572
|
+
discardedEntries: r.discardedEntries ?? 0,
|
|
573
|
+
scanFrom: r.identity?.deploymentBlock ?? null, scanTo: r.headBlock,
|
|
574
|
+
pages: r.pages, blocksScanned: r.blocksScanned, logsFound: r.logsFound,
|
|
575
|
+
entries: r.entries, headBlock: r.headBlock,
|
|
576
|
+
comparison: r.comparison, durationMs: r.durationMs, persisted: r.persisted === true,
|
|
577
|
+
indexPath: IQ.currentIndexPath(home()),
|
|
578
|
+
writesMoney: false, writesKeys: false,
|
|
579
|
+
note: r.identityChanged
|
|
580
|
+
? '身份变更 → **干净重建**: 旧身份的记录被丢弃 (不是标 suspect), 索引现在只属于当前身份; 索引只是本地缓存, 不是结算事实'
|
|
581
|
+
: '全量重建 (身份未变): 与增量结果逐条比对, 旧的 suspect 记录照旧保留; 索引只是本地缓存, 不是结算事实',
|
|
582
|
+
};
|
|
583
|
+
const diff = r.comparison ? (r.comparison.missingInB.length + r.comparison.extraInB.length + r.comparison.mismatched.length) : 0;
|
|
584
|
+
if (r.identityChanged) {
|
|
585
|
+
return {
|
|
586
|
+
envelope: okEnvelope('OK', `身份变更 → 干净重建完成: 丢弃 ${data.discardedEntries} 条属于旧身份 (${identityLine(data.oldIdentity)}) 的记录, 索引现在只属于 ${identityLine(data.newIdentity)} · ${r.entries} 条事件`, data, [String(r.newIdentity?.escrowAddress ?? ''), String(r.headBlock)].filter(Boolean), null),
|
|
587
|
+
human: [
|
|
588
|
+
title(head),
|
|
589
|
+
line('身份变更', '是 (换合约部署/换链实例) — 这是**干净重建**, 不是重组'),
|
|
590
|
+
line('旧身份 (已丢弃)', `${identityLine(data.oldIdentity)} · 丢弃 ${data.discardedEntries} 条记录 (不标 suspect)`),
|
|
591
|
+
line('新身份 (已采用)', identityLine(data.newIdentity)),
|
|
592
|
+
line('扫描区间', `${data.scanFrom} → ${data.scanTo}`),
|
|
593
|
+
line('重建结果', `${r.entries} 条事件 (与重建前逐条比对 same=${r.comparison?.same})`),
|
|
594
|
+
line('索引文件', String(data.indexPath)),
|
|
595
|
+
].join('\n'),
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
envelope: okEnvelope('OK', `全量重建完成: ${r.entries} 条事件 (身份未变; 与增量比对 same=${r.comparison?.same}, 差 ${diff} 处)`, data, [String(r.headBlockHash ?? ''), String(r.headBlock)].filter(Boolean), null),
|
|
600
|
+
human: [
|
|
601
|
+
title(head),
|
|
602
|
+
line('身份变更', '否 (索引身份与当前链一致)'),
|
|
603
|
+
line('索引身份', identityLine(data.newIdentity)),
|
|
604
|
+
line('扫描区间', `${data.scanFrom} → ${data.scanTo}`),
|
|
605
|
+
line('重建结果', `${r.entries} 条事件 · 与增量比对 same=${r.comparison?.same} (差 ${diff} 处)`),
|
|
606
|
+
line('索引文件', String(data.indexPath)),
|
|
607
|
+
].join('\n'),
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
envelope: failEnvelope('INVALID_ARGUMENT', sub ? `未知 chain index 子命令: ${sub}` : '缺少 chain index 子命令 (status|stats|sync|rebuild)', { usage: plain(CHAIN_USAGE.trim()) }, [], 'needs_human'),
|
|
612
|
+
human: CHAIN_USAGE,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
const STAGE_LABEL = { create: 'createEscrowV2', proof: 'submitProofV2', release: 'releaseV2' };
|
|
616
|
+
/**
|
|
617
|
+
* ★ 一步链上结果 → 信封 (唯一的映射点; 只做**翻译**, 不重新判定)。
|
|
618
|
+
* 顺序即安全性顺序: 未授权 → 没发出去 → 回滚 → 重组 → 没确定 → (只有全过才) ok。
|
|
619
|
+
*/
|
|
620
|
+
function stepEnvelope(stage, step, extra = {}) {
|
|
621
|
+
const v = step.verdict || {};
|
|
622
|
+
const data = {
|
|
623
|
+
stage: step.stage,
|
|
624
|
+
method: step.method ?? STAGE_LABEL[stage] ?? null,
|
|
625
|
+
authorized: step.authorized === true,
|
|
626
|
+
authReason: step.authReason ?? null,
|
|
627
|
+
txHash: step.txHash || null,
|
|
628
|
+
blockNumber: step.blockNumber ?? null,
|
|
629
|
+
gasUsed: step.gasUsed ?? null,
|
|
630
|
+
requestId: step.requestId ?? null,
|
|
631
|
+
chainSettled: step.chainSettled === true,
|
|
632
|
+
chainStatus: step.chainStatus ?? null,
|
|
633
|
+
eventMatched: v.eventMatched ?? null,
|
|
634
|
+
matchedEvent: v.matchedEvent ?? null,
|
|
635
|
+
confirmations: v.confirmations ?? null,
|
|
636
|
+
confirmationsRequired: v.confirmationsRequired ?? null,
|
|
637
|
+
requiredGate: v.requiredGate ?? null,
|
|
638
|
+
escrowState: step.escrowState ?? null,
|
|
639
|
+
rpcAvailable: v.rpcAvailable ?? null,
|
|
640
|
+
grantsVerified: step.grantsVerified === true,
|
|
641
|
+
judgmentRecorded: step.recorded === true,
|
|
642
|
+
signatureAuditWritten: step.auditWritten === true,
|
|
643
|
+
privateKeyTouched: false,
|
|
644
|
+
reason: step.reason,
|
|
645
|
+
...extra,
|
|
646
|
+
};
|
|
647
|
+
const evidence = [step.txHash, step.requestId].filter(Boolean);
|
|
648
|
+
if (!step.authorized) {
|
|
649
|
+
return failEnvelope('NOT_AUTHORIZED', `签名放行闸拒绝 (fail-closed, 未取私钥/未发交易): ${step.authReason || step.reason}`, data, evidence, 'needs_human');
|
|
650
|
+
}
|
|
651
|
+
if (!step.txHash) {
|
|
652
|
+
const insufficient = INSUFFICIENT_RE.test(step.reason);
|
|
653
|
+
return failEnvelope(insufficient ? 'INSUFFICIENT_FUNDS' : 'CHAIN_TX_REVERTED', insufficient
|
|
654
|
+
? `余额/授权不足 —— 交易在广播前就被拒 (钱没动): ${step.reason}`
|
|
655
|
+
: `交易没有发出去 (广播前失败/合约拒绝): ${step.reason}`, data, evidence, insufficient ? 'raise_budget' : 'needs_human');
|
|
656
|
+
}
|
|
657
|
+
if (step.chainStatus === 'reverted') {
|
|
658
|
+
return failEnvelope('CHAIN_TX_REVERTED', `链上明确回滚 (receipt.status=0, 钱没动): ${step.reason}`, data, evidence, 'needs_human');
|
|
659
|
+
}
|
|
660
|
+
if (step.chainStatus === 'reorged') {
|
|
661
|
+
return failEnvelope('REORG_SUSPECTED', `交易被重组 (不可信, 绝不当已结算): ${step.reason}`, data, evidence, 'reconcile');
|
|
662
|
+
}
|
|
663
|
+
if (step.chainSettled !== true) {
|
|
664
|
+
return failEnvelope('CHAIN_UNCERTAIN', `链上结论未定 (${step.chainStatus}): ${step.reason} —— 不确定 ≠ 失败, 也绝不报成功`, data, evidence, 'reconcile');
|
|
665
|
+
}
|
|
666
|
+
if (stage === 'release' && step.grantsVerified !== true) {
|
|
667
|
+
return failEnvelope('CHAIN_UNCERTAIN', `释放已上链但未全过 (事件/状态/确认数) → 不许标 verified: ${step.reason}`, data, evidence, 'reconcile');
|
|
668
|
+
}
|
|
669
|
+
return okEnvelope('OK', `${STAGE_LABEL[stage] ?? stage} 链上成立 (${step.chainStatus}, 确认数 ${v.confirmations ?? '?'}/${v.confirmationsRequired ?? '?'})`, data, evidence, null);
|
|
670
|
+
}
|
|
671
|
+
function humanStep(stage, env) {
|
|
672
|
+
const d = env.data;
|
|
673
|
+
const rows = [
|
|
674
|
+
title(`bolloon chain trade ${stage}${d.method ? ` (${d.method})` : ''}`),
|
|
675
|
+
line('结果', env.ok ? '链上成立' : '未成立'),
|
|
676
|
+
line('txHash', String(d.txHash ?? '(无 — 没发出去)')),
|
|
677
|
+
line('block/确认', `${d.blockNumber ?? '?'} / ${d.confirmations ?? '?'} (需 ${d.confirmationsRequired ?? '?'})`),
|
|
678
|
+
line('chainSettled', String(d.chainSettled)),
|
|
679
|
+
line('chainStatus', String(d.chainStatus ?? '?')),
|
|
680
|
+
line('事件对上', String(d.eventMatched)),
|
|
681
|
+
line('escrow 状态', String(d.escrowState ?? '(读不到)')),
|
|
682
|
+
line('签名审计', String(d.signatureAuditWritten)),
|
|
683
|
+
];
|
|
684
|
+
if (!env.ok)
|
|
685
|
+
rows.push(`\n ✗ ${env.message}\n code: ${env.code} · 下一步: ${env.next_action ?? '无需额外动作'}`);
|
|
686
|
+
return rows.join('\n');
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* deadline 的默认值: max(链上最新块时间, 本机时间) + 窗口。
|
|
690
|
+
* 为什么不能只用本机时间: 链的时钟**可能领先/落后本机**(本地 anvil 常常是),
|
|
691
|
+
* 用本机时间算出来的 deadline 会被合约判成 "deadline in past" —— 这不是猜, 是链上事实。
|
|
692
|
+
*/
|
|
693
|
+
async function defaultDeadline(client, windowSec = 3600) {
|
|
694
|
+
let chainTs = 0;
|
|
695
|
+
try {
|
|
696
|
+
const b = await client?.provider?.getBlock?.('latest');
|
|
697
|
+
chainTs = Number(b?.timestamp || 0);
|
|
698
|
+
}
|
|
699
|
+
catch { /* 读不到块就退回本机时间 (不猜链时间) */ }
|
|
700
|
+
const base = Math.max(Number.isFinite(chainTs) ? chainTs : 0, Math.floor(Date.now() / 1000));
|
|
701
|
+
return BigInt(base + windowSec);
|
|
702
|
+
}
|
|
703
|
+
/** 组装一条链上交易意图 (摘要口径与 task-onchain-runner 完全一致: 均由 taskId 确定性派生) */
|
|
704
|
+
async function buildTradeRequest(flags, cfg, client) {
|
|
705
|
+
const OT = await import('../../agents/chain/onchain-trade.js');
|
|
706
|
+
const taskId = opt(flags, '--task-id') || flags.requestId || '';
|
|
707
|
+
if (!taskId) {
|
|
708
|
+
return { ok: false, envelope: failEnvelope('INVALID_ARGUMENT', '缺少 --task-id (链上 taskKey 由它确定性派生)', { usage: plain(CHAIN_USAGE.trim()) }, [], 'needs_human') };
|
|
709
|
+
}
|
|
710
|
+
// ★ 授权意图 (链上写操作): 只**翻译**成放行闸能看的两个字段, 不在这里判"能不能签"。
|
|
711
|
+
// 给了就必须是冻结词表里的值 (非法值拒绝, 不静默退回); 没给 = 沿用历史口径 (CLI 老用法不变)。
|
|
712
|
+
const modeRaw = opt(flags, '--payment-mode');
|
|
713
|
+
if (modeRaw !== undefined && !isPaymentMode(modeRaw)) {
|
|
714
|
+
return {
|
|
715
|
+
ok: false,
|
|
716
|
+
envelope: failEnvelope('INVALID_ARGUMENT', `--payment-mode 非法: ${String(modeRaw).slice(0, 40)} (要 ${PAYMENT_MODES.join('|')})`, { paymentMode: String(modeRaw).slice(0, 40), accepted: [...PAYMENT_MODES] }, [], 'needs_human'),
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
const paymentMode = isPaymentMode(modeRaw) ? modeRaw : undefined;
|
|
720
|
+
const decimals = Number(cfg.tokenDecimals || 6);
|
|
721
|
+
const amountUsdc = opt(flags, '--amount') ?? null;
|
|
722
|
+
// 卖方地址 (create 必填; proof/release 由合约里的 escrow 决定, 这里只做形状校验)
|
|
723
|
+
const agent = opt(flags, '--agent');
|
|
724
|
+
if (agent !== undefined && !ADDR_RE.test(agent)) {
|
|
725
|
+
return { ok: false, envelope: failEnvelope('INVALID_ARGUMENT', `--agent 不是合法地址: ${agent}`, {}, [], 'needs_human') };
|
|
726
|
+
}
|
|
727
|
+
const paymentAsset = opt(flags, '--asset') || cfg.tokenAddress;
|
|
728
|
+
if (!paymentAsset) {
|
|
729
|
+
// ★ 可操作的错: 真写需要**付款资产**地址, 但三层 (env / chain.json / 仓库 manifest) 都拿不到。
|
|
730
|
+
// 报 CHAIN_NOT_CONFIGURED (不是含糊的 INVALID_ARGUMENT) + 明说怎么修。
|
|
731
|
+
const CFG = await import('../../agents/chain/chain-config.js');
|
|
732
|
+
return {
|
|
733
|
+
ok: false,
|
|
734
|
+
envelope: failEnvelope('CHAIN_NOT_CONFIGURED', CFG.tokenAddressGuidance({ home: home(), networkName: cfg.networkName, env: process.env }), {
|
|
735
|
+
missing: ['tokenAddress'],
|
|
736
|
+
tokenAddress: cfg.tokenAddress ?? null,
|
|
737
|
+
networkName: cfg.networkName,
|
|
738
|
+
configPath: CFG.chainConfigPath(home()),
|
|
739
|
+
deploymentsDir: CFG.deploymentsDir(process.env),
|
|
740
|
+
manifestSource: cfg.sources?.deploymentManifest ?? null,
|
|
741
|
+
howToFix: [
|
|
742
|
+
'export BOLLOON_TOKEN_ADDRESS=0x… (真 USDC 或本地 MockERC20)',
|
|
743
|
+
`在 ${CFG.chainConfigPath(home())} 写 {"tokenAddress":"0x…","tokenDecimals":6}`,
|
|
744
|
+
'让部署 manifest 记 token 地址, 并给锚 (BOLLOON_CHAIN_ID / BOLLOON_NETWORK_NAME) 让它唯一可选中',
|
|
745
|
+
'本次显式传 --asset 0x… (只影响这一次调用)',
|
|
746
|
+
],
|
|
747
|
+
}, [], 'needs_human'),
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
if (!ADDR_RE.test(String(paymentAsset))) {
|
|
751
|
+
return { ok: false, envelope: failEnvelope('INVALID_ARGUMENT', `--asset 不是合法地址: ${paymentAsset}`, {}, [], 'needs_human') };
|
|
752
|
+
}
|
|
753
|
+
const amountAtomic = amountUsdc === null ? null : toAtomicUnits(amountUsdc, decimals);
|
|
754
|
+
if (amountUsdc !== null && amountAtomic === null) {
|
|
755
|
+
return { ok: false, envelope: failEnvelope('INVALID_ARGUMENT', `--amount 非法 (要正的十进制, 最多 ${decimals} 位小数): ${amountUsdc}`, { tokenDecimals: decimals }, [], 'needs_human') };
|
|
756
|
+
}
|
|
757
|
+
const deadlineArg = optInt(flags, '--deadline', undefined);
|
|
758
|
+
const deadline = deadlineArg !== undefined
|
|
759
|
+
? BigInt(deadlineArg)
|
|
760
|
+
: await defaultDeadline(client, 3600);
|
|
761
|
+
const confirmationWindow = optInt(flags, '--confirmation-window', 3600);
|
|
762
|
+
const proofVersion = optInt(flags, '--proof-version', 1);
|
|
763
|
+
const gate = (opt(flags, '--gate') === 'finalized' ? 'finalized' : 'confirmed');
|
|
764
|
+
const manifestRaw = opt(flags, '--manifest-digest');
|
|
765
|
+
const req = {
|
|
766
|
+
client,
|
|
767
|
+
home: home(),
|
|
768
|
+
taskId,
|
|
769
|
+
agentAddress: agent || '0x' + '00'.repeat(20),
|
|
770
|
+
amountAtomic: amountAtomic ?? 0n,
|
|
771
|
+
paymentAsset: String(paymentAsset),
|
|
772
|
+
termsDigest: OT.sha256Digest(`terms:${taskId}`),
|
|
773
|
+
quoteDigest: OT.sha256Digest(`quote:${taskId}`),
|
|
774
|
+
inputDigest: OT.sha256Digest(`input:${taskId}`),
|
|
775
|
+
manifestDigest: manifestRaw ? digestOf(manifestRaw, OT.sha256Digest) : OT.sha256Digest(`manifest:${taskId}`),
|
|
776
|
+
proofVersion,
|
|
777
|
+
deadline,
|
|
778
|
+
confirmationWindow,
|
|
779
|
+
network: cfg.networkName,
|
|
780
|
+
tokenDecimals: decimals,
|
|
781
|
+
gate,
|
|
782
|
+
env: process.env,
|
|
783
|
+
// ★ 授权意图 (只往下传, 不在这里判定): 模式 + 显式幂等/授权键
|
|
784
|
+
...(paymentMode ? { paymentMode } : {}),
|
|
785
|
+
...(flags.requestId ? { intentNonce: flags.requestId } : {}),
|
|
786
|
+
};
|
|
787
|
+
if (deps?.verifyOnChain)
|
|
788
|
+
req.verifyOnChain = deps.verifyOnChain;
|
|
789
|
+
return { ok: true, req, taskId, taskKey: OT.onchainTaskKey(taskId), decimals, amountUsdc, paymentMode: paymentMode ?? null };
|
|
790
|
+
}
|
|
791
|
+
async function chainTrade(flags) {
|
|
792
|
+
const sub = flags.positionals[1];
|
|
793
|
+
if (!['create', 'submit-proof', 'submit_proof', 'release', 'recover'].includes(String(sub))) {
|
|
794
|
+
return {
|
|
795
|
+
envelope: failEnvelope('INVALID_ARGUMENT', sub ? `未知 chain trade 子命令: ${sub}` : '缺少 chain trade 子命令 (create|submit-proof|release|recover)', { usage: plain(CHAIN_USAGE.trim()) }, [], 'needs_human'),
|
|
796
|
+
human: CHAIN_USAGE,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
// recover 是**纯读盘** (不联网/不发交易) → 不需要链客户端, 也**不要求链配置**
|
|
800
|
+
if (sub === 'recover')
|
|
801
|
+
return chainTradeRecover(flags);
|
|
802
|
+
// create 是**真写** (真移钱) → 必须有付款资产地址; 拿不到就报可操作的 CHAIN_NOT_CONFIGURED
|
|
803
|
+
// (submit-proof / release 不在这里拦: 它们的资产字段是"声明口径", 由 buildTradeRequest 兜底)
|
|
804
|
+
const loaded = await loadCfg({ requireToken: sub === 'create' });
|
|
805
|
+
if (!loaded.ok)
|
|
806
|
+
return { envelope: loaded.envelope, human: humanFail('bolloon chain trade', loaded.envelope) };
|
|
807
|
+
const cfg = loaded.cfg;
|
|
808
|
+
let client;
|
|
809
|
+
try {
|
|
810
|
+
client = await makeClient(cfg);
|
|
811
|
+
}
|
|
812
|
+
catch (e) {
|
|
813
|
+
return { envelope: failEnvelope('CHAIN_NOT_CONFIGURED', `造不出链客户端: ${String(e?.message || e).slice(0, 240)}`, {}, [], 'needs_human'), human: humanFail('bolloon chain trade', { code: 'CHAIN_NOT_CONFIGURED', message: '造不出链客户端', next_action: 'needs_human' }) };
|
|
814
|
+
}
|
|
815
|
+
const built = await buildTradeRequest(flags, cfg, client);
|
|
816
|
+
if (!built.ok)
|
|
817
|
+
return { envelope: built.envelope, human: humanFail('bolloon chain trade', built.envelope) };
|
|
818
|
+
const { req, taskKey, decimals, amountUsdc, paymentMode } = built;
|
|
819
|
+
const OT = await import('../../agents/chain/onchain-trade.js');
|
|
820
|
+
/**
|
|
821
|
+
* 回显调用方的**授权意图声明** (不是授权结论)。
|
|
822
|
+
* ★ 口径: `paymentMode` 是"我按什么模式声明的", `declaredRequestId` 是"我为哪次请求声明"——
|
|
823
|
+
* 真正放不放行仍由本机放行闸 (`authorizeWalletSignature`, fail-closed) 决定, 这里不替它表态。
|
|
824
|
+
*/
|
|
825
|
+
const authIntent = {
|
|
826
|
+
paymentMode: paymentMode ?? 'agent-authorized',
|
|
827
|
+
declaredRequestId: flags.requestId ?? null,
|
|
828
|
+
note: '声明 ≠ 授权: 是否真签名由本机放行闸决定 (未授权 / 重复 requestId / 超额 一律拒)',
|
|
829
|
+
};
|
|
830
|
+
if (sub === 'create') {
|
|
831
|
+
if (!amountUsdc) {
|
|
832
|
+
return { envelope: failEnvelope('INVALID_ARGUMENT', 'create 需要 --amount <USDC> (如 0.02)', {}, [], 'needs_human'), human: CHAIN_USAGE };
|
|
833
|
+
}
|
|
834
|
+
if (!ADDR_RE.test(String(opt(flags, '--agent') || ''))) {
|
|
835
|
+
return { envelope: failEnvelope('INVALID_ARGUMENT', 'create 需要 --agent <卖方收款地址>', {}, [], 'needs_human'), human: CHAIN_USAGE };
|
|
836
|
+
}
|
|
837
|
+
// M1 预算闸 (P4 checkOnchainAmount 是唯一预算判定; 本命令组不另立一条)
|
|
838
|
+
const budget = OT.checkOnchainAmount({ amountAtomic: req.amountAtomic, decimals });
|
|
839
|
+
if (!budget.ok) {
|
|
840
|
+
const env = failEnvelope('BUDGET_EXCEEDED', `预算门拒绝 (${budget.layer || 'budget'}): ${budget.reason}`, { layer: budget.layer ?? null, amountUsdc: budget.amountUsdc, budget: budget.budget, why: budget.why, authIntent }, [], 'raise_budget');
|
|
841
|
+
return { envelope: env, human: humanFail('bolloon chain trade create', env) };
|
|
842
|
+
}
|
|
843
|
+
const step = await OT.createEscrowStep(req);
|
|
844
|
+
const env = stepEnvelope('create', step, { taskId: req.taskId, taskKey, amountAtomic: req.amountAtomic.toString(), amountUsdc: budget.amountUsdc, escrowAddress: cfg.escrowAddress, authIntent });
|
|
845
|
+
return { envelope: env, human: humanStep('create', env) };
|
|
846
|
+
}
|
|
847
|
+
if (sub === 'submit-proof' || sub === 'submit_proof') {
|
|
848
|
+
const rawResult = opt(flags, '--result');
|
|
849
|
+
if (!rawResult) {
|
|
850
|
+
return { envelope: failEnvelope('INVALID_ARGUMENT', 'submit-proof 需要 --result <正文|sha256:hex> (链上 resultHash 的来源)', {}, [], 'needs_human'), human: CHAIN_USAGE };
|
|
851
|
+
}
|
|
852
|
+
const resultDigest = digestOf(rawResult, OT.sha256Digest);
|
|
853
|
+
const manifestRaw = opt(flags, '--manifest-digest');
|
|
854
|
+
const step = await OT.submitProofStep(req, resultDigest, manifestRaw ? digestOf(manifestRaw, OT.sha256Digest) : req.manifestDigest);
|
|
855
|
+
const env = stepEnvelope('proof', step, {
|
|
856
|
+
taskId: req.taskId, taskKey, resultDigest,
|
|
857
|
+
onchainResultHash: OT.chainHashOf(resultDigest),
|
|
858
|
+
authIntent,
|
|
859
|
+
});
|
|
860
|
+
return { envelope: env, human: humanStep('submit-proof', env) };
|
|
861
|
+
}
|
|
862
|
+
// release
|
|
863
|
+
const step = await OT.releaseStep(req);
|
|
864
|
+
const env = stepEnvelope('release', step, { taskId: req.taskId, taskKey, authIntent });
|
|
865
|
+
return { envelope: env, human: humanStep('release', env) };
|
|
866
|
+
}
|
|
867
|
+
/** `chain trade recover`: 纯读盘重建本机链上事实 → 下一步 (不联网、不猜、不改记录、不发交易) */
|
|
868
|
+
async function chainTradeRecover(flags) {
|
|
869
|
+
const head = 'bolloon chain trade recover';
|
|
870
|
+
const OT = await import('../../agents/chain/onchain-trade.js');
|
|
871
|
+
const taskId = opt(flags, '--task-id') || flags.requestId;
|
|
872
|
+
const taskKey = opt(flags, '--task-key') || flags.positionals[2];
|
|
873
|
+
if (!taskId && !isHexTaskKey(taskKey)) {
|
|
874
|
+
return { envelope: failEnvelope('INVALID_ARGUMENT', '需要 --task-id <id> 或 --task-key <0x..64hex>', { usage: plain(CHAIN_USAGE.trim()) }, [], 'needs_human'), human: CHAIN_USAGE };
|
|
875
|
+
}
|
|
876
|
+
const r = OT.recoverOnchainTrade({ home: home(), taskId, taskKey });
|
|
877
|
+
const nextMap = {
|
|
878
|
+
create_escrow: null, submit_proof: 'needs_human', release: 'needs_human',
|
|
879
|
+
verify_only: 'reconcile', done: null, needs_human: 'needs_human',
|
|
880
|
+
};
|
|
881
|
+
const data = {
|
|
882
|
+
found: r.found, taskId: r.taskId, taskKey: r.taskKey,
|
|
883
|
+
nextAction: r.nextAction, nextActionToken: nextMap[r.nextAction] ?? 'needs_human',
|
|
884
|
+
mustNotRepay: r.mustNotRepay, verified: r.verified, reason: r.reason, blockedBy: r.blockedBy,
|
|
885
|
+
statePath: r.statePath,
|
|
886
|
+
records: r.records.map((x) => ({ method: x.method, txHash: x.txHash, status: x.status, confirmations: x.confirmations, blockNumber: x.blockNumber, suspect: x.suspect, suspectReason: x.suspectReason ?? null, escrowState: x.escrowState ?? null })),
|
|
887
|
+
writesMoney: false, writesKeys: false,
|
|
888
|
+
note: '纯读盘 (~/.bolloon/chain/chain-state.json): 只给下一步建议, 不发交易/不重付/不自动退款',
|
|
889
|
+
};
|
|
890
|
+
const evidence = r.records.map((x) => x.txHash).filter(Boolean);
|
|
891
|
+
const human = [
|
|
892
|
+
title(head),
|
|
893
|
+
line('找到链上事实', r.found ? '是' : '否'),
|
|
894
|
+
line('taskKey', r.taskKey),
|
|
895
|
+
line('下一步', `${r.nextAction} (next_action=${nextMap[r.nextAction] ?? 'needs_human'})`),
|
|
896
|
+
line('绝不重付', String(r.mustNotRepay)),
|
|
897
|
+
line('链上可信', String(r.verified)),
|
|
898
|
+
...r.records.map((x) => ` ${x.method} ${x.status} conf=${x.confirmations} suspect=${x.suspect} ${String(x.txHash).slice(0, 18)}…`),
|
|
899
|
+
`\n ${r.reason}`,
|
|
900
|
+
].join('\n');
|
|
901
|
+
const suspectRecords = r.records.filter((x) => x.suspect === true);
|
|
902
|
+
const suspect = suspectRecords.length > 0 || (r.nextAction === 'needs_human' && /可疑|重组/.test(r.reason));
|
|
903
|
+
if (suspect) {
|
|
904
|
+
// ★ 只要这条 taskKey 下有**任何**被标不可信的记录, 就一律按「绝不重付 + 待人工」处理 ——
|
|
905
|
+
// 即使 P4 因为缺 create 记录而给出 create_escrow, 也不许在这里把可疑记录读成"可以安全从头走"。
|
|
906
|
+
// P4 的原始判定仍原样保留在 p4NextAction / p4MustNotRepay 里 (不篡改口径)。
|
|
907
|
+
return {
|
|
908
|
+
envelope: failEnvelope('REORG_SUSPECTED', `有链上记录被标不可信 (重组/事件消失) → 待人工, 绝不当已结算: ${r.reason}`, {
|
|
909
|
+
...data,
|
|
910
|
+
mustNotRepay: true,
|
|
911
|
+
p4NextAction: r.nextAction,
|
|
912
|
+
p4MustNotRepay: r.mustNotRepay,
|
|
913
|
+
suspectRecords: suspectRecords.map((x) => ({ method: x.method, txHash: x.txHash, suspectReason: x.suspectReason ?? null })),
|
|
914
|
+
}, evidence, 'needs_human'),
|
|
915
|
+
human,
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
if (r.nextAction === 'verify_only') {
|
|
919
|
+
return { envelope: failEnvelope('CHAIN_UNCERTAIN', `链上结论未定 → 先对账, 不许重发: ${r.reason}`, data, evidence, 'reconcile'), human };
|
|
920
|
+
}
|
|
921
|
+
if (r.nextAction === 'needs_human') {
|
|
922
|
+
const reverted = /回滚/.test(r.reason);
|
|
923
|
+
return { envelope: failEnvelope(reverted ? 'CHAIN_TX_REVERTED' : 'CHAIN_UNCERTAIN', r.reason, data, evidence, 'needs_human'), human };
|
|
924
|
+
}
|
|
925
|
+
return { envelope: okEnvelope('OK', `本机链上事实: nextAction=${r.nextAction} · verified=${r.verified}`, data, evidence, nextMap[r.nextAction] ?? null), human };
|
|
926
|
+
}
|