@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,705 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* onchain-trade.ts — P4「任务交易闭环」: 把链上 escrow 真正接进任务链路
|
|
3
|
+
* =========================================================================
|
|
4
|
+
* 一条任务的链上闭环 (每一步的「链上到底成没成」都走 P3 的 verifyPaymentOnChain,
|
|
5
|
+
* 本文件**不新写**任何链上判定):
|
|
6
|
+
*
|
|
7
|
+
* buyer createEscrowV2 → 真执行(可执行 Skill) → seller submitProofV2 → buyer releaseV2
|
|
8
|
+
* ↓ ↓ ↓ ↓
|
|
9
|
+
* EscrowCreatedV2 resultHash = ProofSubmittedV2 ReleasedV2
|
|
10
|
+
* (托管已注资) keccak256(utf8( (结果已上链承诺) (钱真到账 seller)
|
|
11
|
+
* "sha256:<hex>"))
|
|
12
|
+
*
|
|
13
|
+
* 硬规则 (与 settlement-state.ts 的冻结口径逐条一致, 不许放宽):
|
|
14
|
+
* ① 只有 `chainSettled === true` (receipt.status==1 **且** 确认数达标 **且**
|
|
15
|
+
* 事件 taskKey/resultHash 对得上 **且** 合约状态是期望值) 才允许把交易标 `verified`;
|
|
16
|
+
* ② `local-dev` 路径永远产生不了 `fully_settled` (写路径全走 settlement-state 的门);
|
|
17
|
+
* ③ 非法状态迁移**拒绝**并给出原因, 不静默修正;
|
|
18
|
+
* ④ `payment uncertain ≠ failed` (读不到 RPC → `unknown`, 保持未结算);
|
|
19
|
+
* `failed ≠ safe to retry` (明确回滚也只是"钱没动", 要不要重发由人决定);
|
|
20
|
+
* ⑤ 不自动重付、不静默标 verified、不静默关闭/退款。
|
|
21
|
+
*
|
|
22
|
+
* 重启恢复: 链上事实全在 `~/.bolloon/chain/chain-state.json` (P3 chain-state-store)。
|
|
23
|
+
* · `recoverOnchainTrade()` 纯读盘重建 → nextAction (不联网、不猜)
|
|
24
|
+
* · `resumeOnchainTrade()` 拿持久记录重新对账 (RPC 恢复后再判一次) 并安全继续
|
|
25
|
+
*/
|
|
26
|
+
import { createHash } from 'crypto';
|
|
27
|
+
import { computeTaskKeyOffChain, computeResultHashOffChain, } from './escrow-client.js';
|
|
28
|
+
import { createChainSettlementVerifier, } from './chain-settlement.js';
|
|
29
|
+
import { sendChainTxGuarded, chainRequestIdOf, } from './chain-wallet.js';
|
|
30
|
+
import { recordVerdict, loadChainState, chainStatePath, } from './chain-state-store.js';
|
|
31
|
+
import { M1_BUDGET_LIMITS, resolveTaskBudget, checkPurchaseAllowed } from '../task/task-budget.js';
|
|
32
|
+
import { isPaymentMode } from '../task-contract.js';
|
|
33
|
+
import { evaluateVerifiedGate, canTransitionSettlement, checkLifecycleMove, } from '../x402/settlement-state.js';
|
|
34
|
+
import { readTransaction, updateTransaction } from '../x402/transaction-store.js';
|
|
35
|
+
// ── 摘要口径 (与 MODEL_FREEZE / AgentEscrow.computeResultHash 一致) ───────────
|
|
36
|
+
/** `sha256:<hex>` 摘要串 —— 链上 hash 的输入形态 */
|
|
37
|
+
export function sha256Digest(input) {
|
|
38
|
+
return `sha256:${createHash('sha256').update(input).digest('hex')}`;
|
|
39
|
+
}
|
|
40
|
+
/** 摘要串 → 链上 bytes32 (keccak256(utf8("sha256:<hex>"))) */
|
|
41
|
+
export function chainHashOf(digest) {
|
|
42
|
+
return computeResultHashOffChain(digest);
|
|
43
|
+
}
|
|
44
|
+
/** 任务键 (链上幂等键): keccak256(abi.encode(bytes32("bolloon.task.v1"), taskId)) */
|
|
45
|
+
export function onchainTaskKey(taskId) {
|
|
46
|
+
return computeTaskKeyOffChain(taskId);
|
|
47
|
+
}
|
|
48
|
+
let cachedVerifyPaymentOnChain = null;
|
|
49
|
+
/**
|
|
50
|
+
* 链上验真的唯一入口 = P3 的 `verifyPaymentOnChain` (paid-info-store)。
|
|
51
|
+
* 用**动态 import** 拿它: 避免 chain/ ↔ x402/ 的静态环, 也保证"判定只有一份"。
|
|
52
|
+
*/
|
|
53
|
+
async function defaultVerifyPaymentOnChain() {
|
|
54
|
+
if (!cachedVerifyPaymentOnChain) {
|
|
55
|
+
const mod = await import('../x402/paid-info-store.js');
|
|
56
|
+
cachedVerifyPaymentOnChain = mod.verifyPaymentOnChain;
|
|
57
|
+
}
|
|
58
|
+
return cachedVerifyPaymentOnChain;
|
|
59
|
+
}
|
|
60
|
+
/** 原子单位 → 十进制 USDC 字符串 (不四舍五入到别的数, 只是位移) */
|
|
61
|
+
export function atomicToUsdc(amountAtomic, decimals) {
|
|
62
|
+
const s = amountAtomic.toString().padStart(decimals + 1, '0');
|
|
63
|
+
const int = s.slice(0, s.length - decimals);
|
|
64
|
+
const frac = s.slice(s.length - decimals).replace(/0+$/, '');
|
|
65
|
+
return frac ? `${int}.${frac}` : int;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* ★ M1 预算硬约束: 单任务 0.05 USDC / 单次购买 0.02 USDC (所有层取最小值)。
|
|
69
|
+
* 超了 → 拒绝并**指明哪一层** (不静默改金额, 也不"就近取小")。
|
|
70
|
+
*/
|
|
71
|
+
export function checkOnchainAmount(opts) {
|
|
72
|
+
const budget = {
|
|
73
|
+
taskBudget: opts.budget?.taskBudget ?? M1_BUDGET_LIMITS.task,
|
|
74
|
+
perPurchase: opts.budget?.perPurchase ?? M1_BUDGET_LIMITS.perPurchase,
|
|
75
|
+
};
|
|
76
|
+
const parsed = resolveTaskBudget({ taskBudget: budget.taskBudget, perPurchase: budget.perPurchase });
|
|
77
|
+
if (!parsed.ok || !parsed.plan) {
|
|
78
|
+
return { ok: false, reason: `预算不合法: ${parsed.error}`, amountUsdc: '', budget, why: [] };
|
|
79
|
+
}
|
|
80
|
+
const plan = parsed.plan;
|
|
81
|
+
const amountUsdc = atomicToUsdc(opts.amountAtomic, opts.decimals);
|
|
82
|
+
const amount = Number(amountUsdc);
|
|
83
|
+
const why = [...plan.why];
|
|
84
|
+
if (!Number.isFinite(amount) || amount <= 0) {
|
|
85
|
+
return { ok: false, layer: 'amount', reason: `金额非法: ${amountUsdc}`, amountUsdc, budget, why };
|
|
86
|
+
}
|
|
87
|
+
const decision = checkPurchaseAllowed({ amount: amountUsdc, plan });
|
|
88
|
+
if (!decision.allowed) {
|
|
89
|
+
return { ok: false, layer: decision.layer, reason: decision.reason || '预算门拒绝', amountUsdc, budget, why };
|
|
90
|
+
}
|
|
91
|
+
why.push(`本次托管 ${amountUsdc} USDC ≤ 单次上限 ${plan.perPurchase} / 任务预算 ${plan.taskBudget} (M1 硬约束)`);
|
|
92
|
+
return { ok: true, reason: `预算门通过: ${amountUsdc} USDC (M1 单次上限 ${M1_BUDGET_LIMITS.perPurchase})`, amountUsdc, budget, why };
|
|
93
|
+
}
|
|
94
|
+
// ── 单步: 放行闸 → 真发交易 → verifyPaymentOnChain → 落盘 ────────────────────
|
|
95
|
+
function intentOf(req, taskKey, method, amountAtomic) {
|
|
96
|
+
return {
|
|
97
|
+
method,
|
|
98
|
+
taskKey,
|
|
99
|
+
amountAtomic,
|
|
100
|
+
network: req.network || 'unknown',
|
|
101
|
+
capability: 'chain.escrow',
|
|
102
|
+
// 调用方显式声明才用它 (非法值不做任何解释, 直接退回历史口径 → 由放行闸判)
|
|
103
|
+
mode: isPaymentMode(req.paymentMode) ? req.paymentMode : 'agent-authorized',
|
|
104
|
+
taskId: req.taskId,
|
|
105
|
+
...(req.intentNonce ? { intentNonce: String(req.intentNonce) } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function emptyStep(stage, partial) {
|
|
109
|
+
return {
|
|
110
|
+
stage, method: null, ok: false, chainSettled: false, chainStatus: null, txHash: '',
|
|
111
|
+
blockNumber: null, requestId: null, authorized: false, verdict: null, escrowState: null,
|
|
112
|
+
recorded: false, grantsVerified: false, ...partial,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** 用 P3 的 verifyPaymentOnChain 判一次 (本文件唯一的链上判定入口) */
|
|
116
|
+
async function judge(req, taskKey, txHash, expect, recorded) {
|
|
117
|
+
const gate = req.gate || 'confirmed';
|
|
118
|
+
const verify = req.verifyOnChain || (await defaultVerifyPaymentOnChain());
|
|
119
|
+
const run = () => verify({
|
|
120
|
+
txHash,
|
|
121
|
+
chainSettlement: {
|
|
122
|
+
verifier: createChainSettlementVerifier(req.client, { gate }),
|
|
123
|
+
expect,
|
|
124
|
+
gate,
|
|
125
|
+
recorded,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
let v = await run();
|
|
129
|
+
// ★ 未定/读偏的有界重读: 交易已经发出去了 (钱/证明在链上), 一次读偏不该定案 —— 只重读, 不重发。
|
|
130
|
+
// 两种读偏形态都要覆盖:
|
|
131
|
+
// · 'pending' —— eth_blockNumber 还停在前一个块 → 确认数数成 0
|
|
132
|
+
// · 'event_mismatch' —— 事件读到了 (receipt 按 txHash 取, 一定新), 但合约状态 eth_call 读回旧值
|
|
133
|
+
// 真 mismatch (event/taskKey 真对不上) 重读 3 次后结论不变 → 不会把错的判成对的。
|
|
134
|
+
const attempts = req.settleRetryAttempts ?? 3;
|
|
135
|
+
const delayMs = req.settleRetryDelayMs ?? 1500;
|
|
136
|
+
for (let i = 1; i <= attempts && !v.chainSettled && v.rpcAvailable !== false
|
|
137
|
+
&& (v.status === 'pending' || v.status === 'event_mismatch'); i++) {
|
|
138
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
139
|
+
v = await run();
|
|
140
|
+
}
|
|
141
|
+
return v;
|
|
142
|
+
}
|
|
143
|
+
/** 判定落盘 (P3 chain-state-store) */
|
|
144
|
+
async function persist(req, method, taskKey, txHash, amountAtomic, verdict) {
|
|
145
|
+
if (req.persistState === false)
|
|
146
|
+
return false;
|
|
147
|
+
try {
|
|
148
|
+
await recordVerdict({
|
|
149
|
+
requestId: chainRequestIdOf(intentOf(req, taskKey, method, amountAtomic), req.client.chainId),
|
|
150
|
+
method,
|
|
151
|
+
taskKey,
|
|
152
|
+
escrowAddress: req.client.escrowAddress,
|
|
153
|
+
chainId: Number(req.client.chainId ?? 0),
|
|
154
|
+
txHash,
|
|
155
|
+
resultHash: verdict.resultHash,
|
|
156
|
+
}, verdict, req.home);
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** 把 TxOutcome 的原始事实翻成一步结论 (没有 txHash = 交易根本没发出去) */
|
|
164
|
+
function outcomeFacts(outcome) {
|
|
165
|
+
return {
|
|
166
|
+
txHash: String(outcome?.txHash || ''),
|
|
167
|
+
blockNumber: outcome?.blockNumber ?? null,
|
|
168
|
+
gasUsed: outcome?.gasUsed ?? null,
|
|
169
|
+
broadcast: outcome?.broadcast === true,
|
|
170
|
+
status: outcome?.status ?? null,
|
|
171
|
+
error: outcome?.error,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** buyer 侧: createEscrowV2 */
|
|
175
|
+
export async function createEscrowStep(req) {
|
|
176
|
+
const taskKey = onchainTaskKey(req.taskId);
|
|
177
|
+
const decimals = req.tokenDecimals ?? 6;
|
|
178
|
+
const budget = checkOnchainAmount({ amountAtomic: req.amountAtomic, decimals, budget: req.budget });
|
|
179
|
+
if (!budget.ok) {
|
|
180
|
+
return emptyStep('create', { method: 'createEscrowV2', reason: `预算门拒绝 (${budget.layer || 'budget'}): ${budget.reason}` });
|
|
181
|
+
}
|
|
182
|
+
const amountAtomic = req.amountAtomic.toString();
|
|
183
|
+
const intent = intentOf(req, taskKey, 'createEscrowV2', amountAtomic);
|
|
184
|
+
const requestId = chainRequestIdOf(intent, req.client.chainId);
|
|
185
|
+
const res = await sendChainTxGuarded({
|
|
186
|
+
client: req.client,
|
|
187
|
+
intent,
|
|
188
|
+
home: req.home,
|
|
189
|
+
env: req.env,
|
|
190
|
+
signer: req.buyerSigner,
|
|
191
|
+
execute: (signer) => req.client.createEscrowV2({
|
|
192
|
+
taskKey,
|
|
193
|
+
agent: req.agentAddress,
|
|
194
|
+
amount: req.amountAtomic,
|
|
195
|
+
paymentAsset: req.paymentAsset,
|
|
196
|
+
termsHash: chainHashOf(req.termsDigest),
|
|
197
|
+
quoteHash: chainHashOf(req.quoteDigest),
|
|
198
|
+
inputHash: chainHashOf(req.inputDigest),
|
|
199
|
+
manifestHash: chainHashOf(req.manifestDigest || req.inputDigest),
|
|
200
|
+
deadline: req.deadline,
|
|
201
|
+
confirmationWindow: req.confirmationWindow,
|
|
202
|
+
proofVersion: req.proofVersion ?? 1,
|
|
203
|
+
}, signer),
|
|
204
|
+
});
|
|
205
|
+
if (!res.allowed) {
|
|
206
|
+
return emptyStep('create', { method: 'createEscrowV2', requestId, authorized: false, authReason: res.reason, reason: `签名放行闸拒绝: ${res.reason}` });
|
|
207
|
+
}
|
|
208
|
+
const f = outcomeFacts(res.outcome);
|
|
209
|
+
if (!f.txHash) {
|
|
210
|
+
return emptyStep('create', {
|
|
211
|
+
method: 'createEscrowV2', requestId, authorized: true, auditWritten: res.auditWritten,
|
|
212
|
+
reason: `createEscrowV2 没有发出去 (广播前失败): ${f.error || '无 txHash'}`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const verdict = await judge(req, taskKey, f.txHash, { kind: 'escrow', taskKey, eventName: 'EscrowCreatedV2' });
|
|
216
|
+
const recorded = await persist(req, 'createEscrowV2', taskKey, f.txHash, amountAtomic, verdict);
|
|
217
|
+
return {
|
|
218
|
+
stage: 'create', method: 'createEscrowV2', ok: verdict.chainSettled, chainSettled: verdict.chainSettled,
|
|
219
|
+
chainStatus: verdict.status, txHash: f.txHash, blockNumber: verdict.blockNumber ?? f.blockNumber,
|
|
220
|
+
gasUsed: f.gasUsed, requestId, authorized: true, auditWritten: res.auditWritten,
|
|
221
|
+
verdict, escrowState: verdict.escrowState ?? null,
|
|
222
|
+
reason: verdict.reason, recorded, grantsVerified: false,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/** seller 侧: submitProofV2 */
|
|
226
|
+
export async function submitProofStep(req, resultDigest, manifestDigest) {
|
|
227
|
+
const taskKey = onchainTaskKey(req.taskId);
|
|
228
|
+
const resultHash = chainHashOf(resultDigest);
|
|
229
|
+
const manifestHash = chainHashOf(manifestDigest || req.manifestDigest || req.inputDigest);
|
|
230
|
+
const proofVersion = req.proofVersion ?? 1;
|
|
231
|
+
const intent = intentOf(req, taskKey, 'submitProofV2', '0');
|
|
232
|
+
const requestId = chainRequestIdOf(intent, req.client.chainId);
|
|
233
|
+
const res = await sendChainTxGuarded({
|
|
234
|
+
client: req.client,
|
|
235
|
+
intent,
|
|
236
|
+
home: req.home,
|
|
237
|
+
env: req.env,
|
|
238
|
+
signer: req.sellerSigner,
|
|
239
|
+
execute: (signer) => req.client.submitProofV2(taskKey, resultHash, manifestHash, proofVersion, signer),
|
|
240
|
+
});
|
|
241
|
+
if (!res.allowed) {
|
|
242
|
+
return emptyStep('proof', { method: 'submitProofV2', requestId, authorized: false, authReason: res.reason, reason: `签名放行闸拒绝: ${res.reason}` });
|
|
243
|
+
}
|
|
244
|
+
const f = outcomeFacts(res.outcome);
|
|
245
|
+
if (!f.txHash) {
|
|
246
|
+
return emptyStep('proof', {
|
|
247
|
+
method: 'submitProofV2', requestId, authorized: true, auditWritten: res.auditWritten,
|
|
248
|
+
reason: `submitProofV2 没有发出去 (广播前失败): ${f.error || '无 txHash'}`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
// ★ 事件必须同时对上 taskKey + resultHash; 合约状态必须还是 ACTIVE (还没被释放)
|
|
252
|
+
const verdict = await judge(req, taskKey, f.txHash, {
|
|
253
|
+
kind: 'escrow', taskKey, resultHash, eventName: 'ProofSubmittedV2', expectEscrowState: 'ACTIVE',
|
|
254
|
+
});
|
|
255
|
+
const recorded = await persist(req, 'submitProofV2', taskKey, f.txHash, '0', verdict);
|
|
256
|
+
return {
|
|
257
|
+
stage: 'proof', method: 'submitProofV2', ok: verdict.chainSettled, chainSettled: verdict.chainSettled,
|
|
258
|
+
chainStatus: verdict.status, txHash: f.txHash, blockNumber: verdict.blockNumber ?? f.blockNumber,
|
|
259
|
+
gasUsed: f.gasUsed, requestId, authorized: true, auditWritten: res.auditWritten,
|
|
260
|
+
verdict, escrowState: verdict.escrowState ?? null,
|
|
261
|
+
reason: verdict.reason, recorded, grantsVerified: false,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/** buyer 侧: releaseV2 —— 只有这一步全过才 `grantsVerified` */
|
|
265
|
+
export async function releaseStep(req) {
|
|
266
|
+
const taskKey = onchainTaskKey(req.taskId);
|
|
267
|
+
const intent = intentOf(req, taskKey, 'releaseV2', '0');
|
|
268
|
+
const requestId = chainRequestIdOf(intent, req.client.chainId);
|
|
269
|
+
const res = await sendChainTxGuarded({
|
|
270
|
+
client: req.client,
|
|
271
|
+
intent,
|
|
272
|
+
home: req.home,
|
|
273
|
+
env: req.env,
|
|
274
|
+
signer: req.buyerSigner,
|
|
275
|
+
execute: (signer) => req.client.releaseV2(taskKey, signer),
|
|
276
|
+
});
|
|
277
|
+
if (!res.allowed) {
|
|
278
|
+
return emptyStep('release', { method: 'releaseV2', requestId, authorized: false, authReason: res.reason, reason: `签名放行闸拒绝: ${res.reason}` });
|
|
279
|
+
}
|
|
280
|
+
const f = outcomeFacts(res.outcome);
|
|
281
|
+
if (!f.txHash) {
|
|
282
|
+
return emptyStep('release', {
|
|
283
|
+
method: 'releaseV2', requestId, authorized: true, auditWritten: res.auditWritten,
|
|
284
|
+
reason: `releaseV2 没有发出去 (广播前失败): ${f.error || '无 txHash'}`,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
const verdict = await judge(req, taskKey, f.txHash, {
|
|
288
|
+
kind: 'escrow', taskKey, eventName: 'ReleasedV2', expectEscrowState: 'RELEASED',
|
|
289
|
+
});
|
|
290
|
+
const recorded = await persist(req, 'releaseV2', taskKey, f.txHash, '0', verdict);
|
|
291
|
+
const grantsVerified = verdict.chainSettled === true
|
|
292
|
+
&& (verdict.status === 'confirmed' || verdict.status === 'finalized')
|
|
293
|
+
&& verdict.eventMatched === true
|
|
294
|
+
&& verdict.escrowState === 'RELEASED';
|
|
295
|
+
return {
|
|
296
|
+
stage: 'release', method: 'releaseV2', ok: verdict.chainSettled, chainSettled: verdict.chainSettled,
|
|
297
|
+
chainStatus: verdict.status, txHash: f.txHash, blockNumber: verdict.blockNumber ?? f.blockNumber,
|
|
298
|
+
gasUsed: f.gasUsed, requestId, authorized: true, auditWritten: res.auditWritten,
|
|
299
|
+
verdict, escrowState: verdict.escrowState ?? null,
|
|
300
|
+
reason: verdict.reason + (grantsVerified ? '; 事件/状态/确认数全过 → 才允许标 verified' : '; 未全过 → 不许标 verified'),
|
|
301
|
+
recorded, grantsVerified,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* 走完整条链上闭环: create → 执行 → submitProof → release。
|
|
306
|
+
*
|
|
307
|
+
* 任何一步的链上事实不成立 → **原样停下**, 不执行下一步 (fail-closed);
|
|
308
|
+
* 执行失败但托管已注资 → 如实报, 钱留在托管里, 不自动退款/不重付。
|
|
309
|
+
*/
|
|
310
|
+
export async function runOnchainTradeLoop(req, execute) {
|
|
311
|
+
const taskKey = onchainTaskKey(req.taskId);
|
|
312
|
+
const decimals = req.tokenDecimals ?? 6;
|
|
313
|
+
const budgetCheck = checkOnchainAmount({ amountAtomic: req.amountAtomic, decimals, budget: req.budget });
|
|
314
|
+
const budget = {
|
|
315
|
+
taskBudget: budgetCheck.budget.taskBudget,
|
|
316
|
+
perPurchase: budgetCheck.budget.perPurchase,
|
|
317
|
+
};
|
|
318
|
+
const steps = [];
|
|
319
|
+
const finish = (opts) => {
|
|
320
|
+
let recovery;
|
|
321
|
+
try {
|
|
322
|
+
recovery = recoverOnchainTrade({ home: req.home, taskKey });
|
|
323
|
+
}
|
|
324
|
+
catch (e) {
|
|
325
|
+
recovery = blankRecovery(taskKey, req.home, `重建恢复状态失败: ${String(e?.message || e)}`);
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
ok: opts.ok, taskId: req.taskId, taskKey, amountAtomic: req.amountAtomic.toString(), budget,
|
|
329
|
+
steps, create: opts.create, proof: opts.proof, release: opts.release, execution: opts.execution,
|
|
330
|
+
verified: opts.verified, verifiedReason: opts.reason,
|
|
331
|
+
txHashes: {
|
|
332
|
+
...(opts.create?.txHash ? { create: opts.create.txHash } : {}),
|
|
333
|
+
...(opts.proof?.txHash ? { proof: opts.proof.txHash } : {}),
|
|
334
|
+
...(opts.release?.txHash ? { release: opts.release.txHash } : {}),
|
|
335
|
+
},
|
|
336
|
+
recovery,
|
|
337
|
+
};
|
|
338
|
+
};
|
|
339
|
+
if (!budgetCheck.ok) {
|
|
340
|
+
return finish({ ok: false, verified: false, reason: `预算门拒绝 (${budgetCheck.layer || 'budget'}): ${budgetCheck.reason}`, create: null, proof: null, release: null, execution: null });
|
|
341
|
+
}
|
|
342
|
+
const create = await createEscrowStep(req);
|
|
343
|
+
steps.push(create);
|
|
344
|
+
if (!create.ok) {
|
|
345
|
+
return finish({ ok: false, verified: false, reason: `createEscrowV2 未成立 → 不执行、不提交证明: ${create.reason}`, create, proof: null, release: null, execution: null });
|
|
346
|
+
}
|
|
347
|
+
const exec = await execute();
|
|
348
|
+
if (!exec.ok || !exec.resultDigest) {
|
|
349
|
+
return finish({
|
|
350
|
+
ok: false, verified: false,
|
|
351
|
+
reason: `托管已注资但执行失败 → 钱留在托管里, 不重付/不自动退款: ${exec.reason || '执行未产出结果'}`,
|
|
352
|
+
create, proof: null, release: null,
|
|
353
|
+
execution: { ok: false, resultDigest: exec.resultDigest, reason: exec.reason },
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
const proof = await submitProofStep(req, exec.resultDigest, exec.manifestDigest);
|
|
357
|
+
steps.push(proof);
|
|
358
|
+
if (!proof.ok) {
|
|
359
|
+
return finish({
|
|
360
|
+
ok: false, verified: false,
|
|
361
|
+
reason: `submitProofV2 未成立 → 不释放: ${proof.reason}`,
|
|
362
|
+
create, proof, release: null,
|
|
363
|
+
execution: { ok: true, resultDigest: exec.resultDigest },
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
const release = await releaseStep(req);
|
|
367
|
+
steps.push(release);
|
|
368
|
+
const verified = release.grantsVerified === true;
|
|
369
|
+
return finish({
|
|
370
|
+
ok: verified, verified,
|
|
371
|
+
reason: verified
|
|
372
|
+
? `链上闭环成立: create/proof/release 三笔都有 receipt.status=1 + 事件对得上 + 确认数达标 (${release.verdict?.status})`
|
|
373
|
+
: `release 未全过 → 不标 verified: ${release.reason}`,
|
|
374
|
+
create, proof, release,
|
|
375
|
+
execution: { ok: true, resultDigest: exec.resultDigest },
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
function blankRecovery(taskKey, home, reason) {
|
|
379
|
+
return {
|
|
380
|
+
found: false, taskId: null, taskKey, nextAction: 'needs_human', mustNotRepay: false, verified: false,
|
|
381
|
+
reason, create: null, proof: null, release: null, others: [], records: [],
|
|
382
|
+
statePath: chainStatePath(home), blockedBy: [reason],
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
function settledRec(r) {
|
|
386
|
+
return !!r && (r.status === 'confirmed' || r.status === 'finalized') && r.suspect !== true;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* ★ 重启后第一步: 纯读 `~/.bolloon/chain/chain-state.json` 重建状态, 给出下一步。
|
|
390
|
+
* 不联网、不猜、不改任何记录。判定顺序即安全性顺序。
|
|
391
|
+
*/
|
|
392
|
+
export function recoverOnchainTrade(opts) {
|
|
393
|
+
const taskKey = opts.taskKey || (opts.taskId ? onchainTaskKey(opts.taskId) : '');
|
|
394
|
+
const statePath = chainStatePath(opts.home);
|
|
395
|
+
if (!taskKey)
|
|
396
|
+
return blankRecovery('', opts.home, '既没给 taskKey 也没给 taskId → 不知道要恢复哪条交易 (不猜)');
|
|
397
|
+
const all = Object.values(loadChainState(opts.home).records).filter((r) => String(r.taskKey).toLowerCase() === taskKey.toLowerCase());
|
|
398
|
+
const by = (m) => all.find((r) => r.method === m) || null;
|
|
399
|
+
const create = by('createEscrowV2');
|
|
400
|
+
const proof = by('submitProofV2');
|
|
401
|
+
const release = by('releaseV2');
|
|
402
|
+
const others = all.filter((r) => !['createEscrowV2', 'submitProofV2', 'releaseV2'].includes(r.method));
|
|
403
|
+
const base = {
|
|
404
|
+
found: all.length > 0, taskId: opts.taskId ?? null, taskKey, create, proof, release, others, records: all,
|
|
405
|
+
statePath, pendingResultDigest: opts.pendingResultDigest,
|
|
406
|
+
};
|
|
407
|
+
const blockedBy = [];
|
|
408
|
+
// 0) 从来没有链上交易 → 可以安全从头走 (没花过钱)
|
|
409
|
+
if (!create) {
|
|
410
|
+
return { ...base, nextAction: 'create_escrow', mustNotRepay: false, verified: false, blockedBy: [], reason: 'chain-state.json 里没有这笔任务的链上交易 → 可以安全从头走 (没有支付事实)' };
|
|
411
|
+
}
|
|
412
|
+
// 1) 任何一条被标可疑 (重组) → 人工介入, 绝不当已结算
|
|
413
|
+
const suspectOne = all.find((r) => r.suspect === true);
|
|
414
|
+
if (suspectOne) {
|
|
415
|
+
blockedBy.push(`记录 ${suspectOne.requestId} 被标可疑: ${suspectOne.suspectReason || suspectOne.reason}`);
|
|
416
|
+
return { ...base, nextAction: 'needs_human', mustNotRepay: true, verified: false, blockedBy, reason: `有链上记录被标不可信 (重组/事件消失) → 待人工: ${blockedBy[0]}` };
|
|
417
|
+
}
|
|
418
|
+
// 2) 托管创建明确回滚 → 钱没动, 但重发要人决定 (failed ≠ safe to retry)
|
|
419
|
+
if (create.status === 'reverted') {
|
|
420
|
+
blockedBy.push(`createEscrowV2 回滚 (receipt.status=0): ${create.reason}`);
|
|
421
|
+
return { ...base, nextAction: 'needs_human', mustNotRepay: false, verified: false, blockedBy, reason: '托管创建回滚了 (钱没动) —— 回滚≠可以自动重发, 待人工确认后重走' };
|
|
422
|
+
}
|
|
423
|
+
// 3) 托管创建但结论没定 (确认数不够 / RPC 读不到) → 只能再验, 不许重发
|
|
424
|
+
if (!settledRec(create)) {
|
|
425
|
+
blockedBy.push(`createEscrowV2 状态=${create.status}: ${create.reason}`);
|
|
426
|
+
return { ...base, nextAction: 'verify_only', mustNotRepay: true, verified: false, blockedBy, reason: `托管创建尚无确定结论 (${create.status}) —— 不确定≠失败, 先重新对账, 不许重发` };
|
|
427
|
+
}
|
|
428
|
+
// 4) 托管已确认 → 钱在链上托管里, 从这里开始都不许重付
|
|
429
|
+
if (!proof) {
|
|
430
|
+
if (opts.pendingResultDigest) {
|
|
431
|
+
return { ...base, nextAction: 'submit_proof', mustNotRepay: true, verified: false, blockedBy: [], reason: '托管已确认, 且执行产物摘要在手 → 可以安全提交证明 (不会再付一次钱)' };
|
|
432
|
+
}
|
|
433
|
+
blockedBy.push('托管已注资但没有已上链的证明, 且拿不到执行产物摘要');
|
|
434
|
+
return { ...base, nextAction: 'needs_human', mustNotRepay: true, verified: false, blockedBy, reason: '托管已注资 (EscrowCreatedV2 已确认) 但没有可提交的结果 → 钱留在托管里, 待人工处理 (不重付/不自动退款)' };
|
|
435
|
+
}
|
|
436
|
+
if (proof.status === 'reverted') {
|
|
437
|
+
blockedBy.push(`submitProofV2 回滚: ${proof.reason}`);
|
|
438
|
+
return { ...base, nextAction: 'needs_human', mustNotRepay: true, verified: false, blockedBy, reason: '证明提交回滚 (托管里的钱没动) —— 待人工决定是否重提' };
|
|
439
|
+
}
|
|
440
|
+
if (!settledRec(proof)) {
|
|
441
|
+
blockedBy.push(`submitProofV2 状态=${proof.status}: ${proof.reason}`);
|
|
442
|
+
return { ...base, nextAction: 'verify_only', mustNotRepay: true, verified: false, blockedBy, reason: `证明提交尚无确定结论 (${proof.status}) —— 先重新对账` };
|
|
443
|
+
}
|
|
444
|
+
if (!release) {
|
|
445
|
+
return { ...base, nextAction: 'release', mustNotRepay: true, verified: false, blockedBy: [], reason: '证明已在链上确认 → 可以安全释放 (释放的是托管里的钱, 不是第二次付款)' };
|
|
446
|
+
}
|
|
447
|
+
if (release.status === 'reverted') {
|
|
448
|
+
blockedBy.push(`releaseV2 回滚: ${release.reason}`);
|
|
449
|
+
return { ...base, nextAction: 'needs_human', mustNotRepay: true, verified: false, blockedBy, reason: '释放交易回滚 (钱仍在托管) —— 待人工决定是否重发' };
|
|
450
|
+
}
|
|
451
|
+
if (!settledRec(release)) {
|
|
452
|
+
blockedBy.push(`releaseV2 状态=${release.status}: ${release.reason}`);
|
|
453
|
+
return { ...base, nextAction: 'verify_only', mustNotRepay: true, verified: false, blockedBy, reason: `释放尚无确定结论 (${release.status}) —— 不确定≠失败, 重新对账后才可能标 verified` };
|
|
454
|
+
}
|
|
455
|
+
const stateOk = release.escrowState === 'RELEASED';
|
|
456
|
+
if (!stateOk)
|
|
457
|
+
blockedBy.push(`合约状态=${release.escrowState ?? '(读不到)'}, 期望 RELEASED`);
|
|
458
|
+
return {
|
|
459
|
+
...base,
|
|
460
|
+
nextAction: stateOk ? 'done' : 'needs_human',
|
|
461
|
+
mustNotRepay: true,
|
|
462
|
+
verified: stateOk,
|
|
463
|
+
blockedBy,
|
|
464
|
+
reason: stateOk
|
|
465
|
+
? `链上释放已确认 (${release.status}, ${release.confirmations} 个确认), 合约状态 RELEASED → 可信`
|
|
466
|
+
: `释放交易确认了但合约状态不是 RELEASED → 待人工核对`,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* ★ 重启后继续: 拿 chain-state.json 里的旧事实**重新对账** (RPC 恢复后再判一次),
|
|
471
|
+
* 然后只在"安全"的动作上推进 (submit_proof / release) —— 其它一律停下如实报。
|
|
472
|
+
*/
|
|
473
|
+
export async function resumeOnchainTrade(opts) {
|
|
474
|
+
const taskKey = onchainTaskKey(opts.taskId);
|
|
475
|
+
const recoveryBefore = recoverOnchainTrade({ home: opts.home, taskKey, taskId: opts.taskId, pendingResultDigest: opts.pendingResultDigest });
|
|
476
|
+
const reverified = [];
|
|
477
|
+
const steps = [];
|
|
478
|
+
// ① 重新对账: 每条旧记录都拿 P3 判定重算一遍 (重组/掉 RPC 在这里暴露)
|
|
479
|
+
// 注意 expect 的口径: create/proof 这两笔**历史**交易不再要求"合约当前状态是 ACTIVE" ——
|
|
480
|
+
// 释放之后状态本来就变成 RELEASED 了, 拿今天的状态去否定昨天的事实会造出假 negative。
|
|
481
|
+
// 只有 release 自己必须落在 RELEASED 上 (那是它的终态)。
|
|
482
|
+
for (const rec of recoveryBefore.records) {
|
|
483
|
+
if (!rec.txHash)
|
|
484
|
+
continue;
|
|
485
|
+
const expect = rec.method === 'submitProofV2'
|
|
486
|
+
? { kind: 'escrow', taskKey, resultHash: rec.resultHash, eventName: 'ProofSubmittedV2' }
|
|
487
|
+
: rec.method === 'releaseV2'
|
|
488
|
+
? { kind: 'escrow', taskKey, eventName: 'ReleasedV2', expectEscrowState: 'RELEASED' }
|
|
489
|
+
: { kind: 'escrow', taskKey, eventName: 'EscrowCreatedV2' };
|
|
490
|
+
let verdict;
|
|
491
|
+
try {
|
|
492
|
+
verdict = await judge(opts, taskKey, rec.txHash, expect, {
|
|
493
|
+
blockNumber: rec.blockNumber, confirmations: rec.confirmations, status: rec.status,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
verdict = {
|
|
498
|
+
chainSettled: false, status: 'unknown',
|
|
499
|
+
reason: `重新对账失败 (RPC?): ${String(e?.message || e).slice(0, 160)}`,
|
|
500
|
+
txHash: rec.txHash, confirmationsRequired: 0, requiredGate: opts.gate || 'confirmed',
|
|
501
|
+
rpcAvailable: false, checkedAt: Date.now(), evidence: {},
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
await persist(opts, rec.method, taskKey, rec.txHash, rec.method === 'createEscrowV2' ? opts.amountAtomic.toString() : '0', verdict);
|
|
505
|
+
reverified.push({ method: rec.method, txHash: rec.txHash, status: verdict.status, chainSettled: verdict.chainSettled, reason: verdict.reason });
|
|
506
|
+
}
|
|
507
|
+
let recovery = recoverOnchainTrade({ home: opts.home, taskKey, taskId: opts.taskId, pendingResultDigest: opts.pendingResultDigest });
|
|
508
|
+
if (opts.dryRun) {
|
|
509
|
+
return { recoveryBefore, reverified, steps, recovery, done: recovery.nextAction === 'done', verified: recovery.verified, reason: `(dry-run) ${recovery.reason}` };
|
|
510
|
+
}
|
|
511
|
+
// ② 只在安全动作上推进
|
|
512
|
+
if (recovery.nextAction === 'submit_proof' && opts.pendingResultDigest) {
|
|
513
|
+
const step = await submitProofStep(opts, opts.pendingResultDigest, opts.pendingManifestDigest);
|
|
514
|
+
steps.push(step);
|
|
515
|
+
recovery = recoverOnchainTrade({ home: opts.home, taskKey, taskId: opts.taskId, pendingResultDigest: opts.pendingResultDigest });
|
|
516
|
+
if (!step.ok) {
|
|
517
|
+
return { recoveryBefore, reverified, steps, recovery, done: false, verified: false, reason: `恢复中提交证明未成立 → 停在这里: ${step.reason}` };
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (recovery.nextAction === 'release') {
|
|
521
|
+
const step = await releaseStep(opts);
|
|
522
|
+
steps.push(step);
|
|
523
|
+
recovery = recoverOnchainTrade({ home: opts.home, taskKey, taskId: opts.taskId, pendingResultDigest: opts.pendingResultDigest });
|
|
524
|
+
return {
|
|
525
|
+
recoveryBefore, reverified, steps, recovery,
|
|
526
|
+
done: recovery.nextAction === 'done', verified: recovery.verified,
|
|
527
|
+
reason: step.ok ? '恢复流程已推进到链上释放成立' : `恢复中释放未成立: ${step.reason}`,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
recoveryBefore, reverified, steps, recovery,
|
|
532
|
+
done: recovery.nextAction === 'done', verified: recovery.verified,
|
|
533
|
+
reason: `nextAction=${recovery.nextAction}: ${recovery.reason}`,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* ★ 验真门 (纯判定, 不写盘)。
|
|
538
|
+
* 只有 `chainSettled === true` + 事件对得上 + 合约 RELEASED + 八项门全过 → verified。
|
|
539
|
+
* 任何一项不过都列出原因, 绝不"就近算过"。
|
|
540
|
+
*/
|
|
541
|
+
export function evaluateChainVerifiedGate(input) {
|
|
542
|
+
const { rec, verdict } = input;
|
|
543
|
+
const blockedBy = [];
|
|
544
|
+
const targetFact = input.targetFact === 'payment_verified' ? 'payment_verified' : 'fully_settled';
|
|
545
|
+
if (verdict.chainSettled !== true) {
|
|
546
|
+
blockedBy.push(`chainSettled !== true (status=${verdict.status}): ${verdict.reason}`);
|
|
547
|
+
return { verified: false, blockedBy, reason: `链上没有确定结算 → 不许标 verified`, targetFact, chainSettled: false };
|
|
548
|
+
}
|
|
549
|
+
if (verdict.status !== 'confirmed' && verdict.status !== 'finalized')
|
|
550
|
+
blockedBy.push(`判定状态是 ${verdict.status}, 不是 confirmed/finalized`);
|
|
551
|
+
if (!verdict.txHash)
|
|
552
|
+
blockedBy.push('判定里没有 txHash');
|
|
553
|
+
if (verdict.eventMatched !== true)
|
|
554
|
+
blockedBy.push('事件与 taskKey/resultHash 对不上 (eventMatched !== true)');
|
|
555
|
+
if (verdict.escrowState !== 'RELEASED')
|
|
556
|
+
blockedBy.push(`合约状态是 ${verdict.escrowState ?? '(读不到)'}, 期望 RELEASED`);
|
|
557
|
+
if (verdict.confirmations == null || verdict.confirmations < verdict.confirmationsRequired) {
|
|
558
|
+
blockedBy.push(`确认数 ${verdict.confirmations ?? '(未知)'} < 门槛 ${verdict.confirmationsRequired}`);
|
|
559
|
+
}
|
|
560
|
+
// 结算事实迁移必须合法 (local-dev 想写 fully_settled 会在这里被拒)
|
|
561
|
+
const from = String(rec.settlementFact || 'unpaid');
|
|
562
|
+
const factChk = canTransitionSettlement(from, targetFact, {
|
|
563
|
+
paymentMode: rec.paymentMode, chainSettled: verdict.chainSettled, txHash: verdict.txHash,
|
|
564
|
+
});
|
|
565
|
+
if (!factChk.ok)
|
|
566
|
+
blockedBy.push(`结算事实迁移被拒: ${factChk.reason}`);
|
|
567
|
+
// 八项门 (chainSettled + protocolVerified + 正文在盘上 + 哈希对上 + 回执绑定 + 执行成功 + 输出合契约 + Goal 判据)
|
|
568
|
+
const effective = { ...rec, chainSettled: true, txHash: verdict.txHash, settlementFact: targetFact };
|
|
569
|
+
const pre = checkLifecycleMove(effective, 'verified');
|
|
570
|
+
if (!pre.ok)
|
|
571
|
+
blockedBy.push(`生命周期迁移被拒: ${pre.reason}`);
|
|
572
|
+
const gate = evaluateVerifiedGate({ rec: effective, home: input.home, execution: input.execution, goalCriteriaMet: input.goalCriteriaMet });
|
|
573
|
+
if (!gate.verified)
|
|
574
|
+
blockedBy.push(...gate.missing.map((m) => `八项门: ${m}`));
|
|
575
|
+
if (blockedBy.length) {
|
|
576
|
+
return { verified: false, blockedBy, reason: `未达 verified: ${blockedBy[0]}`, targetFact, chainSettled: true };
|
|
577
|
+
}
|
|
578
|
+
return {
|
|
579
|
+
verified: true, blockedBy: [], targetFact, chainSettled: true,
|
|
580
|
+
reason: `链上结算成立 (${verdict.status}, 确认数 ${verdict.confirmations}) + 事件/状态对上 + 八项门全过 → verified`,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* ★ 唯一把"链上判定"写进交易记录的地方。
|
|
585
|
+
* · chainSettled false → 只更新链上事实字段 (保持未结算; unknown 不改结算事实)
|
|
586
|
+
* · chainSettled true 且八项门全过 → 一次原子写: chainSettled + txHash + fully_settled + verified
|
|
587
|
+
* · 任何一条不过 → 只写能写的部分, 如实报原因 (不静默标 verified)
|
|
588
|
+
*/
|
|
589
|
+
export async function applyChainSettlementToTransaction(opts) {
|
|
590
|
+
const rec = await readTransaction(opts.rec.transactionId, opts.home);
|
|
591
|
+
if (!rec) {
|
|
592
|
+
return {
|
|
593
|
+
ok: false, verified: false, chainSettled: opts.verdict.chainSettled, chainStatus: opts.verdict.status,
|
|
594
|
+
settlementFact: null, status: null, blockedBy: ['交易记录不存在'], reason: `交易记录不存在: ${opts.rec.transactionId}`, events: [], record: null,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
const verdict = opts.verdict;
|
|
598
|
+
const chainPatch = {
|
|
599
|
+
...(verdict.txHash ? { txHash: verdict.txHash } : {}),
|
|
600
|
+
chainSettled: verdict.chainSettled === true,
|
|
601
|
+
...(opts.protocolVerified === true ? { protocolVerified: true } : {}),
|
|
602
|
+
...(opts.receipt ? { receiptHash: `chain-receipt:${createHash('sha256').update(opts.receipt).digest('hex')}` } : {}),
|
|
603
|
+
...(opts.patch || {}),
|
|
604
|
+
};
|
|
605
|
+
const judged = evaluateChainVerifiedGate({
|
|
606
|
+
rec: { ...rec, ...chainPatch },
|
|
607
|
+
home: opts.home,
|
|
608
|
+
verdict,
|
|
609
|
+
taskKey: opts.taskKey,
|
|
610
|
+
execution: opts.execution,
|
|
611
|
+
goalCriteriaMet: opts.goalCriteriaMet,
|
|
612
|
+
targetFact: opts.targetFact,
|
|
613
|
+
});
|
|
614
|
+
const events = [];
|
|
615
|
+
// ① 链上没有确定结算 → 只写事实, 绝不 verified
|
|
616
|
+
if (!verdict.chainSettled) {
|
|
617
|
+
// 不确定 ≠ 失败: 只有明确"没发过/回滚"才把事实往回写, unknown 保持原值
|
|
618
|
+
const factPatch = {};
|
|
619
|
+
if (verdict.status === 'reverted' || verdict.status === 'not_attempted') {
|
|
620
|
+
const alreadyPaid = ['payment_submitted', 'payment_verified', 'partially_settled', 'fully_settled'].includes(String(rec.settlementFact || ''));
|
|
621
|
+
if (!alreadyPaid && String(rec.settlementFact || '') !== 'unknown')
|
|
622
|
+
factPatch.settlementFact = 'unknown';
|
|
623
|
+
}
|
|
624
|
+
if (opts.dryRun) {
|
|
625
|
+
return {
|
|
626
|
+
ok: false, verified: false, chainSettled: false, chainStatus: verdict.status,
|
|
627
|
+
settlementFact: String(factPatch.settlementFact ?? rec.settlementFact ?? ''), status: String(rec.status),
|
|
628
|
+
blockedBy: judged.blockedBy, reason: `(dry-run) ${judged.reason}`, events, record: rec,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
const saved = await updateTransaction(rec.transactionId, {
|
|
632
|
+
...chainPatch,
|
|
633
|
+
...factPatch,
|
|
634
|
+
event: {
|
|
635
|
+
kind: `chain:${verdict.status}`,
|
|
636
|
+
detail: `链上判定 ${verdict.status} (确认数 ${verdict.confirmations ?? '未知'}/${verdict.confirmationsRequired}): ${verdict.reason}`,
|
|
637
|
+
},
|
|
638
|
+
}, opts.home);
|
|
639
|
+
events.push(`chain:${verdict.status}`);
|
|
640
|
+
if (factPatch.settlementFact)
|
|
641
|
+
events.push(`settlement:${factPatch.settlementFact}`);
|
|
642
|
+
return {
|
|
643
|
+
ok: false, verified: false, chainSettled: false, chainStatus: verdict.status,
|
|
644
|
+
settlementFact: String(saved?.settlementFact ?? factPatch.settlementFact ?? rec.settlementFact ?? ''),
|
|
645
|
+
status: String(saved?.status ?? rec.status),
|
|
646
|
+
blockedBy: judged.blockedBy, reason: judged.reason, events, record: saved,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
// ② 链上成立但门没过 → 只写链上事实 + 到 payment_verified, 不标 verified
|
|
650
|
+
if (!judged.verified) {
|
|
651
|
+
if (opts.dryRun) {
|
|
652
|
+
return {
|
|
653
|
+
ok: false, verified: false, chainSettled: true, chainStatus: verdict.status,
|
|
654
|
+
settlementFact: String(rec.settlementFact ?? ''), status: String(rec.status),
|
|
655
|
+
blockedBy: judged.blockedBy, reason: `(dry-run) ${judged.reason}`, events, record: rec,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
const fact = 'payment_verified';
|
|
659
|
+
const canUpgrade = canTransitionSettlement(String(rec.settlementFact || 'unpaid'), fact, {
|
|
660
|
+
paymentMode: rec.paymentMode, chainSettled: true, txHash: verdict.txHash,
|
|
661
|
+
}).ok && String(rec.settlementFact) !== fact;
|
|
662
|
+
const saved = await updateTransaction(rec.transactionId, {
|
|
663
|
+
...chainPatch,
|
|
664
|
+
...(canUpgrade ? { settlementFact: fact } : {}),
|
|
665
|
+
event: { kind: 'chain:settled_unverified', detail: `链上结算成立但未达 verified: ${judged.blockedBy[0]}` },
|
|
666
|
+
}, opts.home);
|
|
667
|
+
events.push('chain:settled_unverified');
|
|
668
|
+
if (canUpgrade)
|
|
669
|
+
events.push(`settlement:${fact}`);
|
|
670
|
+
return {
|
|
671
|
+
ok: false, verified: false, chainSettled: true, chainStatus: verdict.status,
|
|
672
|
+
settlementFact: String(saved?.settlementFact ?? rec.settlementFact ?? ''), status: String(saved?.status ?? rec.status),
|
|
673
|
+
blockedBy: judged.blockedBy, reason: judged.reason, events, record: saved,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
// ③ 全过 → 先写链上事实与结算事实, 再单独写 verified
|
|
677
|
+
// (transaction-store 的门要求: 记录里**已经**有 chainSettled=true 才允许迁到 verified ——
|
|
678
|
+
// 所以这里刻意分两步写, 不把两件事塞进一次调用去绕门)
|
|
679
|
+
if (opts.dryRun) {
|
|
680
|
+
return {
|
|
681
|
+
ok: true, verified: true, chainSettled: true, chainStatus: verdict.status,
|
|
682
|
+
settlementFact: judged.targetFact, status: 'verified',
|
|
683
|
+
blockedBy: [], reason: `(dry-run) ${judged.reason}`, events, record: rec,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
const withFacts = await updateTransaction(rec.transactionId, {
|
|
687
|
+
...chainPatch,
|
|
688
|
+
settlementFact: judged.targetFact,
|
|
689
|
+
verificationTrust: 'verified',
|
|
690
|
+
event: { kind: 'chain:settled', detail: judged.reason },
|
|
691
|
+
}, opts.home);
|
|
692
|
+
events.push('chain:settled', `settlement:${judged.targetFact}`);
|
|
693
|
+
const saved = await updateTransaction(rec.transactionId, {
|
|
694
|
+
status: 'verified',
|
|
695
|
+
verifiedAt: new Date().toISOString(),
|
|
696
|
+
event: { kind: 'status:verified', detail: `链上事实已在记录里 (txHash=${verdict.txHash}), 迁移到 verified` },
|
|
697
|
+
}, opts.home);
|
|
698
|
+
events.push('status:verified');
|
|
699
|
+
return {
|
|
700
|
+
ok: true, verified: true, chainSettled: true, chainStatus: verdict.status,
|
|
701
|
+
settlementFact: String(saved?.settlementFact ?? withFacts?.settlementFact ?? judged.targetFact),
|
|
702
|
+
status: String(saved?.status ?? 'verified'),
|
|
703
|
+
blockedBy: [], reason: judged.reason, events, record: saved,
|
|
704
|
+
};
|
|
705
|
+
}
|