@bolloon/bolloon-agent 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +4 -4
  2. package/dist/agents/chain/transfer-classify.js +99 -0
  3. package/dist/agents/chain/transfer-index.js +354 -0
  4. package/dist/agents/identity/address-binding.js +613 -0
  5. package/dist/agents/network-pulse.js +706 -27
  6. package/dist/agents/pi-sdk-tools.js +67 -15
  7. package/dist/agents/pi-sdk.js +7 -1
  8. package/dist/agents/session-store.js +144 -3
  9. package/dist/agents/wallet-tools.js +63 -0
  10. package/dist/agents/x402/direct-payment.js +689 -0
  11. package/dist/agents/x402/order-identity.js +408 -0
  12. package/dist/agents/x402/paid-info-store.js +17 -0
  13. package/dist/agents/x402/seller-signing.js +614 -0
  14. package/dist/agents/x402/seller-summary.js +398 -0
  15. package/dist/cli/identity-command.js +327 -2
  16. package/dist/cli/ink-app.js +79 -27
  17. package/dist/cli/input-history.js +119 -0
  18. package/dist/cli/loading-tui.js +66 -6
  19. package/dist/cli/log-gate.js +39 -1
  20. package/dist/cli/protocol-envelope.js +4 -0
  21. package/dist/cli/session-summary.js +129 -0
  22. package/dist/cli/session-tree.js +141 -0
  23. package/dist/cli/status-segments.js +70 -0
  24. package/dist/cli/stores.js +16 -0
  25. package/dist/cli/update-commands.js +41 -25
  26. package/dist/cli/wallet-store.js +159 -0
  27. package/dist/cli/wallet-table.js +46 -0
  28. package/dist/cli/x402-buyer-command.js +463 -0
  29. package/dist/cli/x402-seller-command.js +289 -0
  30. package/dist/cli-entry.js +43 -5
  31. package/dist/electron-build/agents/chain/transfer-classify.js +106 -0
  32. package/dist/electron-build/agents/chain/transfer-classify.js.map +1 -0
  33. package/dist/electron-build/agents/chain/transfer-index.js +400 -0
  34. package/dist/electron-build/agents/chain/transfer-index.js.map +1 -0
  35. package/dist/electron-build/agents/identity/address-binding.js +672 -0
  36. package/dist/electron-build/agents/identity/address-binding.js.map +1 -0
  37. package/dist/electron-build/agents/network-pulse.js +714 -28
  38. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  39. package/dist/electron-build/agents/pi-sdk-tools.js +68 -15
  40. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  41. package/dist/electron-build/agents/pi-sdk.js +7 -1
  42. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  43. package/dist/electron-build/agents/session-store.js +144 -3
  44. package/dist/electron-build/agents/session-store.js.map +1 -1
  45. package/dist/electron-build/agents/wallet-tools.js +102 -0
  46. package/dist/electron-build/agents/wallet-tools.js.map +1 -0
  47. package/dist/electron-build/agents/x402/direct-payment.js +744 -0
  48. package/dist/electron-build/agents/x402/direct-payment.js.map +1 -0
  49. package/dist/electron-build/agents/x402/order-identity.js +458 -0
  50. package/dist/electron-build/agents/x402/order-identity.js.map +1 -0
  51. package/dist/electron-build/agents/x402/paid-info-protocol.js.map +1 -1
  52. package/dist/electron-build/agents/x402/paid-info-store.js +18 -0
  53. package/dist/electron-build/agents/x402/paid-info-store.js.map +1 -1
  54. package/dist/electron-build/agents/x402/seller-signing.js +682 -0
  55. package/dist/electron-build/agents/x402/seller-signing.js.map +1 -0
  56. package/dist/electron-build/cli/wallet-store.js +200 -0
  57. package/dist/electron-build/cli/wallet-store.js.map +1 -0
  58. package/dist/electron-build/llm/pi-ai.js +25 -0
  59. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  60. package/dist/electron-build/utils/auto-update.js +2 -2
  61. package/dist/electron-build/utils/auto-update.js.map +1 -1
  62. package/dist/electron-build/utils/runtime-bootstrap.js +1 -1
  63. package/dist/electron-build/utils/runtime-bootstrap.js.map +1 -1
  64. package/dist/electron-build/utils/update-manager.js +3 -3
  65. package/dist/electron-build/utils/update-manager.js.map +1 -1
  66. package/dist/electron-build/utils/version-identity.js +1 -1
  67. package/dist/electron-build/utils/version-identity.js.map +1 -1
  68. package/dist/electron-build/web/routes-x402-info.js +28 -1
  69. package/dist/electron-build/web/routes-x402-info.js.map +1 -1
  70. package/dist/index.js +829 -104
  71. package/dist/llm/pi-ai.js +23 -0
  72. package/dist/utils/auto-update.js +2 -2
  73. package/dist/utils/runtime-bootstrap.js +1 -1
  74. package/dist/utils/update-health.js +2 -2
  75. package/dist/utils/update-manager.js +3 -3
  76. package/dist/utils/version-identity.js +1 -1
  77. package/dist/web/bolloon-web.json +3 -3
  78. package/dist/web/routes-x402-info.js +28 -1
  79. package/dist/web/sw.js +1 -1
  80. package/package.json +2 -1
