@drparadox05/lido-mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,474 @@
1
+ import { erc20Abi, formatEther, formatUnits, parseEther } from 'viem';
2
+ import { hasPrivateKeyConfigured } from '../config/env.js';
3
+ import { VOTING_ABI, WSTETH_ABI, WITHDRAWAL_QUEUE_ABI } from './abis.js';
4
+ import { getAccountOverview } from './account.js';
5
+ import { getAccount, getPublicClient, normalizeAddress } from './clients.js';
6
+ import { getGovernanceProposals } from './governance.js';
7
+ import { getNetworkConfig } from './networks.js';
8
+ import { parseTokenAmount, toBigIntIds } from './utils.js';
9
+ import { getWithdrawalRequests } from './withdrawals.js';
10
+ function getSignerContext() {
11
+ if (!hasPrivateKeyConfigured()) {
12
+ return {
13
+ configured: false,
14
+ address: null,
15
+ error: 'No private key configured. Set LIDO_PRIVATE_KEY, WALLET_PRIVATE_KEY, or PRIVATE_KEY.',
16
+ };
17
+ }
18
+ try {
19
+ return {
20
+ configured: true,
21
+ address: getAccount().address,
22
+ error: null,
23
+ };
24
+ }
25
+ catch (error) {
26
+ return {
27
+ configured: true,
28
+ address: null,
29
+ error: error instanceof Error ? error.message : String(error),
30
+ };
31
+ }
32
+ }
33
+ function requireSummaryAddress(address) {
34
+ if (address) {
35
+ return normalizeAddress(address);
36
+ }
37
+ const signer = getSignerContext();
38
+ if (!signer.address) {
39
+ throw new Error('Provide address explicitly or configure a wallet private key before requesting a portfolio summary.');
40
+ }
41
+ return signer.address;
42
+ }
43
+ function pushCheck(checks, blockers, name, status, detail) {
44
+ checks.push({ name, status, detail });
45
+ if (status === 'fail') {
46
+ blockers.push(detail);
47
+ }
48
+ }
49
+ function finalizePreflight(action, network, signer, checks, blockers, details, nextSteps) {
50
+ return {
51
+ network,
52
+ action,
53
+ wallet: signer,
54
+ ready: blockers.length === 0,
55
+ checks,
56
+ blockers,
57
+ next_steps: nextSteps,
58
+ details,
59
+ };
60
+ }
61
+ export async function preflightWriteAction(params) {
62
+ const { network, action, amountEth, amountSteth, amountWsteth, token, amounts, owner, recipient, requestIds, voteId, support, executesIfDecided, referral, approveIfNeeded, } = params;
63
+ const signer = getSignerContext();
64
+ const checks = [];
65
+ const blockers = [];
66
+ const nextSteps = [];
67
+ const config = getNetworkConfig(network);
68
+ const publicClient = getPublicClient(network);
69
+ pushCheck(checks, blockers, 'wallet_configured', signer.address ? 'pass' : 'fail', signer.address ? `Signer ${signer.address} is available for write preflight.` : signer.error ?? 'No signer is configured.');
70
+ if (action === 'stake') {
71
+ if (!amountEth) {
72
+ pushCheck(checks, blockers, 'amount_eth', 'fail', 'Provide amount_eth for stake preflight.');
73
+ return finalizePreflight(action, network, signer, checks, blockers, {}, nextSteps);
74
+ }
75
+ if (!signer.address) {
76
+ return finalizePreflight(action, network, signer, checks, blockers, { amount_eth: amountEth }, nextSteps);
77
+ }
78
+ let value;
79
+ try {
80
+ value = parseEther(amountEth);
81
+ pushCheck(checks, blockers, 'amount_eth', 'pass', `Stake amount ${amountEth} ETH is valid.`);
82
+ }
83
+ catch {
84
+ pushCheck(checks, blockers, 'amount_eth', 'fail', `Invalid ETH amount: ${amountEth}.`);
85
+ return finalizePreflight(action, network, signer, checks, blockers, { amount_eth: amountEth }, nextSteps);
86
+ }
87
+ const balance = await publicClient.getBalance({ address: signer.address });
88
+ pushCheck(checks, blockers, 'eth_balance', balance >= value ? 'pass' : 'fail', balance >= value
89
+ ? `Wallet has enough ETH for the requested stake amount (${formatEther(balance)} ETH available).`
90
+ : `Insufficient ETH balance. Need ${amountEth} ETH plus gas, current balance is ${formatEther(balance)} ETH.`);
91
+ nextSteps.push('Run lido_stake_eth with dry_run=true to inspect the final transaction request.');
92
+ return finalizePreflight(action, network, signer, checks, blockers, {
93
+ amount_eth: amountEth,
94
+ referral: referral ?? null,
95
+ available_eth: formatEther(balance),
96
+ }, nextSteps);
97
+ }
98
+ if (action === 'wrap') {
99
+ if (!amountSteth) {
100
+ pushCheck(checks, blockers, 'amount_steth', 'fail', 'Provide amount_steth for wrap preflight.');
101
+ return finalizePreflight(action, network, signer, checks, blockers, {}, nextSteps);
102
+ }
103
+ if (!signer.address) {
104
+ return finalizePreflight(action, network, signer, checks, blockers, { amount_steth: amountSteth }, nextSteps);
105
+ }
106
+ let amount;
107
+ try {
108
+ amount = parseTokenAmount(amountSteth, 'stETH');
109
+ pushCheck(checks, blockers, 'amount_steth', 'pass', `Wrap amount ${amountSteth} stETH is valid.`);
110
+ }
111
+ catch {
112
+ pushCheck(checks, blockers, 'amount_steth', 'fail', `Invalid stETH amount: ${amountSteth}.`);
113
+ return finalizePreflight(action, network, signer, checks, blockers, { amount_steth: amountSteth }, nextSteps);
114
+ }
115
+ const [balance, allowance, expectedOut] = await Promise.all([
116
+ publicClient.readContract({ address: config.steth, abi: erc20Abi, functionName: 'balanceOf', args: [signer.address] }),
117
+ publicClient.readContract({
118
+ address: config.steth,
119
+ abi: erc20Abi,
120
+ functionName: 'allowance',
121
+ args: [signer.address, config.wsteth],
122
+ }),
123
+ publicClient.readContract({
124
+ address: config.wsteth,
125
+ abi: WSTETH_ABI,
126
+ functionName: 'getWstETHByStETH',
127
+ args: [amount],
128
+ }),
129
+ ]);
130
+ pushCheck(checks, blockers, 'steth_balance', balance >= amount ? 'pass' : 'fail', balance >= amount
131
+ ? `Wallet has enough stETH (${formatUnits(balance, 18)} available).`
132
+ : `Insufficient stETH balance. Need ${amountSteth}, current balance is ${formatUnits(balance, 18)}.`);
133
+ const approvalStatus = allowance >= amount ? 'pass' : approveIfNeeded ? 'warn' : 'fail';
134
+ const approvalDetail = allowance >= amount
135
+ ? `Allowance is already sufficient (${formatUnits(allowance, 18)} approved).`
136
+ : approveIfNeeded
137
+ ? 'Allowance is insufficient, but approve_if_needed=true so the live path can send approval first.'
138
+ : 'Allowance is insufficient and approve_if_needed=false.';
139
+ pushCheck(checks, blockers, 'allowance', approvalStatus, approvalDetail);
140
+ if (allowance < amount) {
141
+ nextSteps.push('Run lido_wrap_steth with approve_if_needed=true and dry_run=true to inspect the approval-plus-wrap path.');
142
+ }
143
+ else {
144
+ nextSteps.push('Run lido_wrap_steth with dry_run=true to inspect the wrap transaction request.');
145
+ }
146
+ return finalizePreflight(action, network, signer, checks, blockers, {
147
+ amount_steth: amountSteth,
148
+ current_steth_balance: formatUnits(balance, 18),
149
+ current_allowance: formatUnits(allowance, 18),
150
+ expected_wsteth_out: formatUnits(expectedOut, 18),
151
+ approval_required: allowance < amount,
152
+ }, nextSteps);
153
+ }
154
+ if (action === 'unwrap') {
155
+ if (!amountWsteth) {
156
+ pushCheck(checks, blockers, 'amount_wsteth', 'fail', 'Provide amount_wsteth for unwrap preflight.');
157
+ return finalizePreflight(action, network, signer, checks, blockers, {}, nextSteps);
158
+ }
159
+ if (!signer.address) {
160
+ return finalizePreflight(action, network, signer, checks, blockers, { amount_wsteth: amountWsteth }, nextSteps);
161
+ }
162
+ let amount;
163
+ try {
164
+ amount = parseTokenAmount(amountWsteth, 'wstETH');
165
+ pushCheck(checks, blockers, 'amount_wsteth', 'pass', `Unwrap amount ${amountWsteth} wstETH is valid.`);
166
+ }
167
+ catch {
168
+ pushCheck(checks, blockers, 'amount_wsteth', 'fail', `Invalid wstETH amount: ${amountWsteth}.`);
169
+ return finalizePreflight(action, network, signer, checks, blockers, { amount_wsteth: amountWsteth }, nextSteps);
170
+ }
171
+ const [balance, expectedOut] = await Promise.all([
172
+ publicClient.readContract({ address: config.wsteth, abi: erc20Abi, functionName: 'balanceOf', args: [signer.address] }),
173
+ publicClient.readContract({
174
+ address: config.wsteth,
175
+ abi: WSTETH_ABI,
176
+ functionName: 'getStETHByWstETH',
177
+ args: [amount],
178
+ }),
179
+ ]);
180
+ pushCheck(checks, blockers, 'wsteth_balance', balance >= amount ? 'pass' : 'fail', balance >= amount
181
+ ? `Wallet has enough wstETH (${formatUnits(balance, 18)} available).`
182
+ : `Insufficient wstETH balance. Need ${amountWsteth}, current balance is ${formatUnits(balance, 18)}.`);
183
+ nextSteps.push('Run lido_unwrap_wsteth with dry_run=true to inspect the unwrap transaction request.');
184
+ return finalizePreflight(action, network, signer, checks, blockers, {
185
+ amount_wsteth: amountWsteth,
186
+ current_wsteth_balance: formatUnits(balance, 18),
187
+ expected_steth_out: formatUnits(expectedOut, 18),
188
+ }, nextSteps);
189
+ }
190
+ if (action === 'request_unstake') {
191
+ if (!token || !amounts || amounts.length === 0) {
192
+ pushCheck(checks, blockers, 'amounts', 'fail', 'Provide token plus at least one amount for request_unstake preflight.');
193
+ return finalizePreflight(action, network, signer, checks, blockers, {}, nextSteps);
194
+ }
195
+ if (!signer.address) {
196
+ return finalizePreflight(action, network, signer, checks, blockers, { token, amounts }, nextSteps);
197
+ }
198
+ let parsedAmounts;
199
+ try {
200
+ parsedAmounts = amounts.map((value) => parseTokenAmount(value, token));
201
+ pushCheck(checks, blockers, 'amounts', 'pass', `Validated ${amounts.length} withdrawal request amount(s).`);
202
+ }
203
+ catch {
204
+ pushCheck(checks, blockers, 'amounts', 'fail', 'One or more unstake amounts are invalid.');
205
+ return finalizePreflight(action, network, signer, checks, blockers, { token, amounts }, nextSteps);
206
+ }
207
+ const totalAmount = parsedAmounts.reduce((sum, value) => sum + value, 0n);
208
+ const tokenAddress = token === 'steth' ? config.steth : config.wsteth;
209
+ const ownerAddress = owner ? normalizeAddress(owner) : signer.address;
210
+ const [balance, allowance, existingRequests] = await Promise.all([
211
+ publicClient.readContract({ address: tokenAddress, abi: erc20Abi, functionName: 'balanceOf', args: [signer.address] }),
212
+ publicClient.readContract({
213
+ address: tokenAddress,
214
+ abi: erc20Abi,
215
+ functionName: 'allowance',
216
+ args: [signer.address, config.withdrawalQueue],
217
+ }),
218
+ publicClient.readContract({
219
+ address: config.withdrawalQueue,
220
+ abi: WITHDRAWAL_QUEUE_ABI,
221
+ functionName: 'getWithdrawalRequests',
222
+ args: [ownerAddress],
223
+ }),
224
+ ]);
225
+ pushCheck(checks, blockers, `${token}_balance`, balance >= totalAmount ? 'pass' : 'fail', balance >= totalAmount
226
+ ? `Wallet has enough ${token} (${formatUnits(balance, 18)} available).`
227
+ : `Insufficient ${token} balance. Need ${formatUnits(totalAmount, 18)}, current balance is ${formatUnits(balance, 18)}.`);
228
+ const approvalStatus = allowance >= totalAmount ? 'pass' : approveIfNeeded ? 'warn' : 'fail';
229
+ pushCheck(checks, blockers, 'allowance', approvalStatus, allowance >= totalAmount
230
+ ? `Allowance is already sufficient (${formatUnits(allowance, 18)} approved).`
231
+ : approveIfNeeded
232
+ ? 'Allowance is insufficient, but approve_if_needed=true so the live path can send approval first.'
233
+ : 'Allowance is insufficient and approve_if_needed=false.');
234
+ nextSteps.push('Run lido_request_unstake with dry_run=true to inspect the queue request path.');
235
+ return finalizePreflight(action, network, signer, checks, blockers, {
236
+ token,
237
+ amounts,
238
+ total_amount: formatUnits(totalAmount, 18),
239
+ owner: ownerAddress,
240
+ current_balance: formatUnits(balance, 18),
241
+ current_allowance: formatUnits(allowance, 18),
242
+ existing_request_count: existingRequests.length,
243
+ approval_required: allowance < totalAmount,
244
+ }, nextSteps);
245
+ }
246
+ if (action === 'claim_withdrawals') {
247
+ if (!requestIds || requestIds.length === 0) {
248
+ pushCheck(checks, blockers, 'request_ids', 'fail', 'Provide request_ids for claim_withdrawals preflight.');
249
+ return finalizePreflight(action, network, signer, checks, blockers, {}, nextSteps);
250
+ }
251
+ const sortedIds = toBigIntIds(requestIds).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
252
+ const recipientAddress = recipient ? normalizeAddress(recipient) : null;
253
+ const lastCheckpointIndex = await publicClient.readContract({
254
+ address: config.withdrawalQueue,
255
+ abi: WITHDRAWAL_QUEUE_ABI,
256
+ functionName: 'getLastCheckpointIndex',
257
+ });
258
+ pushCheck(checks, blockers, 'finalized_checkpoints', lastCheckpointIndex > 0n ? 'pass' : 'fail', lastCheckpointIndex > 0n
259
+ ? `Withdrawal queue has finalized checkpoints up to index ${lastCheckpointIndex}.`
260
+ : 'No finalized withdrawal checkpoints exist yet.');
261
+ if (lastCheckpointIndex === 0n) {
262
+ return finalizePreflight(action, network, signer, checks, blockers, { request_ids: sortedIds }, nextSteps);
263
+ }
264
+ const hints = await publicClient.readContract({
265
+ address: config.withdrawalQueue,
266
+ abi: WITHDRAWAL_QUEUE_ABI,
267
+ functionName: 'findCheckpointHints',
268
+ args: [sortedIds, 1n, lastCheckpointIndex],
269
+ });
270
+ const [statuses, claimable] = await Promise.all([
271
+ publicClient.readContract({
272
+ address: config.withdrawalQueue,
273
+ abi: WITHDRAWAL_QUEUE_ABI,
274
+ functionName: 'getWithdrawalStatus',
275
+ args: [sortedIds],
276
+ }),
277
+ publicClient.readContract({
278
+ address: config.withdrawalQueue,
279
+ abi: WITHDRAWAL_QUEUE_ABI,
280
+ functionName: 'getClaimableEther',
281
+ args: [sortedIds, hints],
282
+ }),
283
+ ]);
284
+ const totalClaimable = claimable.reduce((sum, value) => sum + value, 0n);
285
+ pushCheck(checks, blockers, 'claimable_eth', totalClaimable > 0n ? 'pass' : 'fail', totalClaimable > 0n
286
+ ? `${formatEther(totalClaimable)} ETH is currently claimable for the provided request ids.`
287
+ : 'None of the provided request ids are currently claimable.');
288
+ if (!signer.address) {
289
+ nextSteps.push('Configure a wallet private key so ownership checks and the actual claim can be performed.');
290
+ return finalizePreflight(action, network, signer, checks, blockers, {
291
+ request_ids: sortedIds,
292
+ recipient: recipientAddress,
293
+ total_claimable_eth: formatEther(totalClaimable),
294
+ }, nextSteps);
295
+ }
296
+ const wrongOwnerCount = statuses.filter((status) => status.owner.toLowerCase() !== signer.address.toLowerCase()).length;
297
+ pushCheck(checks, blockers, 'request_ownership', wrongOwnerCount === 0 ? 'pass' : 'fail', wrongOwnerCount === 0
298
+ ? 'Configured wallet owns all provided withdrawal requests.'
299
+ : 'Configured wallet does not own all provided withdrawal requests.');
300
+ nextSteps.push('Run lido_claim_withdrawals with dry_run=true to inspect the claim transaction request.');
301
+ return finalizePreflight(action, network, signer, checks, blockers, {
302
+ request_ids: sortedIds,
303
+ recipient: recipientAddress ?? signer.address,
304
+ total_claimable_eth: formatEther(totalClaimable),
305
+ requests: sortedIds.map((requestId, index) => ({
306
+ request_id: requestId,
307
+ owner: statuses[index].owner,
308
+ is_finalized: statuses[index].isFinalized,
309
+ is_claimed: statuses[index].isClaimed,
310
+ claimable_eth: formatEther(claimable[index]),
311
+ })),
312
+ }, nextSteps);
313
+ }
314
+ if (action === 'vote_on_proposal') {
315
+ if (voteId === undefined) {
316
+ pushCheck(checks, blockers, 'vote_id', 'fail', 'Provide vote_id for vote_on_proposal preflight.');
317
+ return finalizePreflight(action, network, signer, checks, blockers, {}, nextSteps);
318
+ }
319
+ if (support === undefined) {
320
+ pushCheck(checks, blockers, 'support', 'fail', 'Provide support=true or support=false for vote_on_proposal preflight.');
321
+ return finalizePreflight(action, network, signer, checks, blockers, { vote_id: voteId }, nextSteps);
322
+ }
323
+ const voteIdBigInt = BigInt(voteId);
324
+ const vote = await publicClient.readContract({
325
+ address: config.voting,
326
+ abi: VOTING_ABI,
327
+ functionName: 'getVote',
328
+ args: [voteIdBigInt],
329
+ });
330
+ pushCheck(checks, blockers, 'vote_open', vote[0] ? 'pass' : 'fail', vote[0] ? 'Proposal is currently open for voting.' : 'Proposal is not currently open for voting.');
331
+ if (!signer.address) {
332
+ nextSteps.push('Configure a wallet private key so voter eligibility can be checked.');
333
+ return finalizePreflight(action, network, signer, checks, blockers, {
334
+ vote_id: voteIdBigInt,
335
+ support,
336
+ executes_if_decided: executesIfDecided ?? false,
337
+ }, nextSteps);
338
+ }
339
+ const [canVote, voterState, ldoBalance] = await Promise.all([
340
+ publicClient.readContract({
341
+ address: config.voting,
342
+ abi: VOTING_ABI,
343
+ functionName: 'canVote',
344
+ args: [voteIdBigInt, signer.address],
345
+ }),
346
+ publicClient.readContract({
347
+ address: config.voting,
348
+ abi: VOTING_ABI,
349
+ functionName: 'getVoterState',
350
+ args: [voteIdBigInt, signer.address],
351
+ }),
352
+ publicClient.readContract({
353
+ address: config.ldo,
354
+ abi: erc20Abi,
355
+ functionName: 'balanceOf',
356
+ args: [signer.address],
357
+ }),
358
+ ]);
359
+ pushCheck(checks, blockers, 'voter_eligibility', canVote ? 'pass' : 'fail', canVote ? 'Configured wallet can vote on this proposal.' : 'Configured wallet cannot vote on this proposal at the current snapshot/state.');
360
+ nextSteps.push('Run lido_vote_on_proposal with dry_run=true to inspect the governance vote transaction request.');
361
+ return finalizePreflight(action, network, signer, checks, blockers, {
362
+ vote_id: voteIdBigInt,
363
+ support,
364
+ executes_if_decided: executesIfDecided ?? false,
365
+ current_ldo_balance: formatUnits(ldoBalance, 18),
366
+ current_voter_state: Number(voterState),
367
+ proposal_open: vote[0],
368
+ }, nextSteps);
369
+ }
370
+ if (action === 'execute_proposal') {
371
+ if (voteId === undefined) {
372
+ pushCheck(checks, blockers, 'vote_id', 'fail', 'Provide vote_id for execute_proposal preflight.');
373
+ return finalizePreflight(action, network, signer, checks, blockers, {}, nextSteps);
374
+ }
375
+ const voteIdBigInt = BigInt(voteId);
376
+ const canExecute = await publicClient.readContract({
377
+ address: config.voting,
378
+ abi: VOTING_ABI,
379
+ functionName: 'canExecute',
380
+ args: [voteIdBigInt],
381
+ });
382
+ pushCheck(checks, blockers, 'proposal_executable', canExecute ? 'pass' : 'fail', canExecute ? 'Proposal is executable right now.' : 'Proposal is not executable right now.');
383
+ if (!signer.address) {
384
+ nextSteps.push('Configure a wallet private key before attempting live proposal execution.');
385
+ return finalizePreflight(action, network, signer, checks, blockers, { vote_id: voteIdBigInt }, nextSteps);
386
+ }
387
+ nextSteps.push('Run lido_execute_proposal with dry_run=true to inspect the execution transaction request.');
388
+ return finalizePreflight(action, network, signer, checks, blockers, { vote_id: voteIdBigInt }, nextSteps);
389
+ }
390
+ return finalizePreflight(action, network, signer, checks, ['Unsupported write action.'], {}, nextSteps);
391
+ }
392
+ export async function getPortfolioSummary(params) {
393
+ const owner = requireSummaryAddress(params.address);
394
+ const ethereumConfig = getNetworkConfig('ethereum');
395
+ const ethereumClient = getPublicClient('ethereum');
396
+ const [ethereumOverview, baseOverview, optimismOverview, arbitrumOverview, withdrawals, proposals, ldoBalance] = await Promise.all([
397
+ getAccountOverview('ethereum', owner),
398
+ getAccountOverview('base', owner),
399
+ getAccountOverview('optimism', owner),
400
+ getAccountOverview('arbitrum', owner),
401
+ getWithdrawalRequests('ethereum', owner),
402
+ getGovernanceProposals({ network: 'ethereum', recentLimit: params.governanceRecentLimit, voter: owner }),
403
+ ethereumClient.readContract({
404
+ address: ethereumConfig.ldo,
405
+ abi: erc20Abi,
406
+ functionName: 'balanceOf',
407
+ args: [owner],
408
+ }),
409
+ ]);
410
+ const perNetwork = [ethereumOverview, baseOverview, optimismOverview, arbitrumOverview];
411
+ const totalStethRaw = perNetwork.reduce((sum, overview) => sum + (overview.steth_balance?.raw ?? 0n), 0n);
412
+ const totalWstethRaw = perNetwork.reduce((sum, overview) => sum + (overview.wsteth_balance?.raw ?? 0n), 0n);
413
+ const totalWstethUnderlying = totalWstethRaw > 0n
414
+ ? await ethereumClient.readContract({
415
+ address: ethereumConfig.wsteth,
416
+ abi: WSTETH_ABI,
417
+ functionName: 'getStETHByWstETH',
418
+ args: [totalWstethRaw],
419
+ })
420
+ : 0n;
421
+ const totalExposure = totalStethRaw + totalWstethUnderlying;
422
+ const withdrawalRequests = withdrawals.requests ?? [];
423
+ const totalClaimableEth = withdrawalRequests.reduce((sum, request) => sum + parseEther(request.claimable_eth), 0n);
424
+ const actionableProposals = proposals.proposals.filter((proposal) => proposal.can_execute || proposal.voter_context?.can_vote === true || proposal.open);
425
+ const recommendations = [];
426
+ if (totalClaimableEth > 0n) {
427
+ recommendations.push('Claim finalized withdrawals on Ethereum because claimable ETH is available now.');
428
+ }
429
+ if ((ethereumOverview.steth_balance?.raw ?? 0n) > 0n) {
430
+ recommendations.push('If you want non-rebasing exposure or DeFi composability, consider wrapping some Ethereum stETH into wstETH.');
431
+ }
432
+ if (actionableProposals.some((proposal) => proposal.voter_context?.can_vote)) {
433
+ recommendations.push('You have at least one recent governance proposal where the configured or provided wallet can vote.');
434
+ }
435
+ if (recommendations.length === 0) {
436
+ recommendations.push('No immediate action is required based on balances, claimable withdrawals, and recent governance context.');
437
+ }
438
+ return {
439
+ address: owner,
440
+ balance_overview: {
441
+ ethereum: ethereumOverview,
442
+ base: baseOverview,
443
+ optimism: optimismOverview,
444
+ arbitrum: arbitrumOverview,
445
+ },
446
+ aggregated_exposure: {
447
+ total_steth_raw: totalStethRaw,
448
+ total_steth_formatted: formatUnits(totalStethRaw, 18),
449
+ total_wsteth_raw: totalWstethRaw,
450
+ total_wsteth_formatted: formatUnits(totalWstethRaw, 18),
451
+ total_wsteth_underlying_steth_raw: totalWstethUnderlying,
452
+ total_wsteth_underlying_steth_formatted: formatUnits(totalWstethUnderlying, 18),
453
+ total_steth_equivalent_raw: totalExposure,
454
+ total_steth_equivalent_formatted: formatUnits(totalExposure, 18),
455
+ },
456
+ withdrawals: {
457
+ request_count: withdrawalRequests.length,
458
+ finalized_count: withdrawalRequests.filter((request) => request.is_finalized).length,
459
+ claimable_count: withdrawalRequests.filter((request) => parseEther(request.claimable_eth) > 0n).length,
460
+ total_claimable_eth_raw: totalClaimableEth,
461
+ total_claimable_eth_formatted: formatEther(totalClaimableEth),
462
+ requests: withdrawalRequests,
463
+ },
464
+ governance: {
465
+ ldo_balance_raw: ldoBalance,
466
+ ldo_balance_formatted: formatUnits(ldoBalance, 18),
467
+ recent_limit: params.governanceRecentLimit,
468
+ returned: proposals.returned,
469
+ actionable_count: actionableProposals.length,
470
+ proposals: proposals.proposals,
471
+ },
472
+ recommendations,
473
+ };
474
+ }
@@ -0,0 +1,34 @@
1
+ import { createPublicClient, createWalletClient, getAddress, http } from 'viem';
2
+ import { privateKeyToAccount } from 'viem/accounts';
3
+ import { getPrivateKey } from '../config/env.js';
4
+ import { getNetworkConfig, getRpcUrl } from './networks.js';
5
+ export function getPublicClient(network) {
6
+ const config = getNetworkConfig(network);
7
+ return createPublicClient({
8
+ chain: config.chain,
9
+ transport: http(getRpcUrl(network)),
10
+ });
11
+ }
12
+ export function getAccount() {
13
+ return privateKeyToAccount(getPrivateKey());
14
+ }
15
+ export function getWalletContext(network) {
16
+ const config = getNetworkConfig(network);
17
+ const account = getAccount();
18
+ const publicClient = getPublicClient(network);
19
+ const walletClient = createWalletClient({
20
+ account,
21
+ chain: config.chain,
22
+ transport: http(getRpcUrl(network)),
23
+ });
24
+ return { account, publicClient, walletClient, config };
25
+ }
26
+ export function normalizeAddress(value) {
27
+ return getAddress(value);
28
+ }
29
+ export function getQueryAddress(address) {
30
+ if (address) {
31
+ return normalizeAddress(address);
32
+ }
33
+ return getAccount().address;
34
+ }