@bolloon/bolloon-agent 0.4.26 → 0.4.28
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/execution-supervisor.js +51 -0
- package/dist/agents/p2p-info.js +175 -0
- package/dist/agents/run-store.js +15 -0
- package/dist/agents/task/local-seller.js +149 -0
- package/dist/agents/task/report-card.js +146 -0
- package/dist/agents/task/resource-advisor.js +182 -0
- package/dist/agents/task/task-budget.js +103 -0
- package/dist/agents/task/task-runner.js +618 -0
- package/dist/agents/trace-export.js +125 -0
- package/dist/agents/write-staging.js +12 -4
- package/dist/agents/x402/goal-run-bridge.js +109 -0
- package/dist/agents/x402/milestone-settlement.js +150 -0
- package/dist/agents/x402/paid-info-store.js +66 -12
- package/dist/agents/x402/payment-recovery.js +290 -0
- package/dist/agents/x402/resource-contract.js +484 -0
- package/dist/agents/x402/settlement-state.js +378 -0
- package/dist/agents/x402/trade.js +257 -0
- package/dist/agents/x402/transaction-protocol.js +99 -0
- package/dist/agents/x402/transaction-store.js +350 -0
- package/dist/cli-entry.js +168 -0
- package/dist/index.js +103 -0
- package/dist/web/routes-x402-info.js +1 -0
- package/dist/web/server.js +82 -0
- package/package.json +1 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trace-export.ts — 智能体**工具执行轨迹**的导出/解析 (2026-09-18)
|
|
3
|
+
*
|
|
4
|
+
* 定位: Run 的 steps[] 就是这台机器上真实发生过的工具调用事实
|
|
5
|
+
* (n / ts / tool / argsDigest / ok / ms / summary / error)。
|
|
6
|
+
* 这一层只做一件事: 把这份事实变成**可读、可交换、可再解析**的文本/JSON,
|
|
7
|
+
* 好让 App、CLI、Web 与外部工具(如小红书小工具)看到同一份轨迹。
|
|
8
|
+
*
|
|
9
|
+
* 文本格式 (小工具侧解析器依赖, 改前先看 minitools/agent-card/src/assets/app.js 的 parseTraceText):
|
|
10
|
+
* # Bolloon 执行轨迹 · run <runId> (<N> 步)
|
|
11
|
+
* 1. [ok] 2026-09-18T08:46:15.093Z shell_exec — ls -la /tmp (12ms)
|
|
12
|
+
* 2. [fail] 2026-09-18T08:46:17.001Z write_file — EACCES: permission denied (3ms)
|
|
13
|
+
* 规则: "<n>. [ok|fail] <无空格时间戳> <工具名> — <细节>" —— 时间戳与工具名不能含空格。
|
|
14
|
+
*/
|
|
15
|
+
export const TRACE_HEADER_PREFIX = '# Bolloon 执行轨迹';
|
|
16
|
+
function stepDetail(s) {
|
|
17
|
+
const base = String(s.summary || s.error || (s.ok ? '完成' : '失败')).replace(/\s+/g, ' ').trim();
|
|
18
|
+
const ms = s.ms ? ` (${s.ms}ms)` : '';
|
|
19
|
+
const args = s.argsDigest ? ` [args:${String(s.argsDigest).slice(0, 8)}]` : '';
|
|
20
|
+
return `${base}${args}${ms}`.slice(0, 400);
|
|
21
|
+
}
|
|
22
|
+
/** Run → 轨迹 JSON (机器可读; 所有消费方都应基于这个结构, 而不是各自解析文本) */
|
|
23
|
+
export function runToTraceJson(run) {
|
|
24
|
+
const steps = (run.steps || []).map((s) => ({
|
|
25
|
+
n: s.n,
|
|
26
|
+
ts: s.ts,
|
|
27
|
+
tool: s.tool,
|
|
28
|
+
ok: !!s.ok,
|
|
29
|
+
ms: s.ms,
|
|
30
|
+
summary: s.summary,
|
|
31
|
+
error: s.error,
|
|
32
|
+
argsDigest: s.argsDigest,
|
|
33
|
+
}));
|
|
34
|
+
const ok = steps.filter((s) => s.ok).length;
|
|
35
|
+
const byTool = new Map();
|
|
36
|
+
for (const s of steps) {
|
|
37
|
+
const cur = byTool.get(s.tool) || { tool: s.tool, count: 0, fail: 0 };
|
|
38
|
+
cur.count += 1;
|
|
39
|
+
if (!s.ok)
|
|
40
|
+
cur.fail += 1;
|
|
41
|
+
byTool.set(s.tool, cur);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
schema: 'bolloon-agent-trace/1',
|
|
45
|
+
runId: run.runId,
|
|
46
|
+
goalId: run.goalId,
|
|
47
|
+
surface: run.surface,
|
|
48
|
+
status: run.status,
|
|
49
|
+
goal: run.goal,
|
|
50
|
+
startedAt: run.startedAt || (run.steps || [])[0]?.ts,
|
|
51
|
+
updatedAt: run.updatedAt,
|
|
52
|
+
steps,
|
|
53
|
+
counts: { total: steps.length, ok, fail: steps.length - ok, totalMs: steps.reduce((a, s) => a + (s.ms || 0), 0) },
|
|
54
|
+
tools: Array.from(byTool.values()).sort((a, b) => b.count - a.count),
|
|
55
|
+
evidence: run.evidence,
|
|
56
|
+
error: run.error,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Run → 轨迹文本 (人可读 + 可被小工具解析; 供 CLI / 复制粘贴) */
|
|
60
|
+
export function runToTraceText(run, opts = {}) {
|
|
61
|
+
const steps = (run.steps || []).slice(opts.limit ? Math.max(0, (run.steps || []).length - opts.limit) : 0);
|
|
62
|
+
const lines = [];
|
|
63
|
+
lines.push(`${TRACE_HEADER_PREFIX} · run ${run.runId} (${(run.steps || []).length} 步)`);
|
|
64
|
+
if (run.goal)
|
|
65
|
+
lines.push(`# 目标: ${String(run.goal).replace(/\s+/g, ' ').slice(0, 160)}`);
|
|
66
|
+
lines.push(`# 状态: ${run.status}${run.goalId ? ` · goal ${run.goalId}` : ''}${run.surface ? ` · surface ${run.surface}` : ''}`);
|
|
67
|
+
if (!steps.length)
|
|
68
|
+
lines.push('# (这次运行没有工具步骤)');
|
|
69
|
+
for (const s of steps) {
|
|
70
|
+
lines.push(`${s.n}. [${s.ok ? 'ok' : 'fail'}] ${s.ts} ${s.tool} — ${stepDetail(s)}`);
|
|
71
|
+
}
|
|
72
|
+
if (run.error)
|
|
73
|
+
lines.push(`# 结束原因: ${String(run.error).replace(/\s+/g, ' ').slice(0, 200)}`);
|
|
74
|
+
return lines.join('\n');
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 解析轨迹文本 (容错: 只认 "<n>. [ok|fail] <ts> <tool> — <detail>" 这一行格式)。
|
|
78
|
+
* 用于: 把别处(小工具/别的机器)的轨迹读回来, 以及本模块的自校验往返。
|
|
79
|
+
*/
|
|
80
|
+
export function parseTraceText(text) {
|
|
81
|
+
const out = { steps: [] };
|
|
82
|
+
const lines = String(text || '').split('\n');
|
|
83
|
+
const re = /^\s*(\d+)\.\s*\[(ok|fail)\]\s*(\S+)\s+(\S+)\s*(?:—\s*)?([\s\S]*)$/;
|
|
84
|
+
for (const raw of lines) {
|
|
85
|
+
const line = raw.replace(/\r$/, '');
|
|
86
|
+
if (line.startsWith(TRACE_HEADER_PREFIX)) {
|
|
87
|
+
out.header = line;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (line.startsWith('# 目标:')) {
|
|
91
|
+
out.goal = line.slice(5).trim();
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (line.startsWith('# 状态:')) {
|
|
95
|
+
out.status = line.slice(5).trim();
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (line.startsWith('# 结束原因:')) {
|
|
99
|
+
out.errorLine = line.slice(7).trim();
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!line.trim() || line.startsWith('#'))
|
|
103
|
+
continue;
|
|
104
|
+
const m = re.exec(line);
|
|
105
|
+
if (!m)
|
|
106
|
+
continue;
|
|
107
|
+
const detail = String(m[5] || '');
|
|
108
|
+
const msMatch = /\s*\((\d+)ms\)\s*$/.exec(detail);
|
|
109
|
+
out.steps.push({
|
|
110
|
+
n: Number(m[1]),
|
|
111
|
+
ok: m[2] === 'ok',
|
|
112
|
+
ts: m[3],
|
|
113
|
+
tool: m[4],
|
|
114
|
+
ms: msMatch ? Number(msMatch[1]) : undefined,
|
|
115
|
+
summary: msMatch ? detail.slice(0, msMatch.index).trim() : detail.trim(),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
/** 一行摘要 (CLI 列表/日志用) */
|
|
121
|
+
export function summarizeTrace(run) {
|
|
122
|
+
const j = runToTraceJson(run);
|
|
123
|
+
const tools = j.tools.slice(0, 3).map((t) => `${t.tool}×${t.count}`).join(' ');
|
|
124
|
+
return `${j.counts.total} 步 (✓${j.counts.ok}/✗${j.counts.fail}, ${j.counts.totalMs}ms)${tools ? ` · ${tools}` : ''}`;
|
|
125
|
+
}
|
|
@@ -18,9 +18,16 @@ const home = () => process.env.HOME || os.homedir() || '/tmp';
|
|
|
18
18
|
export function writeLogDir(homeDir = home()) {
|
|
19
19
|
return path.join(homeDir, '.bolloon', 'write-log');
|
|
20
20
|
}
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* 生成唯一 stage id。
|
|
23
|
+
* ★ 2026-09-18: 加**进程内单调序号** —— 原来只有 `Date.now()-随机`, 同一毫秒内的两次写入
|
|
24
|
+
* 顺序由随机后缀决定, `listStagedWrites` 的"最新在前"就不成立了 (真跑: 同毫秒 a.txt/b.txt 偶发反序,
|
|
25
|
+
* 让 write-staging 单测在全量跑时随机变红)。序号按 3 位 36 进制, 与时间戳一起保证字典序 == 发生顺序。
|
|
26
|
+
*/
|
|
27
|
+
let stageSeq = 0;
|
|
22
28
|
function genId() {
|
|
23
|
-
|
|
29
|
+
const seq = (stageSeq++ % 46656).toString(36).padStart(3, '0');
|
|
30
|
+
return `${Date.now()}-${seq}-${Math.random().toString(36).substring(2, 8)}`;
|
|
24
31
|
}
|
|
25
32
|
/**
|
|
26
33
|
* 写前暂存: 记录一次写操作 (准备阶段). 返回记录; 失败静默返回 null.
|
|
@@ -54,13 +61,14 @@ export async function listStagedWrites(homeDir = home()) {
|
|
|
54
61
|
return [];
|
|
55
62
|
}
|
|
56
63
|
const out = [];
|
|
57
|
-
for (const f of files.filter(f => f.endsWith('.json'))
|
|
64
|
+
for (const f of files.filter(f => f.endsWith('.json'))) {
|
|
58
65
|
try {
|
|
59
66
|
out.push(JSON.parse(await fs.readFile(path.join(dir, f), 'utf-8')));
|
|
60
67
|
}
|
|
61
68
|
catch { /* 坏文件跳过 */ }
|
|
62
69
|
}
|
|
63
|
-
|
|
70
|
+
// 最新在前: 先按 createdAt, 同一毫秒再按 id (id 内含单调序号) —— 确定性, 不看 readdir 顺序
|
|
71
|
+
return out.sort((a, b) => (b.createdAt - a.createdAt) || String(b.id).localeCompare(String(a.id)));
|
|
64
72
|
}
|
|
65
73
|
/** 撤销最近一次写 (若文件内容仍等于 afterContent → 恢复 beforeContent). 返回是否撤销. */
|
|
66
74
|
export async function undoLastWrite(homeDir = home()) {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* goal-run-bridge.ts — 交易证据接入 Run / Goal (Phase 3, 2026-09-16)
|
|
3
|
+
*
|
|
4
|
+
* 支付不能只是工具副作用: 一次 Run 必须能回答 —— 为什么付款、买了什么、花了多少、
|
|
5
|
+
* 是否真结算、拿到什么、资源是否验证通过、结果对 Goal 有没有帮助。
|
|
6
|
+
*
|
|
7
|
+
* 事件映射 (与 leo 的规格一致):
|
|
8
|
+
* discovered → transaction.discovered · quoted → transaction.quoted · policy_denied → transaction.policy_denied
|
|
9
|
+
* paying → transaction.paying · settled → transaction.settled · delivered → transaction.delivered
|
|
10
|
+
* verified → transaction.verified · delivery_failed/verification_failed → 同名
|
|
11
|
+
*
|
|
12
|
+
* Goal 侧只在 **交易 verified + 资源执行成功 + 命中判据** 时累计成功证据;
|
|
13
|
+
* 仅付款成功或仅拿到内容 **不能**满足 Goal 判据。
|
|
14
|
+
*/
|
|
15
|
+
import { recordStep, addRunEvidence } from '../run-store.js';
|
|
16
|
+
import { addEvidence as addGoalEvidenceViaStore } from '../goal-store.js';
|
|
17
|
+
import { aggregateMilestones, milestoneGoalEligibility } from './milestone-settlement.js';
|
|
18
|
+
export function bridgeEventFor(status) {
|
|
19
|
+
switch (status) {
|
|
20
|
+
case 'discovered': return 'transaction.discovered';
|
|
21
|
+
case 'quoted': return 'transaction.quoted';
|
|
22
|
+
case 'policy_denied': return 'transaction.policy_denied';
|
|
23
|
+
case 'paying':
|
|
24
|
+
case 'payment_required': return 'transaction.paying';
|
|
25
|
+
case 'settled': return 'transaction.settled';
|
|
26
|
+
case 'delivered': return 'transaction.delivered';
|
|
27
|
+
case 'verified': return 'transaction.verified';
|
|
28
|
+
case 'delivery_failed': return 'transaction.delivery_failed';
|
|
29
|
+
case 'verification_failed': return 'transaction.verification_failed';
|
|
30
|
+
default: return 'transaction.quoted';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** 一次交易的证据行 (写进 Run evidence / Goal evidence 的同一组字段) */
|
|
34
|
+
export function transactionEvidenceLines(rec) {
|
|
35
|
+
// 2026-09-18: `goal-store.addEvidence` 每行**截断 300 字** —— 所以审计关键字段必须排在**前面**,
|
|
36
|
+
// 哈希类(长且可从交易记录反查)放后面。真跑抓到过: verificationTrust/executionOk 被截掉。
|
|
37
|
+
return [
|
|
38
|
+
`transactionId=${rec.transactionId}`,
|
|
39
|
+
`itemId=${rec.itemId}`,
|
|
40
|
+
`transactionStatus=${rec.status}`,
|
|
41
|
+
`paymentMode=${rec.paymentMode}`,
|
|
42
|
+
`chainSettled=${rec.chainSettled}`,
|
|
43
|
+
`settlementFact=${rec.settlementFact || '(legacy-未记)'}`, // 两层状态: 钱到底动没动, 审计一眼可见
|
|
44
|
+
`verificationTrust=${rec.verificationTrust || 'unverified'}`,
|
|
45
|
+
`protocolVerified=${rec.protocolVerified === true}`,
|
|
46
|
+
...(rec.execution ? [`executionOk=${rec.execution.ok === true} schemaOk=${rec.execution.schemaOk === true}`] : []),
|
|
47
|
+
...(rec.resourceOutcome ? [`resourceInstalled=${rec.resourceOutcome.installed} resourceExecuted=${rec.resourceOutcome.executed} outputContract=${rec.resourceOutcome.outputContract} criteriaHit=${rec.resourceOutcome.criteriaHit}${rec.resourceOutcome.failureStage ? ` failureStage=${rec.resourceOutcome.failureStage}` : ''}`] : []),
|
|
48
|
+
...(rec.responsibility ? [`responsibility=${rec.responsibility.type}(${rec.responsibility.reason})`] : []),
|
|
49
|
+
...(rec.milestones?.length ? (() => { const a = aggregateMilestones(rec.milestones); return [`milestones=${a.verified}/${a.total}`, `milestoneSettlement=${a.settlementFact}`]; })() : []),
|
|
50
|
+
...(rec.dispute ? [`dispute=opened(${rec.dispute.reason})`, `disputeResolved=${rec.dispute.resolution ? rec.dispute.resolution.decision : 'no'}`] : []),
|
|
51
|
+
// 长哈希垫底 (可从交易记录反查; 300 字截断时优先牺牲它们, 而不是上面的判定字段)
|
|
52
|
+
`txHash=${rec.txHash || '(none)'}`,
|
|
53
|
+
`receiptHash=${rec.receiptHash || '(none)'}`,
|
|
54
|
+
`contentHash=${rec.contentHash || '(none)'}`,
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 把一笔交易写进 Run (step + evidence) 与 Goal (仅在满足条件时)。
|
|
59
|
+
* @param opts.goalCriteriaHit 资源执行结果是否命中 Goal 判据 (由调用方判定, 默认 false)
|
|
60
|
+
* @param opts.executionOk 资源是否被实际执行且符合契约
|
|
61
|
+
*/
|
|
62
|
+
export async function bridgeTransactionToRunGoal(rec, opts = {}) {
|
|
63
|
+
const out = { runId: opts.runId, goalId: opts.goalId, stepWritten: false, evidenceWritten: false, goalEvidenceWritten: false };
|
|
64
|
+
const lines = transactionEvidenceLines(rec);
|
|
65
|
+
const ok = rec.status === 'verified' || rec.status === 'delivered';
|
|
66
|
+
if (opts.runId) {
|
|
67
|
+
try {
|
|
68
|
+
const step = {
|
|
69
|
+
tool: 'x402_transaction',
|
|
70
|
+
ok,
|
|
71
|
+
summary: `${bridgeEventFor(rec.status)} · ${opts.summary || rec.itemId} (${rec.amount || rec.price || '?'} ${rec.currency || ''} via ${rec.paymentMode} · 结算 ${rec.settlementFact || '?'})`,
|
|
72
|
+
error: ok ? undefined : (rec.failureReason || rec.status),
|
|
73
|
+
args: { itemId: rec.itemId, amount: rec.amount, currency: rec.currency, network: rec.network, requestId: rec.requestId },
|
|
74
|
+
};
|
|
75
|
+
await recordStep(opts.runId, step);
|
|
76
|
+
out.stepWritten = true;
|
|
77
|
+
await addRunEvidence(opts.runId, lines);
|
|
78
|
+
out.evidenceWritten = true;
|
|
79
|
+
}
|
|
80
|
+
catch { /* 记账失败不改变交易事实, 但上面会让 out.* 保持 false (调用方可见) */ }
|
|
81
|
+
}
|
|
82
|
+
// Goal 侧: 只有 verified + 执行成功 + 命中判据 才计入成功证据
|
|
83
|
+
if (opts.goalId) {
|
|
84
|
+
// 纵深防御 + Phase 4 门槛: 链上结算 + 全部里程碑完成 + 无争议 + 执行成功 + 命中判据
|
|
85
|
+
// (partially_settled 一律不算 —— leo: 不要让 partially_settled 直接进 Goal 成功证据)
|
|
86
|
+
const elig = milestoneGoalEligibility(rec, { executionOk: opts.executionOk, goalCriteriaHit: opts.goalCriteriaHit });
|
|
87
|
+
const eligible = elig.eligible;
|
|
88
|
+
try {
|
|
89
|
+
if (eligible) {
|
|
90
|
+
await addGoalEvidenceViaStore(opts.goalId, [
|
|
91
|
+
`付费资源已执行并命中判据: ${lines.join(' ')}`,
|
|
92
|
+
]);
|
|
93
|
+
out.goalEvidenceWritten = true;
|
|
94
|
+
}
|
|
95
|
+
else if (rec.status === 'verified' || (rec.milestones?.length || 0) > 0) {
|
|
96
|
+
await addGoalEvidenceViaStore(opts.goalId, [
|
|
97
|
+
`付费资源未达成功证据门槛 (${elig.reason}): ${lines.join(' ')}`,
|
|
98
|
+
]);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
await addGoalEvidenceViaStore(opts.goalId, [
|
|
102
|
+
`交易未成立 (${rec.status}): ${lines.join(' ')}`,
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch { /* 同上 */ }
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* milestone-settlement.ts — 里程碑结算 / 争议 / 责任落地 (Phase 4, 2026-09-18)
|
|
3
|
+
*
|
|
4
|
+
* leo 的 Phase 4:
|
|
5
|
+
* 4.1 **PartiallySettled**: 分阶段服务按里程碑结算 (第一版只支持明确里程碑)
|
|
6
|
+
* 每里程碑: milestoneId / amount / paymentStatus / deliveryStatus / verificationStatus / evidence
|
|
7
|
+
* 规则: 部分成功 → partially_settled; 全部支付且全部交付验证 → verified; 任一交付失败 → disputed 或 delivery_failed
|
|
8
|
+
* ★ `partially_settled` **不许**直接进 Goal 成功证据
|
|
9
|
+
* 4.2 **争议**: disputed / refund_pending / refunded; 争议必须绑定
|
|
10
|
+
* 原始报价 · Payment Header · facilitator response · txHash · 内容哈希 · 签名信封 · Run step · Goal evidence · 失败时点 · 责任候选
|
|
11
|
+
* 三条禁令: 不能自动重付 · 不能标 verified · **不能静默关闭**
|
|
12
|
+
* 4.3 **责任判定**: 机器只给候选 (Phase 0 的 deriveResponsibility), 候选连同证据进交易记录 + Run/Goal 证据
|
|
13
|
+
*/
|
|
14
|
+
import { deriveResponsibility } from './settlement-state.js';
|
|
15
|
+
/** 里程碑的不变式: 金额必须是正整数原子单位字符串 (不许浮点/负数/空) */
|
|
16
|
+
export function validateMilestoneSpec(m) {
|
|
17
|
+
if (!m.milestoneId)
|
|
18
|
+
return { ok: false, reason: 'milestoneId 不能为空' };
|
|
19
|
+
if (!/^\d+$/.test(String(m.amount)))
|
|
20
|
+
return { ok: false, reason: `amount 必须是正整数原子单位字符串, 实际: ${m.amount}` };
|
|
21
|
+
if (Number(m.amount) <= 0)
|
|
22
|
+
return { ok: false, reason: 'amount 必须大于 0' };
|
|
23
|
+
return { ok: true };
|
|
24
|
+
}
|
|
25
|
+
export function makeMilestone(m) {
|
|
26
|
+
const chk = validateMilestoneSpec(m);
|
|
27
|
+
if (!chk.ok)
|
|
28
|
+
throw new Error(chk.reason);
|
|
29
|
+
return {
|
|
30
|
+
milestoneId: m.milestoneId, title: m.title, amount: String(m.amount),
|
|
31
|
+
paymentStatus: 'unpaid', deliveryStatus: 'pending', verificationStatus: 'pending',
|
|
32
|
+
evidence: [], updatedAt: new Date().toISOString(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** 里程碑总金额必须等于交易金额 (否则就是账不平) */
|
|
36
|
+
export function milestonesMatchAmount(ms, amount) {
|
|
37
|
+
const sum = ms.reduce((a, m) => a + BigInt(m.amount || '0'), 0n).toString();
|
|
38
|
+
if (amount && String(amount) !== sum)
|
|
39
|
+
return { ok: false, sum, reason: `里程碑金额合计 ${sum} ≠ 交易金额 ${amount}` };
|
|
40
|
+
return { ok: true, sum };
|
|
41
|
+
}
|
|
42
|
+
export function aggregateMilestones(ms) {
|
|
43
|
+
const list = ms || [];
|
|
44
|
+
const paid = list.filter((m) => m.paymentStatus === 'paid').length;
|
|
45
|
+
const delivered = list.filter((m) => m.deliveryStatus === 'delivered').length;
|
|
46
|
+
const verified = list.filter((m) => m.verificationStatus === 'verified').length;
|
|
47
|
+
const failed = list.filter((m) => m.deliveryStatus === 'failed' || m.verificationStatus === 'failed').length;
|
|
48
|
+
const allComplete = list.length > 0 && verified === list.length;
|
|
49
|
+
const anyProgress = paid > 0 || delivered > 0 || verified > 0;
|
|
50
|
+
const partiallyComplete = anyProgress && !allComplete && failed === 0;
|
|
51
|
+
const next = list.find((m) => m.verificationStatus !== 'verified' && m.deliveryStatus !== 'failed');
|
|
52
|
+
let settlementFact = 'unpaid';
|
|
53
|
+
if (allComplete)
|
|
54
|
+
settlementFact = 'fully_settled';
|
|
55
|
+
else if (partiallyComplete)
|
|
56
|
+
settlementFact = 'partially_settled';
|
|
57
|
+
else if (paid > 0 || delivered > 0)
|
|
58
|
+
settlementFact = 'payment_submitted';
|
|
59
|
+
const shouldDispute = failed > 0;
|
|
60
|
+
const reason = shouldDispute
|
|
61
|
+
? `${failed} 个里程碑交付/验真失败 → 进争议 (不许静默关闭)`
|
|
62
|
+
: allComplete ? '全部里程碑支付+交付+验真完成'
|
|
63
|
+
: partiallyComplete ? `${verified}/${list.length} 个里程碑完成 → partially_settled (不算完成, 也不进 Goal 成功证据)`
|
|
64
|
+
: list.length === 0 ? '没有里程碑 (单笔交易)' : '还没有里程碑完成';
|
|
65
|
+
return { total: list.length, paid, delivered, verified, failed, allComplete, partiallyComplete, nextMilestoneId: next?.milestoneId, settlementFact, shouldDispute, reason };
|
|
66
|
+
}
|
|
67
|
+
/** 应用一个里程碑结果并给出应落的结算事实 (纯函数; 落盘由调用方做, 保证可测) */
|
|
68
|
+
export function applyMilestoneResult(ms, milestoneId, result) {
|
|
69
|
+
const list = (ms || []).map((m) => ({ ...m, evidence: [...(m.evidence || [])] }));
|
|
70
|
+
const target = list.find((m) => m.milestoneId === milestoneId);
|
|
71
|
+
if (!target)
|
|
72
|
+
return { milestones: list, aggregate: aggregateMilestones(list), ok: false, reason: `没有这个里程碑: ${milestoneId}` };
|
|
73
|
+
if (result.paymentStatus)
|
|
74
|
+
target.paymentStatus = result.paymentStatus;
|
|
75
|
+
if (result.deliveryStatus)
|
|
76
|
+
target.deliveryStatus = result.deliveryStatus;
|
|
77
|
+
if (result.verificationStatus)
|
|
78
|
+
target.verificationStatus = result.verificationStatus;
|
|
79
|
+
if (result.evidence?.length)
|
|
80
|
+
target.evidence.push(...result.evidence);
|
|
81
|
+
target.updatedAt = new Date().toISOString();
|
|
82
|
+
return { milestones: list, aggregate: aggregateMilestones(list), ok: true };
|
|
83
|
+
}
|
|
84
|
+
const REQUIRED_EVIDENCE_FIELDS = [
|
|
85
|
+
'quote', 'txHash', 'contentHash', 'envelopeDigest', 'failurePoint',
|
|
86
|
+
];
|
|
87
|
+
/** 开争议: 记录绑定的证据 + 缺口; 生命周期进 disputed (自动化到此为止) */
|
|
88
|
+
export function buildDispute(opts) {
|
|
89
|
+
const missing = REQUIRED_EVIDENCE_FIELDS.filter((f) => {
|
|
90
|
+
const v = opts.evidence[f];
|
|
91
|
+
if (v === undefined || v === null)
|
|
92
|
+
return true;
|
|
93
|
+
if (typeof v === 'string')
|
|
94
|
+
return v.trim().length === 0;
|
|
95
|
+
if (Array.isArray(v))
|
|
96
|
+
return v.length === 0;
|
|
97
|
+
if (typeof v === 'object')
|
|
98
|
+
return Object.values(v).every((x) => x === undefined || x === null || x === '');
|
|
99
|
+
return false;
|
|
100
|
+
});
|
|
101
|
+
const responsibility = opts.evidence.responsibility
|
|
102
|
+
|| (opts.responsibilityEvidence ? deriveResponsibility(opts.responsibilityEvidence) : undefined);
|
|
103
|
+
return {
|
|
104
|
+
openedAt: new Date().toISOString(),
|
|
105
|
+
reason: opts.reason,
|
|
106
|
+
evidence: { ...opts.evidence, responsibility },
|
|
107
|
+
missingEvidence: missing,
|
|
108
|
+
mustNotRepay: true,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** 争议禁令的唯一实现在 settlement-state (`disputeForbids`), 这里只做转发, 避免两份判断漂移 */
|
|
112
|
+
export { disputeForbids } from './settlement-state.js';
|
|
113
|
+
/** 争议收尾: 必须带决定 + 依据 + 证据 (退款走结算层 refund_pending → refunded) */
|
|
114
|
+
export function resolveDispute(rec, opts) {
|
|
115
|
+
if (!rec.dispute)
|
|
116
|
+
throw new Error('这笔交易没有争议记录');
|
|
117
|
+
if (!opts.evidence?.length)
|
|
118
|
+
throw new Error('争议收尾必须带证据 (不许无凭据关闭)');
|
|
119
|
+
return {
|
|
120
|
+
...rec.dispute,
|
|
121
|
+
resolution: { decision: opts.decision, at: new Date().toISOString(), by: opts.by, reason: opts.reason, evidence: [...opts.evidence] },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 里程碑/争议叠加后的 Goal 成功证据门槛:
|
|
126
|
+
* 交易 verified 或 (全部里程碑完成 + 结算 fully_settled) ∧ 无争议 ∧ 执行成功 ∧ 命中判据
|
|
127
|
+
* —— `partially_settled` 一律不算 (leo: 不要让 partially_settled 直接进 Goal 成功证据)
|
|
128
|
+
*/
|
|
129
|
+
export function milestoneGoalEligibility(rec, opts = {}) {
|
|
130
|
+
if (rec.dispute && !rec.dispute.resolution)
|
|
131
|
+
return { eligible: false, reason: '交易在争议中 (未收尾) → 不计入 Goal 成功证据' };
|
|
132
|
+
const agg = aggregateMilestones(rec.milestones);
|
|
133
|
+
if (agg.total > 0) {
|
|
134
|
+
if (agg.shouldDispute)
|
|
135
|
+
return { eligible: false, reason: '里程碑有失败项 → 需争议处理, 不计入成功证据' };
|
|
136
|
+
if (!agg.allComplete)
|
|
137
|
+
return { eligible: false, reason: `里程碑未全部完成 (${agg.verified}/${agg.total}) → partially_settled 不计入成功证据` };
|
|
138
|
+
}
|
|
139
|
+
if (rec.settlementFact === 'partially_settled')
|
|
140
|
+
return { eligible: false, reason: '结算事实是 partially_settled → 不计入成功证据' };
|
|
141
|
+
if (rec.status !== 'verified')
|
|
142
|
+
return { eligible: false, reason: `交易状态是 ${rec.status}, 不是 verified` };
|
|
143
|
+
if (rec.chainSettled !== true)
|
|
144
|
+
return { eligible: false, reason: '链上没有真实结算' };
|
|
145
|
+
if (opts.executionOk !== true)
|
|
146
|
+
return { eligible: false, reason: '资源执行没成功 (买到 ≠ 用上)' };
|
|
147
|
+
if (opts.goalCriteriaHit !== true)
|
|
148
|
+
return { eligible: false, reason: '没有命中 Goal 判据' };
|
|
149
|
+
return { eligible: true, reason: '链上结算 + 全部里程碑完成 + 无争议 + 执行成功 + 命中判据' };
|
|
150
|
+
}
|
|
@@ -166,12 +166,23 @@ export async function checkAndSettlePayment(opts) {
|
|
|
166
166
|
const req = opts.requirements.accepts[0];
|
|
167
167
|
const network = String(req.network);
|
|
168
168
|
if (!opts.paymentHeader)
|
|
169
|
-
return { ok: false, mode: 'none', error: '缺少 X-PAYMENT 头 (未付款)' };
|
|
169
|
+
return { ok: false, mode: 'none', error: '缺少 X-PAYMENT 头 (未付款)', attempted: false };
|
|
170
170
|
const payload = decodePaymentHeader(opts.paymentHeader);
|
|
171
171
|
if (!payload)
|
|
172
|
-
return { ok: false, mode: 'none', error: 'X-PAYMENT 不是合法 base64 JSON' };
|
|
172
|
+
return { ok: false, mode: 'none', error: 'X-PAYMENT 不是合法 base64 JSON', attempted: false };
|
|
173
173
|
const facilitatorUrl = opts.facilitatorUrl ?? process.env.BOLLOON_X402_FACILITATOR ?? '';
|
|
174
174
|
const allowLocalDev = opts.allowLocalDev ?? (process.env.BOLLOON_X402_LOCAL_VERIFY === '1');
|
|
175
|
+
// ★ 凭据绑定校验必须在**分模式之前** (两种模式都查): 拿旧回执去换另一条资源 → 一律拒绝。
|
|
176
|
+
// 真跑抓到过: 这段原来只对 local-dev 生效, facilitator 模式提前 return → 跨资源复用没被拦。
|
|
177
|
+
const boundItem = payload?.accepted?.extra?.itemId || payload?.accepted?.itemId || payload?.itemId;
|
|
178
|
+
if (opts.expectedItemId) {
|
|
179
|
+
if (!boundItem) {
|
|
180
|
+
return { ok: false, mode: 'none', attempted: false, error: '支付凭据没有绑定 itemId: 无法证明这笔钱是为这条资源付的' };
|
|
181
|
+
}
|
|
182
|
+
if (String(boundItem) !== String(opts.expectedItemId)) {
|
|
183
|
+
return { ok: false, mode: 'none', attempted: false, error: `支付凭据绑定的资源 (${boundItem}) 与本次请求 (${opts.expectedItemId}) 不一致 — 回执不能跨资源复用` };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
175
186
|
if (facilitatorUrl) {
|
|
176
187
|
const f = opts.fetchImpl ?? fetch;
|
|
177
188
|
const body = { x402Version: 2, paymentPayload: payload, paymentRequirements: req };
|
|
@@ -181,26 +192,28 @@ export async function checkAndSettlePayment(opts) {
|
|
|
181
192
|
});
|
|
182
193
|
const v = await vres.json();
|
|
183
194
|
if (!v?.isValid) {
|
|
184
|
-
return { ok: false, mode: 'facilitator', error: `facilitator 校验未通过: ${v?.invalidReason || v?.invalidMessage || 'unknown'}` };
|
|
195
|
+
return { ok: false, mode: 'facilitator', attempted: true, verifyRejected: true, settlementUncertain: false, error: `facilitator 校验未通过: ${v?.invalidReason || v?.invalidMessage || 'unknown'}` };
|
|
185
196
|
}
|
|
186
197
|
const sres = await f(`${facilitatorUrl.replace(/\/$/, '')}/settle`, {
|
|
187
198
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
188
199
|
});
|
|
189
200
|
const s = await sres.json();
|
|
190
201
|
if (!s?.success) {
|
|
191
|
-
|
|
202
|
+
// settle 失败: 链上可能已经动了钱 → 不确定 (不是"没付过")
|
|
203
|
+
return { ok: false, mode: 'facilitator', attempted: true, verifyRejected: false, settlementUncertain: true, error: `结算失败: ${s?.errorReason || s?.errorMessage || 'unknown'}` };
|
|
192
204
|
}
|
|
193
205
|
const receipt = encodePaymentResponse(s);
|
|
194
|
-
return { ok: true, mode: 'facilitator', receipt, txHash: s.transaction, payer: s.payer || v.payer, network };
|
|
206
|
+
return { ok: true, mode: 'facilitator', receipt, txHash: s.transaction, payer: s.payer || v.payer, network, attempted: true };
|
|
195
207
|
}
|
|
196
208
|
catch (e) {
|
|
197
|
-
return { ok: false, mode: 'facilitator', error: `facilitator 不可达: ${String(e?.message || e).slice(0, 160)}` };
|
|
209
|
+
return { ok: false, mode: 'facilitator', attempted: true, verifyRejected: false, settlementUncertain: true, error: `facilitator 不可达: ${String(e?.message || e).slice(0, 160)}` };
|
|
198
210
|
}
|
|
199
211
|
}
|
|
200
212
|
if (!allowLocalDev) {
|
|
201
213
|
return {
|
|
202
214
|
ok: false,
|
|
203
215
|
mode: 'none',
|
|
216
|
+
attempted: false,
|
|
204
217
|
error: '未配置 facilitator (BOLLOON_X402_FACILITATOR), 也未开启本机联调模式 (BOLLOON_X402_LOCAL_VERIFY=1) — 无法校验真实付款',
|
|
205
218
|
};
|
|
206
219
|
}
|
|
@@ -227,9 +240,14 @@ export async function checkAndSettlePayment(opts) {
|
|
|
227
240
|
* 购买一条信息: 未付款时服务端回 402, 这里用标准 x402 客户端 (402→签名→重试) 完成支付。
|
|
228
241
|
* 无钱包私钥时, 只有显式 allowLocalDev 才走本机联调头。
|
|
229
242
|
*/
|
|
243
|
+
function makeTracker(onEvent) {
|
|
244
|
+
return async (e) => { if (onEvent)
|
|
245
|
+
await onEvent(e).catch(() => null); };
|
|
246
|
+
}
|
|
230
247
|
export async function buyInfo(params) {
|
|
231
248
|
const { verifyEnvelope } = await import('./paid-info-protocol.js');
|
|
232
249
|
const doFetch = params.fetchImpl ?? fetch;
|
|
250
|
+
const trackEvent = makeTracker(params.onEvent);
|
|
233
251
|
// ① 先探一次: 判断是否 402 (以及免费信息直接返回)
|
|
234
252
|
let res;
|
|
235
253
|
try {
|
|
@@ -248,11 +266,22 @@ export async function buyInfo(params) {
|
|
|
248
266
|
}
|
|
249
267
|
return { ok: false, status: res.status, error: `服务端返回 ${res.status}: ${text.slice(0, 200)}` };
|
|
250
268
|
}
|
|
251
|
-
// ② 402 → 支付 → 重试
|
|
269
|
+
// ② 402 → (策略门) → 支付 → 重试
|
|
252
270
|
const requirementBody = safeJson(await res.text());
|
|
253
271
|
const requirements = requirementBody?.accepts?.[0];
|
|
254
272
|
if (!requirements)
|
|
255
273
|
return { ok: false, status: 402, error: '402 响应缺少 accepts' };
|
|
274
|
+
const metadata = requirementBody?.metadata || requirementBody?.item || null;
|
|
275
|
+
await trackEvent({ kind: 'payment_required', detail: `amount=${requirements.amount} network=${requirements.network}` });
|
|
276
|
+
// Phase 2: 策略门 —— 必须在解密钱包/签名/付款之前 (顺序不可颠倒)
|
|
277
|
+
if (params.prePayGuard) {
|
|
278
|
+
const gate = await params.prePayGuard({ requirements, url: params.url });
|
|
279
|
+
if (!gate.ok) {
|
|
280
|
+
await trackEvent({ kind: 'policy_denied', detail: gate.reason });
|
|
281
|
+
return { ok: false, status: 402, policyDenied: true, error: gate.reason || '策略拒绝', metadata, raw: JSON.stringify(requirementBody) };
|
|
282
|
+
}
|
|
283
|
+
await trackEvent({ kind: 'policy_allowed', detail: '策略通过, 允许进入付款' });
|
|
284
|
+
}
|
|
256
285
|
let paymentHeader = '';
|
|
257
286
|
let mode = '';
|
|
258
287
|
if (params.privateKey) {
|
|
@@ -264,16 +293,32 @@ export async function buyInfo(params) {
|
|
|
264
293
|
maxPaymentAmount: params.maxPaymentAmount,
|
|
265
294
|
rpcUrl: params.rpcUrl,
|
|
266
295
|
});
|
|
296
|
+
// ★ 真把付款凭据发出去了 → 结算事实 payment_submitted (这一步之后失败都不能当"没付过钱")
|
|
297
|
+
await trackEvent({ kind: 'payment_sending', detail: 'facilitator 模式: 已发出 x402 付款请求', patch: { settlementFact: 'payment_submitted' } });
|
|
267
298
|
const retry = await paymentFetch(params.url, { method: 'GET' });
|
|
268
299
|
const text = await retry.text();
|
|
269
300
|
const parsed = safeJson(text);
|
|
270
301
|
const receipt = retry.headers.get('x-payment-response') || parsed?.payment?.receipt || '';
|
|
271
302
|
if (retry.status < 200 || retry.status >= 300) {
|
|
272
|
-
|
|
303
|
+
// 付款这一步已经发出去了 (可能已上链), 只是拿资源失败 → 绝不许当"没付过钱"
|
|
304
|
+
return {
|
|
305
|
+
ok: false, status: retry.status,
|
|
306
|
+
payment: { mode: 'facilitator', receipt: receipt || undefined, attempted: true, settled: true, settlementUncertain: !receipt },
|
|
307
|
+
error: `付款后重试失败 ${retry.status}: ${text.slice(0, 200)}`,
|
|
308
|
+
};
|
|
273
309
|
}
|
|
274
310
|
mode = 'facilitator';
|
|
311
|
+
// ★ 链上事实需要 txHash: facilitator 说成功但没有 txHash → 不能标 chainSettled (真跑抓到过这类"假结算")
|
|
312
|
+
const txHash = parsed?.payment?.txHash || retry.headers.get('x-payment-txhash') || parsed?.txHash || '';
|
|
313
|
+
await trackEvent({
|
|
314
|
+
kind: 'settled',
|
|
315
|
+
detail: `mode=facilitator receipt=${receipt.slice(0, 24)}… txHash=${txHash ? `${String(txHash).slice(0, 16)}…` : '(缺失: 不能认定链上结算完成)'}`,
|
|
316
|
+
patch: { paymentMode: 'facilitator', paymentReceipt: receipt, chainSettled: !!txHash, ...(txHash ? { txHash: String(txHash) } : {}) },
|
|
317
|
+
});
|
|
275
318
|
const report = parsed?.proof ? await verifyEnvelope(parsed, { resolveDid: params.resolveDid, expectItemId: params.expectItemId }) : undefined;
|
|
276
|
-
|
|
319
|
+
if (report)
|
|
320
|
+
await trackEvent({ kind: 'delivered', detail: `trust=${report.trust}`, patch: { verificationTrust: report.trust, contentHash: parsed?.contentHash, protocolVerified: report.trust === 'verified' } });
|
|
321
|
+
return { ok: true, status: retry.status, envelope: parsed, verify: report, payment: { mode, receipt }, metadata, raw: text };
|
|
277
322
|
}
|
|
278
323
|
if (!params.allowLocalDev) {
|
|
279
324
|
return { ok: false, status: 402, error: '需要钱包私钥才能支付 (未提供 privateKey; 本机联调请显式 allowLocalDev)' };
|
|
@@ -284,17 +329,26 @@ export async function buyInfo(params) {
|
|
|
284
329
|
payload: { localDev: true, at: new Date().toISOString() },
|
|
285
330
|
payer: 'local-dev',
|
|
286
331
|
}), 'utf-8').toString('base64');
|
|
332
|
+
await trackEvent({ kind: 'payment_sending', detail: 'local-dev 模式: 已发出 X-PAYMENT 请求 (非链上)', patch: { settlementFact: 'payment_submitted' } });
|
|
287
333
|
const retry = await doFetch(params.url, { method: 'GET', headers: { 'X-PAYMENT': paymentHeader } });
|
|
288
334
|
const text = await retry.text();
|
|
289
335
|
const parsed = safeJson(text);
|
|
290
336
|
if (retry.status < 200 || retry.status >= 300) {
|
|
291
|
-
return {
|
|
337
|
+
return {
|
|
338
|
+
ok: false, status: retry.status,
|
|
339
|
+
payment: { mode: 'local-dev', attempted: true, settled: false, settlementUncertain: false },
|
|
340
|
+
error: `本机联调付款被拒 ${retry.status}: ${text.slice(0, 200)}`,
|
|
341
|
+
};
|
|
292
342
|
}
|
|
293
343
|
mode = 'local-dev';
|
|
344
|
+
const receiptLd = retry.headers.get('x-payment-response') || parsed?.payment?.receipt || '';
|
|
345
|
+
await trackEvent({ kind: 'settled', detail: 'mode=local-dev (非链上)', patch: { paymentMode: 'local-dev', paymentReceipt: receiptLd, chainSettled: false } });
|
|
294
346
|
const report = parsed?.proof ? await verifyEnvelope(parsed, { resolveDid: params.resolveDid, expectItemId: params.expectItemId }) : undefined;
|
|
347
|
+
if (report)
|
|
348
|
+
await trackEvent({ kind: 'delivered', detail: `trust=${report.trust} (本机联调)`, patch: { verificationTrust: report.trust, contentHash: parsed?.contentHash, protocolVerified: report.trust !== 'unverified' } });
|
|
295
349
|
return {
|
|
296
|
-
ok: true, status: retry.status, envelope: parsed, verify: report,
|
|
297
|
-
payment: { mode, receipt:
|
|
350
|
+
ok: true, status: retry.status, envelope: parsed, verify: report, metadata,
|
|
351
|
+
payment: { mode, receipt: receiptLd, attempted: true }, raw: text,
|
|
298
352
|
};
|
|
299
353
|
}
|
|
300
354
|
function safeJson(text) {
|