@swarmclawai/swarmclaw 0.7.7 → 0.8.0

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 (281) hide show
  1. package/README.md +12 -14
  2. package/next.config.ts +13 -2
  3. package/package.json +4 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +9 -0
  5. package/src/app/api/agents/route.ts +4 -0
  6. package/src/app/api/agents/thread-route.test.ts +133 -0
  7. package/src/app/api/approvals/route.test.ts +148 -0
  8. package/src/app/api/canvas/[sessionId]/route.ts +3 -1
  9. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
  10. package/src/app/api/chats/[id]/devserver/route.ts +48 -7
  11. package/src/app/api/chats/[id]/messages/route.ts +42 -18
  12. package/src/app/api/chats/[id]/route.ts +1 -1
  13. package/src/app/api/chats/[id]/stop/route.ts +5 -4
  14. package/src/app/api/chats/route.ts +23 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +46 -3
  17. package/src/app/api/connectors/route.ts +12 -8
  18. package/src/app/api/external-agents/route.test.ts +165 -0
  19. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  20. package/src/app/api/gateways/[id]/route.ts +2 -0
  21. package/src/app/api/gateways/health-route.test.ts +135 -0
  22. package/src/app/api/gateways/route.ts +2 -0
  23. package/src/app/api/mcp-servers/route.test.ts +130 -0
  24. package/src/app/api/openclaw/deploy/route.ts +38 -5
  25. package/src/app/api/plugins/install/route.ts +46 -6
  26. package/src/app/api/plugins/marketplace/route.ts +48 -15
  27. package/src/app/api/preview-server/route.ts +26 -11
  28. package/src/app/api/projects/[id]/route.ts +6 -2
  29. package/src/app/api/projects/route.ts +4 -3
  30. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  31. package/src/app/api/schedules/route.test.ts +86 -0
  32. package/src/app/api/schedules/route.ts +6 -1
  33. package/src/app/api/secrets/[id]/route.ts +1 -0
  34. package/src/app/api/secrets/route.ts +2 -1
  35. package/src/app/api/settings/route.ts +2 -0
  36. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  37. package/src/app/api/setup/check-provider/route.ts +40 -10
  38. package/src/app/api/skills/[id]/route.ts +12 -0
  39. package/src/app/api/skills/import/route.ts +14 -12
  40. package/src/app/api/skills/route.ts +13 -1
  41. package/src/app/api/tasks/[id]/route.ts +10 -1
  42. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  43. package/src/app/api/tasks/import/github/route.ts +337 -0
  44. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  45. package/src/app/api/wallets/[id]/route.ts +79 -33
  46. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  47. package/src/app/api/wallets/route.ts +78 -61
  48. package/src/app/api/webhooks/[id]/route.ts +33 -6
  49. package/src/app/api/webhooks/route.test.ts +272 -0
  50. package/src/cli/index.js +1 -0
  51. package/src/cli/spec.js +1 -0
  52. package/src/components/agents/agent-card.tsx +9 -2
  53. package/src/components/agents/agent-chat-list.tsx +18 -2
  54. package/src/components/agents/agent-list.tsx +1 -0
  55. package/src/components/agents/agent-sheet.tsx +257 -38
  56. package/src/components/agents/inspector-panel.tsx +41 -0
  57. package/src/components/canvas/canvas-panel.tsx +236 -65
  58. package/src/components/chat/chat-area.tsx +36 -19
  59. package/src/components/chat/chat-card.tsx +36 -13
  60. package/src/components/chat/chat-header.tsx +48 -16
  61. package/src/components/chat/chat-list.tsx +28 -4
  62. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  63. package/src/components/chat/delegation-banner.test.ts +14 -1
  64. package/src/components/chat/delegation-banner.tsx +1 -1
  65. package/src/components/chat/message-bubble.tsx +208 -145
  66. package/src/components/chat/message-list.tsx +48 -19
  67. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  68. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  69. package/src/components/connectors/connector-health.tsx +1 -1
  70. package/src/components/connectors/connector-list.tsx +7 -2
  71. package/src/components/connectors/connector-sheet.tsx +337 -148
  72. package/src/components/gateways/gateway-sheet.tsx +2 -2
  73. package/src/components/layout/app-layout.tsx +40 -23
  74. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  75. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  76. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  77. package/src/components/plugins/plugin-list.tsx +45 -9
  78. package/src/components/plugins/plugin-sheet.tsx +55 -7
  79. package/src/components/projects/project-detail.tsx +217 -0
  80. package/src/components/projects/project-sheet.tsx +176 -4
  81. package/src/components/providers/provider-list.tsx +2 -1
  82. package/src/components/providers/provider-sheet.tsx +21 -2
  83. package/src/components/schedules/schedule-card.tsx +25 -1
  84. package/src/components/schedules/schedule-sheet.tsx +44 -2
  85. package/src/components/secrets/secret-sheet.tsx +21 -2
  86. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  87. package/src/components/shared/bottom-sheet.tsx +13 -3
  88. package/src/components/shared/command-palette.tsx +8 -1
  89. package/src/components/shared/confirm-dialog.tsx +19 -4
  90. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  91. package/src/components/shared/connector-platform-icon.tsx +39 -6
  92. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  93. package/src/components/shared/settings/section-capability-policy.tsx +45 -3
  94. package/src/components/shared/settings/section-voice.tsx +11 -3
  95. package/src/components/skills/skill-list.tsx +25 -0
  96. package/src/components/skills/skill-sheet.tsx +84 -12
  97. package/src/components/tasks/approvals-panel.tsx +289 -34
  98. package/src/components/tasks/task-board.tsx +410 -25
  99. package/src/components/tasks/task-card.tsx +66 -8
  100. package/src/components/tasks/task-sheet.tsx +16 -4
  101. package/src/components/ui/dialog.tsx +2 -2
  102. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  103. package/src/components/wallets/wallet-panel.tsx +435 -90
  104. package/src/components/wallets/wallet-section.tsx +198 -48
  105. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  106. package/src/lib/approval-display.ts +20 -0
  107. package/src/lib/canvas-content.ts +198 -0
  108. package/src/lib/chat-artifact-summary.ts +165 -0
  109. package/src/lib/chat-display.test.ts +91 -0
  110. package/src/lib/chat-display.ts +58 -0
  111. package/src/lib/chat-streaming-state.test.ts +47 -1
  112. package/src/lib/chat-streaming-state.ts +42 -0
  113. package/src/lib/ollama-model.ts +10 -0
  114. package/src/lib/openclaw-endpoint.test.ts +8 -0
  115. package/src/lib/openclaw-endpoint.ts +6 -1
  116. package/src/lib/plugin-install-cors.ts +46 -0
  117. package/src/lib/plugin-sources.test.ts +43 -0
  118. package/src/lib/plugin-sources.ts +77 -0
  119. package/src/lib/providers/ollama.ts +16 -6
  120. package/src/lib/providers/openclaw.test.ts +54 -0
  121. package/src/lib/providers/openclaw.ts +127 -11
  122. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  123. package/src/lib/schedule-dedupe.test.ts +66 -1
  124. package/src/lib/schedule-dedupe.ts +169 -12
  125. package/src/lib/schedule-origin.test.ts +20 -0
  126. package/src/lib/schedule-origin.ts +15 -0
  127. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  128. package/src/lib/server/agent-availability.ts +16 -0
  129. package/src/lib/server/agent-runtime-config.ts +12 -4
  130. package/src/lib/server/agent-thread-session.test.ts +51 -0
  131. package/src/lib/server/agent-thread-session.ts +7 -0
  132. package/src/lib/server/approval-match.ts +205 -0
  133. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  134. package/src/lib/server/approvals.ts +214 -1
  135. package/src/lib/server/assistant-control.test.ts +29 -0
  136. package/src/lib/server/assistant-control.ts +23 -0
  137. package/src/lib/server/build-llm.test.ts +79 -0
  138. package/src/lib/server/build-llm.ts +14 -4
  139. package/src/lib/server/canvas-content.test.ts +32 -0
  140. package/src/lib/server/canvas-content.ts +6 -0
  141. package/src/lib/server/capability-router.test.ts +33 -0
  142. package/src/lib/server/capability-router.ts +80 -19
  143. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  144. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  145. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  146. package/src/lib/server/chat-execution.ts +378 -73
  147. package/src/lib/server/clawhub-client.test.ts +14 -8
  148. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  149. package/src/lib/server/connectors/manager.test.ts +1147 -0
  150. package/src/lib/server/connectors/manager.ts +461 -137
  151. package/src/lib/server/connectors/pairing.ts +26 -5
  152. package/src/lib/server/connectors/types.ts +2 -0
  153. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  154. package/src/lib/server/connectors/whatsapp.ts +271 -47
  155. package/src/lib/server/context-manager.ts +6 -1
  156. package/src/lib/server/daemon-state.ts +84 -47
  157. package/src/lib/server/data-dir.test.ts +37 -0
  158. package/src/lib/server/data-dir.ts +20 -1
  159. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  160. package/src/lib/server/devserver-launch.test.ts +60 -0
  161. package/src/lib/server/devserver-launch.ts +85 -0
  162. package/src/lib/server/elevenlabs.test.ts +247 -1
  163. package/src/lib/server/elevenlabs.ts +147 -43
  164. package/src/lib/server/ethereum.ts +590 -0
  165. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  166. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  167. package/src/lib/server/eval/agent-regression.ts +383 -11
  168. package/src/lib/server/evm-swap.ts +475 -0
  169. package/src/lib/server/execution-log.ts +1 -0
  170. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  171. package/src/lib/server/heartbeat-service.ts +20 -11
  172. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  173. package/src/lib/server/heartbeat-wake.ts +338 -57
  174. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  175. package/src/lib/server/main-agent-loop.test.ts +260 -0
  176. package/src/lib/server/main-agent-loop.ts +559 -14
  177. package/src/lib/server/mcp-client.test.ts +16 -0
  178. package/src/lib/server/mcp-client.ts +25 -0
  179. package/src/lib/server/memory-integration.test.ts +719 -0
  180. package/src/lib/server/memory-policy.test.ts +43 -0
  181. package/src/lib/server/memory-policy.ts +132 -0
  182. package/src/lib/server/memory-tiers.test.ts +60 -0
  183. package/src/lib/server/memory-tiers.ts +16 -0
  184. package/src/lib/server/ollama-runtime.ts +58 -0
  185. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  186. package/src/lib/server/openclaw-deploy.ts +557 -81
  187. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  188. package/src/lib/server/openclaw-gateway.ts +10 -4
  189. package/src/lib/server/openclaw-health.test.ts +35 -0
  190. package/src/lib/server/openclaw-health.ts +215 -47
  191. package/src/lib/server/orchestrator-lg.ts +3 -2
  192. package/src/lib/server/orchestrator.ts +2 -0
  193. package/src/lib/server/plugins-advanced.test.ts +351 -0
  194. package/src/lib/server/plugins.ts +211 -6
  195. package/src/lib/server/project-context.ts +162 -0
  196. package/src/lib/server/project-utils.ts +150 -0
  197. package/src/lib/server/queue-advanced.test.ts +528 -0
  198. package/src/lib/server/queue-followups.test.ts +409 -2
  199. package/src/lib/server/queue-reconcile.test.ts +128 -0
  200. package/src/lib/server/queue.ts +527 -68
  201. package/src/lib/server/scheduler.ts +29 -1
  202. package/src/lib/server/session-note.test.ts +36 -0
  203. package/src/lib/server/session-note.ts +42 -0
  204. package/src/lib/server/session-run-manager.ts +83 -4
  205. package/src/lib/server/session-tools/canvas.ts +14 -12
  206. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  207. package/src/lib/server/session-tools/connector.test.ts +138 -0
  208. package/src/lib/server/session-tools/connector.ts +366 -54
  209. package/src/lib/server/session-tools/context.ts +17 -3
  210. package/src/lib/server/session-tools/crud.ts +484 -84
  211. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  212. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  213. package/src/lib/server/session-tools/delegate.ts +102 -10
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  215. package/src/lib/server/session-tools/discovery.ts +80 -12
  216. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  217. package/src/lib/server/session-tools/file.ts +43 -4
  218. package/src/lib/server/session-tools/human-loop.ts +35 -5
  219. package/src/lib/server/session-tools/index.ts +44 -9
  220. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  221. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  222. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  223. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  224. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  225. package/src/lib/server/session-tools/memory.test.ts +93 -0
  226. package/src/lib/server/session-tools/memory.ts +554 -75
  227. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  228. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  229. package/src/lib/server/session-tools/platform.ts +60 -19
  230. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  231. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  232. package/src/lib/server/session-tools/schedule.ts +6 -1
  233. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  234. package/src/lib/server/session-tools/shell.ts +22 -3
  235. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  236. package/src/lib/server/session-tools/wallet.ts +1374 -139
  237. package/src/lib/server/session-tools/web-inputs.test.ts +178 -0
  238. package/src/lib/server/session-tools/web.ts +621 -70
  239. package/src/lib/server/skill-discovery.ts +128 -0
  240. package/src/lib/server/skill-eligibility.test.ts +84 -0
  241. package/src/lib/server/skill-eligibility.ts +95 -0
  242. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  243. package/src/lib/server/skill-prompt-budget.ts +125 -0
  244. package/src/lib/server/skills-normalize.test.ts +54 -0
  245. package/src/lib/server/skills-normalize.ts +372 -26
  246. package/src/lib/server/solana.ts +214 -29
  247. package/src/lib/server/storage.ts +65 -36
  248. package/src/lib/server/stream-agent-chat.test.ts +437 -2
  249. package/src/lib/server/stream-agent-chat.ts +957 -79
  250. package/src/lib/server/system-events.ts +1 -1
  251. package/src/lib/server/tool-aliases.ts +2 -0
  252. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  253. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  254. package/src/lib/server/tool-capability-policy.ts +29 -1
  255. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  256. package/src/lib/server/tool-loop-detection.ts +260 -0
  257. package/src/lib/server/tool-planning.test.ts +44 -0
  258. package/src/lib/server/tool-planning.ts +271 -0
  259. package/src/lib/server/wallet-execution.test.ts +198 -0
  260. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  261. package/src/lib/server/wallet-portfolio.ts +724 -0
  262. package/src/lib/server/wallet-service.test.ts +57 -0
  263. package/src/lib/server/wallet-service.ts +213 -0
  264. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  265. package/src/lib/server/watch-jobs.ts +17 -2
  266. package/src/lib/server/workspace-context.ts +111 -0
  267. package/src/lib/skill-save-payload.test.ts +39 -0
  268. package/src/lib/skill-save-payload.ts +37 -0
  269. package/src/lib/tasks.ts +28 -0
  270. package/src/lib/tool-definitions.ts +2 -1
  271. package/src/lib/tool-event-summary.test.ts +30 -0
  272. package/src/lib/tool-event-summary.ts +37 -0
  273. package/src/lib/validation/schemas.ts +1 -0
  274. package/src/lib/wallet-transactions.test.ts +75 -0
  275. package/src/lib/wallet-transactions.ts +43 -0
  276. package/src/lib/wallet.test.ts +17 -0
  277. package/src/lib/wallet.ts +183 -0
  278. package/src/proxy.test.ts +31 -0
  279. package/src/proxy.ts +34 -2
  280. package/src/stores/use-chat-store.ts +15 -1
  281. package/src/types/index.ts +249 -14
