auramaxx 1.0.0-alpha.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 (363) hide show
  1. package/LICENSE +26 -0
  2. package/README.md +112 -0
  3. package/bin/aurawallet.js +121 -0
  4. package/docs/ADAPTERS.md +467 -0
  5. package/docs/API.md +2679 -0
  6. package/docs/APPS.md +198 -0
  7. package/docs/ARCHITECTURE.md +350 -0
  8. package/docs/AUTH.md +698 -0
  9. package/docs/BEST-PRACTICES.md +121 -0
  10. package/docs/CLI.md +61 -0
  11. package/docs/DEVELOPING-APPS.md +452 -0
  12. package/docs/EXTENSION.md +97 -0
  13. package/docs/JOBS.md +33 -0
  14. package/docs/MCP.md +76 -0
  15. package/docs/PROTOCOL.md +142 -0
  16. package/docs/SETUP.md +219 -0
  17. package/docs/WORKSPACE.md +672 -0
  18. package/docs/agent-auth.md +63 -0
  19. package/docs/aura-file.md +48 -0
  20. package/docs/credentials.md +53 -0
  21. package/docs/external/getting-started.md +65 -0
  22. package/docs/external/overview.md +45 -0
  23. package/docs/external/use-cases.md +48 -0
  24. package/docs/external/why-aura.md +35 -0
  25. package/docs/jobs/connect-agent.md +77 -0
  26. package/docs/jobs/migrate-from-dotenv.md +79 -0
  27. package/docs/jobs/recover-from-lockout.md +72 -0
  28. package/docs/jobs/secure-ci.md +63 -0
  29. package/docs/oauth2.md +42 -0
  30. package/docs/passkeys.md +60 -0
  31. package/docs/security.md +540 -0
  32. package/docs/specs/aura-open-protocol.md +61 -0
  33. package/docs/specs/aura-provider-plugin.md +24 -0
  34. package/docs/specs/aura-registry-model.md +31 -0
  35. package/docs/specs/fixtures/invalid-bad-key.aura +1 -0
  36. package/docs/specs/fixtures/invalid-bad-unicode-escape.aura +1 -0
  37. package/docs/specs/fixtures/invalid-duplicate-key.aura +2 -0
  38. package/docs/specs/fixtures/valid-basic.aura +4 -0
  39. package/docs/specs/fixtures/valid-provider-ref.aura +1 -0
  40. package/docs/specs/fixtures/valid-quoted-escapes.aura +2 -0
  41. package/docs/templates/RELEASE_NOTES_TEMPLATE.md +22 -0
  42. package/docs/totp.md +40 -0
  43. package/docs/wallet/AI.md +508 -0
  44. package/docs/wallet/DEVELOPING-STRATEGIES.md +713 -0
  45. package/docs/wallet/README.md +47 -0
  46. package/docs/wallet/STRATEGY.md +89 -0
  47. package/next.config.ts +21 -0
  48. package/package.json +151 -0
  49. package/postcss.config.mjs +8 -0
  50. package/prisma/migrations/20260214170000_baseline/migration.sql +511 -0
  51. package/prisma/migrations/20260216214537_add_passkey_model/migration.sql +18 -0
  52. package/prisma/migrations/20260217150500_add_credential_access_audit/migration.sql +31 -0
  53. package/prisma/migrations/migration_lock.toml +3 -0
  54. package/prisma/schema.prisma +447 -0
  55. package/public/logo-chevron.svg +31 -0
  56. package/public/logo-concentric.svg +31 -0
  57. package/public/logo-crosshatch.svg +39 -0
  58. package/public/logo-dashed.svg +39 -0
  59. package/public/logo-horizontal.svg +31 -0
  60. package/public/logo-m56.svg +64 -0
  61. package/public/logo.webp +0 -0
  62. package/scripts/add-app.js +245 -0
  63. package/scripts/init.sh +57 -0
  64. package/scripts/migrate-apikeys-to-credentials.ts +35 -0
  65. package/scripts/sandbox-agent-flow.sh +235 -0
  66. package/scripts/sandbox.sh +175 -0
  67. package/scripts/validate-job-docs.mjs +125 -0
  68. package/server/abi/SwapHelper.json +438 -0
  69. package/server/cli/approval.ts +447 -0
  70. package/server/cli/commands/app.ts +204 -0
  71. package/server/cli/commands/cron.ts +24 -0
  72. package/server/cli/commands/doctor.ts +1007 -0
  73. package/server/cli/commands/env.ts +456 -0
  74. package/server/cli/commands/init.ts +752 -0
  75. package/server/cli/commands/mcp.ts +125 -0
  76. package/server/cli/commands/restore.ts +314 -0
  77. package/server/cli/commands/shell-hook.ts +468 -0
  78. package/server/cli/commands/start.ts +62 -0
  79. package/server/cli/commands/status.ts +59 -0
  80. package/server/cli/commands/stop.ts +14 -0
  81. package/server/cli/commands/token.ts +180 -0
  82. package/server/cli/commands/unlock.ts +49 -0
  83. package/server/cli/commands/vault.ts +417 -0
  84. package/server/cli/index.ts +328 -0
  85. package/server/cli/lib/aura-parser.ts +64 -0
  86. package/server/cli/lib/credential-create.ts +74 -0
  87. package/server/cli/lib/credential-resolve.ts +254 -0
  88. package/server/cli/lib/dotenv-migrate.ts +116 -0
  89. package/server/cli/lib/dotenv-parser.ts +146 -0
  90. package/server/cli/lib/http.ts +91 -0
  91. package/server/cli/lib/init-steps.ts +76 -0
  92. package/server/cli/lib/local-agent-trust.ts +45 -0
  93. package/server/cli/lib/process.ts +136 -0
  94. package/server/cli/lib/prompt.ts +85 -0
  95. package/server/cli/lib/theme.ts +240 -0
  96. package/server/cli/socket.ts +570 -0
  97. package/server/cli/transport-client.ts +50 -0
  98. package/server/cron/index.ts +137 -0
  99. package/server/cron/job.ts +31 -0
  100. package/server/cron/jobs/balance-sync.ts +436 -0
  101. package/server/cron/jobs/incoming-scan.ts +506 -0
  102. package/server/cron/jobs/native-price.ts +70 -0
  103. package/server/cron/jobs/orphan-cleanup.ts +40 -0
  104. package/server/cron/jobs/strategy-runner.ts +175 -0
  105. package/server/cron/scheduler.ts +125 -0
  106. package/server/index.ts +406 -0
  107. package/server/lib/adapters/factory.ts +110 -0
  108. package/server/lib/adapters/index.ts +19 -0
  109. package/server/lib/adapters/router.ts +297 -0
  110. package/server/lib/adapters/telegram.ts +645 -0
  111. package/server/lib/adapters/types.ts +89 -0
  112. package/server/lib/adapters/webhook.ts +95 -0
  113. package/server/lib/address.ts +49 -0
  114. package/server/lib/agent-auth/contracts.ts +1194 -0
  115. package/server/lib/agent-profiles.ts +328 -0
  116. package/server/lib/ai.ts +285 -0
  117. package/server/lib/api-registry/contracts.ts +86 -0
  118. package/server/lib/api-registry/validation.ts +172 -0
  119. package/server/lib/apikey-migration.ts +189 -0
  120. package/server/lib/app-installer.ts +505 -0
  121. package/server/lib/app-tokens.ts +247 -0
  122. package/server/lib/auth.ts +314 -0
  123. package/server/lib/batch.ts +242 -0
  124. package/server/lib/cold.ts +874 -0
  125. package/server/lib/config.ts +381 -0
  126. package/server/lib/credential-access-audit.ts +85 -0
  127. package/server/lib/credential-access-policy.ts +110 -0
  128. package/server/lib/credential-health.ts +343 -0
  129. package/server/lib/credential-import.ts +487 -0
  130. package/server/lib/credential-scope.ts +87 -0
  131. package/server/lib/credential-shares.ts +190 -0
  132. package/server/lib/credential-transport.ts +342 -0
  133. package/server/lib/credential-vault.ts +77 -0
  134. package/server/lib/credentials.ts +333 -0
  135. package/server/lib/crypto.ts +8 -0
  136. package/server/lib/db.ts +15 -0
  137. package/server/lib/defaults.ts +366 -0
  138. package/server/lib/dex/index.ts +80 -0
  139. package/server/lib/dex/relay.ts +235 -0
  140. package/server/lib/dex/types.ts +59 -0
  141. package/server/lib/dex/uniswap.ts +370 -0
  142. package/server/lib/e2e-agent/artifacts.ts +36 -0
  143. package/server/lib/e2e-agent/contracts.ts +112 -0
  144. package/server/lib/e2e-agent/validation.ts +135 -0
  145. package/server/lib/encrypt.ts +128 -0
  146. package/server/lib/error.ts +20 -0
  147. package/server/lib/events.ts +205 -0
  148. package/server/lib/hot.ts +357 -0
  149. package/server/lib/key-fingerprint.ts +28 -0
  150. package/server/lib/logger.ts +331 -0
  151. package/server/lib/network.ts +137 -0
  152. package/server/lib/notifications.ts +219 -0
  153. package/server/lib/oauth2-refresh.ts +241 -0
  154. package/server/lib/oursecret.ts +54 -0
  155. package/server/lib/passkey-credential.ts +360 -0
  156. package/server/lib/passkey.ts +68 -0
  157. package/server/lib/permissions.ts +248 -0
  158. package/server/lib/pino.ts +24 -0
  159. package/server/lib/policy-preview.ts +138 -0
  160. package/server/lib/price.ts +338 -0
  161. package/server/lib/prices.ts +34 -0
  162. package/server/lib/project-scope.ts +239 -0
  163. package/server/lib/resolve-action.ts +427 -0
  164. package/server/lib/resolve.ts +36 -0
  165. package/server/lib/sessions.ts +632 -0
  166. package/server/lib/solana/connection.ts +26 -0
  167. package/server/lib/solana/jupiter.ts +128 -0
  168. package/server/lib/solana/transfer.ts +108 -0
  169. package/server/lib/solana/wallet.ts +136 -0
  170. package/server/lib/strategy/emits.ts +21 -0
  171. package/server/lib/strategy/engine.ts +1305 -0
  172. package/server/lib/strategy/executor.ts +115 -0
  173. package/server/lib/strategy/hook-context.ts +158 -0
  174. package/server/lib/strategy/hooks.ts +990 -0
  175. package/server/lib/strategy/index.ts +28 -0
  176. package/server/lib/strategy/installer.ts +305 -0
  177. package/server/lib/strategy/loader.ts +256 -0
  178. package/server/lib/strategy/message.ts +235 -0
  179. package/server/lib/strategy/repository.ts +218 -0
  180. package/server/lib/strategy/session-logger.ts +693 -0
  181. package/server/lib/strategy/sources.ts +288 -0
  182. package/server/lib/strategy/state.ts +189 -0
  183. package/server/lib/strategy/templates.ts +403 -0
  184. package/server/lib/strategy/tick.ts +404 -0
  185. package/server/lib/strategy/types.ts +230 -0
  186. package/server/lib/swap.ts +3 -0
  187. package/server/lib/temp.ts +86 -0
  188. package/server/lib/token-metadata.ts +86 -0
  189. package/server/lib/token-safety.ts +200 -0
  190. package/server/lib/token-search.ts +444 -0
  191. package/server/lib/totp.ts +194 -0
  192. package/server/lib/transactions.ts +123 -0
  193. package/server/lib/transport.ts +75 -0
  194. package/server/lib/txhistory/decoder.ts +262 -0
  195. package/server/lib/txhistory/enricher.ts +652 -0
  196. package/server/lib/txhistory/index.ts +391 -0
  197. package/server/lib/txhistory/signatures.ts +59 -0
  198. package/server/lib/verified-summary.ts +421 -0
  199. package/server/mcp/profile-policy.ts +30 -0
  200. package/server/mcp/server.ts +619 -0
  201. package/server/mcp/tools.ts +523 -0
  202. package/server/middleware/auth.ts +119 -0
  203. package/server/middleware/requestLogger.ts +84 -0
  204. package/server/routes/actions.ts +459 -0
  205. package/server/routes/adapters.ts +703 -0
  206. package/server/routes/addressbook.ts +113 -0
  207. package/server/routes/ai.ts +34 -0
  208. package/server/routes/apikeys.ts +295 -0
  209. package/server/routes/apps.ts +601 -0
  210. package/server/routes/auth.ts +457 -0
  211. package/server/routes/backup.ts +340 -0
  212. package/server/routes/batch.ts +270 -0
  213. package/server/routes/bookmarks.ts +162 -0
  214. package/server/routes/credential-shares.ts +198 -0
  215. package/server/routes/credential-vaults.ts +154 -0
  216. package/server/routes/credentials.ts +1290 -0
  217. package/server/routes/dashboard.ts +71 -0
  218. package/server/routes/defaults.ts +124 -0
  219. package/server/routes/fund.ts +229 -0
  220. package/server/routes/import.ts +352 -0
  221. package/server/routes/launch.ts +665 -0
  222. package/server/routes/lock.ts +54 -0
  223. package/server/routes/logs.ts +68 -0
  224. package/server/routes/nuke.ts +111 -0
  225. package/server/routes/passkey-credentials.ts +99 -0
  226. package/server/routes/passkey.ts +346 -0
  227. package/server/routes/portfolio.ts +217 -0
  228. package/server/routes/price.ts +63 -0
  229. package/server/routes/resolve.ts +31 -0
  230. package/server/routes/security.ts +45 -0
  231. package/server/routes/send-evm.ts +241 -0
  232. package/server/routes/send-solana.ts +281 -0
  233. package/server/routes/send.ts +178 -0
  234. package/server/routes/setup.ts +210 -0
  235. package/server/routes/strategy.ts +894 -0
  236. package/server/routes/swap-evm.ts +353 -0
  237. package/server/routes/swap-solana.ts +177 -0
  238. package/server/routes/swap.ts +356 -0
  239. package/server/routes/token.ts +247 -0
  240. package/server/routes/unlock.ts +403 -0
  241. package/server/routes/wallet-assets.ts +361 -0
  242. package/server/routes/wallet-transactions.ts +515 -0
  243. package/server/routes/wallet.ts +710 -0
  244. package/server/types.ts +146 -0
  245. package/skills/aurawallet/SKILL.md +739 -0
  246. package/skills/aurawallet-setup/SKILL.md +74 -0
  247. package/skills/security-review/SKILL.md +148 -0
  248. package/src/app/api/agent-requests/route.ts +30 -0
  249. package/src/app/api/apps/install/route.ts +126 -0
  250. package/src/app/api/apps/manifests/route.ts +16 -0
  251. package/src/app/api/apps/static/[...path]/route.ts +57 -0
  252. package/src/app/api/events/route.ts +92 -0
  253. package/src/app/api/page.tsx +212 -0
  254. package/src/app/api/workspace/[id]/apps/[wid]/route.ts +119 -0
  255. package/src/app/api/workspace/[id]/apps/route.ts +81 -0
  256. package/src/app/api/workspace/[id]/export/route.ts +67 -0
  257. package/src/app/api/workspace/[id]/route.ts +168 -0
  258. package/src/app/api/workspace/auth.ts +34 -0
  259. package/src/app/api/workspace/config/route.ts +106 -0
  260. package/src/app/api/workspace/import/route.ts +127 -0
  261. package/src/app/api/workspace/route.ts +116 -0
  262. package/src/app/app/page.tsx +2122 -0
  263. package/src/app/apple-icon.png +0 -0
  264. package/src/app/docs/page.tsx +178 -0
  265. package/src/app/favicon.ico +0 -0
  266. package/src/app/globals.css +572 -0
  267. package/src/app/health/page.tsx +5 -0
  268. package/src/app/hello/page.tsx +15 -0
  269. package/src/app/icon.png +0 -0
  270. package/src/app/layout.tsx +34 -0
  271. package/src/app/page.tsx +986 -0
  272. package/src/app/providers.tsx +90 -0
  273. package/src/app/share/[token]/page.tsx +295 -0
  274. package/src/components/ChainSelector.tsx +144 -0
  275. package/src/components/HumanActionBar.tsx +695 -0
  276. package/src/components/NotificationDrawer.tsx +129 -0
  277. package/src/components/apps/AgentKeysApp.tsx +490 -0
  278. package/src/components/apps/App.tsx +153 -0
  279. package/src/components/apps/AppGrid.tsx +15 -0
  280. package/src/components/apps/DetailedAddressDrawer.tsx +325 -0
  281. package/src/components/apps/DraggableApp.tsx +562 -0
  282. package/src/components/apps/IFrameApp.tsx +73 -0
  283. package/src/components/apps/LogsApp.tsx +360 -0
  284. package/src/components/apps/SendApp.tsx +394 -0
  285. package/src/components/apps/SetupWizardApp.tsx +1004 -0
  286. package/src/components/apps/SystemDefaultsApp.tsx +845 -0
  287. package/src/components/apps/ThirdPartyApp.tsx +428 -0
  288. package/src/components/apps/TokenApp.tsx +319 -0
  289. package/src/components/apps/TransactionsApp.tsx +438 -0
  290. package/src/components/apps/WalletDetailApp.tsx +1505 -0
  291. package/src/components/apps/index.ts +13 -0
  292. package/src/components/design-system/Button.tsx +53 -0
  293. package/src/components/design-system/ChainIndicator.tsx +65 -0
  294. package/src/components/design-system/ChainSelector.tsx +137 -0
  295. package/src/components/design-system/ConfirmationModal.tsx +106 -0
  296. package/src/components/design-system/ConfirmationPopover.tsx +81 -0
  297. package/src/components/design-system/Drawer.tsx +123 -0
  298. package/src/components/design-system/FilterDropdown.tsx +72 -0
  299. package/src/components/design-system/Modal.tsx +206 -0
  300. package/src/components/design-system/Popover.tsx +142 -0
  301. package/src/components/design-system/TextInput.tsx +85 -0
  302. package/src/components/design-system/Toggle.tsx +58 -0
  303. package/src/components/design-system/index.ts +11 -0
  304. package/src/components/docs/DocsThemeToggle.tsx +49 -0
  305. package/src/components/health/CredentialHealthDashboard.tsx +214 -0
  306. package/src/components/icons/ChainIcons.tsx +72 -0
  307. package/src/components/layout/AppStoreDrawer.tsx +369 -0
  308. package/src/components/layout/ContentArea.tsx +21 -0
  309. package/src/components/layout/TabBar.tsx +278 -0
  310. package/src/components/layout/WalletSidebar.tsx +1033 -0
  311. package/src/components/layout/index.ts +4 -0
  312. package/src/components/marketing/AuraWalletSpecOverlay.tsx +635 -0
  313. package/src/components/marketing/DeviceMorphExperience.tsx +216 -0
  314. package/src/components/vault/ApiKeysConsole.tsx +1080 -0
  315. package/src/components/vault/AuditConsole.tsx +584 -0
  316. package/src/components/vault/CredentialDetail.tsx +455 -0
  317. package/src/components/vault/CredentialEmpty.tsx +55 -0
  318. package/src/components/vault/CredentialField.tsx +361 -0
  319. package/src/components/vault/CredentialForm.tsx +1212 -0
  320. package/src/components/vault/CredentialList.tsx +165 -0
  321. package/src/components/vault/CredentialRow.tsx +97 -0
  322. package/src/components/vault/CredentialShareModal.tsx +178 -0
  323. package/src/components/vault/CredentialVault.tsx +754 -0
  324. package/src/components/vault/CredentialWalletWidget.tsx +103 -0
  325. package/src/components/vault/ImportCredentialsModal.tsx +515 -0
  326. package/src/components/vault/LargeTypeModal.tsx +64 -0
  327. package/src/components/vault/PasswordGenerator.tsx +224 -0
  328. package/src/components/vault/TOTPDisplay.tsx +123 -0
  329. package/src/components/vault/VaultSidebar.tsx +413 -0
  330. package/src/components/vault/types.ts +54 -0
  331. package/src/context/AuthContext.tsx +337 -0
  332. package/src/context/PriceContext.tsx +113 -0
  333. package/src/context/ThemeContext.tsx +164 -0
  334. package/src/context/WebSocketContext.tsx +269 -0
  335. package/src/context/WorkspaceContext.tsx +668 -0
  336. package/src/hooks/index.ts +3 -0
  337. package/src/hooks/useAgentActions.ts +368 -0
  338. package/src/hooks/useBalance.ts +103 -0
  339. package/src/hooks/useBalances.ts +129 -0
  340. package/src/instrumentation.ts +12 -0
  341. package/src/lib/api.ts +449 -0
  342. package/src/lib/app-loader.ts +148 -0
  343. package/src/lib/app-registry.ts +178 -0
  344. package/src/lib/app-sdk.ts +157 -0
  345. package/src/lib/audit-console-adapter.ts +151 -0
  346. package/src/lib/auth-client.ts +75 -0
  347. package/src/lib/config.ts +74 -0
  348. package/src/lib/crypto.ts +112 -0
  349. package/src/lib/db.ts +21 -0
  350. package/src/lib/docs.ts +390 -0
  351. package/src/lib/events.ts +361 -0
  352. package/src/lib/pino.ts +24 -0
  353. package/src/lib/theme-handlers.ts +168 -0
  354. package/src/lib/theme.ts +351 -0
  355. package/src/lib/tokenData.ts +378 -0
  356. package/src/lib/vault-crypto.ts +129 -0
  357. package/src/lib/websocket-server.ts +302 -0
  358. package/src/lib/websocket-setup.ts +79 -0
  359. package/src/lib/wordlist.ts +2050 -0
  360. package/src/lib/workspace-handlers.ts +285 -0
  361. package/start.sh +80 -0
  362. package/tailwind.config.ts +99 -0
  363. package/tsconfig.json +42 -0
