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