@@ -0,0 +1,724 @@
1
+ import { Contract, Interface, JsonRpcProvider, getAddress, zeroPadValue } from 'ethers'
2
+ import { PublicKey } from '@solana/web3.js'
3
+
4
+ import { formatAtomicAmount, getWalletAssetSymbol } from '@/lib/wallet'
5
+ import type { AgentWallet, WalletAssetBalance, WalletPortfolioSummary } from '@/types'
6
+
7
+ import { getProvider as getEthereumProvider } from './ethereum'
8
+ import { getConnection as getSolanaConnection } from './solana'
9
+
10
+ const TOKEN_PROGRAM_IDS = [
11
+ new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'),
12
+ new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'),
13
+ ] as const
14
+ const METAPLEX_METADATA_PROGRAM_ID = new PublicKey('metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s')
15
+
16
+ const SOLSCAN_ACCOUNT_BASE = 'https://solscan.io/account/'
17
+ const SOLSCAN_TOKEN_BASE = 'https://solscan.io/token/'
18
+ const ERC20_TRANSFER_IFACE = new Interface([
19
+ 'event Transfer(address indexed from, address indexed to, uint256 value)',
20
+ ])
21
+ const ERC20_TRANSFER_TOPIC = ERC20_TRANSFER_IFACE.getEvent('Transfer')?.topicHash || ''
22
+ const ERC20_SYMBOL_BYTES32_IFACE = new Interface(['function symbol() view returns (bytes32)'])
23
+ const ERC20_NAME_BYTES32_IFACE = new Interface(['function name() view returns (bytes32)'])
24
+ const ERC20_ABI = [
25
+ 'function balanceOf(address owner) view returns (uint256)',
26
+ 'function decimals() view returns (uint8)',
27
+ 'function symbol() view returns (string)',
28
+ 'function name() view returns (string)',
29
+ ] as const
30
+ const PORTFOLIO_CACHE_TTL_MS = 20_000
31
+ const EVM_CONTRACT_CACHE_TTL_MS = 10 * 60 * 1000
32
+ const SOLANA_METADATA_BATCH_SIZE = 100
33
+ const TOKEN_METADATA_NAME_OFFSET = 1 + 32 + 32
34
+ const TOKEN_METADATA_NAME_LENGTH = 32
35
+ const TOKEN_METADATA_SYMBOL_OFFSET = TOKEN_METADATA_NAME_OFFSET + TOKEN_METADATA_NAME_LENGTH
36
+ const TOKEN_METADATA_SYMBOL_LENGTH = 10
37
+
38
+ interface WalletPortfolioCacheEntry {
39
+ expiresAt: number
40
+ portfolio: WalletPortfolio
41
+ }
42
+
43
+ interface EvmContractDiscoveryCacheEntry {
44
+ expiresAt: number
45
+ walletCreatedAt: number
46
+ scannedToBlock: number
47
+ contractAddresses: string[]
48
+ }
49
+
50
+ interface EvmNetworkConfig {
51
+ id: string
52
+ label: string
53
+ rpcUrl: string
54
+ addressExplorerBaseUrl: string
55
+ tokenExplorerBaseUrl: string
56
+ avgBlockMs: number
57
+ maxDiscoveryBlocks: number
58
+ maxLogRange?: number
59
+ knownTokens?: KnownEvmTokenConfig[]
60
+ }
61
+
62
+ interface KnownEvmTokenConfig {
63
+ address: string
64
+ symbol: string
65
+ name: string
66
+ decimals: number
67
+ }
68
+
69
+ export interface WalletPortfolio {
70
+ balanceAtomic: string
71
+ balanceFormatted: string
72
+ balanceSymbol: string
73
+ balanceDisplay: string
74
+ balanceLamports?: number
75
+ balanceSol?: number
76
+ assets: WalletAssetBalance[]
77
+ summary: WalletPortfolioSummary
78
+ }
79
+
80
+ export interface GetWalletPortfolioOptions {
81
+ timeoutMs?: number
82
+ allowStale?: boolean
83
+ }
84
+
85
+ const portfolioCache = new Map<string, WalletPortfolioCacheEntry>()
86
+ const evmContractDiscoveryCache = new Map<string, EvmContractDiscoveryCacheEntry>()
87
+
88
+ const KNOWN_SOLANA_TOKENS: Record<string, { symbol: string; name: string }> = {
89
+ EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: { symbol: 'USDC', name: 'USD Coin' },
90
+ }
91
+
92
+ const KNOWN_EVM_TOKENS: Record<string, KnownEvmTokenConfig[]> = {
93
+ ethereum: [
94
+ {
95
+ address: getAddress('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'),
96
+ symbol: 'USDC',
97
+ name: 'USD Coin',
98
+ decimals: 6,
99
+ },
100
+ ],
101
+ arbitrum: [
102
+ {
103
+ address: getAddress('0xaf88d065e77c8cC2239327C5EDb3A432268e5831'),
104
+ symbol: 'USDC',
105
+ name: 'USD Coin',
106
+ decimals: 6,
107
+ },
108
+ ],
109
+ base: [
110
+ {
111
+ address: getAddress('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'),
112
+ symbol: 'USDC',
113
+ name: 'USD Coin',
114
+ decimals: 6,
115
+ },
116
+ ],
117
+ }
118
+
119
+ function getEnabledEvmNetworks(): EvmNetworkConfig[] {
120
+ return [
121
+ {
122
+ id: 'ethereum',
123
+ label: 'Ethereum',
124
+ rpcUrl: process.env.ETHEREUM_RPC_URL || process.env.EVM_RPC_URL || 'https://ethereum-rpc.publicnode.com',
125
+ addressExplorerBaseUrl: 'https://etherscan.io/address/',
126
+ tokenExplorerBaseUrl: 'https://etherscan.io/token/',
127
+ avgBlockMs: 12_000,
128
+ maxDiscoveryBlocks: 300_000,
129
+ maxLogRange: 50_000,
130
+ knownTokens: KNOWN_EVM_TOKENS.ethereum,
131
+ },
132
+ {
133
+ id: 'arbitrum',
134
+ label: 'Arbitrum',
135
+ rpcUrl: process.env.ARBITRUM_RPC_URL || 'https://arbitrum-one-rpc.publicnode.com',
136
+ addressExplorerBaseUrl: 'https://arbiscan.io/address/',
137
+ tokenExplorerBaseUrl: 'https://arbiscan.io/token/',
138
+ avgBlockMs: 250,
139
+ maxDiscoveryBlocks: 2_000_000,
140
+ maxLogRange: 50_000,
141
+ knownTokens: KNOWN_EVM_TOKENS.arbitrum,
142
+ },
143
+ {
144
+ id: 'base',
145
+ label: 'Base',
146
+ rpcUrl: process.env.BASE_RPC_URL || 'https://base-rpc.publicnode.com',
147
+ addressExplorerBaseUrl: 'https://basescan.org/address/',
148
+ tokenExplorerBaseUrl: 'https://basescan.org/token/',
149
+ avgBlockMs: 2_000,
150
+ maxDiscoveryBlocks: 800_000,
151
+ maxLogRange: 50_000,
152
+ knownTokens: KNOWN_EVM_TOKENS.base,
153
+ },
154
+ ].filter((network) => Boolean(network.rpcUrl))
155
+ }
156
+
157
+ export function getKnownEvmTokenContracts(networkId: string): string[] {
158
+ return (KNOWN_EVM_TOKENS[networkId] || []).map((token) => token.address)
159
+ }
160
+
161
+ export function estimateDiscoveryStartBlock(input: {
162
+ latestBlock: number
163
+ walletCreatedAt: number
164
+ avgBlockMs: number
165
+ maxDiscoveryBlocks: number
166
+ now?: number
167
+ safetyBlocks?: number
168
+ }): number {
169
+ const now = input.now ?? Date.now()
170
+ const safetyBlocks = input.safetyBlocks ?? 5_000
171
+ const ageMs = Math.max(0, now - input.walletCreatedAt)
172
+ const estimatedBlocks = Math.min(
173
+ input.maxDiscoveryBlocks,
174
+ Math.max(safetyBlocks, Math.ceil(ageMs / input.avgBlockMs) + safetyBlocks),
175
+ )
176
+ return Math.max(0, input.latestBlock - estimatedBlocks)
177
+ }
178
+
179
+ export function buildLogDiscoveryRanges(
180
+ fromBlock: number,
181
+ toBlock: number,
182
+ maxLogRange?: number,
183
+ ): Array<{ fromBlock: number; toBlock: number }> {
184
+ if (toBlock < fromBlock) return []
185
+ if (!maxLogRange || maxLogRange < 1 || toBlock - fromBlock + 1 <= maxLogRange) {
186
+ return [{ fromBlock, toBlock }]
187
+ }
188
+
189
+ const ranges: Array<{ fromBlock: number; toBlock: number }> = []
190
+ for (let start = fromBlock; start <= toBlock; start += maxLogRange) {
191
+ ranges.push({
192
+ fromBlock: start,
193
+ toBlock: Math.min(toBlock, start + maxLogRange - 1),
194
+ })
195
+ }
196
+ return ranges
197
+ }
198
+
199
+ function shortId(value: string): string {
200
+ return value.length <= 10 ? value : `${value.slice(0, 4)}...${value.slice(-4)}`
201
+ }
202
+
203
+ function readFixedUtf8String(data: Uint8Array, offset: number, length: number): string {
204
+ if (data.length < offset + length) return ''
205
+ return Buffer.from(data.subarray(offset, offset + length))
206
+ .toString('utf8')
207
+ .replace(/\0/g, '')
208
+ .trim()
209
+ }
210
+
211
+ export function parseMetaplexMetadataFields(data: Uint8Array): { name?: string; symbol?: string } | null {
212
+ const name = readFixedUtf8String(data, TOKEN_METADATA_NAME_OFFSET, TOKEN_METADATA_NAME_LENGTH)
213
+ const symbol = readFixedUtf8String(data, TOKEN_METADATA_SYMBOL_OFFSET, TOKEN_METADATA_SYMBOL_LENGTH)
214
+ if (!name && !symbol) return null
215
+ return {
216
+ name: name || undefined,
217
+ symbol: symbol || undefined,
218
+ }
219
+ }
220
+
221
+ function getSolanaMetadataPda(mint: string): PublicKey | null {
222
+ try {
223
+ const mintKey = new PublicKey(mint)
224
+ return PublicKey.findProgramAddressSync(
225
+ [
226
+ Buffer.from('metadata'),
227
+ METAPLEX_METADATA_PROGRAM_ID.toBuffer(),
228
+ mintKey.toBuffer(),
229
+ ],
230
+ METAPLEX_METADATA_PROGRAM_ID,
231
+ )[0]
232
+ } catch {
233
+ return null
234
+ }
235
+ }
236
+
237
+ async function resolveSolanaMetadata(mints: string[]): Promise<Map<string, { name?: string; symbol?: string }>> {
238
+ const result = new Map<string, { name?: string; symbol?: string }>()
239
+ if (mints.length === 0) return result
240
+
241
+ const connection = getSolanaConnection()
242
+ const entries = mints
243
+ .map((mint) => ({ mint, pda: getSolanaMetadataPda(mint) }))
244
+ .filter((entry): entry is { mint: string; pda: PublicKey } => Boolean(entry.pda))
245
+
246
+ for (let index = 0; index < entries.length; index += SOLANA_METADATA_BATCH_SIZE) {
247
+ const batch = entries.slice(index, index + SOLANA_METADATA_BATCH_SIZE)
248
+ try {
249
+ const accounts = await connection.getMultipleAccountsInfo(batch.map((entry) => entry.pda))
250
+ for (let accountIndex = 0; accountIndex < batch.length; accountIndex += 1) {
251
+ const account = accounts[accountIndex]
252
+ if (!account?.data) continue
253
+ const parsed = parseMetaplexMetadataFields(account.data)
254
+ if (!parsed) continue
255
+ result.set(batch[accountIndex].mint, parsed)
256
+ }
257
+ } catch {
258
+ continue
259
+ }
260
+ }
261
+
262
+ return result
263
+ }
264
+
265
+ function normalizeAssetDisplay(
266
+ symbol: string,
267
+ balanceAtomic: string,
268
+ decimals: number,
269
+ opts?: { minFractionDigits?: number; maxFractionDigits?: number },
270
+ ): { balanceFormatted: string; balanceDisplay: string } {
271
+ const balanceFormatted = formatAtomicAmount(balanceAtomic, decimals, {
272
+ minFractionDigits: opts?.minFractionDigits ?? 0,
273
+ maxFractionDigits: opts?.maxFractionDigits ?? 6,
274
+ })
275
+ return {
276
+ balanceFormatted,
277
+ balanceDisplay: `${balanceFormatted} ${symbol}`,
278
+ }
279
+ }
280
+
281
+ function buildPortfolioSummary(assets: WalletAssetBalance[]): WalletPortfolioSummary {
282
+ const nonZeroAssets = assets.filter((asset) => BigInt(asset.balanceAtomic) > BigInt(0))
283
+ return {
284
+ totalAssets: assets.length,
285
+ nonZeroAssets: nonZeroAssets.length,
286
+ tokenAssets: assets.filter((asset) => !asset.isNative && BigInt(asset.balanceAtomic) > BigInt(0)).length,
287
+ networkCount: new Set(nonZeroAssets.map((asset) => asset.networkId)).size,
288
+ }
289
+ }
290
+
291
+ function sortAssets(assets: WalletAssetBalance[]): WalletAssetBalance[] {
292
+ return [...assets].sort((left, right) => {
293
+ const leftNonZero = BigInt(left.balanceAtomic) > BigInt(0)
294
+ const rightNonZero = BigInt(right.balanceAtomic) > BigInt(0)
295
+ if (leftNonZero !== rightNonZero) return leftNonZero ? -1 : 1
296
+ if (left.isNative !== right.isNative) return left.isNative ? -1 : 1
297
+ const networkCompare = left.networkLabel.localeCompare(right.networkLabel)
298
+ if (networkCompare !== 0) return networkCompare
299
+ return left.symbol.localeCompare(right.symbol)
300
+ })
301
+ }
302
+
303
+ function buildPortfolioCacheKey(wallet: Pick<AgentWallet, 'id' | 'updatedAt'>): string {
304
+ return `${wallet.id}:${wallet.updatedAt}`
305
+ }
306
+
307
+ function getCurrentCachedPortfolioEntry(wallet: Pick<AgentWallet, 'id' | 'updatedAt'>): WalletPortfolioCacheEntry | null {
308
+ return portfolioCache.get(buildPortfolioCacheKey(wallet)) || null
309
+ }
310
+
311
+ function getLatestCachedPortfolioEntry(walletId: string): WalletPortfolioCacheEntry | null {
312
+ let latest: WalletPortfolioCacheEntry | null = null
313
+ for (const [key, entry] of portfolioCache.entries()) {
314
+ if (!key.startsWith(`${walletId}:`)) continue
315
+ if (!latest || entry.expiresAt > latest.expiresAt) latest = entry
316
+ }
317
+ return latest
318
+ }
319
+
320
+ async function withWalletPortfolioTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
321
+ let timer: ReturnType<typeof setTimeout> | null = null
322
+ try {
323
+ return await Promise.race([
324
+ promise,
325
+ new Promise<never>((_, reject) => {
326
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)
327
+ }),
328
+ ])
329
+ } finally {
330
+ if (timer) clearTimeout(timer)
331
+ }
332
+ }
333
+
334
+ export function buildEmptyWalletPortfolio(wallet: Pick<AgentWallet, 'chain' | 'publicKey'>): WalletPortfolio {
335
+ const balanceSymbol = wallet.chain === 'ethereum' ? 'ETH' : 'SOL'
336
+ const balanceDisplay = `0.0000 ${balanceSymbol}`
337
+ return {
338
+ balanceAtomic: '0',
339
+ balanceFormatted: '0.0000',
340
+ balanceDisplay,
341
+ balanceSymbol,
342
+ assets: [],
343
+ summary: { totalAssets: 0, nonZeroAssets: 0, tokenAssets: 0, networkCount: 0 },
344
+ }
345
+ }
346
+
347
+ export async function resolveWalletPortfolioWithTimeout<T>(
348
+ params: {
349
+ load: () => Promise<T>
350
+ timeoutMs?: number
351
+ stale?: T | null
352
+ label: string
353
+ },
354
+ ): Promise<T> {
355
+ try {
356
+ if (typeof params.timeoutMs === 'number' && params.timeoutMs > 0) {
357
+ return await withWalletPortfolioTimeout(params.load(), params.timeoutMs, params.label)
358
+ }
359
+ return await params.load()
360
+ } catch (err) {
361
+ if (params.stale != null) return params.stale
362
+ throw err
363
+ }
364
+ }
365
+
366
+ async function fetchSolanaAssets(wallet: AgentWallet): Promise<WalletPortfolio> {
367
+ const connection = getSolanaConnection()
368
+ const publicKey = new PublicKey(wallet.publicKey)
369
+ const nativeBalanceAtomic = String(await connection.getBalance(publicKey))
370
+ const nativeDisplay = normalizeAssetDisplay('SOL', nativeBalanceAtomic, 9, { minFractionDigits: 4 })
371
+ const assets: WalletAssetBalance[] = [{
372
+ id: `solana:mainnet:native`,
373
+ chain: 'solana',
374
+ networkId: 'solana-mainnet',
375
+ networkLabel: 'Solana',
376
+ symbol: 'SOL',
377
+ name: 'Solana',
378
+ decimals: 9,
379
+ balanceAtomic: nativeBalanceAtomic,
380
+ ...nativeDisplay,
381
+ isNative: true,
382
+ explorerUrl: `${SOLSCAN_ACCOUNT_BASE}${wallet.publicKey}`,
383
+ }]
384
+
385
+ const tokenAccounts = await Promise.all(
386
+ TOKEN_PROGRAM_IDS.map((programId) => connection.getParsedTokenAccountsByOwner(publicKey, { programId })),
387
+ )
388
+
389
+ const tokensByMint = new Map<string, WalletAssetBalance>()
390
+ for (const result of tokenAccounts) {
391
+ for (const account of result.value) {
392
+ const parsed = (account.account.data as { parsed?: { info?: Record<string, unknown> } }).parsed
393
+ const info = parsed?.info || {}
394
+ const mint = typeof info.mint === 'string' ? info.mint : ''
395
+ const tokenAmount = info.tokenAmount as { amount?: string; decimals?: number } | undefined
396
+ const amountAtomic = String(tokenAmount?.amount || '0')
397
+ if (!mint || BigInt(amountAtomic) <= BigInt(0)) continue
398
+ const decimals = typeof tokenAmount?.decimals === 'number' ? tokenAmount.decimals : 0
399
+ const known = KNOWN_SOLANA_TOKENS[mint]
400
+ const symbol = known?.symbol || shortId(mint)
401
+ const name = known?.name || `SPL Token ${shortId(mint)}`
402
+ const display = normalizeAssetDisplay(symbol, amountAtomic, decimals)
403
+ const existing = tokensByMint.get(mint)
404
+ if (existing) {
405
+ const nextAtomic = (BigInt(existing.balanceAtomic) + BigInt(amountAtomic)).toString()
406
+ tokensByMint.set(mint, {
407
+ ...existing,
408
+ balanceAtomic: nextAtomic,
409
+ ...normalizeAssetDisplay(existing.symbol, nextAtomic, existing.decimals),
410
+ })
411
+ continue
412
+ }
413
+ tokensByMint.set(mint, {
414
+ id: `solana:mainnet:${mint}`,
415
+ chain: 'solana',
416
+ networkId: 'solana-mainnet',
417
+ networkLabel: 'Solana',
418
+ symbol,
419
+ name,
420
+ decimals,
421
+ balanceAtomic: amountAtomic,
422
+ ...display,
423
+ isNative: false,
424
+ tokenMint: mint,
425
+ explorerUrl: `${SOLSCAN_TOKEN_BASE}${mint}`,
426
+ })
427
+ }
428
+ }
429
+
430
+ const unknownMints = [...tokensByMint.keys()].filter((mint) => !KNOWN_SOLANA_TOKENS[mint])
431
+ const metadataByMint = await resolveSolanaMetadata(unknownMints)
432
+ for (const [mint, metadata] of metadataByMint.entries()) {
433
+ const existing = tokensByMint.get(mint)
434
+ if (!existing) continue
435
+ const symbol = metadata.symbol?.trim() || existing.symbol
436
+ const name = metadata.name?.trim() || existing.name
437
+ tokensByMint.set(mint, {
438
+ ...existing,
439
+ symbol,
440
+ name,
441
+ ...normalizeAssetDisplay(symbol, existing.balanceAtomic, existing.decimals),
442
+ })
443
+ }
444
+
445
+ assets.push(...tokensByMint.values())
446
+ const sortedAssets = sortAssets(assets)
447
+ return {
448
+ balanceAtomic: nativeBalanceAtomic,
449
+ balanceFormatted: nativeDisplay.balanceFormatted,
450
+ balanceDisplay: nativeDisplay.balanceDisplay,
451
+ balanceSymbol: 'SOL',
452
+ balanceLamports: Number.parseInt(nativeBalanceAtomic, 10),
453
+ balanceSol: Number.parseFloat(nativeDisplay.balanceFormatted),
454
+ assets: sortedAssets,
455
+ summary: buildPortfolioSummary(sortedAssets),
456
+ }
457
+ }
458
+
459
+ async function fetchAlchemyTokenContracts(provider: JsonRpcProvider, address: string): Promise<string[] | null> {
460
+ try {
461
+ const response = await provider.send('alchemy_getTokenBalances', [address, 'erc20']) as {
462
+ tokenBalances?: Array<{ contractAddress?: string; tokenBalance?: string }>
463
+ }
464
+ const balances = Array.isArray(response?.tokenBalances) ? response.tokenBalances : []
465
+ return balances
466
+ .map((entry) => String(entry?.contractAddress || '').trim())
467
+ .filter((value) => value && value !== '0x0000000000000000000000000000000000000000')
468
+ } catch {
469
+ return null
470
+ }
471
+ }
472
+
473
+ async function readErc20Bytes32Metadata(
474
+ provider: JsonRpcProvider,
475
+ contractAddress: string,
476
+ method: 'symbol' | 'name',
477
+ ): Promise<string> {
478
+ try {
479
+ const iface = method === 'symbol' ? ERC20_SYMBOL_BYTES32_IFACE : ERC20_NAME_BYTES32_IFACE
480
+ const raw = await provider.call({
481
+ to: contractAddress,
482
+ data: iface.encodeFunctionData(method, []),
483
+ })
484
+ if (!raw || raw === '0x') return ''
485
+ const decoded = iface.decodeFunctionResult(method, raw)[0]
486
+ if (typeof decoded !== 'string') return ''
487
+ return readFixedUtf8String(Buffer.from(decoded.slice(2), 'hex'), 0, 32)
488
+ } catch {
489
+ return ''
490
+ }
491
+ }
492
+
493
+ async function getLogsWithChunking(
494
+ provider: JsonRpcProvider,
495
+ filter: { fromBlock: number; toBlock: number; topics: Array<string | null> },
496
+ maxLogRange?: number,
497
+ ) {
498
+ const ranges = buildLogDiscoveryRanges(filter.fromBlock, filter.toBlock, maxLogRange)
499
+ const logs = []
500
+ for (const range of ranges) {
501
+ logs.push(...await provider.getLogs({
502
+ ...filter,
503
+ fromBlock: range.fromBlock,
504
+ toBlock: range.toBlock,
505
+ }))
506
+ }
507
+ return logs
508
+ }
509
+
510
+ async function discoverErc20ContractsFromLogs(
511
+ provider: JsonRpcProvider,
512
+ address: string,
513
+ walletId: string,
514
+ walletCreatedAt: number,
515
+ network: EvmNetworkConfig,
516
+ ): Promise<string[]> {
517
+ const cacheKey = `${walletId}:${network.id}:${address.toLowerCase()}`
518
+ const cached = evmContractDiscoveryCache.get(cacheKey)
519
+ try {
520
+ const latestBlock = await provider.getBlockNumber()
521
+ const contractSet = new Set(cached?.contractAddresses || [])
522
+ const fallbackFromBlock = estimateDiscoveryStartBlock({
523
+ latestBlock,
524
+ walletCreatedAt,
525
+ avgBlockMs: network.avgBlockMs,
526
+ maxDiscoveryBlocks: network.maxDiscoveryBlocks,
527
+ })
528
+ const fromBlock = cached && cached.walletCreatedAt === walletCreatedAt
529
+ ? Math.max(fallbackFromBlock, cached.scannedToBlock + 1)
530
+ : fallbackFromBlock
531
+ if (fromBlock > latestBlock) return [...contractSet]
532
+
533
+ const paddedAddress = zeroPadValue(getAddress(address), 32)
534
+ const [incoming, outgoing] = await Promise.all([
535
+ getLogsWithChunking(provider, {
536
+ fromBlock,
537
+ toBlock: latestBlock,
538
+ topics: [ERC20_TRANSFER_TOPIC, null, paddedAddress],
539
+ }, network.maxLogRange),
540
+ getLogsWithChunking(provider, {
541
+ fromBlock,
542
+ toBlock: latestBlock,
543
+ topics: [ERC20_TRANSFER_TOPIC, paddedAddress],
544
+ }, network.maxLogRange),
545
+ ])
546
+ for (const contractAddress of [...incoming, ...outgoing]
547
+ .map((log) => {
548
+ try {
549
+ return getAddress(log.address)
550
+ } catch {
551
+ return ''
552
+ }
553
+ })
554
+ .filter(Boolean)) {
555
+ contractSet.add(contractAddress)
556
+ }
557
+ evmContractDiscoveryCache.set(cacheKey, {
558
+ expiresAt: Date.now() + EVM_CONTRACT_CACHE_TTL_MS,
559
+ walletCreatedAt,
560
+ scannedToBlock: latestBlock,
561
+ contractAddresses: [...contractSet],
562
+ })
563
+ return [...contractSet]
564
+ } catch {
565
+ return cached?.contractAddresses || []
566
+ }
567
+ }
568
+
569
+ async function resolveAlchemyMetadata(
570
+ provider: JsonRpcProvider,
571
+ contractAddress: string,
572
+ ): Promise<{ symbol?: string; name?: string; decimals?: number } | null> {
573
+ try {
574
+ const metadata = await provider.send('alchemy_getTokenMetadata', [contractAddress]) as {
575
+ symbol?: string
576
+ name?: string
577
+ decimals?: number
578
+ }
579
+ return metadata || null
580
+ } catch {
581
+ return null
582
+ }
583
+ }
584
+
585
+ async function resolveErc20Asset(
586
+ provider: JsonRpcProvider,
587
+ address: string,
588
+ contractAddress: string,
589
+ network: EvmNetworkConfig,
590
+ ): Promise<WalletAssetBalance | null> {
591
+ try {
592
+ const normalizedContractAddress = getAddress(contractAddress)
593
+ const contract = new Contract(normalizedContractAddress, ERC20_ABI, provider)
594
+ const metadata = await resolveAlchemyMetadata(provider, contractAddress)
595
+ const knownToken = network.knownTokens?.find((token) => token.address.toLowerCase() === normalizedContractAddress.toLowerCase())
596
+ const [balanceRaw, decimalsRaw, symbolRaw, nameRaw] = await Promise.all([
597
+ contract.balanceOf(address).catch(() => BigInt(0)),
598
+ metadata?.decimals != null
599
+ ? Promise.resolve(metadata.decimals)
600
+ : knownToken?.decimals != null
601
+ ? Promise.resolve(knownToken.decimals)
602
+ : contract.decimals().catch(() => 18),
603
+ metadata?.symbol
604
+ ? Promise.resolve(metadata.symbol)
605
+ : knownToken?.symbol
606
+ ? Promise.resolve(knownToken.symbol)
607
+ : contract.symbol().catch(() => readErc20Bytes32Metadata(provider, normalizedContractAddress, 'symbol')),
608
+ metadata?.name
609
+ ? Promise.resolve(metadata.name)
610
+ : knownToken?.name
611
+ ? Promise.resolve(knownToken.name)
612
+ : contract.name().catch(() => readErc20Bytes32Metadata(provider, normalizedContractAddress, 'name')),
613
+ ])
614
+ const balanceAtomic = balanceRaw.toString()
615
+ if (BigInt(balanceAtomic) <= BigInt(0)) return null
616
+ const decimals = typeof decimalsRaw === 'number' ? decimalsRaw : Number(decimalsRaw ?? 18)
617
+ const symbol = typeof symbolRaw === 'string' && symbolRaw.trim() ? symbolRaw.trim() : shortId(normalizedContractAddress)
618
+ const name = typeof nameRaw === 'string' && nameRaw.trim() ? nameRaw.trim() : `ERC-20 ${shortId(normalizedContractAddress)}`
619
+ const display = normalizeAssetDisplay(symbol, balanceAtomic, decimals)
620
+ return {
621
+ id: `${network.id}:${normalizedContractAddress.toLowerCase()}`,
622
+ chain: 'ethereum',
623
+ networkId: network.id,
624
+ networkLabel: network.label,
625
+ symbol,
626
+ name,
627
+ decimals,
628
+ balanceAtomic,
629
+ ...display,
630
+ isNative: false,
631
+ contractAddress: normalizedContractAddress,
632
+ explorerUrl: `${network.tokenExplorerBaseUrl}${normalizedContractAddress}`,
633
+ }
634
+ } catch {
635
+ return null
636
+ }
637
+ }
638
+
639
+ async function fetchEvmAssets(wallet: AgentWallet): Promise<WalletPortfolio> {
640
+ const assets: WalletAssetBalance[] = []
641
+ let totalNativeAtomic = BigInt(0)
642
+
643
+ for (const network of getEnabledEvmNetworks()) {
644
+ const provider = getEthereumProvider(network.rpcUrl)
645
+ let nativeBalanceAtomic = '0'
646
+ try {
647
+ nativeBalanceAtomic = (await provider.getBalance(wallet.publicKey)).toString()
648
+ } catch {
649
+ nativeBalanceAtomic = '0'
650
+ }
651
+ totalNativeAtomic += BigInt(nativeBalanceAtomic)
652
+ assets.push({
653
+ id: `${network.id}:native`,
654
+ chain: 'ethereum',
655
+ networkId: network.id,
656
+ networkLabel: network.label,
657
+ symbol: 'ETH',
658
+ name: `${network.label} ETH`,
659
+ decimals: 18,
660
+ balanceAtomic: nativeBalanceAtomic,
661
+ ...normalizeAssetDisplay('ETH', nativeBalanceAtomic, 18, { minFractionDigits: 4 }),
662
+ isNative: true,
663
+ explorerUrl: `${network.addressExplorerBaseUrl}${wallet.publicKey}`,
664
+ })
665
+
666
+ const contractSet = new Set<string>()
667
+ for (const knownToken of network.knownTokens || []) contractSet.add(knownToken.address)
668
+ const alchemyContracts = await fetchAlchemyTokenContracts(provider, wallet.publicKey)
669
+ for (const contractAddress of alchemyContracts || []) contractSet.add(getAddress(contractAddress))
670
+ const discoveredContracts = await discoverErc20ContractsFromLogs(provider, wallet.publicKey, wallet.id, wallet.createdAt, network)
671
+ for (const contractAddress of discoveredContracts) contractSet.add(contractAddress)
672
+
673
+ const tokenAssets = await Promise.all(
674
+ [...contractSet].map((contractAddress) => resolveErc20Asset(provider, wallet.publicKey, contractAddress, network)),
675
+ )
676
+ assets.push(...tokenAssets.filter((asset): asset is WalletAssetBalance => Boolean(asset)))
677
+ }
678
+
679
+ const nativeBalanceAtomic = totalNativeAtomic.toString()
680
+ const nativeDisplay = normalizeAssetDisplay('ETH', nativeBalanceAtomic, 18, { minFractionDigits: 4 })
681
+ const sortedAssets = sortAssets(assets)
682
+ return {
683
+ balanceAtomic: nativeBalanceAtomic,
684
+ balanceFormatted: nativeDisplay.balanceFormatted,
685
+ balanceDisplay: nativeDisplay.balanceDisplay,
686
+ balanceSymbol: getWalletAssetSymbol('ethereum'),
687
+ assets: sortedAssets,
688
+ summary: buildPortfolioSummary(sortedAssets),
689
+ }
690
+ }
691
+
692
+ export async function getWalletPortfolio(wallet: AgentWallet, options?: GetWalletPortfolioOptions): Promise<WalletPortfolio> {
693
+ const cacheKey = buildPortfolioCacheKey(wallet)
694
+ const cached = getCurrentCachedPortfolioEntry(wallet)
695
+ if (cached && cached.expiresAt > Date.now()) return cached.portfolio
696
+
697
+ const stale = options?.allowStale ? getLatestCachedPortfolioEntry(wallet.id)?.portfolio || null : null
698
+ const portfolio = await resolveWalletPortfolioWithTimeout({
699
+ load: () => (wallet.chain === 'ethereum' ? fetchEvmAssets(wallet) : fetchSolanaAssets(wallet)),
700
+ timeoutMs: options?.timeoutMs,
701
+ stale,
702
+ label: `wallet portfolio ${wallet.id}`,
703
+ })
704
+
705
+ portfolioCache.set(cacheKey, {
706
+ expiresAt: Date.now() + PORTFOLIO_CACHE_TTL_MS,
707
+ portfolio,
708
+ })
709
+ return portfolio
710
+ }
711
+
712
+ export function clearWalletPortfolioCache(walletId?: string) {
713
+ if (!walletId) {
714
+ portfolioCache.clear()
715
+ evmContractDiscoveryCache.clear()
716
+ return
717
+ }
718
+ for (const key of portfolioCache.keys()) {
719
+ if (key.startsWith(`${walletId}:`)) portfolioCache.delete(key)
720
+ }
721
+ for (const [key, entry] of evmContractDiscoveryCache.entries()) {
722
+ if (entry.expiresAt <= Date.now() || key.startsWith(`${walletId}:`)) evmContractDiscoveryCache.delete(key)
723
+ }
724
+ }