@@ -0,0 +1,1505 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useCallback } from 'react';
4
+ import {
5
+ Copy,
6
+ Edit2,
7
+ Save,
8
+ EyeOff,
9
+ Eye,
10
+ X,
11
+ RefreshCw,
12
+ Coins,
13
+ ArrowUpDown,
14
+ Search,
15
+ Loader2,
16
+ ExternalLink,
17
+ Wifi,
18
+ WifiOff,
19
+ Plus,
20
+ Trash2,
21
+ Lock,
22
+ } from 'lucide-react';
23
+ import { Button, ChainSelector, FilterDropdown, Popover, TextInput } from '@/components/design-system';
24
+ import { useWebSocket } from '@/context/WebSocketContext';
25
+ import { usePrice } from '@/context/PriceContext';
26
+ import { useAuth } from '@/context/AuthContext';
27
+ import { api, Api, type AssetsResponse, type TrackedAsset, type TransactionsResponse } from '@/lib/api';
28
+ import { useBalance } from '@/hooks/useBalance';
29
+ import { fetchTokenData, fetchSolanaTokenData, calculateUsdValue, formatUsdValue, type TokenData } from '@/lib/tokenData';
30
+ import { WALLET_EVENTS, type AssetChangedData, type BalanceUpdatedData, type TxCreatedData } from '@/lib/events';
31
+
32
+ interface WalletData {
33
+ address: string;
34
+ tier: 'cold' | 'hot' | 'temp';
35
+ chain: string;
36
+ balance?: string;
37
+ name?: string;
38
+ color?: string;
39
+ emoji?: string;
40
+ description?: string;
41
+ hidden?: boolean;
42
+ tokenHash?: string;
43
+ createdAt?: string;
44
+ }
45
+
46
+
47
+ interface Transaction {
48
+ id: string;
49
+ walletAddress: string;
50
+ txHash: string | null;
51
+ type: string;
52
+ status: string;
53
+ amount: string | null;
54
+ tokenAddress: string | null;
55
+ tokenAmount: string | null;
56
+ from: string | null;
57
+ to: string | null;
58
+ description: string | null;
59
+ blockNumber: number | null;
60
+ chain: string;
61
+ createdAt: string;
62
+ updatedAt: string;
63
+ executedAt: string | null;
64
+ }
65
+
66
+ // Self-contained app - only needs config with walletAddress
67
+ interface WalletDetailAppProps {
68
+ config?: {
69
+ walletAddress?: string;
70
+ };
71
+ }
72
+
73
+ const EMOJI_OPTIONS = ['🔥', '💎', '🚀', '⚡', '🌙', '🌟', '💰', '🎯', '🔮', '🌈'];
74
+ const COLOR_OPTIONS = ['#ff4d00', '#0047ff', '#00c853', '#ffab00', '#9c27b0', '#00bcd4', '#e91e63', '#607d8b'];
75
+
76
+ type TabType = 'assets' | 'transactions';
77
+
78
+ function formatTimeAgo(dateString: string): string {
79
+ const date = new Date(dateString);
80
+ const now = new Date();
81
+ const diffMs = now.getTime() - date.getTime();
82
+ const diffMins = Math.floor(diffMs / 60000);
83
+ const diffHours = Math.floor(diffMins / 60);
84
+ const diffDays = Math.floor(diffHours / 24);
85
+
86
+ if (diffMins < 1) return 'just now';
87
+ if (diffMins < 60) return `${diffMins}m ago`;
88
+ if (diffHours < 24) return `${diffHours}h ago`;
89
+ return `${diffDays}d ago`;
90
+ }
91
+
92
+ function shortenAddress(address: string, chars = 6): string {
93
+ return `${address.slice(0, chars + 2)}...${address.slice(-chars)}`;
94
+ }
95
+
96
+ function isSolanaChain(chain: string): boolean {
97
+ return chain === 'solana' || chain === 'solana-devnet';
98
+ }
99
+
100
+ /** Chain-aware address comparison: case-insensitive for EVM hex, exact for Solana base58 */
101
+ function addressesMatch(a: string, b: string): boolean {
102
+ if (!a || !b) return false;
103
+ if (a.startsWith('0x') || b.startsWith('0x')) {
104
+ return a.toLowerCase() === b.toLowerCase();
105
+ }
106
+ return a === b;
107
+ }
108
+
109
+ const TX_TYPE_COLORS: Record<string, string> = {
110
+ send: 'var(--color-warning, #ff4d00)',
111
+ receive: 'var(--color-success, #00c853)',
112
+ swap: 'var(--color-info, #0047ff)',
113
+ contract: 'var(--color-text-muted, #888)',
114
+ manual: 'var(--color-text-muted, #888)',
115
+ };
116
+
117
+ const TX_TYPE_OPTIONS = [
118
+ { value: 'all', label: 'ALL' },
119
+ { value: 'send', label: 'SEND' },
120
+ { value: 'receive', label: 'RECEIVE' },
121
+ { value: 'swap', label: 'SWAP' },
122
+ { value: 'contract', label: 'CONTRACT' },
123
+ { value: 'manual', label: 'MANUAL' },
124
+ ];
125
+
126
+
127
+
128
+ export const WalletDetailApp: React.FC<WalletDetailAppProps> = ({ config }) => {
129
+ const [manualAddress, setManualAddress] = useState('');
130
+ const [committedAddress, setCommittedAddress] = useState('');
131
+ const walletAddress = config?.walletAddress || committedAddress || undefined;
132
+ const { getRpcUrl, getConfiguredChains, getChainConfig, isUnlocked } = useAuth();
133
+ const chainOptions = Object.keys(getConfiguredChains()).map(c => ({ value: c, label: c.toUpperCase() }));
134
+ const { subscribe, connected } = useWebSocket();
135
+ const { ethPrice, formatUsd } = usePrice();
136
+
137
+ // Wallet data (fetched from API)
138
+ const [wallet, setWallet] = useState<WalletData | null>(null);
139
+ const [walletLoading, setWalletLoading] = useState(!!walletAddress);
140
+ const [walletError, setWalletError] = useState<string | null>(null);
141
+
142
+ // Copy state (internal)
143
+ const [copied, setCopied] = useState(false);
144
+
145
+ // Edit mode
146
+ const [isEditMode, setIsEditMode] = useState(false);
147
+ const [saving, setSaving] = useState(false);
148
+ const [activeTab, setActiveTab] = useState<TabType>('assets');
149
+
150
+ // Edit form state (initialized empty, updated when wallet loads)
151
+ const [editName, setEditName] = useState('');
152
+ const [editDescription, setEditDescription] = useState('');
153
+ const [editEmoji, setEditEmoji] = useState('');
154
+ const [editColor, setEditColor] = useState('');
155
+ const [editHidden, setEditHidden] = useState(false);
156
+
157
+ // Assets state
158
+ const [assets, setAssets] = useState<TrackedAsset[]>([]);
159
+ const [assetsLoading, setAssetsLoading] = useState(false);
160
+ const [assetsSearch, setAssetsSearch] = useState('');
161
+ const [tokenDataMap, setTokenDataMap] = useState<Map<string, TokenData>>(new Map());
162
+ const [balancesLoading, setBalancesLoading] = useState(false);
163
+
164
+ // Add asset popover state
165
+ const [showAddAsset, setShowAddAsset] = useState(false);
166
+ const [addAssetAnchor, setAddAssetAnchor] = useState<HTMLElement | null>(null);
167
+ const [addAssetForm, setAddAssetForm] = useState({ tokenAddress: '', symbol: '', name: '', chain: 'base' });
168
+ const [addingAsset, setAddingAsset] = useState(false);
169
+
170
+ // Chain filter state
171
+ const [selectedChain, setSelectedChain] = useState<string>('');
172
+
173
+ // Balance from RPC (via hook) — always uses the wallet's own chain, not the filter
174
+ const { balance, loading: balanceLoading, currency } = useBalance(wallet?.address, wallet?.chain);
175
+
176
+ // Transactions state
177
+ const [transactions, setTransactions] = useState<Transaction[]>([]);
178
+ const [txLoading, setTxLoading] = useState(false);
179
+ const [txSearch, setTxSearch] = useState('');
180
+ const [txTypeFilter, setTxTypeFilter] = useState('all');
181
+ const [txHasMore, setTxHasMore] = useState(false);
182
+ const [txOffset, setTxOffset] = useState(0);
183
+
184
+ // Fetch wallet data from API
185
+ const fetchWallet = useCallback(async () => {
186
+ if (!walletAddress || !isUnlocked) return;
187
+
188
+ setWalletLoading(true);
189
+ setWalletError(null);
190
+ try {
191
+ const data = await Promise.race([
192
+ api.get<WalletData>(Api.Wallet, `/wallet/${walletAddress}`),
193
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Server unreachable')), 5000)),
194
+ ]);
195
+ setWallet(data);
196
+ // Initialize edit form with fetched data
197
+ setEditName(data.name || '');
198
+ setEditDescription(data.description || '');
199
+ setEditEmoji(data.emoji || '');
200
+ setEditColor(data.color || '');
201
+ setEditHidden(data.hidden || false);
202
+ setSelectedChain(data.chain || 'base');
203
+ setAddAssetForm(prev => ({ ...prev, chain: data.chain || 'base' }));
204
+ } catch (err) {
205
+ console.error('[WalletDetail] Failed to fetch wallet:', err);
206
+ setWalletError(err instanceof Error ? err.message : 'Failed to fetch wallet');
207
+ } finally {
208
+ setWalletLoading(false);
209
+ }
210
+ }, [walletAddress, isUnlocked]);
211
+
212
+ // Copy address to clipboard
213
+ const copyAddress = useCallback(() => {
214
+ if (wallet?.address) {
215
+ navigator.clipboard.writeText(wallet.address);
216
+ setCopied(true);
217
+ setTimeout(() => setCopied(false), 2000);
218
+ }
219
+ }, [wallet?.address]);
220
+
221
+ // Fetch wallet on mount
222
+ useEffect(() => {
223
+ fetchWallet();
224
+ }, [fetchWallet]);
225
+
226
+ // Fetch assets from backend API
227
+ const fetchAssets = useCallback(async () => {
228
+ if (!wallet || !selectedChain) return;
229
+ setAssetsLoading(true);
230
+ try {
231
+ const data = await api.get<AssetsResponse>(Api.Wallet, `/wallet/${wallet.address}/assets`, {
232
+ sortBy: 'updatedAt',
233
+ sortDir: 'desc',
234
+ limit: 100,
235
+ chain: selectedChain,
236
+ });
237
+ if (data.success) {
238
+ setAssets(data.assets);
239
+ }
240
+ } catch (err) {
241
+ console.error('[WalletDetail] Failed to fetch assets:', err);
242
+ } finally {
243
+ setAssetsLoading(false);
244
+ }
245
+ }, [wallet, selectedChain]);
246
+
247
+ // Fetch token balances (EVM uses batch eth_call, Solana uses getParsedTokenAccountsByOwner)
248
+ const fetchBalances = useCallback(async (assetList: TrackedAsset[]) => {
249
+ if (assetList.length === 0 || !wallet || !selectedChain) return;
250
+
251
+ setBalancesLoading(true);
252
+ try {
253
+ const assetInfos = assetList.map(a => ({
254
+ tokenAddress: a.tokenAddress,
255
+ decimals: a.decimals,
256
+ poolAddress: a.poolAddress,
257
+ poolVersion: a.poolVersion,
258
+ }));
259
+
260
+ const rpcUrl = getRpcUrl(selectedChain);
261
+ const data = isSolanaChain(selectedChain)
262
+ ? await fetchSolanaTokenData(wallet.address, assetInfos, rpcUrl)
263
+ : await fetchTokenData(wallet.address, assetInfos, rpcUrl);
264
+ setTokenDataMap(data);
265
+ } catch (err) {
266
+ console.error('[WalletDetail] Failed to fetch balances:', err);
267
+ } finally {
268
+ setBalancesLoading(false);
269
+ }
270
+ }, [wallet, selectedChain, getRpcUrl]);
271
+
272
+ // Fetch transactions from backend API
273
+ const fetchTransactions = useCallback(async (reset = false) => {
274
+ if (!wallet || !selectedChain) return;
275
+ setTxLoading(true);
276
+ try {
277
+ const offset = reset ? 0 : txOffset;
278
+ const params: Record<string, string | number> = {
279
+ limit: 50,
280
+ offset,
281
+ sortBy: 'createdAt',
282
+ sortDir: 'desc',
283
+ chain: selectedChain,
284
+ };
285
+ if (txTypeFilter !== 'all') {
286
+ params.type = txTypeFilter;
287
+ }
288
+ if (txSearch) {
289
+ params.search = txSearch;
290
+ }
291
+
292
+ const data = await api.get<TransactionsResponse>(Api.Wallet, `/wallet/${wallet.address}/transactions`, params);
293
+ if (data.success) {
294
+ if (reset) {
295
+ setTransactions(data.transactions);
296
+ setTxOffset(data.transactions.length);
297
+ } else {
298
+ setTransactions(prev => [...prev, ...data.transactions]);
299
+ setTxOffset(offset + data.transactions.length);
300
+ }
301
+ setTxHasMore(data.pagination.hasMore);
302
+ }
303
+ } catch (err) {
304
+ console.error('[WalletDetail] Failed to fetch transactions:', err);
305
+ } finally {
306
+ setTxLoading(false);
307
+ }
308
+ }, [wallet, selectedChain, txTypeFilter, txSearch, txOffset]);
309
+
310
+ // Refetch all data when selectedChain changes (also handles initial load)
311
+ useEffect(() => {
312
+ if (wallet && selectedChain) {
313
+ fetchAssets();
314
+ fetchTransactions(true);
315
+ }
316
+ }, [selectedChain]); // eslint-disable-line react-hooks/exhaustive-deps
317
+
318
+ // Fetch balances when assets change
319
+ useEffect(() => {
320
+ if (assets.length > 0) {
321
+ fetchBalances(assets);
322
+ }
323
+ }, [assets, fetchBalances]);
324
+
325
+ // Refetch transactions when filter changes
326
+ useEffect(() => {
327
+ fetchTransactions(true);
328
+ }, [txTypeFilter]); // eslint-disable-line react-hooks/exhaustive-deps
329
+
330
+ // WebSocket subscriptions
331
+ useEffect(() => {
332
+ if (!wallet) return;
333
+
334
+ const unsubscribeAsset = subscribe('asset:changed', (event) => {
335
+ const data = event.data as AssetChangedData;
336
+ if (!addressesMatch(data.walletAddress, wallet.address)) return;
337
+
338
+ // Handle removal
339
+ if (data.removed) {
340
+ setAssets(prev => prev.filter(
341
+ a => !addressesMatch(a.tokenAddress, data.tokenAddress)
342
+ ));
343
+ return;
344
+ }
345
+
346
+ // Update or add asset in local state
347
+ setAssets(prev => {
348
+ const existingIndex = prev.findIndex(
349
+ a => addressesMatch(a.tokenAddress, data.tokenAddress)
350
+ );
351
+
352
+ if (existingIndex >= 0) {
353
+ // Update existing
354
+ const updated = [...prev];
355
+ updated[existingIndex] = {
356
+ ...updated[existingIndex],
357
+ symbol: data.symbol ?? updated[existingIndex].symbol,
358
+ name: data.name ?? updated[existingIndex].name,
359
+ poolAddress: data.poolAddress ?? updated[existingIndex].poolAddress,
360
+ poolVersion: data.poolVersion ?? updated[existingIndex].poolVersion,
361
+ icon: data.icon ?? updated[existingIndex].icon,
362
+ updatedAt: new Date().toISOString(),
363
+ };
364
+ // Re-sort by updatedAt
365
+ return updated.sort((a, b) =>
366
+ new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
367
+ );
368
+ } else {
369
+ // Add new (will need to refetch to get full data)
370
+ fetchAssets();
371
+ return prev;
372
+ }
373
+ });
374
+ });
375
+
376
+ // Subscribe to balance:updated events from cron server
377
+ const unsubscribeBalance = subscribe(WALLET_EVENTS.BALANCE_UPDATED, (event) => {
378
+ const data = event.data as BalanceUpdatedData;
379
+
380
+ if (data.type === 'token') {
381
+ // Update token balances in the assets list
382
+ setAssets(prev => {
383
+ let changed = false;
384
+ const updated = prev.map(asset => {
385
+ const match = data.balances.find(b =>
386
+ addressesMatch(b.walletAddress, wallet.address) &&
387
+ b.tokenAddress && addressesMatch(b.tokenAddress, asset.tokenAddress)
388
+ );
389
+ if (match) {
390
+ changed = true;
391
+ return { ...asset, lastBalance: match.balance, lastBalanceAt: new Date().toISOString() };
392
+ }
393
+ return asset;
394
+ });
395
+ return changed ? updated : prev;
396
+ });
397
+ }
398
+ });
399
+
400
+ const unsubscribeTx = subscribe('tx:created', (event) => {
401
+ const data = event.data as TxCreatedData;
402
+ if (!addressesMatch(data.walletAddress, wallet.address)) return;
403
+
404
+ // Prepend new transaction to local state
405
+ const newTx: Transaction = {
406
+ id: data.id,
407
+ walletAddress: data.walletAddress,
408
+ txHash: data.txHash ?? null,
409
+ type: data.type,
410
+ status: 'confirmed',
411
+ amount: data.amount ?? null,
412
+ tokenAddress: data.tokenAddress ?? null,
413
+ tokenAmount: data.tokenAmount ?? null,
414
+ from: null,
415
+ to: null,
416
+ description: data.description ?? null,
417
+ blockNumber: null,
418
+ chain: wallet.chain || 'base',
419
+ createdAt: new Date().toISOString(),
420
+ updatedAt: new Date().toISOString(),
421
+ executedAt: new Date().toISOString(),
422
+ };
423
+
424
+ setTransactions(prev => [newTx, ...prev]);
425
+ });
426
+
427
+ return () => {
428
+ unsubscribeAsset();
429
+ unsubscribeBalance();
430
+ unsubscribeTx();
431
+ };
432
+ }, [subscribe, wallet, fetchAssets]);
433
+
434
+ const handleSave = async () => {
435
+ if (!wallet) return;
436
+ setSaving(true);
437
+ try {
438
+ await api.post(Api.Wallet, '/wallet/rename', {
439
+ address: wallet.address,
440
+ name: editName || undefined,
441
+ description: editDescription || undefined,
442
+ emoji: editEmoji || undefined,
443
+ color: editColor || undefined,
444
+ hidden: editHidden,
445
+ });
446
+ // Update local wallet state
447
+ setWallet(prev => prev ? {
448
+ ...prev,
449
+ name: editName || undefined,
450
+ description: editDescription || undefined,
451
+ emoji: editEmoji || undefined,
452
+ color: editColor || undefined,
453
+ hidden: editHidden,
454
+ } : null);
455
+ setIsEditMode(false);
456
+ } catch (err) {
457
+ console.error('[WalletDetail] Failed to save:', err);
458
+ } finally {
459
+ setSaving(false);
460
+ }
461
+ };
462
+
463
+ const handleCancel = () => {
464
+ if (!wallet) return;
465
+ setEditName(wallet.name || '');
466
+ setEditDescription(wallet.description || '');
467
+ setEditEmoji(wallet.emoji || '');
468
+ setEditColor(wallet.color || '');
469
+ setEditHidden(wallet.hidden || false);
470
+ setIsEditMode(false);
471
+ };
472
+
473
+ const handleRefreshAssets = () => {
474
+ fetchAssets();
475
+ };
476
+
477
+ const handleRefreshTx = () => {
478
+ fetchTransactions(true);
479
+ };
480
+
481
+ const handleChainChange = (chain: string) => {
482
+ setSelectedChain(chain);
483
+ setAddAssetForm(prev => ({ ...prev, chain }));
484
+ };
485
+
486
+ // Add asset handler
487
+ const handleAddAsset = async () => {
488
+ if (!addAssetForm.tokenAddress || !wallet) return;
489
+
490
+ setAddingAsset(true);
491
+ try {
492
+ const data = await api.post<{ success: boolean; asset?: TrackedAsset; error?: string }>(
493
+ Api.Wallet,
494
+ `/wallet/${wallet.address}/asset`,
495
+ {
496
+ tokenAddress: addAssetForm.tokenAddress,
497
+ symbol: addAssetForm.symbol || undefined,
498
+ name: addAssetForm.name || undefined,
499
+ chain: addAssetForm.chain,
500
+ }
501
+ );
502
+
503
+ if (data.success) {
504
+ setShowAddAsset(false);
505
+ setAddAssetForm({ tokenAddress: '', symbol: '', name: '', chain: wallet.chain || 'base' });
506
+ fetchAssets(); // Refresh the list
507
+ } else {
508
+ console.error('[WalletDetail] Failed to add asset:', data.error);
509
+ }
510
+ } catch (err) {
511
+ console.error('[WalletDetail] Failed to add asset:', err);
512
+ } finally {
513
+ setAddingAsset(false);
514
+ }
515
+ };
516
+
517
+ // Remove asset handler
518
+ const handleRemoveAsset = async (assetId: string) => {
519
+ if (!wallet) return;
520
+
521
+ try {
522
+ const data = await api.delete<{ success: boolean; error?: string }>(
523
+ Api.Wallet,
524
+ `/wallet/${wallet.address}/asset/${assetId}`
525
+ );
526
+
527
+ if (!data.success) {
528
+ console.error('[WalletDetail] Failed to remove asset:', data.error);
529
+ }
530
+ // WebSocket event will update the UI
531
+ } catch (err) {
532
+ console.error('[WalletDetail] Failed to remove asset:', err);
533
+ }
534
+ };
535
+
536
+ // Filter assets by search
537
+ const filteredAssets = assets.filter(a => {
538
+ // Search filter
539
+ if (assetsSearch) {
540
+ const search = assetsSearch.toLowerCase();
541
+ return (
542
+ (a.symbol?.toLowerCase().includes(search)) ||
543
+ (a.name?.toLowerCase().includes(search)) ||
544
+ (a.tokenAddress.toLowerCase().includes(search))
545
+ );
546
+ }
547
+ return true;
548
+ });
549
+
550
+ // Locked state
551
+ if (!isUnlocked) {
552
+ return (
553
+ <div className="py-8 text-center">
554
+ <Lock
555
+ size={24}
556
+ className="mx-auto mb-3"
557
+ style={{ color: 'var(--color-text-muted, #888)' }}
558
+ />
559
+ <div
560
+ className="font-mono text-[10px] tracking-wider"
561
+ style={{ color: 'var(--color-text-muted, #888)' }}
562
+ >
563
+ VAULT LOCKED
564
+ </div>
565
+ </div>
566
+ );
567
+ }
568
+
569
+ // No address configured - show input
570
+ if (!walletAddress) {
571
+ return (
572
+ <div className="space-y-3 py-4 px-1">
573
+ <div className="text-center">
574
+ <Search
575
+ size={20}
576
+ className="mx-auto mb-2"
577
+ style={{ color: 'var(--color-text-muted, #888)' }}
578
+ />
579
+ <div
580
+ className="font-mono text-[10px] tracking-wider"
581
+ style={{ color: 'var(--color-text-muted, #888)' }}
582
+ >
583
+ ENTER WALLET ADDRESS
584
+ </div>
585
+ </div>
586
+ <div className="space-y-2">
587
+ <TextInput
588
+ value={manualAddress}
589
+ onChange={(e) => setManualAddress(e.target.value)}
590
+ placeholder="0x... or base58 address"
591
+ compact
592
+ onKeyDown={(e) => {
593
+ if (e.key === 'Enter' && manualAddress.trim()) {
594
+ setCommittedAddress(manualAddress.trim());
595
+ setWalletLoading(true);
596
+ }
597
+ }}
598
+ />
599
+ <Button
600
+ variant="primary"
601
+ onClick={() => {
602
+ if (manualAddress.trim()) {
603
+ setCommittedAddress(manualAddress.trim());
604
+ setWalletLoading(true);
605
+ }
606
+ }}
607
+ disabled={!manualAddress.trim()}
608
+ className="w-full"
609
+ size="sm"
610
+ >
611
+ LOAD WALLET
612
+ </Button>
613
+ </div>
614
+ </div>
615
+ );
616
+ }
617
+
618
+ // Loading state
619
+ if (walletLoading) {
620
+ return (
621
+ <div className="py-8 text-center">
622
+ <Loader2
623
+ size={24}
624
+ className="mx-auto mb-3 animate-spin"
625
+ style={{ color: 'var(--color-text-muted, #888)' }}
626
+ />
627
+ <div
628
+ className="font-mono text-[10px] tracking-wider"
629
+ style={{ color: 'var(--color-text-muted, #888)' }}
630
+ >
631
+ LOADING WALLET...
632
+ </div>
633
+ </div>
634
+ );
635
+ }
636
+
637
+ // Error state
638
+ if (walletError || !wallet) {
639
+ return (
640
+ <div className="py-8 text-center">
641
+ <div
642
+ className="font-mono text-[10px] tracking-wider"
643
+ style={{ color: 'var(--color-warning, #ff4d00)' }}
644
+ >
645
+ {walletError || 'WALLET NOT FOUND'}
646
+ </div>
647
+ <div
648
+ className="font-mono text-[8px] mt-1"
649
+ style={{ color: 'var(--color-text-muted, #888)' }}
650
+ >
651
+ {walletAddress}
652
+ </div>
653
+ </div>
654
+ );
655
+ }
656
+
657
+ return (
658
+ <div className="space-y-2">
659
+ {/* Balance - Prominent at top */}
660
+ <div className="text-center py-2">
661
+ <div
662
+ className="font-mono text-2xl font-bold"
663
+ style={{ color: 'var(--color-text, #0a0a0a)' }}
664
+ >
665
+ {balanceLoading ? (
666
+ <Loader2 size={20} className="inline animate-spin" style={{ color: 'var(--color-text-muted, #888)' }} />
667
+ ) : (
668
+ <>{balance || '0'} <span className="text-sm">{currency}</span></>
669
+ )}
670
+ </div>
671
+ {ethPrice && balance && !balanceLoading && !isSolanaChain(wallet.chain) && (
672
+ <div className="font-mono text-sm" style={{ color: 'var(--color-text-muted, #888)' }}>
673
+ {formatUsd(balance)}
674
+ </div>
675
+ )}
676
+ </div>
677
+
678
+ {/* Compact Header: Name, Address, Tier */}
679
+ <div className="flex items-center gap-2">
680
+ {wallet.emoji && <span className="text-xs">{wallet.emoji}</span>}
681
+ <span
682
+ className="font-mono text-[9px] font-bold truncate"
683
+ style={{ color: 'var(--color-text, #0a0a0a)' }}
684
+ >
685
+ {wallet.name || 'HOT WALLET'}
686
+ </span>
687
+ {wallet.color && (
688
+ <div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: wallet.color }} />
689
+ )}
690
+ <span
691
+ className="font-mono text-[8px] uppercase px-1 py-0.5"
692
+ style={{
693
+ background: 'var(--color-warning, #ff4d00)',
694
+ color: 'var(--color-surface, #fff)',
695
+ }}
696
+ >
697
+ {wallet.tier}
698
+ </span>
699
+ <div className="ml-auto flex items-center gap-1">
700
+ {connected ? (
701
+ <Wifi size={9} style={{ color: 'var(--color-success, #00c853)' }} />
702
+ ) : (
703
+ <WifiOff size={9} style={{ color: 'var(--color-text-muted, #888)' }} />
704
+ )}
705
+ <button
706
+ onClick={() => setIsEditMode(true)}
707
+ className="p-1 transition-colors"
708
+ style={{ color: 'var(--color-text-muted, #888)' }}
709
+ >
710
+ <Edit2 size={10} />
711
+ </button>
712
+ </div>
713
+ </div>
714
+
715
+ {/* Compact Address with Copy */}
716
+ <div className="flex items-center gap-1.5">
717
+ <code
718
+ className="flex-1 font-mono text-[9px] truncate select-all"
719
+ style={{ color: 'var(--color-text-muted, #888)' }}
720
+ >
721
+ {wallet.address}
722
+ </code>
723
+ <button
724
+ onClick={copyAddress}
725
+ className="p-1 transition-colors shrink-0"
726
+ style={{
727
+ background: copied ? 'var(--color-success, #00c853)' : 'var(--color-background-alt, #f5f5f5)',
728
+ color: copied ? 'var(--color-surface, #fff)' : 'var(--color-text-muted, #888)',
729
+ }}
730
+ >
731
+ <Copy size={10} />
732
+ </button>
733
+ </div>
734
+
735
+ {/* Hidden Badge */}
736
+ {wallet.hidden && !isEditMode && (
737
+ <div
738
+ className="flex items-center gap-1.5 px-2 py-1"
739
+ style={{
740
+ background: 'color-mix(in srgb, var(--color-warning, #ff4d00) 10%, transparent)',
741
+ border: '1px solid color-mix(in srgb, var(--color-warning, #ff4d00) 30%, transparent)',
742
+ }}
743
+ >
744
+ <EyeOff size={9} style={{ color: 'var(--color-warning, #ff4d00)' }} />
745
+ <span className="font-mono text-[8px]" style={{ color: 'var(--color-warning, #ff4d00)' }}>
746
+ HIDDEN - Excluded from totals
747
+ </span>
748
+ </div>
749
+ )}
750
+
751
+ {/* Description */}
752
+ {wallet.description && !isEditMode && (
753
+ <div
754
+ className="font-mono text-[9px] leading-relaxed"
755
+ style={{ color: 'var(--color-text-muted, #888)' }}
756
+ >
757
+ {wallet.description}
758
+ </div>
759
+ )}
760
+
761
+ {/* Edit Mode */}
762
+ {isEditMode ? (
763
+ <div
764
+ className="space-y-2 pt-2"
765
+ style={{ borderTop: '1px solid var(--color-border, #e5e5e5)' }}
766
+ >
767
+ <div>
768
+ <label
769
+ className="font-mono text-[7px] tracking-widest block mb-0.5"
770
+ style={{ color: 'var(--color-text-muted, #888)' }}
771
+ >
772
+ NAME
773
+ </label>
774
+ <input
775
+ type="text"
776
+ value={editName}
777
+ onChange={(e) => setEditName(e.target.value)}
778
+ placeholder="Wallet name..."
779
+ className="w-full px-2 py-1.5 font-mono text-[10px] focus:outline-none"
780
+ style={{
781
+ background: 'var(--color-surface, #fff)',
782
+ border: '1px solid var(--color-border, #e5e5e5)',
783
+ color: 'var(--color-text, #0a0a0a)',
784
+ }}
785
+ />
786
+ </div>
787
+
788
+ <div>
789
+ <label
790
+ className="font-mono text-[7px] tracking-widest block mb-0.5"
791
+ style={{ color: 'var(--color-text-muted, #888)' }}
792
+ >
793
+ DESCRIPTION
794
+ </label>
795
+ <textarea
796
+ value={editDescription}
797
+ onChange={(e) => setEditDescription(e.target.value)}
798
+ placeholder="Add a description..."
799
+ rows={2}
800
+ className="w-full px-2 py-1.5 font-mono text-[10px] focus:outline-none resize-none"
801
+ style={{
802
+ background: 'var(--color-surface, #fff)',
803
+ border: '1px solid var(--color-border, #e5e5e5)',
804
+ color: 'var(--color-text, #0a0a0a)',
805
+ }}
806
+ />
807
+ </div>
808
+
809
+ <div>
810
+ <label
811
+ className="font-mono text-[7px] tracking-widest block mb-0.5"
812
+ style={{ color: 'var(--color-text-muted, #888)' }}
813
+ >
814
+ EMOJI
815
+ </label>
816
+ <div className="flex flex-wrap gap-0.5">
817
+ <button
818
+ onClick={() => setEditEmoji('')}
819
+ className="w-6 h-6 flex items-center justify-center transition-colors"
820
+ style={{
821
+ border: !editEmoji
822
+ ? '1px solid var(--color-text, #0a0a0a)'
823
+ : '1px solid var(--color-border, #e5e5e5)',
824
+ background: !editEmoji ? 'var(--color-background-alt, #f5f5f5)' : 'transparent',
825
+ }}
826
+ >
827
+ <X size={10} style={{ color: 'var(--color-text-muted, #888)' }} />
828
+ </button>
829
+ {EMOJI_OPTIONS.map((emoji) => (
830
+ <button
831
+ key={emoji}
832
+ onClick={() => setEditEmoji(emoji)}
833
+ className="w-6 h-6 flex items-center justify-center text-xs transition-colors"
834
+ style={{
835
+ border: editEmoji === emoji
836
+ ? '1px solid var(--color-text, #0a0a0a)'
837
+ : '1px solid var(--color-border, #e5e5e5)',
838
+ background: editEmoji === emoji ? 'var(--color-background-alt, #f5f5f5)' : 'transparent',
839
+ }}
840
+ >
841
+ {emoji}
842
+ </button>
843
+ ))}
844
+ </div>
845
+ </div>
846
+
847
+ <div>
848
+ <label
849
+ className="font-mono text-[7px] tracking-widest block mb-0.5"
850
+ style={{ color: 'var(--color-text-muted, #888)' }}
851
+ >
852
+ COLOR
853
+ </label>
854
+ <div className="flex flex-wrap gap-0.5">
855
+ <button
856
+ onClick={() => setEditColor('')}
857
+ className="w-6 h-6 flex items-center justify-center transition-colors"
858
+ style={{
859
+ border: !editColor
860
+ ? '1px solid var(--color-text, #0a0a0a)'
861
+ : '1px solid var(--color-border, #e5e5e5)',
862
+ }}
863
+ >
864
+ <div
865
+ className="w-3 h-3 rounded-full"
866
+ style={{ background: 'var(--color-border, #e5e5e5)' }}
867
+ />
868
+ </button>
869
+ {COLOR_OPTIONS.map((color) => (
870
+ <button
871
+ key={color}
872
+ onClick={() => setEditColor(color)}
873
+ className="w-6 h-6 flex items-center justify-center transition-colors"
874
+ style={{
875
+ border: editColor === color
876
+ ? '1px solid var(--color-text, #0a0a0a)'
877
+ : '1px solid var(--color-border, #e5e5e5)',
878
+ }}
879
+ >
880
+ <div className="w-3 h-3 rounded-full" style={{ backgroundColor: color }} />
881
+ </button>
882
+ ))}
883
+ </div>
884
+ </div>
885
+
886
+ <div>
887
+ <label
888
+ className="font-mono text-[7px] tracking-widest block mb-0.5"
889
+ style={{ color: 'var(--color-text-muted, #888)' }}
890
+ >
891
+ VISIBILITY
892
+ </label>
893
+ <button
894
+ onClick={() => setEditHidden(!editHidden)}
895
+ className="flex items-center gap-1.5 px-2 py-1.5 w-full transition-colors"
896
+ style={{
897
+ border: editHidden
898
+ ? '1px solid var(--color-warning, #ff4d00)'
899
+ : '1px solid var(--color-border, #e5e5e5)',
900
+ background: editHidden
901
+ ? 'color-mix(in srgb, var(--color-warning, #ff4d00) 5%, transparent)'
902
+ : 'transparent',
903
+ color: editHidden ? 'var(--color-warning, #ff4d00)' : 'var(--color-text, #0a0a0a)',
904
+ }}
905
+ >
906
+ {editHidden ? <EyeOff size={10} /> : <Eye size={10} />}
907
+ <span className="font-mono text-[9px]">
908
+ {editHidden ? 'HIDDEN' : 'VISIBLE'}
909
+ </span>
910
+ </button>
911
+ </div>
912
+
913
+ <div className="flex gap-1.5 pt-1">
914
+ <Button variant="secondary" onClick={handleCancel} className="flex-1">
915
+ CANCEL
916
+ </Button>
917
+ <Button
918
+ variant="primary"
919
+ onClick={handleSave}
920
+ loading={saving}
921
+ icon={<Save size={9} />}
922
+ className="flex-1"
923
+ >
924
+ SAVE
925
+ </Button>
926
+ </div>
927
+ </div>
928
+ ) : (
929
+ <>
930
+ {/* Tab Navigation */}
931
+ <div className="flex items-center gap-2">
932
+ <div
933
+ className="flex flex-1 rounded-sm overflow-hidden"
934
+ style={{
935
+ background: 'var(--color-background-alt, #f5f5f5)',
936
+ border: '1px solid var(--color-border, #e5e5e5)',
937
+ }}
938
+ >
939
+ <TabButton
940
+ active={activeTab === 'assets'}
941
+ onClick={() => setActiveTab('assets')}
942
+ icon={<Coins size={12} />}
943
+ label="ASSETS"
944
+ badge={assets.length > 0 ? assets.length : undefined}
945
+ />
946
+ <TabButton
947
+ active={activeTab === 'transactions'}
948
+ onClick={() => setActiveTab('transactions')}
949
+ icon={<ArrowUpDown size={12} />}
950
+ label="TRANSACTIONS"
951
+ badge={transactions.length > 0 ? transactions.length : undefined}
952
+ />
953
+ </div>
954
+ <Button
955
+ variant="secondary"
956
+ size="sm"
957
+ onClick={activeTab === 'assets' ? handleRefreshAssets : handleRefreshTx}
958
+ disabled={activeTab === 'assets' ? (assetsLoading || balancesLoading) : txLoading}
959
+ icon={<RefreshCw size={12} className={(activeTab === 'assets' ? (assetsLoading || balancesLoading) : txLoading) ? 'animate-spin' : ''} />}
960
+ />
961
+ </div>
962
+
963
+ {/* Tab Content */}
964
+ {activeTab === 'assets' ? (
965
+ <AssetsTab
966
+ assets={filteredAssets}
967
+ loading={assetsLoading || balancesLoading}
968
+ search={assetsSearch}
969
+ onSearchChange={setAssetsSearch}
970
+ tokenDataMap={tokenDataMap}
971
+ ethPrice={ethPrice}
972
+ showAddAsset={showAddAsset}
973
+ addAssetAnchor={addAssetAnchor}
974
+ onShowAddAsset={(show, anchor) => {
975
+ setShowAddAsset(show);
976
+ setAddAssetAnchor(anchor || null);
977
+ }}
978
+ addAssetForm={addAssetForm}
979
+ onAddAssetFormChange={setAddAssetForm}
980
+ onAddAsset={handleAddAsset}
981
+ addingAsset={addingAsset}
982
+ chainOptions={chainOptions}
983
+ selectedChain={selectedChain}
984
+ onChainChange={handleChainChange}
985
+ onRemoveAsset={handleRemoveAsset}
986
+ nativeCurrency={isSolanaChain(selectedChain) ? 'SOL' : 'ETH'}
987
+ />
988
+ ) : (
989
+ <TransactionsTab
990
+ transactions={transactions}
991
+ loading={txLoading}
992
+ search={txSearch}
993
+ onSearchChange={setTxSearch}
994
+ typeFilter={txTypeFilter}
995
+ onTypeFilterChange={setTxTypeFilter}
996
+ hasMore={txHasMore}
997
+ onLoadMore={() => fetchTransactions(false)}
998
+ nativeCurrency={isSolanaChain(selectedChain) ? 'SOL' : 'ETH'}
999
+ explorerUrl={getChainConfig(selectedChain).explorer}
1000
+ />
1001
+ )}
1002
+ </>
1003
+ )}
1004
+ </div>
1005
+ );
1006
+ };
1007
+
1008
+ function TabButton({
1009
+ active,
1010
+ onClick,
1011
+ icon,
1012
+ label,
1013
+ badge,
1014
+ }: {
1015
+ active: boolean;
1016
+ onClick: () => void;
1017
+ icon: React.ReactNode;
1018
+ label: string;
1019
+ badge?: number;
1020
+ }) {
1021
+ return (
1022
+ <button
1023
+ onClick={onClick}
1024
+ className="flex-1 py-2 px-3 font-mono text-[9px] tracking-widest transition-all flex items-center justify-center gap-1.5"
1025
+ style={{
1026
+ background: active ? 'var(--color-surface, #fff)' : 'transparent',
1027
+ color: active ? 'var(--color-text, #0a0a0a)' : 'var(--color-text-muted, #888)',
1028
+ boxShadow: active ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
1029
+ }}
1030
+ >
1031
+ {icon}
1032
+ {label}
1033
+ {badge !== undefined && (
1034
+ <span
1035
+ className="ml-1 px-1.5 py-0.5 rounded-sm text-[8px] font-bold"
1036
+ style={{
1037
+ background: 'var(--color-info, #0047ff)',
1038
+ color: 'var(--color-surface, #fff)',
1039
+ }}
1040
+ >
1041
+ {badge}
1042
+ </span>
1043
+ )}
1044
+ </button>
1045
+ );
1046
+ }
1047
+
1048
+ function AssetsTab({
1049
+ assets,
1050
+ loading,
1051
+ search,
1052
+ onSearchChange,
1053
+ tokenDataMap,
1054
+ ethPrice,
1055
+ showAddAsset,
1056
+ addAssetAnchor,
1057
+ onShowAddAsset,
1058
+ addAssetForm,
1059
+ onAddAssetFormChange,
1060
+ onAddAsset,
1061
+ addingAsset,
1062
+ chainOptions,
1063
+ selectedChain,
1064
+ onChainChange,
1065
+ onRemoveAsset,
1066
+ nativeCurrency,
1067
+ }: {
1068
+ assets: TrackedAsset[];
1069
+ loading: boolean;
1070
+ search: string;
1071
+ onSearchChange: (value: string) => void;
1072
+ tokenDataMap: Map<string, TokenData>;
1073
+ ethPrice: number | null;
1074
+ showAddAsset: boolean;
1075
+ addAssetAnchor: HTMLElement | null;
1076
+ onShowAddAsset: (show: boolean, anchor?: HTMLElement) => void;
1077
+ addAssetForm: { tokenAddress: string; symbol: string; name: string; chain: string };
1078
+ onAddAssetFormChange: (form: { tokenAddress: string; symbol: string; name: string; chain: string }) => void;
1079
+ onAddAsset: () => void;
1080
+ addingAsset: boolean;
1081
+ chainOptions: { value: string; label: string }[];
1082
+ selectedChain: string;
1083
+ onChainChange: (chain: string) => void;
1084
+ onRemoveAsset: (assetId: string) => void;
1085
+ nativeCurrency: string;
1086
+ }) {
1087
+ return (
1088
+ <div className="space-y-2">
1089
+ {/* Chain selector, Search, and Add - all on same line */}
1090
+ <div className="flex items-center gap-2">
1091
+ <div className="w-28">
1092
+ <ChainSelector
1093
+ value={selectedChain}
1094
+ onChange={onChainChange}
1095
+ chains={chainOptions.map(o => o.value)}
1096
+ size="sm"
1097
+ />
1098
+ </div>
1099
+ <div className="flex-1">
1100
+ <TextInput
1101
+ value={search}
1102
+ onChange={(e) => onSearchChange(e.target.value)}
1103
+ placeholder="Search tokens..."
1104
+ leftElement={<Search size={10} />}
1105
+ compact
1106
+ />
1107
+ </div>
1108
+ <Button
1109
+ variant="primary"
1110
+ size="sm"
1111
+ onClick={(e) => onShowAddAsset(true, e.currentTarget as HTMLElement)}
1112
+ icon={<Plus size={10} />}
1113
+ />
1114
+ </div>
1115
+
1116
+ {/* Add Asset Popover */}
1117
+ <Popover
1118
+ isOpen={showAddAsset}
1119
+ onClose={() => onShowAddAsset(false)}
1120
+ anchorEl={addAssetAnchor}
1121
+ title="ADD ASSET"
1122
+ anchor="right"
1123
+ >
1124
+ <div className="space-y-2 w-56">
1125
+ <div>
1126
+ <label className="font-mono text-[8px] tracking-widest block mb-1" style={{ color: 'var(--color-text-muted, #888)' }}>
1127
+ TOKEN ADDRESS *
1128
+ </label>
1129
+ <input
1130
+ type="text"
1131
+ value={addAssetForm.tokenAddress}
1132
+ onChange={(e) => onAddAssetFormChange({ ...addAssetForm, tokenAddress: e.target.value })}
1133
+ placeholder="0x..."
1134
+ className="w-full px-2 py-1.5 font-mono text-[9px] focus:outline-none"
1135
+ style={{
1136
+ background: 'var(--color-surface, #fff)',
1137
+ border: '1px solid var(--color-border, #e5e5e5)',
1138
+ color: 'var(--color-text, #0a0a0a)',
1139
+ }}
1140
+ />
1141
+ </div>
1142
+ <div className="flex gap-2">
1143
+ <div className="flex-1">
1144
+ <label className="font-mono text-[8px] tracking-widest block mb-1" style={{ color: 'var(--color-text-muted, #888)' }}>
1145
+ SYMBOL
1146
+ </label>
1147
+ <input
1148
+ type="text"
1149
+ value={addAssetForm.symbol}
1150
+ onChange={(e) => onAddAssetFormChange({ ...addAssetForm, symbol: e.target.value })}
1151
+ placeholder="TKN"
1152
+ className="w-full px-2 py-1.5 font-mono text-[9px] focus:outline-none"
1153
+ style={{
1154
+ background: 'var(--color-surface, #fff)',
1155
+ border: '1px solid var(--color-border, #e5e5e5)',
1156
+ color: 'var(--color-text, #0a0a0a)',
1157
+ }}
1158
+ />
1159
+ </div>
1160
+ <div className="w-28">
1161
+ <ChainSelector
1162
+ label="CHAIN"
1163
+ value={addAssetForm.chain}
1164
+ onChange={(chain) => onAddAssetFormChange({ ...addAssetForm, chain })}
1165
+ chains={chainOptions.map(o => o.value)}
1166
+ size="sm"
1167
+ />
1168
+ </div>
1169
+ </div>
1170
+ <div>
1171
+ <label className="font-mono text-[8px] tracking-widest block mb-1" style={{ color: 'var(--color-text-muted, #888)' }}>
1172
+ NAME
1173
+ </label>
1174
+ <input
1175
+ type="text"
1176
+ value={addAssetForm.name}
1177
+ onChange={(e) => onAddAssetFormChange({ ...addAssetForm, name: e.target.value })}
1178
+ placeholder="Token Name"
1179
+ className="w-full px-2 py-1.5 font-mono text-[9px] focus:outline-none"
1180
+ style={{
1181
+ background: 'var(--color-surface, #fff)',
1182
+ border: '1px solid var(--color-border, #e5e5e5)',
1183
+ color: 'var(--color-text, #0a0a0a)',
1184
+ }}
1185
+ />
1186
+ </div>
1187
+ <Button
1188
+ onClick={onAddAsset}
1189
+ disabled={!addAssetForm.tokenAddress || addingAsset}
1190
+ loading={addingAsset}
1191
+ className="w-full"
1192
+ size="sm"
1193
+ >
1194
+ {addingAsset ? 'ADDING...' : 'ADD ASSET'}
1195
+ </Button>
1196
+ </div>
1197
+ </Popover>
1198
+
1199
+ {/* Assets List */}
1200
+ {loading && assets.length === 0 ? (
1201
+ <div className="py-6 text-center">
1202
+ <Loader2
1203
+ size={20}
1204
+ className="mx-auto mb-2 animate-spin"
1205
+ style={{ color: 'var(--color-text-muted, #888)' }}
1206
+ />
1207
+ <div className="font-mono text-[9px]" style={{ color: 'var(--color-text-muted, #888)' }}>
1208
+ LOADING ASSETS...
1209
+ </div>
1210
+ </div>
1211
+ ) : assets.length === 0 ? (
1212
+ <div className="py-6 text-center">
1213
+ <Coins size={24} className="mx-auto mb-2" style={{ color: 'var(--color-text-muted, #888)' }} />
1214
+ <div className="font-mono text-[9px]" style={{ color: 'var(--color-text-muted, #888)' }}>
1215
+ NO TRACKED ASSETS
1216
+ </div>
1217
+ <div className="font-mono text-[8px] mt-1" style={{ color: 'var(--color-text-faint, #aaa)' }}>
1218
+ Swap tokens to auto-track them
1219
+ </div>
1220
+ </div>
1221
+ ) : (
1222
+ <div className="space-y-1 max-h-48 overflow-y-auto">
1223
+ {assets.map((asset) => {
1224
+ const tokenKey = isSolanaChain(selectedChain) ? asset.tokenAddress : asset.tokenAddress.toLowerCase();
1225
+ const tokenData = tokenDataMap.get(tokenKey);
1226
+ const balance = tokenData?.balance ?? asset.lastBalance ?? '0';
1227
+ const priceInEth = tokenData?.priceInEth ?? null;
1228
+ const usdValue = calculateUsdValue(balance, priceInEth, ethPrice);
1229
+ // Staleness indicator
1230
+ const balanceAge = asset.lastBalanceAt
1231
+ ? Date.now() - new Date(asset.lastBalanceAt).getTime()
1232
+ : null;
1233
+ const stalenessColor = balanceAge === null ? 'var(--color-text-faint, #ccc)'
1234
+ : balanceAge < 30_000 ? 'var(--color-success, #00c853)'
1235
+ : balanceAge < 300_000 ? 'var(--color-info, #0047ff)'
1236
+ : 'var(--color-warning, #ff4d00)';
1237
+
1238
+ return (
1239
+ <div
1240
+ key={asset.id}
1241
+ className="p-2 rounded-sm group"
1242
+ style={{
1243
+ background: 'var(--color-surface, #fff)',
1244
+ border: '1px solid var(--color-border, #e5e5e5)',
1245
+ }}
1246
+ >
1247
+ <div className="flex items-center justify-between gap-2">
1248
+ <div className="flex items-center gap-2 min-w-0">
1249
+ {/* Token icon placeholder */}
1250
+ <div
1251
+ className="w-6 h-6 rounded-full flex items-center justify-center shrink-0 text-[10px] font-bold"
1252
+ style={{
1253
+ background: 'var(--color-background-alt, #f5f5f5)',
1254
+ color: 'var(--color-text-muted, #888)',
1255
+ }}
1256
+ >
1257
+ {asset.symbol?.charAt(0) || '?'}
1258
+ </div>
1259
+ <div className="min-w-0">
1260
+ <div
1261
+ className="font-mono text-[10px] font-bold truncate"
1262
+ style={{ color: 'var(--color-text, #0a0a0a)' }}
1263
+ >
1264
+ {asset.symbol || shortenAddress(asset.tokenAddress, 4)}
1265
+ </div>
1266
+ {asset.name && (
1267
+ <div
1268
+ className="font-mono text-[8px] truncate"
1269
+ style={{ color: 'var(--color-text-muted, #888)' }}
1270
+ >
1271
+ {asset.name}
1272
+ </div>
1273
+ )}
1274
+ </div>
1275
+ </div>
1276
+ <div className="flex items-center gap-2">
1277
+ <div className="text-right shrink-0">
1278
+ <div className="flex items-center gap-1 justify-end">
1279
+ <div
1280
+ className="w-1 h-1 rounded-full shrink-0"
1281
+ style={{ background: stalenessColor }}
1282
+ title={asset.lastBalanceAt ? `Updated ${formatTimeAgo(asset.lastBalanceAt)}` : 'No cached balance'}
1283
+ />
1284
+ <span
1285
+ className="font-mono text-[10px] font-bold"
1286
+ style={{ color: 'var(--color-text, #0a0a0a)' }}
1287
+ >
1288
+ {parseFloat(balance).toFixed(4)}
1289
+ </span>
1290
+ </div>
1291
+ {usdValue !== null && (
1292
+ <div className="font-mono text-[8px]" style={{ color: 'var(--color-text-muted, #888)' }}>
1293
+ {formatUsdValue(usdValue)}
1294
+ </div>
1295
+ )}
1296
+ </div>
1297
+ <button
1298
+ onClick={() => onRemoveAsset(asset.id)}
1299
+ className="p-1 opacity-0 group-hover:opacity-100 transition-opacity"
1300
+ style={{ color: 'var(--color-warning, #ff4d00)' }}
1301
+ title="Remove asset"
1302
+ >
1303
+ <Trash2 size={10} />
1304
+ </button>
1305
+ </div>
1306
+ </div>
1307
+ {/* Pool info badge */}
1308
+ {asset.poolVersion && (
1309
+ <div className="mt-1.5 flex items-center gap-1">
1310
+ <span
1311
+ className="font-mono text-[7px] px-1 py-0.5 rounded-sm uppercase"
1312
+ style={{
1313
+ background: 'var(--color-background-alt, #f5f5f5)',
1314
+ color: 'var(--color-text-muted, #888)',
1315
+ }}
1316
+ >
1317
+ {asset.poolVersion}
1318
+ </span>
1319
+ {priceInEth !== null && (
1320
+ <span className="font-mono text-[7px]" style={{ color: 'var(--color-text-muted, #888)' }}>
1321
+ {priceInEth.toFixed(8)} {nativeCurrency}
1322
+ </span>
1323
+ )}
1324
+ </div>
1325
+ )}
1326
+ </div>
1327
+ );
1328
+ })}
1329
+ </div>
1330
+ )}
1331
+ </div>
1332
+ );
1333
+ }
1334
+
1335
+ function TransactionsTab({
1336
+ transactions,
1337
+ loading,
1338
+ search,
1339
+ onSearchChange,
1340
+ typeFilter,
1341
+ onTypeFilterChange,
1342
+ hasMore,
1343
+ onLoadMore,
1344
+ nativeCurrency,
1345
+ explorerUrl,
1346
+ }: {
1347
+ transactions: Transaction[];
1348
+ loading: boolean;
1349
+ search: string;
1350
+ onSearchChange: (value: string) => void;
1351
+ typeFilter: string;
1352
+ onTypeFilterChange: (value: string) => void;
1353
+ hasMore: boolean;
1354
+ onLoadMore: () => void;
1355
+ nativeCurrency: string;
1356
+ explorerUrl: string;
1357
+ }) {
1358
+ // Client-side search filter
1359
+ const filteredTx = search
1360
+ ? transactions.filter(tx =>
1361
+ (tx.description?.toLowerCase().includes(search.toLowerCase())) ||
1362
+ (tx.txHash?.toLowerCase().includes(search.toLowerCase()))
1363
+ )
1364
+ : transactions;
1365
+
1366
+ return (
1367
+ <div className="space-y-2">
1368
+ {/* Type Filter and Search */}
1369
+ <div className="flex gap-2">
1370
+ <div className="w-24">
1371
+ <FilterDropdown
1372
+ options={TX_TYPE_OPTIONS}
1373
+ value={typeFilter}
1374
+ onChange={onTypeFilterChange}
1375
+ compact
1376
+ />
1377
+ </div>
1378
+ <div className="flex-1">
1379
+ <TextInput
1380
+ value={search}
1381
+ onChange={(e) => onSearchChange(e.target.value)}
1382
+ placeholder="Search tx..."
1383
+ leftElement={<Search size={10} />}
1384
+ compact
1385
+ />
1386
+ </div>
1387
+ </div>
1388
+
1389
+ {/* Transactions List */}
1390
+ {loading && transactions.length === 0 ? (
1391
+ <div className="py-6 text-center">
1392
+ <Loader2
1393
+ size={20}
1394
+ className="mx-auto mb-2 animate-spin"
1395
+ style={{ color: 'var(--color-text-muted, #888)' }}
1396
+ />
1397
+ <div className="font-mono text-[9px]" style={{ color: 'var(--color-text-muted, #888)' }}>
1398
+ LOADING TRANSACTIONS...
1399
+ </div>
1400
+ </div>
1401
+ ) : filteredTx.length === 0 ? (
1402
+ <div className="py-6 text-center">
1403
+ <ArrowUpDown size={24} className="mx-auto mb-2" style={{ color: 'var(--color-text-muted, #888)' }} />
1404
+ <div className="font-mono text-[9px]" style={{ color: 'var(--color-text-muted, #888)' }}>
1405
+ NO TRANSACTIONS
1406
+ </div>
1407
+ <div className="font-mono text-[8px] mt-1" style={{ color: 'var(--color-text-faint, #aaa)' }}>
1408
+ {search ? 'Try a different search' : 'Transactions will appear here'}
1409
+ </div>
1410
+ </div>
1411
+ ) : (
1412
+ <div className="space-y-1 max-h-48 overflow-y-auto">
1413
+ {filteredTx.map((tx) => {
1414
+ const typeColor = TX_TYPE_COLORS[tx.type] || 'var(--color-text-muted, #888)';
1415
+
1416
+ return (
1417
+ <div
1418
+ key={tx.id}
1419
+ className="p-2 rounded-sm"
1420
+ style={{
1421
+ background: 'var(--color-surface, #fff)',
1422
+ border: '1px solid var(--color-border, #e5e5e5)',
1423
+ }}
1424
+ >
1425
+ <div className="flex items-start justify-between gap-2">
1426
+ <div className="flex items-center gap-2 min-w-0">
1427
+ <div
1428
+ className="w-1.5 h-1.5 rounded-full shrink-0"
1429
+ style={{ background: typeColor }}
1430
+ />
1431
+ <div className="min-w-0">
1432
+ <div className="flex items-center gap-1.5">
1433
+ <span
1434
+ className="font-mono text-[8px] font-bold uppercase"
1435
+ style={{ color: typeColor }}
1436
+ >
1437
+ {tx.type}
1438
+ </span>
1439
+ {tx.amount && (
1440
+ <span
1441
+ className="font-mono text-[9px] font-bold"
1442
+ style={{ color: 'var(--color-text, #0a0a0a)' }}
1443
+ >
1444
+ {parseFloat(tx.amount).toFixed(4)} {nativeCurrency}
1445
+ </span>
1446
+ )}
1447
+ {tx.tokenAmount && (
1448
+ <span
1449
+ className="font-mono text-[9px] font-bold"
1450
+ style={{ color: 'var(--color-text, #0a0a0a)' }}
1451
+ >
1452
+ {parseFloat(tx.tokenAmount).toFixed(4)}
1453
+ </span>
1454
+ )}
1455
+ </div>
1456
+ {tx.description && (
1457
+ <div
1458
+ className="font-mono text-[8px] truncate max-w-[180px]"
1459
+ style={{ color: 'var(--color-text-muted, #888)' }}
1460
+ >
1461
+ {tx.description}
1462
+ </div>
1463
+ )}
1464
+ </div>
1465
+ </div>
1466
+ <div className="flex items-center gap-1 shrink-0">
1467
+ <span className="font-mono text-[7px]" style={{ color: 'var(--color-text-faint, #aaa)' }}>
1468
+ {formatTimeAgo(tx.createdAt)}
1469
+ </span>
1470
+ {tx.txHash && (
1471
+ <a
1472
+ href={`${explorerUrl}/tx/${tx.txHash}`}
1473
+ target="_blank"
1474
+ rel="noopener noreferrer"
1475
+ className="p-0.5 transition-colors"
1476
+ style={{ color: 'var(--color-text-muted, #888)' }}
1477
+ >
1478
+ <ExternalLink size={9} />
1479
+ </a>
1480
+ )}
1481
+ </div>
1482
+ </div>
1483
+ </div>
1484
+ );
1485
+ })}
1486
+ {hasMore && !search && (
1487
+ <button
1488
+ onClick={onLoadMore}
1489
+ disabled={loading}
1490
+ className="w-full py-2 font-mono text-[8px] transition-colors"
1491
+ style={{
1492
+ color: 'var(--color-text-muted, #888)',
1493
+ background: 'var(--color-background-alt, #f5f5f5)',
1494
+ }}
1495
+ >
1496
+ {loading ? 'LOADING...' : 'LOAD MORE'}
1497
+ </button>
1498
+ )}
1499
+ </div>
1500
+ )}
1501
+ </div>
1502
+ );
1503
+ }
1504
+
1505
+ export default WalletDetailApp;