@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,613 @@
1
+ /**
2
+ * address-binding.ts — **地址 ↔ DID 绑定登记** (协议 `diap-address-binding/1`)
3
+ * =========================================================================
4
+ * 目的 (leo 2026-09-29 拍板): 让网关能把链上付款行里的**付款方地址**翻成**智能体身份** ——
5
+ * 链上只有 `Transfer(from, to, value)`, `from` 是一个裸 EOA; 要让读者看出「这笔是谁付的」,
6
+ * 需要一个**可离线验签**的登记, 而不是让页面去猜名字。
7
+ *
8
+ * 身份不另造一套: DID / 公钥 / 私钥全部走仓内既有的 DIAP 身份
9
+ * (`~/.bolloon/identity.json`, `KeyManager.fromFile`, 与 `src/agents/agent-identity.ts` 同源);
10
+ * 签名复用 `ed25519Sign`/`ed25519Verify` + `canonicalize`(`src/agents/x402/paid-info-protocol.ts`)。
11
+ *
12
+ * ── 双侧证明 (不可协商; 少一侧 = 不作数) ─────────────────────────────────
13
+ * ① `sig_did` : DID 私钥 (Ed25519, raw 32 字节种子) 对**声明正文字节串**签名
14
+ * ⇒ 证明「这个 DID 认领这个地址」
15
+ * ② `sig_addr`: 该以太地址私钥 (EIP-191 personal_sign) 对**同一份字节串**签名
16
+ * ⇒ 证明「这个地址的主人同意被这样认领」
17
+ * 验证 = `ed25519Verify(did_public_key, statement_json, sig_did)`
18
+ * **且** `ethers.verifyMessage(statement_json, sig_addr) === statement.address`
19
+ * 任何一侧缺失/不通过 ⇒ **拒**。**不许**静默降级成"可信"/"已验签"。
20
+ *
21
+ * ── 声明正文 (冻结, canonical = 键按 sort_keys 排序 + 无空格, UTF-8) ──────
22
+ * {"protocol":"diap-address-binding/1","did":"did:key:z6Mk…","address":"0x…全小写",
23
+ * "chainId":8453,"issuedAt":"<ISO8601>","expiresAt":null,"nonce":"<32 位小写 hex = 16 字节, 一次性>"}
24
+ * 签名/验签都对 `canonicalize(statement)` 的**字节串**做 (`statement_json` 落盘保存,
25
+ * 验证时**重算**并与落盘值逐字节比对 —— 不是 canonical 的那份直接拒)。
26
+ *
27
+ * ── 名字 (label) 的覆盖方式 (刻意收紧, 不改冻结正文) ─────────────────────
28
+ * 冻结正文里**没有** label 字段 (它的 7 个键就是签名对象)。名字若只写在记录里, 就能被
29
+ * 单独改掉而两个签名照旧通过 —— 那就等于"出名字但没验名字"。做法: label 另有一枚
30
+ * `label_sig`(DID 私钥对 `canonicalize({...statement, label})` 签名);
31
+ * **有 label 就必须验 label_sig**, 验不过 ⇒ 整条绑定拒 (不是"名字不显示"这么轻)。
32
+ * 没有 label 的绑定照旧只要两枚签名。
33
+ *
34
+ * ── 隐私口径 (硬) ────────────────────────────────────────────────────────
35
+ * · 绑定是**链下登记** (签名声明), **不是链上事实** ⇒ 对外一律写成「链下登记(可离线验签)」,
36
+ * 不与块号/tx_hash 混为一类。
37
+ * · 只有**已验签**的绑定才产出对外短写; 未验签/验不过 ⇒ **不出名字、不出 DID**。
38
+ * · 对外短写 = `name_short`(清洗后的 label, ≤24 字符) + `did_short`
39
+ * (`did:key:` 之后前 12 字符 —— **不带 `did:` 前缀**, 页面上不许出现 DID 形态)。
40
+ * · **地址本身不出对外面** (页面/快照的传统口径); `publish` 投影里也只有
41
+ * `address_hash`(sha256 前 16 位) —— 网关是**本机**按地址匹配, 不需要把 EOA 发出去。
42
+ *
43
+ * 本模块**不碰链、不发交易、不读钱包目录**: 私钥一律由调用方以**显式路径**喂进来
44
+ * (CLI 参数), 且只在本进程内用于签名, 不进日志/不进绑定文件。
45
+ */
46
+ import * as fs from 'fs';
47
+ import * as path from 'path';
48
+ import * as os from 'os';
49
+ import * as crypto from 'crypto';
50
+ import { Wallet, verifyMessage } from 'ethers';
51
+ import { canonicalize, ed25519Sign, ed25519Verify, sha256Hex } from '../x402/paid-info-protocol.js';
52
+ // ─────────────────────────────────────────────────────────── 常量 (唯一实现)
53
+ /** 协议名 (冻结; 出现在声明正文 `protocol` 与对外行的 `method`) */
54
+ export const ADDRESS_BINDING_PROTOCOL = 'diap-address-binding/1';
55
+ /** 绑定文件 schema */
56
+ export const ADDRESS_BINDING_SCHEMA_VERSION = 1;
57
+ /** 对外行的来源标注: **链下登记**(签名声明), 绝不是链上事实 */
58
+ export const ADDRESS_BINDING_SOURCE = 'off-chain-signed';
59
+ /** 默认链 (Base 主网; 声明里显式记 chainId, 缺省只用于 CLI 便利值) */
60
+ export const DEFAULT_BINDING_CHAIN_ID = 8453;
61
+ /** nonce = 16 字节 (32 位小写 hex), 一次性 */
62
+ export const BINDING_NONCE_BYTES = 16;
63
+ /** 名字短写上限 (字符) */
64
+ export const NAME_SHORT_MAX = 24;
65
+ /** DID 短写长度 (取 `did:key:` 之后的前 N 个字符) */
66
+ export const DID_SHORT_LEN = 12;
67
+ /** 绑定库目录名 (相对 BOLLOON home) */
68
+ export const BINDINGS_DIRNAME = 'bindings';
69
+ /** 聚合索引文件名 */
70
+ export const BINDING_INDEX_FILENAME = 'index.json';
71
+ export const BINDING_ADDRESS_RE = /^0x[0-9a-f]{40}$/;
72
+ export const BINDING_DID_RE = /^did:key:z[1-9A-HJ-NP-Za-km-z]{20,}$/;
73
+ export const BINDING_NONCE_RE = /^[0-9a-f]{32}$/;
74
+ export const BINDING_SIG_B64_RE = /^[A-Za-z0-9+/]{80,}={0,2}$/;
75
+ export const BINDING_SIG_HEX_RE = /^0x[0-9a-fA-F]{130}$/;
76
+ export const BINDING_PUBKEY_HEX_RE = /^[0-9a-f]{64}$/;
77
+ /** 名字短写允许的字符 (字母/数字/中日文/下划线/点/短横; **不含空格与 0x / did: 之类形态**) */
78
+ export const NAME_SHORT_RE = /^[A-Za-z0-9._\-\u4e00-\u9fff]{1,24}$/;
79
+ /** did_short 形状 (multibase base58btc 的 ed25519 前缀 z6Mk…; 不含 `did:` 前缀) */
80
+ export const DID_SHORT_RE = /^[1-9A-HJ-NP-Za-km-z]{4,16}$/;
81
+ export function bolloonHome(home) {
82
+ return home || process.env.BOLLOON_HOME || process.env.HOME || os.homedir();
83
+ }
84
+ export function bindingsDir(home) {
85
+ return path.join(bolloonHome(home), '.bolloon', BINDINGS_DIRNAME);
86
+ }
87
+ export function bindingIndexPath(home) {
88
+ return path.join(bindingsDir(home), BINDING_INDEX_FILENAME);
89
+ }
90
+ // ─────────────────────────────────────────────────────────── 短写 (唯一实现)
91
+ /**
92
+ * 名字短写: 清洗 label → `[A-Za-z0-9._-]` + 中日文, 其余字符折成 `_`, 截到 24 字符。
93
+ * **绝不**产出 `0x…` / `did:…` / `/` / 空格 —— 页面对这些形态有硬门。
94
+ */
95
+ export function shortAgentName(label, fallback) {
96
+ const raw = String(label ?? '').trim();
97
+ const cleaned = raw
98
+ .replace(/[^A-Za-z0-9._\-\u4e00-\u9fff]+/g, '_')
99
+ .replace(/^[._-]+/, '')
100
+ .replace(/[._-]+$/, '');
101
+ if (cleaned && NAME_SHORT_RE.test(cleaned.slice(0, NAME_SHORT_MAX)))
102
+ return cleaned.slice(0, NAME_SHORT_MAX);
103
+ const fb = String(fallback ?? '').trim();
104
+ if (fb && NAME_SHORT_RE.test(fb.slice(0, NAME_SHORT_MAX)))
105
+ return fb.slice(0, NAME_SHORT_MAX);
106
+ return 'agent';
107
+ }
108
+ /**
109
+ * DID 短写: 取 `did:key:` **之后**的前 12 字符。
110
+ * 为什么去掉前缀: `did:key:` 本身就是**被隐私门禁止的形态** (页面/快照里不许出现 DID);
111
+ * 短写只用来让人眼分辨不同智能体, 不需要方法前缀。
112
+ */
113
+ export function didShort(did) {
114
+ const s = String(did || '').trim();
115
+ const body = s.startsWith('did:key:') ? s.slice('did:key:'.length) : s;
116
+ return body.slice(0, DID_SHORT_LEN);
117
+ }
118
+ /** 地址哈希 (对外投影里的地址指代; sha256(小写地址) 前 16 位) */
119
+ export function addressHash(address) {
120
+ return `sha256:${sha256Hex(String(address || '').toLowerCase()).slice(0, 16)}`;
121
+ }
122
+ /**
123
+ * base58btc 编码 (本文件自用, **不引第三方包** —— 免得依赖一个只作为传递依赖存在的编码器)。
124
+ * did:key 的形状 = `did:key:z` + base58btc(0xed 0x01 ‖ 32 字节 Ed25519 公钥)。
125
+ */
126
+ const B58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
127
+ function base58btc(bytes) {
128
+ let n = 0n;
129
+ for (const b of bytes)
130
+ n = n * 256n + BigInt(b);
131
+ let out = '';
132
+ while (n > 0n) {
133
+ out = B58_ALPHABET[Number(n % 58n)] + out;
134
+ n /= 58n;
135
+ }
136
+ for (const b of bytes) {
137
+ if (b === 0)
138
+ out = '1' + out;
139
+ else
140
+ break;
141
+ }
142
+ return out;
143
+ }
144
+ /** Ed25519 公钥 (hex) → `did:key:z…`; 用来核对声明里的 did 与 `did_public_key` 是否同一把钥匙 */
145
+ export function didFromEd25519PublicKey(publicKeyHex) {
146
+ const hex = String(publicKeyHex || '').trim().toLowerCase();
147
+ if (!BINDING_PUBKEY_HEX_RE.test(hex))
148
+ throw new Error(`Ed25519 公钥必须是 64 位小写 hex, 实得 ${String(publicKeyHex || '').slice(0, 20)}`);
149
+ const raw = Buffer.from(hex, 'hex');
150
+ return `did:key:z${base58btc(Buffer.concat([Buffer.from([0xed, 0x01]), raw]))}`;
151
+ }
152
+ function assertStatement(stmt) {
153
+ if (!stmt || typeof stmt !== 'object')
154
+ throw new Error('绑定声明缺失');
155
+ if (stmt.protocol !== ADDRESS_BINDING_PROTOCOL)
156
+ throw new Error(`protocol 必须是 ${ADDRESS_BINDING_PROTOCOL}, 实得 ${stmt.protocol}`);
157
+ if (!BINDING_DID_RE.test(String(stmt.did || '')))
158
+ throw new Error(`did 形状非法 (要 did:key:z…): ${String(stmt.did || '').slice(0, 40)}`);
159
+ if (!BINDING_ADDRESS_RE.test(String(stmt.address || '')))
160
+ throw new Error(`address 必须是**全小写** 0x+40 位: ${String(stmt.address || '')}`);
161
+ if (!Number.isInteger(stmt.chainId) || stmt.chainId <= 0)
162
+ throw new Error(`chainId 必须是正整数, 实得 ${stmt.chainId}`);
163
+ if (typeof stmt.issuedAt !== 'string' || !stmt.issuedAt)
164
+ throw new Error('issuedAt 必须是 ISO8601 字符串');
165
+ if (stmt.expiresAt !== null && (typeof stmt.expiresAt !== 'string' || !stmt.expiresAt)) {
166
+ throw new Error('expiresAt 必须是 ISO8601 字符串或 null');
167
+ }
168
+ if (!BINDING_NONCE_RE.test(String(stmt.nonce || ''))) {
169
+ throw new Error(`nonce 必须是 ${BINDING_NONCE_BYTES * 2} 位小写 hex (${BINDING_NONCE_BYTES} 字节), 实得 ${String(stmt.nonce || '')}`);
170
+ }
171
+ }
172
+ /** 生成一枚一次性 nonce (16 字节 → 32 位小写 hex) */
173
+ export function newBindingNonce() {
174
+ return crypto.randomBytes(BINDING_NONCE_BYTES).toString('hex');
175
+ }
176
+ /** 规范化一条声明正文 (地址强制小写 + 形状校验); 缺省补齐 protocol/chainId/issuedAt/nonce */
177
+ export function buildBindingStatement(input) {
178
+ const stmt = {
179
+ protocol: ADDRESS_BINDING_PROTOCOL,
180
+ did: String(input.did || '').trim(),
181
+ address: String(input.address || '').trim().toLowerCase(),
182
+ chainId: Number(input.chainId ?? DEFAULT_BINDING_CHAIN_ID),
183
+ issuedAt: String(input.issuedAt || new Date().toISOString()),
184
+ expiresAt: input.expiresAt == null ? null : String(input.expiresAt),
185
+ nonce: String(input.nonce || newBindingNonce()).toLowerCase(),
186
+ };
187
+ assertStatement(stmt);
188
+ return stmt;
189
+ }
190
+ /** canonical 序列化 (键 sort_keys + 无空格) —— **签名与验签的唯一对象** */
191
+ export function canonicalBindingStatement(stmt) {
192
+ return canonicalize(stmt);
193
+ }
194
+ /** 绑定 id = `ab-` + sha256(字节串) 前 16 位 (由正文派生, 改一个字节就变) */
195
+ export function addressBindingId(statementJson) {
196
+ return `ab-${sha256Hex(statementJson).slice(0, 16)}`;
197
+ }
198
+ /** `canonicalize({...statement, label})` —— label_sig 的签名对象 */
199
+ export function canonicalBindingLabeled(stmt, label) {
200
+ return canonicalize({ ...stmt, label: String(label) });
201
+ }
202
+ function normalizeHex32(hex, what) {
203
+ const s = String(hex || '').trim().replace(/^0x/, '');
204
+ if (!/^[0-9a-fA-F]{64}$/.test(s))
205
+ throw new Error(`${what} 必须是 32 字节 hex (64 位), 实得 ${String(hex || '').length} 字符`);
206
+ return Uint8Array.from(Buffer.from(s, 'hex'));
207
+ }
208
+ /**
209
+ * 造一份绑定 (双侧签名 + **立刻自验**)。
210
+ * · DID 侧: `ed25519Sign(didKey, statement_json)` → `sig_did`
211
+ * · 地址侧: `new Wallet(addressKey).signMessage(statement_json)` → `sig_addr` (EIP-191)
212
+ * · 有 label: 再加一枚 `label_sig`(DID 私钥对 `canonicalize({...statement, label})`)
213
+ * **自验不通过就抛错** (绝不落盘一份自己都验不过的绑定)。
214
+ */
215
+ export async function createAddressBinding(input) {
216
+ const didSeed = normalizeHex32(input.didPrivateKeyHex, 'DID 私钥');
217
+ const pub = String(input.didPublicKeyHex || '').trim().toLowerCase().replace(/^0x/, '');
218
+ if (!BINDING_PUBKEY_HEX_RE.test(pub))
219
+ throw new Error(`DID 公钥必须是 64 位 hex, 实得 ${String(input.didPublicKeyHex || '').slice(0, 20)}`);
220
+ const statement = buildBindingStatement({
221
+ did: input.did,
222
+ address: input.address,
223
+ chainId: input.chainId,
224
+ issuedAt: input.issuedAt,
225
+ expiresAt: input.expiresAt,
226
+ nonce: input.nonce,
227
+ });
228
+ const derived = didFromEd25519PublicKey(pub);
229
+ if (derived !== statement.did)
230
+ throw new Error(`DID 与公钥不自洽 (公钥派生出 ${derived.slice(0, 24)}…, 声明写的是 ${statement.did.slice(0, 24)}…)`);
231
+ const wallet = new Wallet(String(input.addressPrivateKeyHex || '').trim());
232
+ if (wallet.address.toLowerCase() !== statement.address) {
233
+ throw new Error(`地址私钥与地址不对应 (钱包 ${wallet.address} ≠ 声明 ${statement.address})`);
234
+ }
235
+ const statementJson = canonicalBindingStatement(statement);
236
+ const sigDid = await ed25519Sign(didSeed, statementJson);
237
+ const sigAddr = await wallet.signMessage(statementJson);
238
+ const label = input.label == null || String(input.label).trim() === '' ? undefined : String(input.label).trim();
239
+ const labelSig = label ? await ed25519Sign(didSeed, canonicalBindingLabeled(statement, label)) : undefined;
240
+ const record = {
241
+ schemaVersion: ADDRESS_BINDING_SCHEMA_VERSION,
242
+ protocol: ADDRESS_BINDING_PROTOCOL,
243
+ id: addressBindingId(statementJson),
244
+ statement,
245
+ statement_json: statementJson,
246
+ did_public_key: pub,
247
+ sig_did: sigDid,
248
+ sig_addr: sigAddr,
249
+ ...(label ? { label } : {}),
250
+ ...(labelSig ? { label_sig: labelSig } : {}),
251
+ verified_at: new Date().toISOString(),
252
+ created_by: input.createdBy || 'bolloon identity bind-address',
253
+ };
254
+ const verdict = await verifyAddressBinding(record);
255
+ if (!verdict.ok) {
256
+ throw new Error(`自验不通过 (拒落盘): ${verdict.reasons.join(' | ')}`);
257
+ }
258
+ record.verified_checks = verdict.checks.filter((c) => c.ok).map((c) => c.name);
259
+ return record;
260
+ }
261
+ /**
262
+ * 验一条绑定 (**唯一验签实现**; 只收紧不放松)。
263
+ * 判据逐条列在 `checks` 里; 任一条不通过 ⇒ `ok=false` 且 `reasons` 非空。
264
+ * 顺序按"先形状/自洽, 后两枚签名, 再时效/重放"排 —— 但**全部都会跑** (不是短路),
265
+ * 这样"缺一侧签名"与"两侧都错"在输出里能分得开。
266
+ */
267
+ export async function verifyAddressBinding(record, opts = {}) {
268
+ const checks = [];
269
+ const reasons = [];
270
+ const add = (name, ok, detail) => { checks.push({ name, ok, detail }); if (!ok)
271
+ reasons.push(name); };
272
+ const rec = record;
273
+ if (!rec || typeof rec !== 'object') {
274
+ add('record_present', false, '记录缺失/不是对象');
275
+ return { ok: false, reasons, checks, id: '', sig_did_ok: false, sig_addr_ok: false, label_covered: null, address: null, did: null };
276
+ }
277
+ add('protocol', rec.protocol === ADDRESS_BINDING_PROTOCOL, `protocol=${String(rec.protocol)} (要 ${ADDRESS_BINDING_PROTOCOL})`);
278
+ const stmt = rec.statement;
279
+ const stmtOk = !!stmt && typeof stmt === 'object';
280
+ add('statement_present', stmtOk, stmtOk ? '声明正文在' : '缺 statement');
281
+ if (!stmtOk) {
282
+ return { ok: false, reasons, checks, id: String(rec.id || ''), sig_did_ok: false, sig_addr_ok: false, label_covered: null, address: null, did: null };
283
+ }
284
+ // ① 形状 / 自洽 (逐条真判)
285
+ const shape = (name, ok, detail) => { add(name, ok, detail); };
286
+ shape('statement_protocol', stmt.protocol === ADDRESS_BINDING_PROTOCOL, `statement.protocol=${String(stmt.protocol)}`);
287
+ shape('statement_did_shape', BINDING_DID_RE.test(String(stmt.did || '')), `did=${String(stmt.did || '').slice(0, 32)}…`);
288
+ shape('statement_address_shape', BINDING_ADDRESS_RE.test(String(stmt.address || '')), `address=${String(stmt.address || '')} (必须是全小写 0x+40)`);
289
+ shape('statement_chain_id', Number.isInteger(stmt.chainId) && stmt.chainId > 0, `chainId=${String(stmt.chainId)}`);
290
+ shape('statement_nonce_shape', BINDING_NONCE_RE.test(String(stmt.nonce || '')), `nonce=${String(stmt.nonce || '').slice(0, 12)}… (要 32 位小写 hex)`);
291
+ shape('did_public_key_shape', BINDING_PUBKEY_HEX_RE.test(String(rec.did_public_key || '').toLowerCase()), `did_public_key=${String(rec.did_public_key || '').slice(0, 16)}…`);
292
+ // did 必须是 did_public_key 派生出来的那条 (否则"这个 DID 认领"这句话落在一把不存在的钥匙上)
293
+ let derivedDid = '';
294
+ try {
295
+ derivedDid = didFromEd25519PublicKey(String(rec.did_public_key || ''));
296
+ }
297
+ catch {
298
+ derivedDid = '';
299
+ }
300
+ shape('did_matches_public_key', !!derivedDid && derivedDid === stmt.did, derivedDid ? `派生 ${derivedDid.slice(0, 28)}… vs 声明 ${String(stmt.did).slice(0, 28)}…` : '公钥形状不对, 派生不出 did');
301
+ // ② canonical 序列化 (**拒**非 canonical 的那份: 重算必须与落盘字节串逐字节相同)
302
+ const recomputed = canonicalBindingStatement(stmt);
303
+ const jsonOk = typeof rec.statement_json === 'string' && rec.statement_json === recomputed;
304
+ add('statement_canonical', jsonOk, jsonOk
305
+ ? '落盘字节串 == canonicalize(statement) (键排序 + 无空格)'
306
+ : `落盘字节串与 canonical 重算**不一致** (落盘 ${String(rec.statement_json || '').length} 字符 / 重算 ${recomputed.length} 字符) —— 非 canonical 版本一律拒`);
307
+ // id 也必须由这份字节串派生 (改一个字节就变 ⇒ 也能抓"改了正文没改 id")
308
+ const expectId = addressBindingId(recomputed);
309
+ add('id_matches_statement', String(rec.id || '') === expectId, `id=${String(rec.id)} vs 派生 ${expectId}`);
310
+ const statementJson = recomputed; // 签名一律对**重算**的 canonical 字节串验 (与落盘值已比对相同)
311
+ // ③ 双侧签名 (分别报结论; 两侧都验, 不短路)
312
+ const sigDidShapeOk = BINDING_SIG_B64_RE.test(String(rec.sig_did || ''));
313
+ const sigDidOk = sigDidShapeOk && await ed25519Verify(String(rec.did_public_key || '').toLowerCase(), statementJson, String(rec.sig_did || ''));
314
+ add('sig_did', sigDidOk, sigDidOk ? 'ed25519Verify(DID 公钥, statement_json, sig_did) = true' : (sigDidShapeOk ? 'ed25519Verify = false (声明正文/DID 公钥/签名 三者不自洽)' : 'sig_did 形状不对 (base64)'));
315
+ const addr = String(stmt.address || '').toLowerCase();
316
+ let recovered = '';
317
+ try {
318
+ recovered = String(verifyMessage(statementJson, String(rec.sig_addr || ''))).toLowerCase();
319
+ }
320
+ catch {
321
+ recovered = '';
322
+ }
323
+ const sigAddrOk = BINDING_SIG_HEX_RE.test(String(rec.sig_addr || '')) && recovered === addr && !!addr;
324
+ add('sig_addr', sigAddrOk, sigAddrOk
325
+ ? `verifyMessage(statement_json, sig_addr) = ${addr} == statement.address`
326
+ : `恢复出 ${recovered || '(验签失败)'} ≠ statement.address ${addr}`);
327
+ // ④ label (名字) 必须被覆盖: 有 label ⇒ 必须有有效的 label_sig
328
+ const hasLabel = rec.label != null && String(rec.label).trim() !== '';
329
+ let labelCovered = null;
330
+ if (hasLabel) {
331
+ const sig = String(rec.label_sig || '');
332
+ const ok = BINDING_SIG_B64_RE.test(sig) && await ed25519Verify(String(rec.did_public_key || '').toLowerCase(), canonicalBindingLabeled(stmt, String(rec.label)), sig);
333
+ labelCovered = ok;
334
+ add('label_covered', ok, ok ? 'label_sig(DID 私钥) 覆盖 canonicalize({...statement, label})' : '有 label 但 label_sig 缺失/验不过 —— **名字没被签名覆盖**, 整条绑定拒');
335
+ add('label_shape', NAME_SHORT_RE.test(shortAgentName(String(rec.label))), `name_short=${shortAgentName(String(rec.label))}`);
336
+ }
337
+ else {
338
+ add('label_absent', true, '无 label (照旧只要两枚签名)');
339
+ }
340
+ // ⑤ 时效
341
+ const now = Number.isFinite(Number(opts.now)) ? Number(opts.now) : Date.now();
342
+ if (stmt.expiresAt != null) {
343
+ const t = Date.parse(String(stmt.expiresAt));
344
+ const ok = Number.isFinite(t) && t > now;
345
+ add('not_expired', ok, ok ? `expiresAt=${stmt.expiresAt} 未过期` : `expiresAt=${String(stmt.expiresAt)} 已过期/解析不了 (now=${new Date(now).toISOString()})`);
346
+ }
347
+ else {
348
+ add('not_expired', true, 'expiresAt=null (不过期)');
349
+ }
350
+ // ⑥ nonce 一次性 (给了索引才判; 没给就如实说明这一条没跑)
351
+ const nonces = opts.nonces && typeof opts.nonces === 'object' ? opts.nonces : null;
352
+ if (nonces) {
353
+ const owner = nonces[String(stmt.nonce)];
354
+ const ok = owner == null || owner === String(rec.id || '');
355
+ add('nonce_unused', ok, ok
356
+ ? (owner ? 'nonce 在本机索引里且就是本条 (重验)' : 'nonce 未在本机索引里出现过')
357
+ : `nonce 已被另一条绑定用掉 (owner=${owner}) —— 重放`);
358
+ }
359
+ else {
360
+ add('nonce_unused', true, '调用方没给 nonce 索引 ⇒ 这一条**没跑** (调用方要知道)');
361
+ }
362
+ void shape; // (形状判据的结论已逐条进 checks/reasons; 这里不需要额外标志位)
363
+ const ok = reasons.length === 0;
364
+ return {
365
+ ok,
366
+ reasons,
367
+ checks,
368
+ id: String(rec.id || ''),
369
+ sig_did_ok: sigDidOk,
370
+ sig_addr_ok: sigAddrOk,
371
+ label_covered: labelCovered,
372
+ address: addr || null,
373
+ did: String(stmt.did || '') || null,
374
+ };
375
+ }
376
+ /** 一条已验签的绑定 → 对外短写 (未验签的**不许**走这里; 调用方负责先验) */
377
+ export function payerIdentityOf(record) {
378
+ const name = shortAgentName(record.label, didShort(record.statement.did));
379
+ return {
380
+ name_short: name,
381
+ did_short: didShort(record.statement.did),
382
+ verified: true,
383
+ method: ADDRESS_BINDING_PROTOCOL,
384
+ source: ADDRESS_BINDING_SOURCE,
385
+ };
386
+ }
387
+ // ─────────────────────────────────────────────────────────── 落盘 / 读取
388
+ /** 原子写 + 0600 (绑定只在本机; 里面**没有**私钥, 但仍然是私有事实) */
389
+ function writeJsonAtomic(file, value) {
390
+ fs.mkdirSync(path.dirname(file), { recursive: true });
391
+ const tmp = `${file}.tmp-${process.pid}`;
392
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 });
393
+ fs.renameSync(tmp, file);
394
+ try {
395
+ fs.chmodSync(file, 0o600);
396
+ }
397
+ catch { /* 平台不支持就跳过 */ }
398
+ }
399
+ export function bindingFileName(id) {
400
+ return `${id}.json`;
401
+ }
402
+ /**
403
+ * 写一份绑定 + 重建索引 (**唯一写入口**)。
404
+ * 不验签的绑定**不写**(调用方应先 `verifyAddressBinding`; 这里再兜一层)。
405
+ */
406
+ export async function writeBindingRecord(record, opts = {}) {
407
+ const dir = opts.dir || bindingsDir();
408
+ const file = path.join(dir, bindingFileName(record.id));
409
+ if (fs.existsSync(file))
410
+ throw new Error(`绑定已存在 (同 id): ${file} —— 改一个字节会换 id, 同 id 说明正文一字未变`);
411
+ writeJsonAtomic(file, record);
412
+ const index = await rebuildBindingIndex({ dir });
413
+ return { file, index };
414
+ }
415
+ export function readBindingRecord(file) {
416
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
417
+ if (!raw || typeof raw !== 'object')
418
+ throw new Error(`绑定文件读不出对象: ${file}`);
419
+ return raw;
420
+ }
421
+ /** 扫目录重建聚合索引 (每份文件真读一遍; 坏文件**记账不吞**) */
422
+ export async function rebuildBindingIndex(opts = {}) {
423
+ const dir = opts.dir || bindingsDir();
424
+ const files = fs.existsSync(dir)
425
+ ? fs.readdirSync(dir).filter((f) => f.startsWith('ab-') && f.endsWith('.json')).sort()
426
+ : [];
427
+ const bindings = [];
428
+ const nonces = {};
429
+ for (const f of files) {
430
+ let rec;
431
+ try {
432
+ rec = readBindingRecord(path.join(dir, f));
433
+ }
434
+ catch {
435
+ continue;
436
+ }
437
+ const id = String(rec.id || f.replace(/\.json$/, ''));
438
+ bindings.push({
439
+ id,
440
+ address: String(rec.statement?.address || '').toLowerCase(),
441
+ did: String(rec.statement?.did || ''),
442
+ did_short: didShort(String(rec.statement?.did || '')),
443
+ name_short: shortAgentName(rec.label, didShort(String(rec.statement?.did || ''))),
444
+ chainId: Number(rec.statement?.chainId) || 0,
445
+ issuedAt: String(rec.statement?.issuedAt || ''),
446
+ expiresAt: rec.statement?.expiresAt == null ? null : String(rec.statement.expiresAt),
447
+ verified_at: String(rec.verified_at || ''),
448
+ file: f,
449
+ });
450
+ const n = String(rec.statement?.nonce || '');
451
+ if (n)
452
+ nonces[n] = id;
453
+ }
454
+ const index = {
455
+ schemaVersion: ADDRESS_BINDING_SCHEMA_VERSION,
456
+ protocol: ADDRESS_BINDING_PROTOCOL,
457
+ updatedAt: Number(opts.now ?? Date.now()),
458
+ dir,
459
+ bindings: bindings.sort((a, b) => a.id.localeCompare(b.id)),
460
+ nonces,
461
+ };
462
+ writeJsonAtomic(path.join(dir, BINDING_INDEX_FILENAME), index);
463
+ return index;
464
+ }
465
+ /** 读聚合索引 (读不出 → 现扫一遍目录重建) */
466
+ export function readBindingIndex(opts = {}) {
467
+ const dir = opts.dir || bindingsDir();
468
+ const p = path.join(dir, BINDING_INDEX_FILENAME);
469
+ try {
470
+ const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
471
+ if (raw && typeof raw === 'object' && Array.isArray(raw.bindings) && raw.nonces && typeof raw.nonces === 'object') {
472
+ return raw;
473
+ }
474
+ }
475
+ catch { /* 落回重建 */ }
476
+ return null;
477
+ }
478
+ // ─────────────────────────────────────────────────────────── 快照用的加载口
479
+ /**
480
+ * 加载「付款方地址 → 短写身份」表 (**给快照构建用**)。
481
+ * · 索引里**每一条都重验一遍** (不是信 `verified_at` 这个时间戳): 重验不过的一条都不进表;
482
+ * · 只有已验签的绑定才产出 `name_short` / `did_short` (**硬闸**: 未验签 = 不出名字不出 DID);
483
+ * · 只读本机目录, **不发网络**; 目录不存在 → 空表 + reason (不猜)。
484
+ */
485
+ export async function loadPayerIdentityIndex(opts = {}) {
486
+ const dir = opts.dir || bindingsDir(opts.home);
487
+ const label = { zh: '链下登记(可离线验签)', en: 'off-chain registration (verifiable offline)' };
488
+ const note = {
489
+ zh: '付款方智能体名来自**链下登记**(地址↔DID 双侧签名的声明), 不是链上事实; 每一条都由本机在加载时重验, 未验签的一律不出名字与 DID; 地址本身不出公开面',
490
+ en: 'Payer agent names come from an off-chain registration (address↔DID statement signed by BOTH keys), not an on-chain fact; every entry is re-verified on load and unverified ones contribute neither name nor DID; the address itself is never published',
491
+ };
492
+ const base = {
493
+ loaded: 0, verified: 0, rejected: [], byAddress: {}, dir, label, note,
494
+ reason: '',
495
+ };
496
+ let index = null;
497
+ try {
498
+ index = readBindingIndex({ dir });
499
+ }
500
+ catch {
501
+ index = null;
502
+ }
503
+ if (!index) {
504
+ base.reason = `本机绑定库不可读/未建 (~/.bolloon/${BINDINGS_DIRNAME}/index.json) → 付款方身份不出 (不是"没有智能体付款")`;
505
+ return base;
506
+ }
507
+ const nonces = index.nonces || {};
508
+ void nonces; // 见下: nonce 唯一性只看**验签通过**的那些 (免被垃圾文件污染)
509
+ const files = index.bindings.map((b) => b.file).filter(Boolean);
510
+ const byFileName = new Map(index.bindings.map((b) => [b.file, b]));
511
+ base.loaded = files.length;
512
+ /** 第一轮: 逐条重验 (只做"这条声明自身成不成立", 不掺别的文件) */
513
+ const prelim = [];
514
+ /** 同一地址可能有多条 (换 DID 重登记) → 取 **issuedAt 最新** 的那条; 平手时取 id 小的 (确定性) */
515
+ const chosen = {};
516
+ for (const f of files) {
517
+ const file = path.join(dir, f);
518
+ let rec;
519
+ try {
520
+ rec = readBindingRecord(file);
521
+ }
522
+ catch (e) {
523
+ base.rejected.push({ id: f.replace(/\.json$/, ''), reasons: [`读不出: ${String(e?.message || e).slice(0, 80)}`] });
524
+ continue;
525
+ }
526
+ const verdict = await verifyAddressBinding(rec, { now: opts.now });
527
+ if (!verdict.ok || !verdict.address) {
528
+ base.rejected.push({ id: verdict.id || f, reasons: verdict.reasons });
529
+ continue;
530
+ }
531
+ prelim.push({ file: f, rec, id: String(verdict.id), address: verdict.address.toLowerCase() });
532
+ }
533
+ /**
534
+ * 第二轮: nonce **一次性** —— 只在验签通过的记录之间判。
535
+ * 为什么不在第一轮就喂索引里的 nonces: 索引是扫目录来的, 一条垃圾文件(甚至一行手写 JSON)
536
+ * 就能把某个 nonce 占掉, 从而把**真绑定**顶成"重放"。现在改成"先各自验自己, 再看重复":
537
+ * 同一枚 nonce 出现两次 ⇒ **两条都拒** (分不清谁是先来的, 就不猜 —— fail-closed)。
538
+ */
539
+ const owners = new Map();
540
+ for (const p of prelim) {
541
+ const n = String(p.rec.statement.nonce || '');
542
+ owners.set(n, [...(owners.get(n) || []), p.id]);
543
+ }
544
+ const replayed = new Set([...owners.values()].filter((ids) => ids.length > 1).flat());
545
+ for (const p of prelim) {
546
+ if (replayed.has(p.id)) {
547
+ base.rejected.push({ id: p.id, reasons: ['nonce_unused'], });
548
+ continue;
549
+ }
550
+ // 同一地址有多条 (换 DID 重登记) → 取 issuedAt 最新的那条 (平手取 id 小的, 确定性)
551
+ const issuedAt = String(byFileName.get(p.file)?.issuedAt || p.rec.statement.issuedAt || '');
552
+ const prev = chosen[p.address];
553
+ base.verified++;
554
+ if (prev) {
555
+ const newer = issuedAt > prev.issuedAt || (issuedAt === prev.issuedAt && p.id < prev.id);
556
+ if (!newer)
557
+ continue;
558
+ }
559
+ chosen[p.address] = { issuedAt, id: p.id };
560
+ base.byAddress[p.address] = payerIdentityOf(p.rec);
561
+ }
562
+ base.reason = base.loaded === 0
563
+ ? '本机绑定库为空 (还没有地址↔DID 登记)'
564
+ : `重验 ${base.loaded} 条 → 通过 ${base.verified} 条${base.rejected.length ? ` · 拒 ${base.rejected.length} 条` : ''}`;
565
+ return base;
566
+ }
567
+ /**
568
+ * 对外投影 (**只出短写**): 逐条重验, 只有通过的才进; **地址只以 `address_hash` 出现**
569
+ * (地址本身不出公开面 —— 谁要按地址核,就在本机用 `bindings list` 看)。
570
+ */
571
+ export async function buildPublicBindingProjection(opts = {}) {
572
+ const dir = opts.dir || bindingsDir(opts.home);
573
+ const index = readBindingIndex({ dir }) || { schemaVersion: ADDRESS_BINDING_SCHEMA_VERSION, protocol: ADDRESS_BINDING_PROTOCOL, updatedAt: Date.now(), dir, bindings: [], nonces: {} };
574
+ const out = {
575
+ protocol: ADDRESS_BINDING_PROTOCOL,
576
+ kind: 'off-chain-registration',
577
+ label: { zh: '链下登记(可离线验签)', en: 'off-chain registration (verifiable offline)' },
578
+ note: {
579
+ zh: '每行 = 一条地址↔DID 绑定声明(链下, 不是链上事实), 由 DID 私钥与地址私钥**分别**签名同一份声明正文; 可用 bindings verify 离线复验; 这里只出短写, 地址只以哈希出现',
580
+ en: 'Each row is an off-chain address↔DID binding statement signed by BOTH the DID key and the address key over the same canonical body; re-verify offline with `bindings verify`. Only short forms are published; the address appears only as a hash',
581
+ },
582
+ generated_at: Number(opts.now ?? Date.now()),
583
+ count: 0,
584
+ bindings: [],
585
+ };
586
+ for (const b of index.bindings) {
587
+ let rec;
588
+ try {
589
+ rec = readBindingRecord(path.join(dir, b.file));
590
+ }
591
+ catch {
592
+ continue;
593
+ }
594
+ const verdict = await verifyAddressBinding(rec, { now: opts.now, nonces: index.nonces });
595
+ if (!verdict.ok || !verdict.address)
596
+ continue;
597
+ out.bindings.push({
598
+ id: verdict.id,
599
+ address_hash: addressHash(verdict.address),
600
+ name_short: shortAgentName(rec.label, didShort(String(rec.statement.did))),
601
+ did_short: didShort(String(rec.statement.did)),
602
+ verified: true,
603
+ method: ADDRESS_BINDING_PROTOCOL,
604
+ source: ADDRESS_BINDING_SOURCE,
605
+ chainId: Number(rec.statement.chainId) || 0,
606
+ issuedAt: String(rec.statement.issuedAt || ''),
607
+ expiresAt: rec.statement.expiresAt == null ? null : String(rec.statement.expiresAt),
608
+ });
609
+ }
610
+ out.bindings.sort((a, b) => a.id.localeCompare(b.id));
611
+ out.count = out.bindings.length;
612
+ return out;
613
+ }