@@ -0,0 +1,689 @@
1
+ /**
2
+ * direct-payment.ts — x402 **去中心化直付**模式 (settlement mode = 'direct')
3
+ *
4
+ * 一句话: **买方自己发 USDC 到卖方地址, 把 txHash 交回; 卖方端点只读链核验** ——
5
+ * 全程**不经过任何第三方托管 / facilitator / 平台账号**。钱从买方钱包直达 payTo。
6
+ *
7
+ * 为什么单开一个模块 (而不是塞进 paid-info-store 的 facilitator 分支):
8
+ * facilitator 模式是「我们(或别人)的 relayer 替买方发交易, 再拿回执」——
9
+ * 链路里有一个**中间人**。direct 模式把中间人删掉了: 交易由买方签、买方发,
10
+ * 服务器只是**一条链上事实的读者**。这两件事的钱动路径不同, 判定口径也不同:
11
+ * · facilitator: 判 facilitator 的回执 (它说成功才算)
12
+ * · direct: 判**链上原始事实** (receipt / Transfer 事件 / 确认数), 而且
13
+ * **两条不同 RPC 各查一遍且一致**才算过 —— 单条 RPC 不构成事实。
14
+ *
15
+ * 硬规则 (与 chain-settlement.ts 同一口径, 但零 npm 依赖: 只走 JSON-RPC):
16
+ * ① chainId 必须与 item 的 network 对得上 (base = 8453)
17
+ * ② receipt 必须存在, 且 receipt.status == 1 (0 = 回滚, 钱没动)
18
+ * ③ 日志里必须有 **USDC 合约** 的 Transfer, 且 to == payTo (逐字节比地址), value >= accepts.amount
19
+ * ④ 确认数 >= 门槛 (缺省 2)
20
+ * ⑤ **两条不同 RPC** 都说 ok 且事实一致 (blockNumber/to/from/value 全一致) → 才算 verified
21
+ * 任何一条 RPC 说"没这回事"→ 不一致 = 不确定 = **不通过** (fail-closed)
22
+ * ⑥ 同一 txHash 只能交付一次 (幂等台账, 落盘 0600); 同一个 txHash 拿去换**另一条**资源 → 拒
23
+ * ⑦ **订单标识 (EIP-3009 nonce, 约定 v1 标签 "BOL1")** —— 普通转账在链上**没有"买的是哪件"的痕迹**,
24
+ * 所以如果这笔交易的日志里还有 `AuthorizationUsed(payer, nonce)` 且 nonce 按 v1 编了
25
+ * "本店 item + orderSeq"的哈希, 就**在同一笔交易里自证**了订单身份 (见 order-identity.ts)。
26
+ * 没有该事件 / 哈希对不上本店 item → **如实降级为「直转(无订单标识)」**,
27
+ * **绝不假装自证** (降级不影响交付, 影响的是"这笔付款对应哪件东西"能不能被链上证明)。
28
+ * ★ 同一 (payer, nonce) 在 EIP-3009 里只能用一次 (链上 authorizationState 置位, 再用必 revert);
29
+ * 台账另加一道 `NONCE_ALREADY_USED`: 同一个 (payer, nonce) 出现在**另一笔 txHash** 上 → 拒。
30
+ *
31
+ * 诚实边界 (不许美化):
32
+ * · 本模式**不退不追**: 买方发错金额/发错地址, 服务器只会拒 (钱在链上, 谁也拿不回来)。
33
+ * · 核验**不是托管**: 服务器没有私钥, 也不经手钱; 它只是"看链"。
34
+ * · 谁拿到 txHash 谁就能自己上链复核 —— 所以 direct 模式下的取件 token
35
+ * (由回执哈希派生) **买方自己能算出来**, 不提供保密性。这是设计使然, 不是缺陷:
36
+ * direct 模式里"付款事实"本来就是公开的。
37
+ */
38
+ import * as fs from 'fs/promises';
39
+ import * as path from 'path';
40
+ import * as os from 'os';
41
+ import { buildPaymentRequired, listInfo } from './paid-info-store.js';
42
+ import { resolvePaidDelivery, paidInfoRetrievePath } from './seller-signing.js';
43
+ // 复用既有哈希实现 (paid-info-protocol.js 已在服务器上, 且只依赖 node:crypto —— 不拖 ethers)
44
+ import { sha256Hex } from './paid-info-protocol.js';
45
+ // ★ 订单标识 (约定 v1, 标签 BOL1): 纯 TS 零依赖 (自带 keccak-256), 服务器上没有 node_modules 也能跑
46
+ import { orderIdentityFromLogs, computeOrderNonce, decodeOrderNonce, ORDER_IDENTITY_PROTOCOL, ORDER_NONCE_TAG, ORDER_NONCE_TAG_ASCII, EIP3009_AUTHORIZATION_USED_TOPIC0, EIP3009_TRANSFER_WITH_AUTHORIZATION_SELECTOR, } from './order-identity.js';
47
+ // 让"卖方核验这一条路"只需 import 一个模块 (订单标识的实现留在 order-identity.ts, 单一实现)
48
+ export { ORDER_IDENTITY_PROTOCOL, ORDER_NONCE_TAG, ORDER_NONCE_TAG_ASCII, EIP3009_AUTHORIZATION_USED_TOPIC0, EIP3009_TRANSFER_WITH_AUTHORIZATION_SELECTOR, computeOrderNonce, decodeOrderNonce, orderIdentityFromLogs, };
49
+ export const DIRECT_PROTOCOL = 'bolloon-x402-direct/1';
50
+ /** 幂等台账 (txHash → 交付事实), 落盘 0600 */
51
+ export const DIRECT_TX_LEDGER_FILE = 'x402-direct-txs.json';
52
+ /**
53
+ * ERC20 Transfer 事件 topic0。
54
+ * ★ 与 chain-settlement.ts 的 ERC20_TRANSFER_TOPIC0 **必须一致** (测试里有等式断言)。
55
+ * 这里抄一份常量而不是 import: chain-settlement.ts 顶层 import ethers, 而本模块要能
56
+ * 在**没有 node_modules 的服务器**上跑 (ECS 只放编译产物) —— 顶层拖进 ethers 就起不来。
57
+ */
58
+ export const ERC20_TRANSFER_TOPIC0 = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';
59
+ /** network 名 → chainId (核验必须比 chainId: 只认网络的"名字"等于没认) */
60
+ export const CHAIN_ID_BY_NETWORK = {
61
+ base: 8453,
62
+ 'base-sepolia': 84532,
63
+ mainnet: 1,
64
+ sepolia: 11155111,
65
+ };
66
+ /**
67
+ * 缺省两条 RPC —— **故意用两家**: 单条 RPC 说"钱到了"不构成事实。
68
+ * base 这一对是**实测可达**的 (2026-09-28 从卖方端点所在的 ECS 与卖方本机各测一次,
69
+ * eth_chainId 都回 0x2105=8453): mainnet.base.org + base.drpc.org。
70
+ * 踩过的两个坑 (不是"应该能用", 是实测不能用):
71
+ * · base.llamarpc.com → 两边都回 Cloudflare 525/HTML
72
+ * · base.publicnode.com → 免费档对稍旧的交易收据直接拒绝:
73
+ * `-32602 Archive requests require a personal token` ⇒ 当前能过、过几十个块就不行, 不能当缺省
74
+ * base-sepolia 这一对**未实测** (本部署只用 base) —— 用到它之前必须先测, 不许想当然。
75
+ */
76
+ export const DEFAULT_DIRECT_RPCS = {
77
+ base: ['https://mainnet.base.org', 'https://base.drpc.org'],
78
+ 'base-sepolia': ['https://sepolia.base.org', 'https://base-sepolia.publicnode.com'],
79
+ };
80
+ /** 至少几条 RPC 一致才算过 (不可下调到 1: 那就退化成"信一家") */
81
+ export const DIRECT_MIN_RPCS = 2;
82
+ export const DEFAULT_DIRECT_CONFIRMATIONS = 2;
83
+ const DEFAULT_TIMEOUT_MS = 12_000;
84
+ // ────────────────────────────────────────────────────────────── 开关 / 配置
85
+ export function directPaymentEnabled(env = process.env) {
86
+ return String(env.BOLLOON_X402_DIRECT || '') === '1';
87
+ }
88
+ /** 这条 network 用哪几条 RPC (env 覆盖优先) */
89
+ export function directRpcUrls(network, env = process.env) {
90
+ const raw = String(env.BOLLOON_X402_DIRECT_RPCS || '').split(',').map((s) => s.trim()).filter(Boolean);
91
+ if (raw.length)
92
+ return raw;
93
+ return DEFAULT_DIRECT_RPCS[network] || [];
94
+ }
95
+ export function directMinConfirmations(env = process.env) {
96
+ const n = Number(env.BOLLOON_X402_DIRECT_CONFIRMATIONS);
97
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : DEFAULT_DIRECT_CONFIRMATIONS;
98
+ }
99
+ /** 只留主机名 (RPC URL 里可能带 key, 不许进响应/日志) */
100
+ function rpcLabel(url) {
101
+ try {
102
+ return new URL(url).host;
103
+ }
104
+ catch {
105
+ return 'invalid-url';
106
+ }
107
+ }
108
+ // ────────────────────────────────────────────────────────────── JSON-RPC
109
+ async function rpcCall(url, method, params, fetchImpl, timeoutMs) {
110
+ const ac = new AbortController();
111
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
112
+ try {
113
+ const res = await fetchImpl(url, {
114
+ method: 'POST',
115
+ headers: { 'content-type': 'application/json' },
116
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
117
+ signal: ac.signal,
118
+ });
119
+ if (!res.ok)
120
+ throw new Error(`HTTP ${res.status}`);
121
+ const j = await res.json();
122
+ if (j?.error)
123
+ throw new Error(`RPC error: ${String(j.error?.message || 'unknown').slice(0, 120)}`);
124
+ return j?.result;
125
+ }
126
+ finally {
127
+ clearTimeout(timer);
128
+ }
129
+ }
130
+ /** 取地址最后 20 字节 (去掉 0x + 32 字节 topic 的左填充) */
131
+ function topicToAddress(topic) {
132
+ const h = String(topic || '').replace(/^0x/, '').toLowerCase();
133
+ return h.length >= 40 ? `0x${h.slice(-40)}` : '';
134
+ }
135
+ /** 一条 RPC 上把「这笔交易是不是我们要的付款」查到底 */
136
+ export async function checkOneRpc(input, rpcUrl) {
137
+ const f = input.fetchImpl ?? fetch;
138
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
139
+ const label = rpcLabel(rpcUrl);
140
+ const base = { rpc: label, ok: false };
141
+ let chainIdHex;
142
+ let receipt;
143
+ let latestHex;
144
+ try {
145
+ // chainId 必须对得上 —— RPC 可能在别的链上 (或被人指到了测试网)
146
+ chainIdHex = await rpcCall(rpcUrl, 'eth_chainId', [], f, timeoutMs);
147
+ const gotChain = Number(BigInt(chainIdHex));
148
+ if (gotChain !== input.chainId) {
149
+ return { ...base, status: 'chain_mismatch', chainId: gotChain, detail: `chainId=${gotChain} 与期望 ${input.chainId} 不一致 —— 换链了, 不能当这笔付款` };
150
+ }
151
+ receipt = await rpcCall(rpcUrl, 'eth_getTransactionReceipt', [input.txHash], f, timeoutMs);
152
+ latestHex = await rpcCall(rpcUrl, 'eth_blockNumber', [], f, timeoutMs);
153
+ }
154
+ catch (e) {
155
+ return { ...base, status: 'unreachable', detail: `RPC 读不到: ${String(e?.message || e).slice(0, 160)} (≠ 没付钱, 但也 ≠ 已付)` };
156
+ }
157
+ if (!receipt) {
158
+ return { ...base, status: 'pending', detail: '链上还没有这个 txHash 的 receipt (没打包 / 或在内存池) —— 未结算' };
159
+ }
160
+ const blockNumber = Number(BigInt(receipt.blockNumber));
161
+ const latestBlock = Number(BigInt(latestHex));
162
+ const confirmations = latestBlock - blockNumber + 1;
163
+ const logs = Array.isArray(receipt.logs) ? receipt.logs : [];
164
+ // ★ 订单标识 (约定 v1): 只读日志。**先算出来再判交付** —— 这样连"付款没到/事件对不上"的
165
+ // 响应里也能给出「这笔交易声明的订单身份是什么」, 排查时不用另开一轮。
166
+ // payerHint 留空: 此时还不知道 Transfer.from, 事件里的 authorizer 才是权威付款方。
167
+ const orderIdentity = orderIdentityFromLogs(logs, {
168
+ itemIds: input.orderItemIds || [],
169
+ asset: String(input.asset),
170
+ });
171
+ if (Number(BigInt(receipt.status ?? '0x0')) !== 1) {
172
+ return { ...base, status: 'reverted', blockNumber, confirmations, latestBlock, orderIdentity, detail: `receipt.status=0 (交易回滚) —— 钱没动` };
173
+ }
174
+ // 找 USDC 合约上、to == payTo、value >= 要求的 Transfer
175
+ const wantAsset = String(input.asset).toLowerCase();
176
+ const wantTo = String(input.to).toLowerCase();
177
+ const minAmount = BigInt(input.minAmount);
178
+ let transferLogs = 0;
179
+ let matched = null;
180
+ for (const l of logs) {
181
+ if (String(l?.address || '').toLowerCase() !== wantAsset)
182
+ continue;
183
+ const topics = Array.isArray(l?.topics) ? l.topics : [];
184
+ if (String(topics[0] || '').toLowerCase() !== ERC20_TRANSFER_TOPIC0)
185
+ continue;
186
+ transferLogs += 1;
187
+ if (topics.length < 3)
188
+ continue;
189
+ const from = topicToAddress(topics[1]);
190
+ const to = topicToAddress(topics[2]);
191
+ let value = 0n;
192
+ try {
193
+ value = BigInt(String(l?.data || '0x0'));
194
+ }
195
+ catch {
196
+ continue;
197
+ }
198
+ if (to === wantTo && value >= minAmount)
199
+ matched = { from, to, value };
200
+ }
201
+ if (!matched) {
202
+ return {
203
+ ...base, status: 'event_mismatch', blockNumber, confirmations, latestBlock, orderIdentity,
204
+ detail: `receipt 里 ${transferLogs} 条 ${label} USDC Transfer, 没有一条 to=${input.to} 且 value>=${input.minAmount} 的 —— 不是这笔付款`,
205
+ };
206
+ }
207
+ // 付款方已知 ⇒ 用 Transfer.from 给订单标识补上 payer (事件里 authorizer 已是权威值, 这里只是兜底)
208
+ const withPayer = orderIdentity.payer ? orderIdentity : { ...orderIdentity, payer: matched.from };
209
+ const status = confirmations >= 12 ? 'finalized' : 'confirmed';
210
+ if (confirmations < input.minConfirmations) {
211
+ return {
212
+ ...base, status: 'pending', blockNumber, confirmations, latestBlock, orderIdentity: withPayer,
213
+ from: matched.from, to: matched.to, value: matched.value.toString(),
214
+ detail: `付款事实成立但确认数 ${confirmations} < ${input.minConfirmations} —— 还没到可以判交付的程度`,
215
+ };
216
+ }
217
+ return {
218
+ rpc: label, ok: true, status, blockNumber, confirmations, latestBlock, orderIdentity: withPayer,
219
+ from: matched.from, to: matched.to, value: matched.value.toString(),
220
+ detail: `receipt.status=1, USDC Transfer to=${matched.to} value=${matched.value} 确认数 ${confirmations}`,
221
+ };
222
+ }
223
+ const TXHASH_RE = /^0x[0-9a-fA-F]{64}$/;
224
+ /**
225
+ * ★ 直付核验的唯一入口。
226
+ * 两条(以上)不同 RPC **各自独立**查一遍, 事实完全一致且都 ok 才算过。
227
+ */
228
+ export async function verifyDirectTransfer(input) {
229
+ const txHash = String(input.txHash || '').trim();
230
+ const minConfirmations = input.minConfirmations ?? DEFAULT_DIRECT_CONFIRMATIONS;
231
+ const rpcUrls = input.rpcUrls ?? directRpcUrls(input.network);
232
+ const shell = {
233
+ txHash, network: input.network, chainId: input.chainId, asset: input.asset,
234
+ to: input.to, minAmount: String(input.minAmount), minConfirmations,
235
+ verifiedBy: [], rpcChecks: [],
236
+ };
237
+ if (!TXHASH_RE.test(txHash)) {
238
+ return { ...shell, ok: false, status: 'txhash_invalid', reason: `txHash 形状不对 (要 0x + 64 hex): ${txHash.slice(0, 40)}` };
239
+ }
240
+ if (rpcUrls.length < DIRECT_MIN_RPCS) {
241
+ return { ...shell, ok: false, status: 'no_rpc_configured', reason: `network=${input.network} 只配了 ${rpcUrls.length} 条 RPC, 少于要求的 ${DIRECT_MIN_RPCS} 条 —— 单条 RPC 不构成事实, 拒判` };
242
+ }
243
+ const checks = await Promise.all(rpcUrls.map((u) => checkOneRpc({ ...input, minConfirmations, rpcUrls }, u)));
244
+ const ok = checks.filter((c) => c.ok);
245
+ const negative = checks.filter((c) => !c.ok && (c.status === 'reverted' || c.status === 'event_mismatch' || c.status === 'chain_mismatch'));
246
+ // ★ 订单标识也从各条 RPC 的核查结果上带出来 (任一分支都带上; 没有则 undefined) ——
247
+ // 这样"付款没到/事件对不上"的 402 里也能给出"这笔交易声明的订单身份", 排查不用另开一轮
248
+ const verdict = { ...shell, rpcChecks: checks, orderIdentity: checks.find((c) => c.orderIdentity)?.orderIdentity };
249
+ // ① 两条以上都 ok → 还要事实**完全一致** (比 blockNumber/from/to/value + ★ 订单 nonce)
250
+ if (ok.length >= DIRECT_MIN_RPCS) {
251
+ const first = ok[0];
252
+ const disagree = ok.find((c) => c.blockNumber !== first.blockNumber || c.from !== first.from || c.to !== first.to || c.value !== first.value
253
+ // ★ 订单标识也要一致: 一条 RPC 看到 AuthorizationUsed 另一条没看到 ⇒ 不确定, 不许挑好的信
254
+ || (c.orderIdentity?.nonce || null) !== (first.orderIdentity?.nonce || null)
255
+ || (c.orderIdentity?.mode || null) !== (first.orderIdentity?.mode || null));
256
+ if (disagree) {
257
+ return {
258
+ ...verdict, ok: false, status: 'rpc_disagreement',
259
+ reason: `RPC 之间事实不一致 (${first.rpc}: 块 ${first.blockNumber}/value ${first.value} vs ${disagree.rpc}: 块 ${disagree.blockNumber}/value ${disagree.value}) —— 不敢判交付`,
260
+ };
261
+ }
262
+ return {
263
+ ...verdict, ok: true, status: first.status === 'finalized' ? 'finalized' : 'confirmed',
264
+ reason: `两条 RPC 一致: receipt.status=1, USDC Transfer to=${first.to} value=${first.value}, 确认数 ${first.confirmations} >= ${minConfirmations}; 订单标识=${first.orderIdentity?.mode || 'unknown'}`,
265
+ verifiedBy: ok.map((c) => c.rpc),
266
+ payer: first.from, amount: first.value, blockNumber: first.blockNumber, confirmations: first.confirmations,
267
+ orderIdentity: first.orderIdentity, orderNonce: first.orderIdentity?.nonce ?? null,
268
+ };
269
+ }
270
+ // ② 只有一条 ok → 另一条不同意/读不到 = 不一致 (fail-closed, 绝不用单条 RPC 交付)
271
+ if (ok.length > 0) {
272
+ const failed = checks.filter((c) => !c.ok);
273
+ return {
274
+ ...verdict, ok: false, status: 'rpc_disagreement',
275
+ reason: `只有 ${ok.length} 条 RPC 认这笔付款 (${ok.map((c) => c.rpc).join(', ')}), ${failed.map((c) => `${c.rpc}:${c.status}`).join(', ')} —— 不一致 = 不确定, 不交付`,
276
+ };
277
+ }
278
+ // ③ 全部否: 两条以上给出同一个**确定性否定** (回滚/事件对不上/换链) 才敢下这个结论
279
+ if (negative.length >= DIRECT_MIN_RPCS) {
280
+ const first = negative[0];
281
+ return {
282
+ ...verdict, ok: false, status: first.status,
283
+ reason: `${negative.length} 条 RPC 一致否定 (${negative.map((c) => `${c.rpc}:${c.status}`).join(', ')}): ${first.detail}`,
284
+ };
285
+ }
286
+ if (negative.length > 0) {
287
+ return {
288
+ ...verdict, ok: false, status: 'rpc_disagreement',
289
+ reason: `RPC 结论不一致 (${checks.map((c) => `${c.rpc}:${c.status}`).join(', ')}) —— 不确定, 不交付`,
290
+ };
291
+ }
292
+ // ④ 全部说"还没到" (还没打包 / 确认数不够) 且至少两条这么说 → pending (明确的"未结算", 不是"不知道")
293
+ const pendingChecks = checks.filter((c) => c.status === 'pending');
294
+ if (pendingChecks.length >= DIRECT_MIN_RPCS) {
295
+ return {
296
+ ...verdict, ok: false, status: 'pending',
297
+ reason: `${pendingChecks.length} 条 RPC 一致: ${pendingChecks[0].detail}`,
298
+ };
299
+ }
300
+ return {
301
+ ...verdict, ok: false, status: 'rpc_insufficient',
302
+ reason: `没有任何两条 RPC 能确认这笔付款 (${checks.map((c) => `${c.rpc}:${c.status}`).join(', ')})`,
303
+ };
304
+ }
305
+ export function directTxLedgerPath(home = os.homedir()) {
306
+ return path.join(home, '.bolloon', DIRECT_TX_LEDGER_FILE);
307
+ }
308
+ export class DirectTxLedger {
309
+ file;
310
+ txs = {};
311
+ loaded = false;
312
+ writeChain = Promise.resolve();
313
+ constructor(file) { this.file = file; }
314
+ async ensureLoaded() {
315
+ if (this.loaded)
316
+ return;
317
+ this.loaded = true;
318
+ try {
319
+ const parsed = JSON.parse(await fs.readFile(this.file, 'utf-8'));
320
+ if (parsed?.txs && typeof parsed.txs === 'object')
321
+ this.txs = parsed.txs;
322
+ }
323
+ catch { /* 首次运行没有台账 = 空台账 */ }
324
+ }
325
+ async persist() {
326
+ const snapshot = JSON.stringify({ protocol: DIRECT_PROTOCOL, txs: this.txs }, null, 2);
327
+ this.writeChain = this.writeChain.then(async () => {
328
+ await fs.mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
329
+ const tmp = `${this.file}.tmp-${process.pid}`;
330
+ await fs.writeFile(tmp, snapshot, { mode: 0o600 });
331
+ await fs.rename(tmp, this.file);
332
+ }).catch(() => undefined);
333
+ await this.writeChain;
334
+ }
335
+ async get(txHash) {
336
+ await this.ensureLoaded();
337
+ return this.txs[String(txHash).toLowerCase()] || null;
338
+ }
339
+ /**
340
+ * 认领一笔交付事实。
341
+ * · 同 txHash + 同 itemId → 幂等命中 (复用首次时间 ⇒ 回执哈希稳定 ⇒ 同一条待办, 不产生第二条)
342
+ * · 同 txHash + 别的 itemId → **拒** (拿一笔付款去换另一条资源 = 跨资源复用)
343
+ * · 同一 (payer, nonce) 出现在**另一笔 txHash** → **拒** (EIP-3009 里 (payer,nonce) 只能用一次;
344
+ * 链上第二笔本来就必 revert, 能走到这里说明有人在换 txHash 蹭同一份授权 —— 不能认)
345
+ */
346
+ async claim(input) {
347
+ await this.ensureLoaded();
348
+ const key = String(input.txHash).toLowerCase();
349
+ const existing = this.txs[key];
350
+ if (existing) {
351
+ if (existing.itemId !== input.itemId) {
352
+ return {
353
+ ok: false, code: 'TXHASH_ALREADY_USED',
354
+ detail: `这笔交易 (${key.slice(0, 18)}…) 已经用来交付过 ${existing.itemId} —— 同一笔付款不能换别的资源`,
355
+ };
356
+ }
357
+ return { ok: true, record: existing, reused: true };
358
+ }
359
+ // ★ (payer, nonce) 唯一性: 与**别的 txHash** 撞同一个 nonce = 重放/换 txHash 蹭授权, 拒
360
+ const nonce = input.orderIdentity?.nonce || '';
361
+ if (nonce) {
362
+ const payer = String(input.orderIdentity?.payer || input.from || '').toLowerCase();
363
+ for (const [otherKey, other] of Object.entries(this.txs)) {
364
+ if (otherKey === key)
365
+ continue;
366
+ const otherNonce = other.orderIdentity?.nonce || '';
367
+ if (!otherNonce || otherNonce !== nonce)
368
+ continue;
369
+ const otherPayer = String(other.orderIdentity?.payer || other.from || '').toLowerCase();
370
+ if (otherPayer !== payer)
371
+ continue;
372
+ return {
373
+ ok: false, code: 'NONCE_ALREADY_USED',
374
+ detail: `同一个 (payer=${payer}, nonce=${nonce.slice(0, 18)}…) 已经用在另一笔交易 ${otherKey.slice(0, 18)}… 上 —— EIP-3009 的 (payer, nonce) 只能用一次, 重复用链上必 revert`,
375
+ };
376
+ }
377
+ }
378
+ const record = {
379
+ txHash: key, itemId: input.itemId, network: input.network, chainId: input.chainId,
380
+ asset: input.asset, to: input.to, from: input.from, amount: input.amount,
381
+ blockNumber: input.blockNumber, confirmations: input.confirmations,
382
+ verifiedBy: input.verifiedBy,
383
+ settledAt: input.settledAt || new Date(input.now ?? Date.now()).toISOString(),
384
+ receiptHash: input.receiptHash,
385
+ // 没有订单标识时不写这个键 (老记录回执逐字不变)
386
+ ...(input.orderIdentity ? { orderIdentity: input.orderIdentity } : {}),
387
+ };
388
+ this.txs[key] = record;
389
+ await this.persist();
390
+ return { ok: true, record, reused: false };
391
+ }
392
+ }
393
+ /**
394
+ * 直付回执 (**确定性**: 同一个 txHash 永远得到同一串字节 ⇒ 同一 receiptHash ⇒ 同一 pendingId)。
395
+ * 键序固定 (不能用 canonicalize 之外的随机顺序); settledAt 取台账首见时间, 不是"现在"。
396
+ */
397
+ export function buildDirectReceipt(record) {
398
+ const obj = {
399
+ protocol: DIRECT_PROTOCOL,
400
+ mode: 'direct',
401
+ success: true,
402
+ network: record.network,
403
+ chainId: record.chainId,
404
+ txHash: record.txHash,
405
+ payer: record.from,
406
+ payTo: record.to,
407
+ asset: record.asset,
408
+ amount: record.amount,
409
+ blockNumber: record.blockNumber,
410
+ confirmations: record.confirmations,
411
+ verifiedBy: [...record.verifiedBy].sort(),
412
+ settledAt: record.settledAt,
413
+ custody: 'none',
414
+ note: '买方直付: 买方自己在钱包里发 USDC 到 payTo, 卖方端点按 txHash 读链核验 (两条 RPC 交叉); 不经过任何第三方托管/facilitator',
415
+ // ★ 订单标识 (约定 v1): **只有带上时才写这个键** —— 普通转账/老记录的回执逐字不变
416
+ // (键序固定, 键在最后 ⇒ 旧台账重算回执得到的是同一串字节 ⇒ 同一 receiptHash ⇒ 同一 pendingId)
417
+ ...(record.orderIdentity ? {
418
+ orderIdentity: {
419
+ protocol: record.orderIdentity.protocol,
420
+ selfAttested: record.orderIdentity.selfAttested,
421
+ mode: record.orderIdentity.mode,
422
+ nonce: record.orderIdentity.nonce,
423
+ orderSeq: record.orderIdentity.orderSeq,
424
+ itemIdHash: record.orderIdentity.itemIdHash,
425
+ // 自证命中的本店 item (排序: 台账/回执要可复现)
426
+ items: [...record.orderIdentity.matchedItemIds].sort(),
427
+ note: record.orderIdentity.selfAttested
428
+ ? '订单自证: nonce 按约定 v1 (标签 BOL1) 编入 keccak256(itemId‖orderSeq), 卖方用本店 item 复算哈希一致'
429
+ : '未自证 (如实降级): 该交易没有带本店可复算的 BOL1 订单标识 —— 只有「谁付·多少·给谁」',
430
+ },
431
+ } : {}),
432
+ };
433
+ return Buffer.from(JSON.stringify(obj), 'utf-8').toString('base64');
434
+ }
435
+ export async function settleDirectPayment(opts) {
436
+ const item = opts.item;
437
+ const home = opts.home ?? os.homedir();
438
+ const network = String(item.price?.network || 'base');
439
+ const currency = String(item.price?.currency || 'USDC');
440
+ const shell = { mode: 'direct', network, amount: String(item.price?.amount ?? '0'), currency };
441
+ if (!opts.allowDisabled && !directPaymentEnabled()) {
442
+ return { ...shell, ok: false, code: 'DIRECT_MODE_DISABLED', detail: '本部署未开启直付模式 (BOLLOON_X402_DIRECT=1 才开) —— 这条路径上没有链上核验能力' };
443
+ }
444
+ const chainId = CHAIN_ID_BY_NETWORK[network];
445
+ if (!chainId) {
446
+ return { ...shell, ok: false, code: 'ITEM_PRICE_UNSUPPORTED', detail: `不认识的 network=${network} → 无法确定 chainId, 不敢核验` };
447
+ }
448
+ // 要求逐字来自既有 buildPaymentRequired (与 402 的 accepts 同源, 绝不另算一份)
449
+ const requirements = buildPaymentRequired(item, opts.resourceUrl);
450
+ const req = requirements.accepts[0];
451
+ const txHash = String(opts.txHash || '').trim();
452
+ if (!TXHASH_RE.test(txHash)) {
453
+ return { ...shell, ok: false, code: 'TXHASH_INVALID', detail: `txHash 形状不对 (要 0x + 64 hex): ${txHash.slice(0, 40)}` };
454
+ }
455
+ const verdict = await verifyDirectTransfer({
456
+ txHash,
457
+ network,
458
+ chainId,
459
+ asset: String(req.asset),
460
+ to: String(req.payTo),
461
+ minAmount: String(req.amount),
462
+ minConfirmations: opts.minConfirmations ?? directMinConfirmations(),
463
+ rpcUrls: opts.rpcUrls ?? directRpcUrls(network),
464
+ // ★ 订单标识自证要拿本店真实 item 复算哈希 —— 缺省只比"正在买的这一件",
465
+ // 调用方给全量列表时可额外区分「买的是本店另一件」与「完全对不上」
466
+ orderItemIds: opts.orderItemIds ?? [item.id],
467
+ fetchImpl: opts.fetchImpl,
468
+ timeoutMs: opts.timeoutMs,
469
+ });
470
+ if (!verdict.ok) {
471
+ return {
472
+ ...shell, ok: false, code: 'DIRECT_PAYMENT_NOT_VERIFIED', detail: verdict.reason, txHash, verdict,
473
+ orderIdentity: verdict.orderIdentity, orderIdentitySelfAttested: !!verdict.orderIdentity?.selfAttested,
474
+ };
475
+ }
476
+ const ledger = opts.ledger ?? new DirectTxLedger(directTxLedgerPath(home));
477
+ // 回执的 settledAt 取**台账首见时间** (第二次提交同一 txHash 会复用同一个时间 ⇒ 回执逐字相同 ⇒
478
+ // 同一 receiptHash ⇒ 同一 pendingId ⇒ 幂等命中同一条待办, 不会因"现在几点"而变出第二条)
479
+ const prior = await ledger.get(verdict.txHash);
480
+ const settledAt = prior?.settledAt ?? new Date(opts.now ?? Date.now()).toISOString();
481
+ // 先在内存里拼出整条记录 (含订单标识), 再算回执哈希 —— 回执与台账**同源**, 不各算一份
482
+ const draft = {
483
+ txHash: verdict.txHash.toLowerCase(), itemId: item.id, network, chainId,
484
+ asset: String(req.asset), to: String(req.payTo), from: verdict.payer || '',
485
+ amount: verdict.amount || '0', blockNumber: verdict.blockNumber ?? 0,
486
+ confirmations: verdict.confirmations ?? 0, verifiedBy: verdict.verifiedBy,
487
+ settledAt, receiptHash: '',
488
+ ...(verdict.orderIdentity ? { orderIdentity: verdict.orderIdentity } : {}),
489
+ };
490
+ const receiptHash = `sha256:${sha256Hex(buildDirectReceipt(draft))}`;
491
+ const claim = await ledger.claim({ ...draft, receiptHash, now: opts.now });
492
+ if (!claim.ok || !claim.record) {
493
+ return {
494
+ ...shell, ok: false, code: claim.code || 'DIRECT_PAYMENT_NOT_VERIFIED', detail: claim.detail || '认领失败', txHash, verdict,
495
+ orderIdentity: verdict.orderIdentity, orderIdentitySelfAttested: !!verdict.orderIdentity?.selfAttested,
496
+ };
497
+ }
498
+ const receipt = buildDirectReceipt(claim.record);
499
+ const orderIdentity = claim.record.orderIdentity ?? verdict.orderIdentity;
500
+ return {
501
+ ...shell, ok: true, detail: claim.reused ? '同一笔交易已交付过 (幂等: 回执逐字相同)' : '链上核验通过 (两条 RPC 交叉一致)',
502
+ receipt, txHash: claim.record.txHash, payer: claim.record.from,
503
+ confirmations: claim.record.confirmations, blockNumber: claim.record.blockNumber,
504
+ verifiedBy: claim.record.verifiedBy, reused: !!claim.reused, verdict,
505
+ orderIdentity, orderIdentitySelfAttested: !!orderIdentity?.selfAttested,
506
+ };
507
+ }
508
+ // ────────────────────────────────────────────────────────────── HTTP 面 (POST /api/x402/info/:id/payment)
509
+ export function directPaymentPath(itemId) {
510
+ return `/api/x402/info/${encodeURIComponent(itemId)}/payment`;
511
+ }
512
+ function jsonRes(status, obj, headers = {}) {
513
+ return {
514
+ status,
515
+ headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers },
516
+ body: JSON.stringify(obj, null, 2),
517
+ };
518
+ }
519
+ /**
520
+ * 处理「提交付款凭据」这件事。**服务器侧唯一实现** (仓内 route 与 ECS server.mjs 共用同一份,
521
+ * 保证两边行为逐字相同)。
522
+ *
523
+ * 202 → 已核验付款, 落待办, 给取件 token (卖方还没签)
524
+ * 200 → 卖方已签: 直接给信封 (幂等)
525
+ * 410 → 待办过期
526
+ * 402 → 核验不通过 (附 accepts 逐字 + 原因) —— 资源仍未付款
527
+ * 400 → txHash 形状不对 / body 不是 JSON
528
+ * 409 → 这个 txHash 已经用来交付过别的资源
529
+ * 503 → 本部署没开直付模式
530
+ */
531
+ export async function handleDirectPayment(req) {
532
+ let txHash = '';
533
+ try {
534
+ const parsed = JSON.parse(String(req.bodyText || ''));
535
+ txHash = String(parsed?.txHash ?? parsed?.transactionHash ?? '').trim();
536
+ }
537
+ catch {
538
+ return jsonRes(400, { ok: false, code: 'INVALID_ARGUMENT', error: '请求体不是合法 JSON (期望 {"txHash":"0x…"})' });
539
+ }
540
+ if (!txHash) {
541
+ return jsonRes(400, { ok: false, code: 'INVALID_ARGUMENT', error: '缺少 txHash (body: {"txHash":"0x…"} —— 买方自己发的 USDC 转账交易哈希)' });
542
+ }
543
+ // ★ 本店真实 item 列表: 没显式给就自己列一遍 (store 列不出来只退回正在买的这一件, 不阻止交付)
544
+ let orderItemIds = req.orderItemIds;
545
+ if (!orderItemIds || orderItemIds.length === 0) {
546
+ try {
547
+ const all = await listInfo(req.home);
548
+ const ids = all.map((i) => i.id).filter(Boolean);
549
+ orderItemIds = ids.length && ids.includes(req.item.id) ? ids : [req.item.id];
550
+ }
551
+ catch {
552
+ orderItemIds = [req.item.id];
553
+ }
554
+ }
555
+ const settled = await settleDirectPayment({
556
+ item: req.item,
557
+ txHash,
558
+ home: req.home,
559
+ resourceUrl: req.resourceUrl,
560
+ fetchImpl: req.fetchImpl,
561
+ rpcUrls: req.rpcUrls,
562
+ minConfirmations: req.minConfirmations,
563
+ timeoutMs: req.timeoutMs,
564
+ ledger: req.ledger,
565
+ now: req.now,
566
+ allowDisabled: req.allowDisabled,
567
+ orderItemIds,
568
+ });
569
+ if (!settled.ok) {
570
+ if (settled.code === 'DIRECT_MODE_DISABLED') {
571
+ return jsonRes(503, { ok: false, code: settled.code, error: settled.detail, hint: '本部署是只发 402 的口径; 要直付需 BOLLOON_X402_DIRECT=1' });
572
+ }
573
+ if (settled.code === 'TXHASH_INVALID') {
574
+ return jsonRes(400, { ok: false, code: settled.code, error: settled.detail });
575
+ }
576
+ if (settled.code === 'TXHASH_ALREADY_USED') {
577
+ return jsonRes(409, { ok: false, code: settled.code, error: settled.detail, hint: '一笔链上付款只能换一条资源' });
578
+ }
579
+ if (settled.code === 'NONCE_ALREADY_USED') {
580
+ return jsonRes(409, {
581
+ ok: false, code: settled.code, error: settled.detail,
582
+ hint: 'EIP-3009 的 (payer, nonce) 只能用一次 —— 买家要重复购买请换 orderSeq 重签授权',
583
+ orderIdentity: settled.orderIdentity,
584
+ });
585
+ }
586
+ // 核验没过: 仍然返回 402 (资源未付款), accepts 与 GET 的 402 逐字同源
587
+ const requirements = buildPaymentRequired(req.item, req.resourceUrl);
588
+ return jsonRes(402, {
589
+ ...requirements,
590
+ error: settled.detail,
591
+ code: settled.code || 'DIRECT_PAYMENT_NOT_VERIFIED',
592
+ paymentAttempt: {
593
+ mode: 'direct',
594
+ verified: false,
595
+ status: settled.verdict?.status,
596
+ rpcChecks: settled.verdict?.rpcChecks,
597
+ minConfirmations: settled.verdict?.minConfirmations,
598
+ // 核验没过时也如实给出"这笔交易声明的订单身份"(方便买家自己看哪里错了)
599
+ orderIdentity: settled.orderIdentity,
600
+ },
601
+ }, { 'X-PAYMENT-REQUIRED': JSON.stringify(requirements.accepts) });
602
+ }
603
+ const delivery = await resolvePaidDelivery({
604
+ item: req.item,
605
+ payment: {
606
+ mode: 'direct',
607
+ receipt: String(settled.receipt || ''),
608
+ txHash: settled.txHash,
609
+ payer: settled.payer,
610
+ network: settled.network,
611
+ amount: settled.amount,
612
+ currency: settled.currency,
613
+ },
614
+ }, req.home, req.now);
615
+ if (delivery.kind === 'envelope') {
616
+ return jsonRes(200, delivery.envelope, {
617
+ 'X-PAYMENT-RESPONSE': String(settled.receipt || ''),
618
+ 'X-BOLLOON-SELLER-DID': delivery.envelope?.proof?.did || '',
619
+ });
620
+ }
621
+ if (delivery.kind === 'expired') {
622
+ return jsonRes(410, {
623
+ ok: false, status: 'pending_expired', pendingId: delivery.pendingId, itemId: req.item.id,
624
+ error: '这条待办已过期 (卖方长期未签名) — 请人工处理, **不要重复付款**', hint: delivery.hint,
625
+ });
626
+ }
627
+ return jsonRes(202, {
628
+ ok: true,
629
+ status: 'paid_awaiting_signature',
630
+ pendingId: delivery.pendingId,
631
+ itemId: req.item.id,
632
+ title: req.item.title,
633
+ contentHash: req.item.contentHash,
634
+ // ★ 订单标识 (约定 v1): 「这笔付款对应哪件东西」能不能在链上自证 —— 这里只报事实, 不美化
635
+ orderIdentity: settled.orderIdentity,
636
+ payment: {
637
+ mode: 'direct',
638
+ receiptHash: delivery.pending?.payment?.receiptHash,
639
+ txHash: settled.txHash,
640
+ payer: settled.payer || null,
641
+ network: settled.network,
642
+ amount: settled.amount,
643
+ currency: settled.currency,
644
+ confirmed: true,
645
+ confirmations: settled.confirmations,
646
+ blockNumber: settled.blockNumber,
647
+ verifiedBy: settled.verifiedBy,
648
+ custody: 'none',
649
+ onchainVerification: 'receipt.status=1 + USDC Transfer to=payTo + value>=accepts.amount + 两条 RPC 一致 (不经过任何第三方托管)',
650
+ orderIdentitySelfAttested: !!settled.orderIdentitySelfAttested,
651
+ orderIdentityMode: settled.orderIdentity?.mode,
652
+ reused: !!settled.reused,
653
+ },
654
+ retrieval: {
655
+ token: delivery.pendingId,
656
+ path: paidInfoRetrievePath(req.item.id, delivery.pendingId),
657
+ note: '卖方不在线时这里一直回 202 (已付款待签名); 签好后回 200 + 信封',
658
+ },
659
+ hint: delivery.hint,
660
+ retry: '卖方签好后, 用 retrieval.path 再取一次 (**不要重复付款**)',
661
+ }, { 'X-PAYMENT-RESPONSE': String(settled.receipt || '') });
662
+ }
663
+ /** 这条路径在当前配置下**能**核验吗 (health 用; 只报事实, 不美化) */
664
+ export function directHealth(env = process.env, network = 'base') {
665
+ const enabled = directPaymentEnabled(env);
666
+ const rpcs = directRpcUrls(network, env).map(rpcLabel);
667
+ return {
668
+ enabled,
669
+ onchain: enabled,
670
+ custody: 'none',
671
+ detail: enabled
672
+ ? `链上核验: 买方直付到 payTo, 服务器按 txHash 查链 (${rpcs.length} 条 RPC 交叉, 确认数 >= ${directMinConfirmations(env)}); **不经过任何第三方托管/facilitator**`
673
+ : '直付模式未开启 (BOLLOON_X402_DIRECT=1 才开)',
674
+ rpcs,
675
+ minRpcAgreement: DIRECT_MIN_RPCS,
676
+ minConfirmations: directMinConfirmations(env),
677
+ paymentPath: '/api/x402/info/:id/payment',
678
+ // ★ 订单标识 (约定 v1): 让机器也能读到"本部署懂这个约定"; 不懂 = index 线不该在页面上声称
679
+ orderIdentity: {
680
+ protocol: ORDER_IDENTITY_PROTOCOL,
681
+ tag: ORDER_NONCE_TAG_ASCII,
682
+ tagHex: ORDER_NONCE_TAG,
683
+ layout: 'tag(4B "BOL1") ‖ orderSeq(uint32 BE, 4B) ‖ keccak256(utf8(itemId)‖uint256be(orderSeq))[0..23](24B)',
684
+ event: 'EIP-3009 AuthorizationUsed(authorizer, nonce)',
685
+ eventTopic0: EIP3009_AUTHORIZATION_USED_TOPIC0,
686
+ note: '自证 = 买方把订单身份编进 EIP-3009 nonce, 与付款在同一笔交易里; 没有该事件 / 哈希对不上本店 item ⇒ 如实降级为「直转(无订单标识)」',
687
+ },
688
+ };
689
+ }