@bolloon/bolloon-agent 0.1.21 → 0.1.23

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.
@@ -2016,8 +2016,9 @@ const walletUnbindBtn = document.getElementById('wallet-unbind-btn');
2016
2016
  const walletNewInfo = document.getElementById('wallet-new-info');
2017
2017
  const walletListEl = document.getElementById('wallet-list');
2018
2018
 
2019
- /** 本次会话生成的私钥, 仅用于提示, 永不上传 */
2019
+ /** 本次会话生成的私钥/助记词, 仅用于本地签名, 永不上传 */
2020
2020
  let walletModalPendingSecret = null;
2021
+ let walletModalPendingMnemonic = null;
2021
2022
 
2022
2023
  function openWalletModal() {
2023
2024
  if (!walletModal) return;
@@ -2044,17 +2045,18 @@ function closeWalletModal() {
2044
2045
  if (walletModalClose) walletModalClose.addEventListener('click', closeWalletModal);
2045
2046
 
2046
2047
  if (walletGenerateBtn) {
2047
- walletGenerateBtn.addEventListener('click', () => {
2048
- const { address, privateKeyHex } = generateLocalWallet();
2049
- walletBindAddress.value = address;
2050
- walletModalPendingSecret = privateKeyHex;
2048
+ walletGenerateBtn.addEventListener('click', async () => {
2051
2049
  walletNewInfo.style.display = 'block';
2052
- walletNewInfo.innerHTML = `
2053
- 已生成本地钱包<br>
2054
- <strong>地址:</strong> <code>${escapeHtml(address)}</code><br>
2055
- <strong>私钥 (本次会话, 刷新即丢):</strong> <code style="color:#f88;">${escapeHtml(privateKeyHex)}</code><br>
2056
- <small style="color:#f88;">⚠ 关闭页面后无法找回。仅地址会发送到服务端。</small>
2057
- `;
2050
+ walletNewInfo.innerHTML = '⏳ 正在生成真实 EVM 钱包...';
2051
+ try {
2052
+ const wallet = await generateRealWalletAsync();
2053
+ walletBindAddress.value = wallet.address;
2054
+ walletModalPendingSecret = wallet.privateKey;
2055
+ walletModalPendingMnemonic = wallet.mnemonic;
2056
+ walletNewInfo.innerHTML = formatWalletInfoHtml(wallet);
2057
+ } catch (err) {
2058
+ walletNewInfo.innerHTML = '✗ 生成钱包失败: ' + escapeHtml(err.message);
2059
+ }
2058
2060
  });
2059
2061
  }
2060
2062
 
@@ -2069,12 +2071,40 @@ if (walletBindBtn) {
2069
2071
  alert('请输入钱包地址或点击「生成」');
2070
2072
  return;
2071
2073
  }
2074
+ const ch = channels.find(c => c.id === currentChannelId);
2075
+ const did = ch?.did || '';
2076
+ if (!did || did === 'undefined' || did === 'null') {
2077
+ alert('当前智能体还没有生成 DID, 请稍等几秒后重试');
2078
+ return;
2079
+ }
2080
+
2081
+ // 服务端会用 recoverMessage 校验签名, 因此必须用本会话生成的私钥签名
2082
+ // (已绑过的钱包重新签名也会过, 因为 challenge 里有 channelId + DID)
2083
+ if (!walletModalPendingSecret) {
2084
+ alert('请先在「钱包管理」面板点击「生成」或导入私钥, 临时私钥仅在本会话保留');
2085
+ return;
2086
+ }
2087
+ let challenge;
2072
2088
  try {
2073
- const res = await fetch(`/channels/${currentChannelId}`, {
2074
- method: 'PATCH',
2089
+ challenge = await signDIDChallengeAsync(walletModalPendingSecret, did, currentChannelId);
2090
+ } catch (err) {
2091
+ alert('签名失败: ' + err.message);
2092
+ return;
2093
+ }
2094
+ if (challenge.address.toLowerCase() !== address.toLowerCase()) {
2095
+ alert(`签名地址 ${challenge.address} 与输入地址 ${address} 不一致, 拒绝绑定`);
2096
+ return;
2097
+ }
2098
+
2099
+ try {
2100
+ const res = await fetch(`/channels/${currentChannelId}/bind-wallet`, {
2101
+ method: 'POST',
2075
2102
  headers: { 'Content-Type': 'application/json' },
2076
2103
  body: JSON.stringify({
2077
- walletAddress: address,
2104
+ walletAddress: challenge.address,
2105
+ signature: challenge.signature,
2106
+ message: challenge.message,
2107
+ did: challenge.did,
2078
2108
  autoInvokeTools: !!walletAutoTools.checked
2079
2109
  })
2080
2110
  });
@@ -2088,6 +2118,13 @@ if (walletBindBtn) {
2088
2118
  renderChannels();
2089
2119
  renderWalletList();
2090
2120
  walletModalPendingSecret = null;
2121
+ walletModalPendingMnemonic = null;
2122
+ walletNewInfo.style.display = 'block';
2123
+ walletNewInfo.innerHTML =
2124
+ '✅ 绑定成功<br>' +
2125
+ '<strong>地址:</strong> <code>' + escapeHtml(updated.walletAddress) + '</code><br>' +
2126
+ '<strong>签名 DID:</strong> <code>' + escapeHtml(did) + '</code><br>' +
2127
+ '<small style="color:#9c9;">服务端已用 recoverMessage 校验签名, 证明你持有该钱包私钥。</small>';
2091
2128
  } catch (err) {
2092
2129
  alert('绑定失败: ' + err.message);
2093
2130
  }
@@ -2230,7 +2267,8 @@ const agentAddWalletInfo = document.getElementById('agent-add-wallet-info');
2230
2267
  const agentGenerateWalletBtn = document.getElementById('agent-generate-wallet-btn');
2231
2268
 
2232
2269
  /** 客户端只为提示, 不向服务端发送私钥 */
2233
- let pendingWalletSecret = null;
2270
+ let pendingWalletSecret = null; // 本会话待绑定的私钥, 仅浏览器内存
2271
+ let pendingWalletMnemonic = null; // 本会话待绑定的助记词, 仅浏览器内存
2234
2272
 
2235
2273
  function openAgentAddModal(existingChannel) {
2236
2274
  if (!agentAddModal) return;
@@ -2263,32 +2301,62 @@ function closeAgentAddModal() {
2263
2301
  pendingWalletSecret = null;
2264
2302
  }
2265
2303
 
2266
- /** 本地生成一个 EVM 风格地址 仅用于演示; 生产应使用 ethers/wagmi 等真实库 */
2267
- function generateLocalWallet() {
2268
- const bytes = new Uint8Array(32);
2269
- crypto.getRandomValues(bytes);
2270
- const privateKeyHex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
2271
- // 简化: 取末 20 字节当 address, 不做 keccak, 仅作为占位
2272
- const addrBytes = bytes.slice(12);
2273
- const address = '0x' + Array.from(addrBytes, b => b.toString(16).padStart(2, '0')).join('');
2274
- return { address, privateKeyHex };
2304
+ /** 生成真实 EVM 钱包: BIP-39 助记词 + secp256k1 私钥 + EIP-55 校验和地址
2305
+ * 通过 window.WalletViem (来自 components/wallet-viem.mjs, viem 驱动) 调用.
2306
+ * 失败时降级到旧的演示模式, UI 会提示用户.
2307
+ */
2308
+ async function generateRealWalletAsync() {
2309
+ if (!window.WalletViem) {
2310
+ throw new Error('钱包模块尚未加载, 请稍后重试');
2311
+ }
2312
+ return window.WalletViem.generateRealWallet();
2313
+ }
2314
+
2315
+ async function importRealWalletByPrivateKey(privateKeyHex) {
2316
+ if (!window.WalletViem) {
2317
+ throw new Error('钱包模块尚未加载, 请稍后重试');
2318
+ }
2319
+ return window.WalletViem.importEVMWallet(privateKeyHex);
2320
+ }
2321
+
2322
+ async function signDIDChallengeAsync(privateKeyHex, did, channelId) {
2323
+ if (!window.WalletViem) {
2324
+ throw new Error('钱包模块尚未加载, 请稍后重试');
2325
+ }
2326
+ return window.WalletViem.signDIDChallenge(privateKeyHex, did, channelId);
2327
+ }
2328
+
2329
+ function formatWalletInfoHtml({ address, privateKey, mnemonic }) {
2330
+ const parts = [
2331
+ '✓ 已生成真实 EVM 钱包 (BIP-39 + secp256k1 + EIP-55)',
2332
+ '<strong>地址:</strong> <code>' + escapeHtml(address) + '</code>',
2333
+ ];
2334
+ if (mnemonic) {
2335
+ parts.push(
2336
+ '<strong>助记词 (12 词, 请抄写保存):</strong>',
2337
+ '<code style="color:#fc6;word-break:break-all;">' + escapeHtml(mnemonic) + '</code>'
2338
+ );
2339
+ }
2340
+ parts.push(
2341
+ '<strong>私钥 (0x + 32 字节):</strong>',
2342
+ '<code style="color:#f88;word-break:break-all;">' + escapeHtml(privateKey) + '</code>',
2343
+ '<small style="color:#f88;">⚠ 助记词 + 私钥均仅在本浏览器内存, 关闭页面后无法找回。</small>',
2344
+ '<small style="color:#999;">签名绑定到 channel DID (EIP-191 personal_sign) 会发送到服务端, 用于证明钱包所有权。</small>'
2345
+ );
2346
+ return parts.join('<br>');
2275
2347
  }
2276
2348
 
2277
2349
  if (agentGenerateWalletBtn) {
2278
- agentGenerateWalletBtn.addEventListener('click', () => {
2350
+ agentGenerateWalletBtn.addEventListener('click', async () => {
2351
+ agentAddWalletInfo.style.display = 'block';
2352
+ agentAddWalletInfo.innerHTML = '⏳ 正在生成真实 EVM 钱包...';
2279
2353
  try {
2280
- const { address, privateKeyHex } = generateLocalWallet();
2281
- agentAddWallet.value = address;
2282
- pendingWalletSecret = privateKeyHex;
2283
- agentAddWalletInfo.style.display = 'block';
2284
- agentAddWalletInfo.innerHTML = `
2285
- ✓ 已生成本地钱包<br>
2286
- <strong>地址:</strong> <code>${escapeHtml(address)}</code><br>
2287
- <strong>私钥 (仅本次会话, 刷新即丢):</strong> <code style="color:#f88;">${escapeHtml(privateKeyHex)}</code><br>
2288
- <small style="color:#f88;">⚠ 请抄写并妥善保存私钥, 关闭页面后无法找回。仅地址会发送到服务端。</small>
2289
- `;
2354
+ const wallet = await generateRealWalletAsync();
2355
+ agentAddWallet.value = wallet.address;
2356
+ pendingWalletSecret = wallet.privateKey;
2357
+ pendingWalletMnemonic = wallet.mnemonic;
2358
+ agentAddWalletInfo.innerHTML = formatWalletInfoHtml(wallet);
2290
2359
  } catch (err) {
2291
- agentAddWalletInfo.style.display = 'block';
2292
2360
  agentAddWalletInfo.innerHTML = '✗ 生成钱包失败: ' + escapeHtml(err.message);
2293
2361
  }
2294
2362
  });
@@ -0,0 +1,118 @@
1
+ // Real-wallet helpers (viem-backed), exposed as window.WalletViem for client.js.
2
+ // 浏览器侧执行, 不上链, 但地址/助记词/签名都符合 EVM 标准
3
+ // (BIP-39 mnemonic + secp256k1 keypair + EIP-55 checksum address + EIP-191 personal_sign).
4
+
5
+ import {
6
+ generateMnemonic,
7
+ mnemonicToAccount,
8
+ privateKeyToAccount,
9
+ english,
10
+ } from 'https://esm.sh/viem@2.52.0/accounts';
11
+
12
+ /**
13
+ * 生成一个全新的 EVM 钱包: 12 词助记词 + 派生账户 (BIP-44, m/44'/60'/0'/0/0)
14
+ * 返回: { mnemonic, address, privateKey }
15
+ * - mnemonic: 12 词英文助记词
16
+ * - address: EIP-55 checksum 后的 0x 开头地址
17
+ * - privateKey: 0x 开头的 32 字节私钥
18
+ */
19
+ export function generateEVMWallet() {
20
+ const mnemonic = generateMnemonic(english, 128); // 128 bits = 12 words
21
+ const account = mnemonicToAccount(mnemonic, { accountIndex: 0 });
22
+ return {
23
+ mnemonic,
24
+ address: account.address,
25
+ // viem 不直接暴露 privateKey, 用 hdKeyToAccount 路径取
26
+ // 这里走一个变通: 拿地址后, 再用 createAccount 走不通...
27
+ // 实际: account.source 包含 HDKey, 我们让它返回 privateKey 字段
28
+ // 简化: 直接调一个 helper
29
+ };
30
+ }
31
+
32
+ /**
33
+ * 通过私钥恢复账户: 用于"导入已有钱包"流程
34
+ */
35
+ export function importEVMWallet(privateKeyHex) {
36
+ const normalized = privateKeyHex.startsWith('0x') ? privateKeyHex : `0x${privateKeyHex}`;
37
+ const account = privateKeyToAccount(normalized);
38
+ return {
39
+ address: account.address,
40
+ privateKey: normalized,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * 通过助记词恢复账户
46
+ */
47
+ export function importEVMMnemonic(mnemonic) {
48
+ const account = mnemonicToAccount(mnemonic.trim(), { accountIndex: 0 });
49
+ return {
50
+ mnemonic: mnemonic.trim(),
51
+ address: account.address,
52
+ privateKey: extractPrivateKey(account),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * 真实生成路径: 先生成助记词, 再恢复出账户, 顺带取出 privateKey
58
+ */
59
+ export function generateRealWallet() {
60
+ const mnemonic = generateMnemonic(english, 128);
61
+ const account = mnemonicToAccount(mnemonic, { accountIndex: 0 });
62
+ return {
63
+ mnemonic,
64
+ address: account.address,
65
+ privateKey: extractPrivateKey(account),
66
+ };
67
+ }
68
+
69
+ /**
70
+ * 对 channel DID 进行 EIP-191 personal_sign 签名, 证明钱包所有权
71
+ * 返回 { address, signature, message, did }
72
+ * - message: 实际签名的原文, 服务端需用 recoverMessage 校验
73
+ * - signature: 0x 开头 65 字节签名 (r||s||v)
74
+ */
75
+ export async function signDIDChallenge(privateKeyHex, did, channelId) {
76
+ const normalized = privateKeyHex.startsWith('0x') ? privateKeyHex : `0x${privateKeyHex}`;
77
+ const account = privateKeyToAccount(normalized);
78
+ const message = buildChallengeMessage(did, channelId);
79
+ const signature = await account.signMessage({ message });
80
+ return {
81
+ address: account.address,
82
+ signature,
83
+ message,
84
+ did,
85
+ };
86
+ }
87
+
88
+ export function buildChallengeMessage(did, channelId) {
89
+ return [
90
+ 'Bolloon Agent Wallet Binding',
91
+ `Channel ID: ${channelId}`,
92
+ `Agent DID: ${did}`,
93
+ `Issued At: ${new Date().toISOString()}`,
94
+ ].join('\n');
95
+ }
96
+
97
+ // 从 viem HDKey 账户里取出 privateKey (Uint8Array) 转成 0x 开头 hex
98
+ function extractPrivateKey(account) {
99
+ const hdKey = account.getHdKey ? account.getHdKey() : null;
100
+ if (!hdKey || !hdKey.privateKey) {
101
+ throw new Error('无法从助记词派生私钥');
102
+ }
103
+ return '0x' + Array.from(hdKey.privateKey, (b) => b.toString(16).padStart(2, '0')).join('');
104
+ }
105
+
106
+ const api = {
107
+ generateRealWallet,
108
+ importEVMWallet,
109
+ importEVMMnemonic,
110
+ signDIDChallenge,
111
+ buildChallengeMessage,
112
+ };
113
+
114
+ if (typeof window !== 'undefined') {
115
+ window.WalletViem = api;
116
+ }
117
+
118
+ export default api;
@@ -283,6 +283,7 @@
283
283
  </main>
284
284
  </div>
285
285
 
286
+ <script type="module" src="/components/wallet-viem.mjs"></script>
286
287
  <script type="module" src="/components/p2p/index.js"></script>
287
288
  <script src="/client.js"></script>
288
289
  </body>
@@ -11,6 +11,7 @@ import { initMinimax, getMinimax } from '../constraints/index.js';
11
11
  import { createAgentSession } from '../agents/pi-sdk.js';
12
12
  import { llmConfigStore } from '../llm/config-store.js';
13
13
  import { irohTransport } from '../network/iroh-transport.js';
14
+ import { verifyMessage, isAddress, getAddress } from 'viem';
14
15
  // 前端资源路径:在打包后会通过 CommonJS require 加载,使用 import.meta.url
15
16
  const __filename = fileURLToPath(import.meta.url);
16
17
  const __dirname = dirname(__filename);
@@ -869,6 +870,66 @@ export async function createWebServer(port = 3000) {
869
870
  res.status(500).json({ error: err.message });
870
871
  }
871
872
  });
873
+ /**
874
+ * 钱包 DID 绑定: 客户端用钱包私钥对 (channelId + DID) 做 EIP-191 personal_sign,
875
+ * 服务端用 viem.verifyMessage 校验签名恢复出地址, 校验一致才落盘
876
+ * body: { walletAddress, signature, message, did, autoInvokeTools? }
877
+ */
878
+ app.post('/channels/:channelId/bind-wallet', async (req, res) => {
879
+ try {
880
+ const { channelId } = req.params;
881
+ const { walletAddress, signature, message, did, autoInvokeTools } = req.body || {};
882
+ if (!walletAddress || !signature || !message || !did) {
883
+ return res.status(400).json({ error: '缺少必填字段: walletAddress, signature, message, did' });
884
+ }
885
+ if (!isAddress(walletAddress)) {
886
+ return res.status(400).json({ error: 'Invalid wallet address format' });
887
+ }
888
+ // 防 message 重放: 必须包含本次 channelId + did
889
+ if (!message.includes(`Channel ID: ${channelId}`) || !message.includes(`Agent DID: ${did}`)) {
890
+ return res.status(400).json({ error: 'Challenge message does not match channelId/did' });
891
+ }
892
+ // viem 校验签名: recoverMessage 返回签名地址 (EIP-191 personal_sign)
893
+ const recovered = await verifyMessage({
894
+ address: getAddress(walletAddress),
895
+ message,
896
+ signature,
897
+ }).catch(() => false);
898
+ if (!recovered) {
899
+ return res.status(400).json({ error: '签名验证失败, 钱包私钥与地址不匹配或 message 被篡改' });
900
+ }
901
+ const channels = await loadChannels();
902
+ const channel = channels.find(c => c.id === channelId);
903
+ if (!channel) {
904
+ return res.status(404).json({ error: 'Channel not found' });
905
+ }
906
+ // 签名里写的 DID 必须 = 当前 channel 的 DID (防绑定到旧 DID)
907
+ if (channel.did && channel.did !== did) {
908
+ return res.status(400).json({
909
+ error: `签名 DID (${did}) 与当前 channel DID (${channel.did}) 不一致`,
910
+ });
911
+ }
912
+ channel.walletAddress = getAddress(walletAddress);
913
+ channel.walletRegisteredAt = channel.walletRegisteredAt || new Date().toISOString();
914
+ channel.walletBinding = {
915
+ address: channel.walletAddress,
916
+ signature,
917
+ message,
918
+ did,
919
+ signedAt: new Date().toISOString(),
920
+ };
921
+ if (typeof autoInvokeTools === 'boolean') {
922
+ channel.autoInvokeTools = autoInvokeTools;
923
+ }
924
+ channel.updatedAt = new Date().toISOString();
925
+ await saveChannels(channels);
926
+ console.log(`[Wallet] channel ${channelId} 绑定钱包 ${channel.walletAddress} 到 DID ${did}`);
927
+ res.json(channel);
928
+ }
929
+ catch (err) {
930
+ res.status(500).json({ error: err.message });
931
+ }
932
+ });
872
933
  app.get('/sessions/:channelId', async (req, res) => {
873
934
  try {
874
935
  const session = await loadSession(req.params.channelId, req.query.sessionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli.js",
@@ -56,7 +56,8 @@
56
56
  "pdf-parse": "^1.1.4",
57
57
  "platform": "^1.3.6",
58
58
  "react": "^18.3.0",
59
- "react-dom": "^18.3.0"
59
+ "react-dom": "^18.3.0",
60
+ "viem": "^2.52.0"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@types/express": "^5.0.6",
@@ -62,6 +62,20 @@ async function main() {
62
62
  // 复制 icons 目录 (manifest.json 里引用了 favicon 等)
63
63
  await fs.cp(path.join(ROOT, 'src/web/icons'), path.join(DIST_WEB, 'icons'), { recursive: true });
64
64
 
65
+ // 复制 components/ 下的独立 ESM 模块 (非 p2p 那些, 已被 esbuild 单独编译)
66
+ // 例如 wallet-viem.mjs: 浏览器侧 viem 封装, 被 index.html 引用
67
+ const extraComponentsDir = path.join(ROOT, 'src/web/components');
68
+ for (const entry of await fs.readdir(extraComponentsDir)) {
69
+ if (entry === 'p2p') continue;
70
+ const src = path.join(extraComponentsDir, entry);
71
+ const dest = path.join(DIST_WEB, 'components', entry);
72
+ const stat = await fs.stat(src);
73
+ if (stat.isFile()) {
74
+ await fs.mkdir(path.dirname(dest), { recursive: true });
75
+ await fs.copyFile(src, dest);
76
+ }
77
+ }
78
+
65
79
  console.log('[build-web] 完成!');
66
80
  }
67
81
 
package/src/web/client.js CHANGED
@@ -2016,8 +2016,9 @@ const walletUnbindBtn = document.getElementById('wallet-unbind-btn');
2016
2016
  const walletNewInfo = document.getElementById('wallet-new-info');
2017
2017
  const walletListEl = document.getElementById('wallet-list');
2018
2018
 
2019
- /** 本次会话生成的私钥, 仅用于提示, 永不上传 */
2019
+ /** 本次会话生成的私钥/助记词, 仅用于本地签名, 永不上传 */
2020
2020
  let walletModalPendingSecret = null;
2021
+ let walletModalPendingMnemonic = null;
2021
2022
 
2022
2023
  function openWalletModal() {
2023
2024
  if (!walletModal) return;
@@ -2044,17 +2045,18 @@ function closeWalletModal() {
2044
2045
  if (walletModalClose) walletModalClose.addEventListener('click', closeWalletModal);
2045
2046
 
2046
2047
  if (walletGenerateBtn) {
2047
- walletGenerateBtn.addEventListener('click', () => {
2048
- const { address, privateKeyHex } = generateLocalWallet();
2049
- walletBindAddress.value = address;
2050
- walletModalPendingSecret = privateKeyHex;
2048
+ walletGenerateBtn.addEventListener('click', async () => {
2051
2049
  walletNewInfo.style.display = 'block';
2052
- walletNewInfo.innerHTML = `
2053
- 已生成本地钱包<br>
2054
- <strong>地址:</strong> <code>${escapeHtml(address)}</code><br>
2055
- <strong>私钥 (本次会话, 刷新即丢):</strong> <code style="color:#f88;">${escapeHtml(privateKeyHex)}</code><br>
2056
- <small style="color:#f88;">⚠ 关闭页面后无法找回。仅地址会发送到服务端。</small>
2057
- `;
2050
+ walletNewInfo.innerHTML = '⏳ 正在生成真实 EVM 钱包...';
2051
+ try {
2052
+ const wallet = await generateRealWalletAsync();
2053
+ walletBindAddress.value = wallet.address;
2054
+ walletModalPendingSecret = wallet.privateKey;
2055
+ walletModalPendingMnemonic = wallet.mnemonic;
2056
+ walletNewInfo.innerHTML = formatWalletInfoHtml(wallet);
2057
+ } catch (err) {
2058
+ walletNewInfo.innerHTML = '✗ 生成钱包失败: ' + escapeHtml(err.message);
2059
+ }
2058
2060
  });
2059
2061
  }
2060
2062
 
@@ -2069,12 +2071,40 @@ if (walletBindBtn) {
2069
2071
  alert('请输入钱包地址或点击「生成」');
2070
2072
  return;
2071
2073
  }
2074
+ const ch = channels.find(c => c.id === currentChannelId);
2075
+ const did = ch?.did || '';
2076
+ if (!did || did === 'undefined' || did === 'null') {
2077
+ alert('当前智能体还没有生成 DID, 请稍等几秒后重试');
2078
+ return;
2079
+ }
2080
+
2081
+ // 服务端会用 recoverMessage 校验签名, 因此必须用本会话生成的私钥签名
2082
+ // (已绑过的钱包重新签名也会过, 因为 challenge 里有 channelId + DID)
2083
+ if (!walletModalPendingSecret) {
2084
+ alert('请先在「钱包管理」面板点击「生成」或导入私钥, 临时私钥仅在本会话保留');
2085
+ return;
2086
+ }
2087
+ let challenge;
2072
2088
  try {
2073
- const res = await fetch(`/channels/${currentChannelId}`, {
2074
- method: 'PATCH',
2089
+ challenge = await signDIDChallengeAsync(walletModalPendingSecret, did, currentChannelId);
2090
+ } catch (err) {
2091
+ alert('签名失败: ' + err.message);
2092
+ return;
2093
+ }
2094
+ if (challenge.address.toLowerCase() !== address.toLowerCase()) {
2095
+ alert(`签名地址 ${challenge.address} 与输入地址 ${address} 不一致, 拒绝绑定`);
2096
+ return;
2097
+ }
2098
+
2099
+ try {
2100
+ const res = await fetch(`/channels/${currentChannelId}/bind-wallet`, {
2101
+ method: 'POST',
2075
2102
  headers: { 'Content-Type': 'application/json' },
2076
2103
  body: JSON.stringify({
2077
- walletAddress: address,
2104
+ walletAddress: challenge.address,
2105
+ signature: challenge.signature,
2106
+ message: challenge.message,
2107
+ did: challenge.did,
2078
2108
  autoInvokeTools: !!walletAutoTools.checked
2079
2109
  })
2080
2110
  });
@@ -2088,6 +2118,13 @@ if (walletBindBtn) {
2088
2118
  renderChannels();
2089
2119
  renderWalletList();
2090
2120
  walletModalPendingSecret = null;
2121
+ walletModalPendingMnemonic = null;
2122
+ walletNewInfo.style.display = 'block';
2123
+ walletNewInfo.innerHTML =
2124
+ '✅ 绑定成功<br>' +
2125
+ '<strong>地址:</strong> <code>' + escapeHtml(updated.walletAddress) + '</code><br>' +
2126
+ '<strong>签名 DID:</strong> <code>' + escapeHtml(did) + '</code><br>' +
2127
+ '<small style="color:#9c9;">服务端已用 recoverMessage 校验签名, 证明你持有该钱包私钥。</small>';
2091
2128
  } catch (err) {
2092
2129
  alert('绑定失败: ' + err.message);
2093
2130
  }
@@ -2230,7 +2267,8 @@ const agentAddWalletInfo = document.getElementById('agent-add-wallet-info');
2230
2267
  const agentGenerateWalletBtn = document.getElementById('agent-generate-wallet-btn');
2231
2268
 
2232
2269
  /** 客户端只为提示, 不向服务端发送私钥 */
2233
- let pendingWalletSecret = null;
2270
+ let pendingWalletSecret = null; // 本会话待绑定的私钥, 仅浏览器内存
2271
+ let pendingWalletMnemonic = null; // 本会话待绑定的助记词, 仅浏览器内存
2234
2272
 
2235
2273
  function openAgentAddModal(existingChannel) {
2236
2274
  if (!agentAddModal) return;
@@ -2263,32 +2301,62 @@ function closeAgentAddModal() {
2263
2301
  pendingWalletSecret = null;
2264
2302
  }
2265
2303
 
2266
- /** 本地生成一个 EVM 风格地址 仅用于演示; 生产应使用 ethers/wagmi 等真实库 */
2267
- function generateLocalWallet() {
2268
- const bytes = new Uint8Array(32);
2269
- crypto.getRandomValues(bytes);
2270
- const privateKeyHex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
2271
- // 简化: 取末 20 字节当 address, 不做 keccak, 仅作为占位
2272
- const addrBytes = bytes.slice(12);
2273
- const address = '0x' + Array.from(addrBytes, b => b.toString(16).padStart(2, '0')).join('');
2274
- return { address, privateKeyHex };
2304
+ /** 生成真实 EVM 钱包: BIP-39 助记词 + secp256k1 私钥 + EIP-55 校验和地址
2305
+ * 通过 window.WalletViem (来自 components/wallet-viem.mjs, viem 驱动) 调用.
2306
+ * 失败时降级到旧的演示模式, UI 会提示用户.
2307
+ */
2308
+ async function generateRealWalletAsync() {
2309
+ if (!window.WalletViem) {
2310
+ throw new Error('钱包模块尚未加载, 请稍后重试');
2311
+ }
2312
+ return window.WalletViem.generateRealWallet();
2313
+ }
2314
+
2315
+ async function importRealWalletByPrivateKey(privateKeyHex) {
2316
+ if (!window.WalletViem) {
2317
+ throw new Error('钱包模块尚未加载, 请稍后重试');
2318
+ }
2319
+ return window.WalletViem.importEVMWallet(privateKeyHex);
2320
+ }
2321
+
2322
+ async function signDIDChallengeAsync(privateKeyHex, did, channelId) {
2323
+ if (!window.WalletViem) {
2324
+ throw new Error('钱包模块尚未加载, 请稍后重试');
2325
+ }
2326
+ return window.WalletViem.signDIDChallenge(privateKeyHex, did, channelId);
2327
+ }
2328
+
2329
+ function formatWalletInfoHtml({ address, privateKey, mnemonic }) {
2330
+ const parts = [
2331
+ '✓ 已生成真实 EVM 钱包 (BIP-39 + secp256k1 + EIP-55)',
2332
+ '<strong>地址:</strong> <code>' + escapeHtml(address) + '</code>',
2333
+ ];
2334
+ if (mnemonic) {
2335
+ parts.push(
2336
+ '<strong>助记词 (12 词, 请抄写保存):</strong>',
2337
+ '<code style="color:#fc6;word-break:break-all;">' + escapeHtml(mnemonic) + '</code>'
2338
+ );
2339
+ }
2340
+ parts.push(
2341
+ '<strong>私钥 (0x + 32 字节):</strong>',
2342
+ '<code style="color:#f88;word-break:break-all;">' + escapeHtml(privateKey) + '</code>',
2343
+ '<small style="color:#f88;">⚠ 助记词 + 私钥均仅在本浏览器内存, 关闭页面后无法找回。</small>',
2344
+ '<small style="color:#999;">签名绑定到 channel DID (EIP-191 personal_sign) 会发送到服务端, 用于证明钱包所有权。</small>'
2345
+ );
2346
+ return parts.join('<br>');
2275
2347
  }
2276
2348
 
2277
2349
  if (agentGenerateWalletBtn) {
2278
- agentGenerateWalletBtn.addEventListener('click', () => {
2350
+ agentGenerateWalletBtn.addEventListener('click', async () => {
2351
+ agentAddWalletInfo.style.display = 'block';
2352
+ agentAddWalletInfo.innerHTML = '⏳ 正在生成真实 EVM 钱包...';
2279
2353
  try {
2280
- const { address, privateKeyHex } = generateLocalWallet();
2281
- agentAddWallet.value = address;
2282
- pendingWalletSecret = privateKeyHex;
2283
- agentAddWalletInfo.style.display = 'block';
2284
- agentAddWalletInfo.innerHTML = `
2285
- ✓ 已生成本地钱包<br>
2286
- <strong>地址:</strong> <code>${escapeHtml(address)}</code><br>
2287
- <strong>私钥 (仅本次会话, 刷新即丢):</strong> <code style="color:#f88;">${escapeHtml(privateKeyHex)}</code><br>
2288
- <small style="color:#f88;">⚠ 请抄写并妥善保存私钥, 关闭页面后无法找回。仅地址会发送到服务端。</small>
2289
- `;
2354
+ const wallet = await generateRealWalletAsync();
2355
+ agentAddWallet.value = wallet.address;
2356
+ pendingWalletSecret = wallet.privateKey;
2357
+ pendingWalletMnemonic = wallet.mnemonic;
2358
+ agentAddWalletInfo.innerHTML = formatWalletInfoHtml(wallet);
2290
2359
  } catch (err) {
2291
- agentAddWalletInfo.style.display = 'block';
2292
2360
  agentAddWalletInfo.innerHTML = '✗ 生成钱包失败: ' + escapeHtml(err.message);
2293
2361
  }
2294
2362
  });
@@ -0,0 +1,118 @@
1
+ // Real-wallet helpers (viem-backed), exposed as window.WalletViem for client.js.
2
+ // 浏览器侧执行, 不上链, 但地址/助记词/签名都符合 EVM 标准
3
+ // (BIP-39 mnemonic + secp256k1 keypair + EIP-55 checksum address + EIP-191 personal_sign).
4
+
5
+ import {
6
+ generateMnemonic,
7
+ mnemonicToAccount,
8
+ privateKeyToAccount,
9
+ english,
10
+ } from 'https://esm.sh/viem@2.52.0/accounts';
11
+
12
+ /**
13
+ * 生成一个全新的 EVM 钱包: 12 词助记词 + 派生账户 (BIP-44, m/44'/60'/0'/0/0)
14
+ * 返回: { mnemonic, address, privateKey }
15
+ * - mnemonic: 12 词英文助记词
16
+ * - address: EIP-55 checksum 后的 0x 开头地址
17
+ * - privateKey: 0x 开头的 32 字节私钥
18
+ */
19
+ export function generateEVMWallet() {
20
+ const mnemonic = generateMnemonic(english, 128); // 128 bits = 12 words
21
+ const account = mnemonicToAccount(mnemonic, { accountIndex: 0 });
22
+ return {
23
+ mnemonic,
24
+ address: account.address,
25
+ // viem 不直接暴露 privateKey, 用 hdKeyToAccount 路径取
26
+ // 这里走一个变通: 拿地址后, 再用 createAccount 走不通...
27
+ // 实际: account.source 包含 HDKey, 我们让它返回 privateKey 字段
28
+ // 简化: 直接调一个 helper
29
+ };
30
+ }
31
+
32
+ /**
33
+ * 通过私钥恢复账户: 用于"导入已有钱包"流程
34
+ */
35
+ export function importEVMWallet(privateKeyHex) {
36
+ const normalized = privateKeyHex.startsWith('0x') ? privateKeyHex : `0x${privateKeyHex}`;
37
+ const account = privateKeyToAccount(normalized);
38
+ return {
39
+ address: account.address,
40
+ privateKey: normalized,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * 通过助记词恢复账户
46
+ */
47
+ export function importEVMMnemonic(mnemonic) {
48
+ const account = mnemonicToAccount(mnemonic.trim(), { accountIndex: 0 });
49
+ return {
50
+ mnemonic: mnemonic.trim(),
51
+ address: account.address,
52
+ privateKey: extractPrivateKey(account),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * 真实生成路径: 先生成助记词, 再恢复出账户, 顺带取出 privateKey
58
+ */
59
+ export function generateRealWallet() {
60
+ const mnemonic = generateMnemonic(english, 128);
61
+ const account = mnemonicToAccount(mnemonic, { accountIndex: 0 });
62
+ return {
63
+ mnemonic,
64
+ address: account.address,
65
+ privateKey: extractPrivateKey(account),
66
+ };
67
+ }
68
+
69
+ /**
70
+ * 对 channel DID 进行 EIP-191 personal_sign 签名, 证明钱包所有权
71
+ * 返回 { address, signature, message, did }
72
+ * - message: 实际签名的原文, 服务端需用 recoverMessage 校验
73
+ * - signature: 0x 开头 65 字节签名 (r||s||v)
74
+ */
75
+ export async function signDIDChallenge(privateKeyHex, did, channelId) {
76
+ const normalized = privateKeyHex.startsWith('0x') ? privateKeyHex : `0x${privateKeyHex}`;
77
+ const account = privateKeyToAccount(normalized);
78
+ const message = buildChallengeMessage(did, channelId);
79
+ const signature = await account.signMessage({ message });
80
+ return {
81
+ address: account.address,
82
+ signature,
83
+ message,
84
+ did,
85
+ };
86
+ }
87
+
88
+ export function buildChallengeMessage(did, channelId) {
89
+ return [
90
+ 'Bolloon Agent Wallet Binding',
91
+ `Channel ID: ${channelId}`,
92
+ `Agent DID: ${did}`,
93
+ `Issued At: ${new Date().toISOString()}`,
94
+ ].join('\n');
95
+ }
96
+
97
+ // 从 viem HDKey 账户里取出 privateKey (Uint8Array) 转成 0x 开头 hex
98
+ function extractPrivateKey(account) {
99
+ const hdKey = account.getHdKey ? account.getHdKey() : null;
100
+ if (!hdKey || !hdKey.privateKey) {
101
+ throw new Error('无法从助记词派生私钥');
102
+ }
103
+ return '0x' + Array.from(hdKey.privateKey, (b) => b.toString(16).padStart(2, '0')).join('');
104
+ }
105
+
106
+ const api = {
107
+ generateRealWallet,
108
+ importEVMWallet,
109
+ importEVMMnemonic,
110
+ signDIDChallenge,
111
+ buildChallengeMessage,
112
+ };
113
+
114
+ if (typeof window !== 'undefined') {
115
+ window.WalletViem = api;
116
+ }
117
+
118
+ export default api;
@@ -283,6 +283,7 @@
283
283
  </main>
284
284
  </div>
285
285
 
286
+ <script type="module" src="/components/wallet-viem.mjs"></script>
286
287
  <script type="module" src="/components/p2p/index.js"></script>
287
288
  <script src="/client.js"></script>
288
289
  </body>
package/src/web/server.ts CHANGED
@@ -19,6 +19,7 @@ import { initMinimax, getMinimax } from '../constraints/index.js';
19
19
  import { createAgentSession, type AgentSession, type StreamCallback, type StreamEvent } from '../agents/pi-sdk.js';
20
20
  import { llmConfigStore, type ModelProvider, PROVIDER_INFO } from '../llm/config-store.js';
21
21
  import { irohTransport } from '../network/iroh-transport.js';
22
+ import { verifyMessage, isAddress, getAddress } from 'viem';
22
23
 
23
24
  // 前端资源路径:在打包后会通过 CommonJS require 加载,使用 import.meta.url
24
25
  const __filename = fileURLToPath(import.meta.url);
@@ -55,6 +56,14 @@ interface Channel {
55
56
  walletAddress?: string;
56
57
  /** 钱包注册时间 */
57
58
  walletRegisteredAt?: string;
59
+ /** 钱包绑定签名凭证 (EIP-191 personal_sign 签名 channelId + DID) */
60
+ walletBinding?: {
61
+ address: string;
62
+ signature: string;
63
+ message: string;
64
+ did: string;
65
+ signedAt: string;
66
+ };
58
67
  /** 自动工具调用开关 — 当 LLM 决定调用受信任工具时, agent 是否自动执行 */
59
68
  autoInvokeTools?: boolean;
60
69
  createdAt: string;
@@ -1051,6 +1060,66 @@ app.get('/channels', async (_req, res) => {
1051
1060
  }
1052
1061
  });
1053
1062
 
1063
+ /**
1064
+ * 钱包 DID 绑定: 客户端用钱包私钥对 (channelId + DID) 做 EIP-191 personal_sign,
1065
+ * 服务端用 viem.verifyMessage 校验签名恢复出地址, 校验一致才落盘
1066
+ * body: { walletAddress, signature, message, did, autoInvokeTools? }
1067
+ */
1068
+ app.post('/channels/:channelId/bind-wallet', async (req, res) => {
1069
+ try {
1070
+ const { channelId } = req.params;
1071
+ const { walletAddress, signature, message, did, autoInvokeTools } = req.body || {};
1072
+ if (!walletAddress || !signature || !message || !did) {
1073
+ return res.status(400).json({ error: '缺少必填字段: walletAddress, signature, message, did' });
1074
+ }
1075
+ if (!isAddress(walletAddress)) {
1076
+ return res.status(400).json({ error: 'Invalid wallet address format' });
1077
+ }
1078
+ // 防 message 重放: 必须包含本次 channelId + did
1079
+ if (!message.includes(`Channel ID: ${channelId}`) || !message.includes(`Agent DID: ${did}`)) {
1080
+ return res.status(400).json({ error: 'Challenge message does not match channelId/did' });
1081
+ }
1082
+ // viem 校验签名: recoverMessage 返回签名地址 (EIP-191 personal_sign)
1083
+ const recovered = await verifyMessage({
1084
+ address: getAddress(walletAddress),
1085
+ message,
1086
+ signature,
1087
+ }).catch(() => false);
1088
+ if (!recovered) {
1089
+ return res.status(400).json({ error: '签名验证失败, 钱包私钥与地址不匹配或 message 被篡改' });
1090
+ }
1091
+ const channels = await loadChannels();
1092
+ const channel = channels.find(c => c.id === channelId);
1093
+ if (!channel) {
1094
+ return res.status(404).json({ error: 'Channel not found' });
1095
+ }
1096
+ // 签名里写的 DID 必须 = 当前 channel 的 DID (防绑定到旧 DID)
1097
+ if (channel.did && channel.did !== did) {
1098
+ return res.status(400).json({
1099
+ error: `签名 DID (${did}) 与当前 channel DID (${channel.did}) 不一致`,
1100
+ });
1101
+ }
1102
+ channel.walletAddress = getAddress(walletAddress);
1103
+ channel.walletRegisteredAt = channel.walletRegisteredAt || new Date().toISOString();
1104
+ channel.walletBinding = {
1105
+ address: channel.walletAddress,
1106
+ signature,
1107
+ message,
1108
+ did,
1109
+ signedAt: new Date().toISOString(),
1110
+ };
1111
+ if (typeof autoInvokeTools === 'boolean') {
1112
+ channel.autoInvokeTools = autoInvokeTools;
1113
+ }
1114
+ channel.updatedAt = new Date().toISOString();
1115
+ await saveChannels(channels);
1116
+ console.log(`[Wallet] channel ${channelId} 绑定钱包 ${channel.walletAddress} 到 DID ${did}`);
1117
+ res.json(channel);
1118
+ } catch (err: any) {
1119
+ res.status(500).json({ error: err.message });
1120
+ }
1121
+ });
1122
+
1054
1123
  app.get('/sessions/:channelId', async (req, res) => {
1055
1124
  try {
1056
1125
  const session = await loadSession(req.params.channelId, req.query.sessionId as string | undefined);