@bolloon/bolloon-agent 0.4.30 → 0.4.32
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/agent-gateway.js +5 -0
- package/dist/agents/agent-manifest-protocol.js +29 -0
- 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/index.js +20 -0
- package/dist/agents/chain/onchain-trade.js +691 -0
- package/dist/agents/economic-policy.js +7 -0
- package/dist/agents/gateway-network.js +6 -0
- package/dist/agents/local-signer.js +59 -0
- package/dist/agents/network-pulse.js +912 -0
- package/dist/agents/task/task-onchain-runner.js +284 -0
- package/dist/agents/task-contract.js +322 -0
- package/dist/agents/task-inbox.js +267 -0
- package/dist/agents/task-transport.js +280 -0
- package/dist/agents/x402/paid-info-store.js +75 -6
- package/dist/agents/x402/transaction-store.js +8 -1
- package/dist/cli/commands/agent.js +230 -0
- package/dist/cli/commands/chain.js +926 -0
- package/dist/cli/commands/index.js +69 -0
- package/dist/cli/commands/mcp.js +98 -0
- package/dist/cli/commands/network.js +334 -0
- package/dist/cli/commands/payment.js +125 -0
- package/dist/cli/commands/tasks.js +1236 -0
- package/dist/cli/commands/trade.js +229 -0
- package/dist/cli/commands/wallet.js +450 -0
- package/dist/cli/mcp/bridge.js +105 -0
- package/dist/cli/mcp/server.js +188 -0
- package/dist/cli/mcp/tools.js +955 -0
- package/dist/cli/protocol-envelope.js +358 -0
- package/dist/cli-entry.js +146 -17
- package/dist/electron-build/agents/agent-gateway.js +5 -0
- package/dist/electron-build/agents/agent-gateway.js.map +1 -1
- package/dist/electron-build/agents/agent-manifest-protocol.js +62 -0
- package/dist/electron-build/agents/agent-manifest-protocol.js.map +1 -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/economic-policy.js +7 -0
- package/dist/electron-build/agents/economic-policy.js.map +1 -1
- package/dist/electron-build/agents/gateway-network.js +6 -0
- package/dist/electron-build/agents/gateway-network.js.map +1 -1
- package/dist/electron-build/agents/network-pulse.js +979 -0
- package/dist/electron-build/agents/network-pulse.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/electron-build/agents/x402/transaction-store.js +8 -1
- package/dist/electron-build/agents/x402/transaction-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 +42 -0
- package/dist/web/task-frame-server.js +211 -0
- package/package.json +2 -1
- package/scripts/build-web.ts +20 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-inbox.ts — bolloon-task/1 收件箱 + 本机任务台账 + 结果落盘 (P3, 2026-09-21)
|
|
3
|
+
*
|
|
4
|
+
* 落盘布局 (全部在 `~/.bolloon/tasks/` 下, 与 M3.2 的 `<task-id>.yaml` 计划状态机同目录不冲突):
|
|
5
|
+
* inbox/<requestId>.json 收到的 TaskRequest (+ 本机的接单状态) —— 待处理收件箱
|
|
6
|
+
* local/<requestId>.json 本机**发出**的任务台账 (状态推进 / 对端回执 / 收到结果)
|
|
7
|
+
* results/<taskId>.json 已存的 TaskResult 信封 (内容哈希/CID + 签名)
|
|
8
|
+
* bodies/<taskId>.txt **交付正文** —— 私有一层: 只在本地落盘, 绝不进公开投影/stdout
|
|
9
|
+
*
|
|
10
|
+
* 纪律:
|
|
11
|
+
* · 幂等: 同一个 requestId 只落一次 (`dedupeInbox` 契约层去重), 重复到达 → 返回既有事实, 不覆盖;
|
|
12
|
+
* · 任务正文 (instruction) 只在**本地**文件里, 公开投影 / Pulse / registry 里永远没有它;
|
|
13
|
+
* · 不绕过任何状态机: 本模块只做落盘/读盘, 状态合法性由 `task-contract.checkTaskMove` 判 (调用方);
|
|
14
|
+
* · 任何落盘失败都不静默吞掉事实: 返回值里带 `{ok:false, error}`。
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from 'fs';
|
|
17
|
+
import * as os from 'os';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import * as crypto from 'crypto';
|
|
20
|
+
import { dedupeInbox, } from './task-contract.js';
|
|
21
|
+
// ── 路径 ────────────────────────────────────────────────────────────────────
|
|
22
|
+
const homeOf = (home) => home || process.env.HOME || os.homedir();
|
|
23
|
+
export const tasksRoot = (home) => path.join(homeOf(home), '.bolloon', 'tasks');
|
|
24
|
+
export const inboxDir = (home) => path.join(tasksRoot(home), 'inbox');
|
|
25
|
+
export const localDir = (home) => path.join(tasksRoot(home), 'local');
|
|
26
|
+
export const resultsDir = (home) => path.join(tasksRoot(home), 'results');
|
|
27
|
+
export const bodiesDir = (home) => path.join(tasksRoot(home), 'bodies');
|
|
28
|
+
function ensure(dir) {
|
|
29
|
+
try {
|
|
30
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function writeJson(file, value) {
|
|
38
|
+
try {
|
|
39
|
+
ensure(path.dirname(file));
|
|
40
|
+
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', 'utf8');
|
|
41
|
+
return { ok: true };
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
return { ok: false, error: String(e?.message || e).slice(0, 200) };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function readJson(file) {
|
|
48
|
+
try {
|
|
49
|
+
if (!fs.existsSync(file))
|
|
50
|
+
return null;
|
|
51
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** 只允许 requestId 当文件名 (防路径穿越); 非法 → null */
|
|
58
|
+
function safeId(id) {
|
|
59
|
+
const s = String(id || '').trim();
|
|
60
|
+
return /^[A-Za-z0-9._-]{4,128}$/.test(s) ? s : null;
|
|
61
|
+
}
|
|
62
|
+
const inboxFile = (requestId, home) => {
|
|
63
|
+
const id = safeId(requestId);
|
|
64
|
+
return id ? path.join(inboxDir(home), `${id}.json`) : null;
|
|
65
|
+
};
|
|
66
|
+
export function listInbox(home) {
|
|
67
|
+
try {
|
|
68
|
+
const dir = inboxDir(home);
|
|
69
|
+
if (!fs.existsSync(dir))
|
|
70
|
+
return [];
|
|
71
|
+
return fs.readdirSync(dir)
|
|
72
|
+
.filter((f) => f.endsWith('.json'))
|
|
73
|
+
.map((f) => readJson(path.join(dir, f)))
|
|
74
|
+
.filter((x) => !!x?.requestId)
|
|
75
|
+
.sort((a, b) => b.receivedAt - a.receivedAt);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function readInboxItem(requestId, home) {
|
|
82
|
+
const f = inboxFile(requestId, home);
|
|
83
|
+
return f ? readJson(f) : null;
|
|
84
|
+
}
|
|
85
|
+
export function writeInboxItem(item, home) {
|
|
86
|
+
const f = inboxFile(item.requestId, home);
|
|
87
|
+
if (!f)
|
|
88
|
+
return { ok: false, error: `requestId 非法 (不能当文件名): ${item.requestId}` };
|
|
89
|
+
return writeJson(f, item);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 收到一个 TaskRequest → 落盘到收件箱。
|
|
93
|
+
* **幂等**: 同一 requestId 已在箱里 → `{dup:true}` 并返回既有条目 (不覆盖、不重复执行、不重复收费)。
|
|
94
|
+
*/
|
|
95
|
+
export function saveIncomingRequest(req, opts = {}) {
|
|
96
|
+
const existing = readInboxItem(req.requestId, opts.home);
|
|
97
|
+
if (existing) {
|
|
98
|
+
const d = dedupeInbox([{ requestId: existing.requestId }], { requestId: req.requestId });
|
|
99
|
+
return { ok: true, dup: true, item: existing, reason: d.reason || 'requestId 已在收件箱 (幂等)' };
|
|
100
|
+
}
|
|
101
|
+
const now = opts.now ?? Date.now();
|
|
102
|
+
const item = {
|
|
103
|
+
protocol: 'bolloon-task/1',
|
|
104
|
+
requestId: req.requestId,
|
|
105
|
+
taskId: req.taskId,
|
|
106
|
+
capability: req.capability,
|
|
107
|
+
instruction: String(req.instruction || ''),
|
|
108
|
+
buyerDid: req.buyerDid,
|
|
109
|
+
providerDid: req.providerDid,
|
|
110
|
+
budget: req.budget,
|
|
111
|
+
deadline: req.deadline,
|
|
112
|
+
paymentMode: req.paymentMode,
|
|
113
|
+
state: 'pending',
|
|
114
|
+
receivedAt: now,
|
|
115
|
+
updatedAt: now,
|
|
116
|
+
requestSignature: String(req.signature || ''),
|
|
117
|
+
requestSignatureVerified: opts.requestVerified ?? null,
|
|
118
|
+
buyerPublicKeyHex: opts.buyerPublicKeyHex ?? null,
|
|
119
|
+
replyTo: opts.replyTo ?? null,
|
|
120
|
+
};
|
|
121
|
+
const w = writeInboxItem(item, opts.home);
|
|
122
|
+
if (!w.ok)
|
|
123
|
+
return { ok: false, dup: false, error: w.error };
|
|
124
|
+
return { ok: true, dup: false, item };
|
|
125
|
+
}
|
|
126
|
+
/** 只改已知字段 (state/accept/reject/result/...); 不存在 → `{ok:false}` */
|
|
127
|
+
export function patchInboxItem(requestId, patch, home) {
|
|
128
|
+
const cur = readInboxItem(requestId, home);
|
|
129
|
+
if (!cur)
|
|
130
|
+
return { ok: false, error: `收件箱里没有 ${requestId}` };
|
|
131
|
+
const next = { ...cur, ...patch, updatedAt: Date.now() };
|
|
132
|
+
const w = writeInboxItem(next, home);
|
|
133
|
+
return w.ok ? { ok: true, item: next } : { ok: false, error: w.error };
|
|
134
|
+
}
|
|
135
|
+
export function findInboxByTaskId(taskId, home) {
|
|
136
|
+
return listInbox(home).find((i) => i.taskId === taskId) || null;
|
|
137
|
+
}
|
|
138
|
+
const localFile = (requestId, home) => {
|
|
139
|
+
const id = safeId(requestId);
|
|
140
|
+
return id ? path.join(localDir(home), `${id}.json`) : null;
|
|
141
|
+
};
|
|
142
|
+
export function listLocalTasks(home) {
|
|
143
|
+
try {
|
|
144
|
+
const dir = localDir(home);
|
|
145
|
+
if (!fs.existsSync(dir))
|
|
146
|
+
return [];
|
|
147
|
+
return fs.readdirSync(dir)
|
|
148
|
+
.filter((f) => f.endsWith('.json'))
|
|
149
|
+
.map((f) => readJson(path.join(dir, f)))
|
|
150
|
+
.filter((x) => !!x?.requestId)
|
|
151
|
+
.sort((a, b) => b.sentAt - a.sentAt);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export function readLocalTask(requestId, home) {
|
|
158
|
+
const f = localFile(requestId, home);
|
|
159
|
+
return f ? readJson(f) : null;
|
|
160
|
+
}
|
|
161
|
+
export function saveLocalTask(rec, home) {
|
|
162
|
+
const f = localFile(rec.requestId, home);
|
|
163
|
+
if (!f)
|
|
164
|
+
return { ok: false, error: `requestId 非法: ${rec.requestId}` };
|
|
165
|
+
return writeJson(f, rec);
|
|
166
|
+
}
|
|
167
|
+
/** 幂等: 同一 requestId 已经有台账 → 返回既有记录 (不覆盖已推进的状态) */
|
|
168
|
+
export function upsertLocalTask(rec, home) {
|
|
169
|
+
const cur = readLocalTask(rec.requestId, home);
|
|
170
|
+
if (cur)
|
|
171
|
+
return { ok: true, created: false, record: cur };
|
|
172
|
+
const w = saveLocalTask(rec, home);
|
|
173
|
+
return w.ok ? { ok: true, created: true, record: rec } : { ok: false, created: false, error: w.error };
|
|
174
|
+
}
|
|
175
|
+
export function patchLocalTask(requestId, patch, home) {
|
|
176
|
+
const cur = readLocalTask(requestId, home);
|
|
177
|
+
if (!cur)
|
|
178
|
+
return { ok: false, error: `本机台账里没有 ${requestId}` };
|
|
179
|
+
const next = { ...cur, ...patch, updatedAt: Date.now() };
|
|
180
|
+
const w = saveLocalTask(next, home);
|
|
181
|
+
return w.ok ? { ok: true, record: next } : { ok: false, error: w.error };
|
|
182
|
+
}
|
|
183
|
+
// ── 结果 (信封 + 正文分开存) ────────────────────────────────────────────────
|
|
184
|
+
const resultFile = (taskId, home) => {
|
|
185
|
+
const id = safeId(taskId);
|
|
186
|
+
return id ? path.join(resultsDir(home), `${id}.json`) : null;
|
|
187
|
+
};
|
|
188
|
+
export function saveResult(result, home) {
|
|
189
|
+
const f = resultFile(result.taskId, home);
|
|
190
|
+
if (!f)
|
|
191
|
+
return { ok: false, error: `taskId 非法: ${result.taskId}` };
|
|
192
|
+
return writeJson(f, result);
|
|
193
|
+
}
|
|
194
|
+
export function readResult(taskId, home) {
|
|
195
|
+
const f = resultFile(taskId, home);
|
|
196
|
+
return f ? readJson(f) : null;
|
|
197
|
+
}
|
|
198
|
+
/** 找结果: 先按 taskId, 再按收件箱/台账里的 requestId 反查 */
|
|
199
|
+
export function findResult(idOrTaskId, home) {
|
|
200
|
+
const direct = readResult(idOrTaskId, home);
|
|
201
|
+
if (direct)
|
|
202
|
+
return { result: direct, taskId: direct.taskId, source: 'results' };
|
|
203
|
+
const inb = readInboxItem(idOrTaskId, home);
|
|
204
|
+
if (inb?.result)
|
|
205
|
+
return { result: inb.result, taskId: inb.taskId, source: 'inbox' };
|
|
206
|
+
const loc = readLocalTask(idOrTaskId, home);
|
|
207
|
+
if (loc?.result)
|
|
208
|
+
return { result: loc.result, taskId: loc.taskId, source: 'local' };
|
|
209
|
+
const byTask = listLocalTasks(home).find((t) => t.taskId === idOrTaskId);
|
|
210
|
+
if (byTask?.result)
|
|
211
|
+
return { result: byTask.result, taskId: byTask.taskId, source: 'local' };
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* 交付正文落盘 (**私有一层**)。返回真实内容哈希 —— 它就是 TaskResult.contentHash 的来源,
|
|
216
|
+
* 绝不由调用方随口给。
|
|
217
|
+
*/
|
|
218
|
+
export function saveBody(taskId, text, home) {
|
|
219
|
+
const id = safeId(taskId);
|
|
220
|
+
if (!id)
|
|
221
|
+
return { ok: false, error: `taskId 非法: ${taskId}` };
|
|
222
|
+
try {
|
|
223
|
+
const dir = bodiesDir(home);
|
|
224
|
+
ensure(dir);
|
|
225
|
+
const file = path.join(dir, `${id}.txt`);
|
|
226
|
+
const buf = Buffer.from(text, 'utf8');
|
|
227
|
+
fs.writeFileSync(file, buf);
|
|
228
|
+
return { ok: true, file, contentHash: crypto.createHash('sha256').update(buf).digest('hex'), bytes: buf.length };
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
return { ok: false, error: String(e?.message || e).slice(0, 200) };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** 读交付正文 + 复核哈希 (正文**不进 stdout**; 调用方只报哈希/字节数) */
|
|
235
|
+
export function readBody(taskId, home) {
|
|
236
|
+
const id = safeId(taskId);
|
|
237
|
+
const dir = bodiesDir(home);
|
|
238
|
+
const file = id ? path.join(dir, `${id}.txt`) : path.join(dir, `${String(taskId)}.txt`);
|
|
239
|
+
const recorded = readResult(taskId, home)?.contentHash || null;
|
|
240
|
+
try {
|
|
241
|
+
if (!id || !fs.existsSync(file))
|
|
242
|
+
return { present: false, file, bytes: 0, contentHash: null, matches: null, reason: '正文不在盘上' };
|
|
243
|
+
const buf = fs.readFileSync(file);
|
|
244
|
+
const h = crypto.createHash('sha256').update(buf).digest('hex');
|
|
245
|
+
return { present: true, file, bytes: buf.length, contentHash: h, matches: recorded ? h === recorded : null, text: buf.toString('utf8') };
|
|
246
|
+
}
|
|
247
|
+
catch (e) {
|
|
248
|
+
return { present: false, file, bytes: 0, contentHash: null, matches: null, reason: String(e?.message || e).slice(0, 120) };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
export function inboxSummary(item, now = Date.now()) {
|
|
252
|
+
return {
|
|
253
|
+
requestId: item.requestId,
|
|
254
|
+
taskId: item.taskId,
|
|
255
|
+
capability: item.capability,
|
|
256
|
+
sender: String(item.buyerDid || '').slice(0, 28),
|
|
257
|
+
state: item.state,
|
|
258
|
+
receivedAt: item.receivedAt,
|
|
259
|
+
deadline: item.deadline ?? null,
|
|
260
|
+
deadlineInMs: item.deadline ? item.deadline - now : null,
|
|
261
|
+
budget: item.budget ?? null,
|
|
262
|
+
paymentMode: item.paymentMode,
|
|
263
|
+
requestSignatureVerified: item.requestSignatureVerified ?? null,
|
|
264
|
+
instructionPreview: String(item.instruction || '').slice(0, 60) + (String(item.instruction || '').length > 60 ? '…' : ''),
|
|
265
|
+
hasResult: !!item.result,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-transport.ts — bolloon-task/1 帧 + **现成传输** (P3, 2026-09-21)
|
|
3
|
+
*
|
|
4
|
+
* 任务书要求: `task.send` 必须经**现成传输**把签名请求真发出去, 传输不可用 → 结构化诚实失败。
|
|
5
|
+
* 本模块把三种现存传输包成同一个 `TaskTransport`:
|
|
6
|
+
*
|
|
7
|
+
* ① `http` —— POST `<peer>/api/task/frame` (对端 = `src/web/task-frame-server.ts` 起的节点端点;
|
|
8
|
+
* 与 `agent-delegate-server` 同一套 express + JSON 形状, 真跨进程/跨机 TCP)。
|
|
9
|
+
* ② `iroh` —— `src/network/iroh-transport.ts` 的 `requestResponse` (P2P, 与 `iroh-delegate-transport` 同一条链路;
|
|
10
|
+
* 需要本机 iroh 端点已就绪, 未就绪 → 如实 `TRANSPORT_UNAVAILABLE`)。
|
|
11
|
+
* ③ `gateway` —— `agent-gateway.gatewayCallAgent` 的发现/协商链 (**刻意不传 privateKey**:
|
|
12
|
+
* 这条路径在物理上无法发起任何 x402 付款)。
|
|
13
|
+
*
|
|
14
|
+
* 纪律:
|
|
15
|
+
* · 帧里的信封 (TaskRequest/…) 是**签名过**的; 传输层只加 `signer{ did, publicKeyHex }` (公开材料)
|
|
16
|
+
* 与 `replyTo` / `body` (私有正文, 只走这条直连任务通道)。
|
|
17
|
+
* · 解析严格: protocol 精确相等 + frame 在白名单 + taskId/requestId 与信封一致, 否则拒。
|
|
18
|
+
* · **绝不发起付款**: 本模块没有任何付款调用; 传输失败就是失败, 不重试到"看起来成功"。
|
|
19
|
+
*/
|
|
20
|
+
import { TASK_PROTOCOL, } from './task-contract.js';
|
|
21
|
+
export const TASK_FRAME_TYPES = ['task_request', 'task_accept', 'task_reject', 'task_result'];
|
|
22
|
+
/** 对端节点接收任务帧的路径 (与 `task-frame-server` 约定) */
|
|
23
|
+
export const TASK_FRAME_PATH = '/api/task/frame';
|
|
24
|
+
export function buildTaskFrame(frame, envelope, signer, opts = {}) {
|
|
25
|
+
return {
|
|
26
|
+
protocol: TASK_PROTOCOL,
|
|
27
|
+
frame,
|
|
28
|
+
sentAt: opts.now ?? Date.now(),
|
|
29
|
+
signer: { did: String(signer.did || ''), publicKeyHex: String(signer.publicKeyHex || '') },
|
|
30
|
+
...(opts.replyTo ? { replyTo: opts.replyTo } : {}),
|
|
31
|
+
...(opts.body ? { body: opts.body } : {}),
|
|
32
|
+
envelope,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function serializeFrame(f) {
|
|
36
|
+
return JSON.stringify(f);
|
|
37
|
+
}
|
|
38
|
+
/** 严格解析 (版本/类型/身份/信封标识逐条查; 不合法就说哪一条不合法) */
|
|
39
|
+
export function parseTaskFrame(text) {
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = JSON.parse(String(text));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return { ok: false, error: '不是 JSON' };
|
|
46
|
+
}
|
|
47
|
+
if (!raw || typeof raw !== 'object')
|
|
48
|
+
return { ok: false, error: '帧不是对象' };
|
|
49
|
+
if (raw.protocol !== TASK_PROTOCOL)
|
|
50
|
+
return { ok: false, error: `帧协议版本不对: ${String(raw.protocol)} (要 ${TASK_PROTOCOL})` };
|
|
51
|
+
if (!TASK_FRAME_TYPES.includes(String(raw.frame))) {
|
|
52
|
+
return { ok: false, error: `未知帧类型: ${String(raw.frame)}` };
|
|
53
|
+
}
|
|
54
|
+
const signer = raw.signer;
|
|
55
|
+
if (!signer || typeof signer !== 'object')
|
|
56
|
+
return { ok: false, error: '缺 signer (用来验信封签名)' };
|
|
57
|
+
if (!/^[0-9a-f]{64}$/i.test(String(signer.publicKeyHex || '')))
|
|
58
|
+
return { ok: false, error: 'signer.publicKeyHex 不是 32 字节公钥 hex' };
|
|
59
|
+
const env = raw.envelope;
|
|
60
|
+
if (!env || typeof env !== 'object')
|
|
61
|
+
return { ok: false, error: '缺 envelope' };
|
|
62
|
+
if (env.protocol !== TASK_PROTOCOL)
|
|
63
|
+
return { ok: false, error: `信封协议版本不对: ${String(env.protocol)}` };
|
|
64
|
+
if (!String(env.taskId || '').trim() || !String(env.requestId || '').trim())
|
|
65
|
+
return { ok: false, error: '信封缺 taskId/requestId' };
|
|
66
|
+
if (!String(env.signature || '').trim())
|
|
67
|
+
return { ok: false, error: '信封没有签名 (SIGNATURE_REQUIRED)' };
|
|
68
|
+
return { ok: true, frame: raw };
|
|
69
|
+
}
|
|
70
|
+
// ── ① HTTP (对端 = task-frame-server) ────────────────────────────────────────
|
|
71
|
+
function frameUrl(endpoint) {
|
|
72
|
+
const e = String(endpoint || '').trim().replace(/\/+$/, '');
|
|
73
|
+
if (!e)
|
|
74
|
+
return '';
|
|
75
|
+
return e.endsWith(TASK_FRAME_PATH) ? e : `${e}${TASK_FRAME_PATH}`;
|
|
76
|
+
}
|
|
77
|
+
export function httpTaskTransport(opts = {}) {
|
|
78
|
+
const defaultTimeout = opts.timeoutMs ?? 20_000;
|
|
79
|
+
return {
|
|
80
|
+
kind: 'http',
|
|
81
|
+
async available() {
|
|
82
|
+
const f = opts.fetchImpl ?? globalThis.fetch;
|
|
83
|
+
return typeof f === 'function'
|
|
84
|
+
? { ok: true }
|
|
85
|
+
: { ok: false, reason: '本机没有 fetch 实现 (Node < 18?)' };
|
|
86
|
+
},
|
|
87
|
+
async send(target, frame, timeoutMs) {
|
|
88
|
+
const t0 = Date.now();
|
|
89
|
+
const url = frameUrl(target);
|
|
90
|
+
if (!url)
|
|
91
|
+
return { ok: false, kind: 'http', target, elapsedMs: 0, error: '目标端点为空 (要 http(s)://…)' };
|
|
92
|
+
const f = opts.fetchImpl ?? fetch;
|
|
93
|
+
try {
|
|
94
|
+
const ctrl = new AbortController();
|
|
95
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs ?? defaultTimeout);
|
|
96
|
+
const resp = await f(url, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'content-type': 'application/json' },
|
|
99
|
+
body: JSON.stringify({ frame }),
|
|
100
|
+
signal: ctrl.signal,
|
|
101
|
+
}).finally(() => clearTimeout(timer));
|
|
102
|
+
const text = await resp.text().catch(() => '');
|
|
103
|
+
if (!resp.ok) {
|
|
104
|
+
return { ok: false, kind: 'http', target: url, elapsedMs: Date.now() - t0, error: `对端 HTTP ${resp.status}`, detail: text.slice(0, 300) };
|
|
105
|
+
}
|
|
106
|
+
let body;
|
|
107
|
+
try {
|
|
108
|
+
body = JSON.parse(text);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return { ok: false, kind: 'http', target: url, elapsedMs: Date.now() - t0, error: '对端回的不是 JSON', detail: text.slice(0, 300) };
|
|
112
|
+
}
|
|
113
|
+
if (body?.ok === false) {
|
|
114
|
+
return { ok: false, kind: 'http', target: url, elapsedMs: Date.now() - t0, error: String(body.error || '对端拒绝了这帧'), detail: body.detail ? String(body.detail).slice(0, 300) : undefined };
|
|
115
|
+
}
|
|
116
|
+
let reply;
|
|
117
|
+
if (body?.reply) {
|
|
118
|
+
const p = parseTaskFrame(typeof body.reply === 'string' ? body.reply : JSON.stringify(body.reply));
|
|
119
|
+
if (!p.ok)
|
|
120
|
+
return { ok: false, kind: 'http', target: url, elapsedMs: Date.now() - t0, error: `对端回的帧不合法: ${p.error}` };
|
|
121
|
+
reply = p.frame;
|
|
122
|
+
}
|
|
123
|
+
return { ok: true, kind: 'http', target: url, elapsedMs: Date.now() - t0, reply, duplicate: body?.duplicate === true, detail: body?.note ? String(body.note).slice(0, 300) : undefined };
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
const msg = String(e?.message || e);
|
|
127
|
+
const kindHint = /abort/i.test(msg) ? `超时 (${timeoutMs ?? defaultTimeout}ms 内没回)` : msg;
|
|
128
|
+
return { ok: false, kind: 'http', target: url, elapsedMs: Date.now() - t0, error: `对端不可达: ${kindHint.slice(0, 200)}` };
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
// ── ② iroh (P2P; 与 iroh-delegate-transport 同一条链路) ──────────────────────
|
|
134
|
+
export function irohTaskTransport(opts = {}) {
|
|
135
|
+
return {
|
|
136
|
+
kind: 'iroh',
|
|
137
|
+
async available() {
|
|
138
|
+
try {
|
|
139
|
+
const mod = await import('../network/iroh-transport.js');
|
|
140
|
+
const t = mod.irohTransport;
|
|
141
|
+
if (!t || typeof t.requestResponse !== 'function')
|
|
142
|
+
return { ok: false, reason: 'irohTransport 不可用' };
|
|
143
|
+
if (typeof t.getNodeId === 'function' && t.getNodeId())
|
|
144
|
+
return { ok: true };
|
|
145
|
+
if (!opts.startIfNeeded)
|
|
146
|
+
return { ok: false, reason: '本机 iroh 端点没起来 (需要先起节点; 或用 --endpoint 走 HTTP 直连)' };
|
|
147
|
+
// 起节点的 secretKey 从 ~/.bolloon/iroh-secret-default.json 来 (跨重启稳定 nodeId), 与既有启动路径一致
|
|
148
|
+
const { loadOrCreateIrohSecret } = await import('./iroh-secret.js');
|
|
149
|
+
const secret = opts.secret || loadOrCreateIrohSecret().secretKey;
|
|
150
|
+
const r = await t.start(secret, false);
|
|
151
|
+
return r?.nodeId ? { ok: true } : { ok: false, reason: 'iroh 端点启动失败 (本机没有可用 iroh 运行时)' };
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
return { ok: false, reason: `iroh 运行时不可用: ${String(e?.message || e).slice(0, 160)}` };
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
async send(target, frame, timeoutMs) {
|
|
158
|
+
const t0 = Date.now();
|
|
159
|
+
const avail = await this.available();
|
|
160
|
+
if (!avail.ok)
|
|
161
|
+
return { ok: false, kind: 'iroh', target, elapsedMs: Date.now() - t0, error: `iroh 传输不可用: ${avail.reason}` };
|
|
162
|
+
try {
|
|
163
|
+
const mod = await import('../network/iroh-transport.js');
|
|
164
|
+
const t = mod.irohTransport;
|
|
165
|
+
const payload = new TextEncoder().encode(serializeFrame(frame));
|
|
166
|
+
const resp = await t.requestResponse(target, 'task_frame', payload, timeoutMs ?? 20_000);
|
|
167
|
+
if (!resp)
|
|
168
|
+
return { ok: false, kind: 'iroh', target, elapsedMs: Date.now() - t0, error: '对端没回 (超时/连不上)' };
|
|
169
|
+
const text = new TextDecoder().decode(resp);
|
|
170
|
+
// 对端直接回一帧, 或回 {ok, reply}
|
|
171
|
+
const direct = parseTaskFrame(text);
|
|
172
|
+
if (direct.ok)
|
|
173
|
+
return { ok: true, kind: 'iroh', target, elapsedMs: Date.now() - t0, reply: direct.frame };
|
|
174
|
+
try {
|
|
175
|
+
const body = JSON.parse(text);
|
|
176
|
+
if (body?.ok === false)
|
|
177
|
+
return { ok: false, kind: 'iroh', target, elapsedMs: Date.now() - t0, error: String(body.error || '对端拒绝') };
|
|
178
|
+
if (body?.reply) {
|
|
179
|
+
const p = parseTaskFrame(typeof body.reply === 'string' ? body.reply : JSON.stringify(body.reply));
|
|
180
|
+
if (p.ok)
|
|
181
|
+
return { ok: true, kind: 'iroh', target, elapsedMs: Date.now() - t0, reply: p.frame, duplicate: body?.duplicate === true };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch { /* 落到下面 */ }
|
|
185
|
+
return { ok: false, kind: 'iroh', target, elapsedMs: Date.now() - t0, error: '对端回的内容不是合法帧', detail: text.slice(0, 200) };
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
return { ok: false, kind: 'iroh', target, elapsedMs: Date.now() - t0, error: `iroh 发送失败: ${String(e?.message || e).slice(0, 200)}` };
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
// ── ③ gateway (agent-gateway 的既有发现/协商链; **不带私钥 → 不可能付款**) ────
|
|
194
|
+
export function gatewayTaskTransport() {
|
|
195
|
+
return {
|
|
196
|
+
kind: 'gateway',
|
|
197
|
+
async available() {
|
|
198
|
+
try {
|
|
199
|
+
const { getAgentRegistry } = await import('./agent-registry.js');
|
|
200
|
+
const reg = getAgentRegistry();
|
|
201
|
+
return reg ? { ok: true } : { ok: false, reason: 'registry 不可用' };
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
return { ok: false, reason: String(e?.message || e).slice(0, 120) };
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
async send(target, frame, timeoutMs) {
|
|
208
|
+
const t0 = Date.now();
|
|
209
|
+
try {
|
|
210
|
+
const { gatewayCallAgent } = await import('./agent-gateway.js');
|
|
211
|
+
let handled = false;
|
|
212
|
+
// ★ 刻意不传 privateKey: 这条路径无法发起 x402 付款 (policy.recordSpend 也不执行)
|
|
213
|
+
const r = await gatewayCallAgent({
|
|
214
|
+
task: serializeFrame(frame),
|
|
215
|
+
budget: Number.MAX_SAFE_INTEGER, // 预算已由任务信封 (budget.maxAmount) 约束; 这里不另设闸
|
|
216
|
+
capability: undefined,
|
|
217
|
+
url: target,
|
|
218
|
+
});
|
|
219
|
+
handled = true;
|
|
220
|
+
if (!r.success) {
|
|
221
|
+
return { ok: false, kind: 'gateway', target, elapsedMs: Date.now() - t0, error: r.error || 'gateway 调用失败', detail: `decision=${r.decision || 'n/a'} paid=false handled=${handled}` };
|
|
222
|
+
}
|
|
223
|
+
const out = String(r.output || '');
|
|
224
|
+
try {
|
|
225
|
+
const body = JSON.parse(out);
|
|
226
|
+
// out = {ok, reply} 形状 (对端 task-frame-server) 或直接是帧
|
|
227
|
+
if (body?.reply) {
|
|
228
|
+
const p = parseTaskFrame(typeof body.reply === 'string' ? body.reply : JSON.stringify(body.reply));
|
|
229
|
+
if (p.ok)
|
|
230
|
+
return { ok: true, kind: 'gateway', target, elapsedMs: Date.now() - t0, reply: p.frame, duplicate: body?.duplicate === true };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch { /* 交给下面 */ }
|
|
234
|
+
const direct = parseTaskFrame(out);
|
|
235
|
+
if (direct.ok)
|
|
236
|
+
return { ok: true, kind: 'gateway', target, elapsedMs: Date.now() - t0, reply: direct.frame };
|
|
237
|
+
return { ok: false, kind: 'gateway', target, elapsedMs: Date.now() - t0, error: 'gateway 执行结果不是合法任务帧', detail: out.slice(0, 200) };
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
return { ok: false, kind: 'gateway', target, elapsedMs: Date.now() - t0, error: `gateway 传输异常: ${String(e?.message || e).slice(0, 200)}` };
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* 不给 `--endpoint`/`--peer` 时: 用 registry 按 capability 找 provider 的 endpoint。
|
|
247
|
+
* 找不到 → null (调用方如实报 CAPABILITY_NOT_FOUND / NETWORK_NOT_JOINED, 不猜地址)。
|
|
248
|
+
*/
|
|
249
|
+
export async function resolveTaskTarget(capability, opts = {}) {
|
|
250
|
+
let registry = opts.registry;
|
|
251
|
+
if (!registry) {
|
|
252
|
+
const { getAgentRegistry } = await import('./agent-registry.js');
|
|
253
|
+
registry = getAgentRegistry();
|
|
254
|
+
}
|
|
255
|
+
const ready = registry?.ready === true;
|
|
256
|
+
let services = [];
|
|
257
|
+
try {
|
|
258
|
+
services = capability ? await registry.discover(capability) : await registry.list();
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
services = [];
|
|
262
|
+
}
|
|
263
|
+
const withEndpoint = services.find((s) => typeof s?.endpoint === 'string' && s.endpoint.trim());
|
|
264
|
+
if (!withEndpoint)
|
|
265
|
+
return { target: null, registryReady: ready, candidates: services.length };
|
|
266
|
+
return {
|
|
267
|
+
target: {
|
|
268
|
+
target: String(withEndpoint.endpoint).trim(),
|
|
269
|
+
kind: 'http',
|
|
270
|
+
agentId: withEndpoint.agentId,
|
|
271
|
+
providerDid: withEndpoint.agentId || undefined,
|
|
272
|
+
wallet: withEndpoint.wallet,
|
|
273
|
+
priceAmount: withEndpoint.service?.price?.amount,
|
|
274
|
+
priceCurrency: withEndpoint.service?.price?.currency,
|
|
275
|
+
source: 'registry.discover(capability) → service.endpoint',
|
|
276
|
+
},
|
|
277
|
+
registryReady: ready,
|
|
278
|
+
candidates: services.length,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
@@ -244,6 +244,42 @@ function makeTracker(onEvent) {
|
|
|
244
244
|
return async (e) => { if (onEvent)
|
|
245
245
|
await onEvent(e).catch(() => null); };
|
|
246
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* ★ F5 修复点。
|
|
249
|
+
* 老代码: `chainSettled = !!txHash` —— 拿到 txHash 就宣称链上已验证 (从不读 receipt/事件/确认数)。
|
|
250
|
+
* 新逻辑:
|
|
251
|
+
* · 没有 txHash → `not_attempted`, chainSettled=false (没发过链上交易)
|
|
252
|
+
* · 读不到 RPC / receipt → `unknown`, chainSettled=false (不确定 ≠ 没付)
|
|
253
|
+
* · receipt.status == 0 → `reverted`, chainSettled=false
|
|
254
|
+
* · 确认数 < 门槛 → `pending`, chainSettled=false
|
|
255
|
+
* · 事件(taskKey/resultHash)对不上 → `event_mismatch`, chainSettled=false
|
|
256
|
+
* · 全过 → `confirmed`/`finalized`, chainSettled=true
|
|
257
|
+
* 验证器本身抛错也**不会**炸付款路径: 一律降级成 `unknown` + chainSettled=false (fail-closed)。
|
|
258
|
+
*/
|
|
259
|
+
export async function verifyPaymentOnChain(args) {
|
|
260
|
+
const gate = args.chainSettlement?.gate || 'confirmed';
|
|
261
|
+
const txHash = String(args.txHash || '');
|
|
262
|
+
if (!txHash) {
|
|
263
|
+
return {
|
|
264
|
+
chainSettled: false, status: 'not_attempted',
|
|
265
|
+
reason: 'facilitator 说成功但没给 txHash: 没有链上交易可查, 不能认定链上结算完成',
|
|
266
|
+
confirmationsRequired: 0, requiredGate: gate, rpcAvailable: true, checkedAt: Date.now(), evidence: {},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
const verifier = args.chainSettlement?.verifier
|
|
271
|
+
?? (await import('../chain/chain-settlement.js')).createDefaultChainSettlementVerifier({ gate });
|
|
272
|
+
return await verifier({ txHash, expect: args.chainSettlement?.expect, gate, recorded: args.chainSettlement?.recorded });
|
|
273
|
+
}
|
|
274
|
+
catch (e) {
|
|
275
|
+
return {
|
|
276
|
+
chainSettled: false, status: 'unknown',
|
|
277
|
+
reason: `链上验证器抛错 → 不能判已结算: ${String(e?.message || e).slice(0, 200)}`,
|
|
278
|
+
txHash, confirmationsRequired: 0, requiredGate: gate, rpcAvailable: false,
|
|
279
|
+
checkedAt: Date.now(), evidence: { verifierError: String(e?.message || e).slice(0, 200) },
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
247
283
|
export async function buyInfo(params) {
|
|
248
284
|
const { verifyEnvelope } = await import('./paid-info-protocol.js');
|
|
249
285
|
const doFetch = params.fetchImpl ?? fetch;
|
|
@@ -299,26 +335,59 @@ export async function buyInfo(params) {
|
|
|
299
335
|
const text = await retry.text();
|
|
300
336
|
const parsed = safeJson(text);
|
|
301
337
|
const receipt = retry.headers.get('x-payment-response') || parsed?.payment?.receipt || '';
|
|
338
|
+
// ★ 链上事实需要 txHash: facilitator 说成功但没有 txHash → 不能标 chainSettled (真跑抓到过这类"假结算")
|
|
339
|
+
const txHash = parsed?.payment?.txHash || retry.headers.get('x-payment-txhash') || parsed?.txHash || '';
|
|
302
340
|
if (retry.status < 200 || retry.status >= 300) {
|
|
303
341
|
// 付款这一步已经发出去了 (可能已上链), 只是拿资源失败 → 绝不许当"没付过钱"
|
|
342
|
+
// ★ F5: 有 txHash 就去链上真查一遍, 而不是"有 txHash 就算结算"
|
|
343
|
+
const v = await verifyPaymentOnChain({ txHash, chainSettlement: params.chainSettlement });
|
|
304
344
|
return {
|
|
305
345
|
ok: false, status: retry.status,
|
|
306
|
-
payment: {
|
|
346
|
+
payment: {
|
|
347
|
+
mode: 'facilitator', receipt: receipt || undefined, attempted: true, settled: true,
|
|
348
|
+
settlementUncertain: !receipt && !v.chainSettled,
|
|
349
|
+
chainSettled: v.chainSettled, chainSettlementStatus: v.status, chainSettlementReason: v.reason,
|
|
350
|
+
confirmations: v.confirmations, confirmationsRequired: v.confirmationsRequired, rpcAvailable: v.rpcAvailable,
|
|
351
|
+
...(txHash ? { txHash } : {}),
|
|
352
|
+
},
|
|
307
353
|
error: `付款后重试失败 ${retry.status}: ${text.slice(0, 200)}`,
|
|
308
354
|
};
|
|
309
355
|
}
|
|
310
356
|
mode = 'facilitator';
|
|
311
|
-
// ★
|
|
312
|
-
const
|
|
357
|
+
// ★ F5 修复: 拿到 txHash ≠ 链上已验证。真验证 = receipt(status==1) + 确认数 + 事件(taskKey/resultHash) + 合约 escrow 状态。
|
|
358
|
+
const verdict = await verifyPaymentOnChain({ txHash, chainSettlement: params.chainSettlement });
|
|
313
359
|
await trackEvent({
|
|
314
360
|
kind: 'settled',
|
|
315
|
-
detail: `mode=facilitator receipt=${receipt.slice(0, 24)}… txHash=${txHash ? `${String(txHash).slice(0, 16)}…` : '(缺失: 不能认定链上结算完成)'}
|
|
316
|
-
|
|
361
|
+
detail: `mode=facilitator receipt=${receipt.slice(0, 24)}… txHash=${txHash ? `${String(txHash).slice(0, 16)}…` : '(缺失: 不能认定链上结算完成)'} 链上判定=${verdict.status}` +
|
|
362
|
+
(verdict.confirmations != null ? ` 确认数=${verdict.confirmations}/${verdict.confirmationsRequired}` : ''),
|
|
363
|
+
patch: {
|
|
364
|
+
paymentMode: 'facilitator', paymentReceipt: receipt,
|
|
365
|
+
chainSettled: verdict.chainSettled,
|
|
366
|
+
chainSettlementStatus: verdict.status,
|
|
367
|
+
chainSettlementReason: verdict.reason,
|
|
368
|
+
...(verdict.confirmations != null ? { chainConfirmations: verdict.confirmations } : {}),
|
|
369
|
+
...(verdict.rpcAvailable ? {} : { chainRpcAvailable: false }),
|
|
370
|
+
...(txHash ? { txHash: String(txHash) } : {}),
|
|
371
|
+
},
|
|
317
372
|
});
|
|
318
373
|
const report = parsed?.proof ? await verifyEnvelope(parsed, { resolveDid: params.resolveDid, expectItemId: params.expectItemId }) : undefined;
|
|
319
374
|
if (report)
|
|
320
375
|
await trackEvent({ kind: 'delivered', detail: `trust=${report.trust}`, patch: { verificationTrust: report.trust, contentHash: parsed?.contentHash, protocolVerified: report.trust === 'verified' } });
|
|
321
|
-
return {
|
|
376
|
+
return {
|
|
377
|
+
ok: true, status: retry.status, envelope: parsed, verify: report, metadata, raw: text,
|
|
378
|
+
payment: {
|
|
379
|
+
mode, receipt,
|
|
380
|
+
...(txHash ? { txHash } : {}),
|
|
381
|
+
attempted: true, settled: true,
|
|
382
|
+
settlementUncertain: verdict.status === 'unknown' || verdict.status === 'config_unavailable' || !verdict.rpcAvailable,
|
|
383
|
+
chainSettled: verdict.chainSettled,
|
|
384
|
+
chainSettlementStatus: verdict.status,
|
|
385
|
+
chainSettlementReason: verdict.reason,
|
|
386
|
+
confirmations: verdict.confirmations,
|
|
387
|
+
confirmationsRequired: verdict.confirmationsRequired,
|
|
388
|
+
rpcAvailable: verdict.rpcAvailable,
|
|
389
|
+
},
|
|
390
|
+
};
|
|
322
391
|
}
|
|
323
392
|
if (!params.allowLocalDev) {
|
|
324
393
|
return { ok: false, status: 402, error: '需要钱包私钥才能支付 (未提供 privateKey; 本机联调请显式 allowLocalDev)' };
|
|
@@ -197,7 +197,14 @@ export async function updateTransaction(transactionId, patch, home = os.homedir(
|
|
|
197
197
|
...rest,
|
|
198
198
|
events: ev2 ? [...(rec.events || []), event(ev2.kind, ev2.detail)] : (rec.events || []),
|
|
199
199
|
};
|
|
200
|
-
|
|
200
|
+
const saved = await saveTransaction(next, home);
|
|
201
|
+
// ★ 公开观察层: 真实交易活动 → 脉冲事件 (fire-and-forget; 统计失败绝不影响交易主路径)
|
|
202
|
+
try {
|
|
203
|
+
const np = await import('../network-pulse.js');
|
|
204
|
+
void np.emitTradePulse(rec, saved, home);
|
|
205
|
+
}
|
|
206
|
+
catch { /* noop */ }
|
|
207
|
+
return saved;
|
|
201
208
|
}
|
|
202
209
|
export async function setTransactionStatus(transactionId, status, detail, home = os.homedir()) {
|
|
203
210
|
return updateTransaction(transactionId, { status, ...(status === 'settled' ? { settledAt: new Date().toISOString() } : {}), ...(status === 'delivered' ? { deliveredAt: new Date().toISOString() } : {}), ...(status === 'verified' ? { verifiedAt: new Date().toISOString() } : {}), event: { kind: `status:${status}`, detail } }, home);
|