@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,378 @@
1
+ import { getOptionalEnv, hasPrivateKeyConfigured } from '../config/env.js';
2
+ import { getUniswapSetupSummary } from '../uniswap/index.js';
3
+ import { getAccount } from './clients.js';
4
+ import { getNetworkConfig } from './networks.js';
5
+ import { SUPPORTED_NETWORKS, WRITE_ACTIONS } from './types.js';
6
+ export const GUIDE_TOPICS = ['overview', 'portfolio', 'stake', 'wrap', 'unstake', 'claim', 'rewards', 'governance', 'swap', 'bridge'];
7
+ const QUICKSTART_PROMPTS = [
8
+ 'Show my Lido MCP setup status.',
9
+ 'Show the Lido agent guide for staking.',
10
+ 'Show my aggregated Lido portfolio summary.',
11
+ 'Preflight a Uniswap route from ETH on Ethereum into wstETH on Base.',
12
+ 'Preflight staking 0.01 ETH on Ethereum.',
13
+ 'Dry run wrapping 0.1 stETH into wstETH on Ethereum.',
14
+ 'Show my withdrawal requests and claimable ETH on Ethereum.',
15
+ 'Show the 5 most recent Lido governance proposals and whether my wallet can vote.',
16
+ ];
17
+ const SAFE_OPERATING_SEQUENCE = [
18
+ 'Call lido_get_setup to confirm wallet and RPC configuration.',
19
+ 'Call lido_get_agent_guide if you need the Lido mental model or tool-selection help for a goal.',
20
+ 'Call lido_get_portfolio_summary or lido_get_account_overview to inspect balances before acting.',
21
+ 'Call lido_preflight_write_action before any write so blockers, approvals, and ownership issues are explicit.',
22
+ 'Call the write tool with dry_run=true before any live transaction.',
23
+ 'Only call the same write tool with dry_run=false after explicit human confirmation.',
24
+ ];
25
+ const TOOL_MAP = {
26
+ discovery: [
27
+ {
28
+ tool: 'lido_get_setup',
29
+ when_to_use: 'First call for any new session or when diagnosing environment problems.',
30
+ returns: 'Wallet status, RPC coverage, safety defaults, and recommended prompts.',
31
+ },
32
+ {
33
+ tool: 'lido_get_agent_guide',
34
+ when_to_use: 'When an agent needs the Lido mental model, workflow guidance, or tool-selection help.',
35
+ returns: 'Goal-specific guidance, safe workflows, pitfalls, and example prompts.',
36
+ },
37
+ ],
38
+ reads: [
39
+ {
40
+ tool: 'lido_get_account_overview',
41
+ when_to_use: 'Inspect one wallet on one network.',
42
+ returns: 'Native balance, stETH, wstETH, and exchange-rate context.',
43
+ },
44
+ {
45
+ tool: 'lido_get_portfolio_summary',
46
+ when_to_use: 'Get the highest-signal cross-network overview for a wallet.',
47
+ returns: 'Balances, withdrawals, governance context, and recommendations.',
48
+ },
49
+ {
50
+ tool: 'lido_get_rewards',
51
+ when_to_use: 'Explain rebasing and reward-aware position changes on Ethereum.',
52
+ returns: 'Current reward context or historical net position delta from a block.',
53
+ },
54
+ {
55
+ tool: 'lido_get_withdrawal_requests',
56
+ when_to_use: 'Inspect queue requests, finalization, and claimability.',
57
+ returns: 'Withdrawal NFTs, claimable ETH, and checkpoint hints.',
58
+ },
59
+ {
60
+ tool: 'lido_get_governance_proposals',
61
+ when_to_use: 'Inspect recent DAO votes and optional voter eligibility.',
62
+ returns: 'Recent proposals, execution state, and voter context.',
63
+ },
64
+ {
65
+ tool: 'lido_get_uniswap_route_status',
66
+ when_to_use: 'Track the status of a source-chain swap or bridge transaction after submission.',
67
+ returns: 'Uniswap-reported status for one or more source-chain transaction hashes.',
68
+ },
69
+ {
70
+ tool: 'lido_get_uniswap_bridgable_tokens',
71
+ when_to_use: 'Discover bridge-compatible destinations for a source token on a source chain.',
72
+ returns: 'The Uniswap-reported swappable or bridgable destination token set.',
73
+ },
74
+ ],
75
+ safety: [
76
+ {
77
+ tool: 'lido_preflight_write_action',
78
+ when_to_use: 'Before every stake, wrap, unwrap, withdrawal, claim, or governance write.',
79
+ returns: 'Readiness, blockers, approval needs, ownership checks, and next steps.',
80
+ },
81
+ {
82
+ tool: 'lido_preflight_uniswap_route',
83
+ when_to_use: 'Before any Uniswap-powered swap or bridge route.',
84
+ returns: 'Approval needs, route type, execution compatibility, blockers, and next steps.',
85
+ },
86
+ ],
87
+ writes: [
88
+ {
89
+ tool: 'lido_stake_eth',
90
+ when_to_use: 'Convert ETH into stETH on Ethereum.',
91
+ returns: 'Dry-run request or executed transaction receipt context.',
92
+ },
93
+ {
94
+ tool: 'lido_wrap_steth',
95
+ when_to_use: 'Convert rebasing stETH into non-rebasing wstETH on Ethereum.',
96
+ returns: 'Approval-aware wrap path with expected output.',
97
+ },
98
+ {
99
+ tool: 'lido_unwrap_wsteth',
100
+ when_to_use: 'Convert wstETH back into stETH on Ethereum.',
101
+ returns: 'Unwrap preview or execution details.',
102
+ },
103
+ {
104
+ tool: 'lido_request_unstake',
105
+ when_to_use: 'Enter the Lido withdrawal queue using stETH or wstETH.',
106
+ returns: 'Queue-request preview or created request ids after execution.',
107
+ },
108
+ {
109
+ tool: 'lido_claim_withdrawals',
110
+ when_to_use: 'Claim finalized withdrawal requests into ETH on Ethereum.',
111
+ returns: 'Claim preview or executed claim receipt details.',
112
+ },
113
+ {
114
+ tool: 'lido_vote_on_proposal',
115
+ when_to_use: 'Cast a yes/no vote on a Lido DAO proposal after explicit human approval.',
116
+ returns: 'Governance vote preview or execution details.',
117
+ },
118
+ {
119
+ tool: 'lido_execute_proposal',
120
+ when_to_use: 'Execute a passed Lido DAO proposal when it is executable.',
121
+ returns: 'Execution preview or executed transaction details.',
122
+ },
123
+ {
124
+ tool: 'lido_execute_uniswap_route',
125
+ when_to_use: 'Dry run or execute a Uniswap swap or bridge after reviewing preflight results.',
126
+ returns: 'Approval transactions if needed, swap calldata preview, or executed transaction details.',
127
+ },
128
+ ],
129
+ };
130
+ const FOCUSED_GUIDANCE = {
131
+ overview: {
132
+ objective: 'Choose the right Lido tool and follow a safe sequence without custom integration code.',
133
+ recommended_tools: ['lido_get_setup', 'lido_get_agent_guide', 'lido_get_portfolio_summary', 'lido_preflight_write_action'],
134
+ workflow: [...SAFE_OPERATING_SEQUENCE],
135
+ pitfalls: [
136
+ 'Do not treat stETH balance changes as transfers by default; stETH rebases.',
137
+ 'Do not promise instant ETH exits from the withdrawal queue.',
138
+ 'Do not send dry_run=false writes without an explicit human instruction.',
139
+ ],
140
+ example_prompts: [...QUICKSTART_PROMPTS],
141
+ },
142
+ portfolio: {
143
+ objective: 'Get a high-signal view of a wallet before deciding on any position action.',
144
+ recommended_tools: ['lido_get_portfolio_summary', 'lido_get_account_overview', 'lido_get_rewards'],
145
+ workflow: [
146
+ 'Call lido_get_portfolio_summary for the cross-network picture.',
147
+ 'Call lido_get_account_overview if you need one network in more detail.',
148
+ 'Call lido_get_rewards on Ethereum if the task is specifically about reward-aware performance.',
149
+ ],
150
+ pitfalls: [
151
+ 'L2 balances usually represent bridged wstETH exposure, not native staking capability.',
152
+ 'Historical reward deltas require archive-capable RPC support.',
153
+ ],
154
+ example_prompts: [
155
+ 'Show my aggregated Lido portfolio summary.',
156
+ 'Show my Lido balances on Base.',
157
+ 'Show my Lido rewards context on Ethereum.',
158
+ ],
159
+ },
160
+ stake: {
161
+ objective: 'Stake ETH into stETH on Ethereum safely.',
162
+ asset_flow: 'ETH -> stETH on Ethereum',
163
+ recommended_tools: ['lido_preflight_write_action', 'lido_stake_eth'],
164
+ required_inputs: ['amount_eth'],
165
+ workflow: [
166
+ 'Preflight the stake amount with action=stake.',
167
+ 'Run lido_stake_eth with dry_run=true.',
168
+ 'If the preview matches intent and the human confirms, run lido_stake_eth with dry_run=false.',
169
+ ],
170
+ pitfalls: [
171
+ 'Staking is an Ethereum action, not an L2 action.',
172
+ 'Leave enough ETH for gas instead of staking the full wallet balance.',
173
+ ],
174
+ example_prompts: [
175
+ 'Preflight staking 0.01 ETH on Ethereum.',
176
+ 'Dry run staking 0.01 ETH on Ethereum.',
177
+ ],
178
+ },
179
+ wrap: {
180
+ objective: 'Move between rebasing stETH and non-rebasing wstETH on Ethereum.',
181
+ asset_flow: 'stETH <-> wstETH on Ethereum',
182
+ recommended_tools: ['lido_preflight_write_action', 'lido_wrap_steth', 'lido_unwrap_wsteth'],
183
+ required_inputs: ['amount_steth for wrap or amount_wsteth for unwrap'],
184
+ workflow: [
185
+ 'For wrapping, preflight with action=wrap to check balance and allowance.',
186
+ 'For unwrapping, preflight with action=unwrap to verify the wstETH balance.',
187
+ 'Run the corresponding write tool with dry_run=true before any live call.',
188
+ ],
189
+ pitfalls: [
190
+ 'Wrapping may require an ERC-20 approval before the main action.',
191
+ 'wstETH keeps token count stable while value accrues through exchange rate, unlike stETH rebasing.',
192
+ ],
193
+ example_prompts: [
194
+ 'Preflight wrapping 0.1 stETH into wstETH on Ethereum.',
195
+ 'Dry run unwrapping 0.05 wstETH into stETH on Ethereum.',
196
+ ],
197
+ },
198
+ unstake: {
199
+ objective: 'Enter the withdrawal queue using stETH or wstETH.',
200
+ asset_flow: 'stETH or wstETH -> withdrawal queue request NFTs -> later ETH claim',
201
+ recommended_tools: ['lido_preflight_write_action', 'lido_request_unstake', 'lido_get_withdrawal_requests'],
202
+ required_inputs: ['token', 'amounts'],
203
+ workflow: [
204
+ 'Preflight with action=request_unstake to validate balances and allowance.',
205
+ 'Run lido_request_unstake with dry_run=true.',
206
+ 'After live execution, monitor status with lido_get_withdrawal_requests until claimable ETH appears.',
207
+ ],
208
+ pitfalls: [
209
+ 'Withdrawal requests are not instant ETH redemption.',
210
+ 'An approval may be required before the queue request can execute.',
211
+ ],
212
+ example_prompts: [
213
+ 'Preflight unstaking 0.2 stETH on Ethereum.',
214
+ 'Show my withdrawal requests and claimable ETH on Ethereum.',
215
+ ],
216
+ },
217
+ claim: {
218
+ objective: 'Claim finalized withdrawal requests into ETH.',
219
+ asset_flow: 'finalized withdrawal requests -> ETH on Ethereum',
220
+ recommended_tools: ['lido_get_withdrawal_requests', 'lido_preflight_write_action', 'lido_claim_withdrawals'],
221
+ required_inputs: ['request_ids'],
222
+ workflow: [
223
+ 'Inspect request status and claimability with lido_get_withdrawal_requests.',
224
+ 'Preflight with action=claim_withdrawals to verify ownership and claimable ETH.',
225
+ 'Run lido_claim_withdrawals with dry_run=true, then only execute live after confirmation.',
226
+ ],
227
+ pitfalls: [
228
+ 'Only finalized requests with positive claimable ETH can be claimed.',
229
+ 'The configured wallet must own every request id being claimed.',
230
+ ],
231
+ example_prompts: [
232
+ 'Preflight claiming my finalized Lido withdrawal requests on Ethereum.',
233
+ 'Dry run claiming withdrawal request ids 123 and 124 on Ethereum.',
234
+ ],
235
+ },
236
+ rewards: {
237
+ objective: 'Explain Lido reward mechanics and reward-aware balance changes.',
238
+ recommended_tools: ['lido_get_rewards', 'lido_get_account_overview', 'lido_get_portfolio_summary'],
239
+ workflow: [
240
+ 'Call lido_get_rewards without from_block for current rebasing and exchange-rate context.',
241
+ 'Provide from_block only when you need a historical net position delta on Ethereum.',
242
+ 'Use account or portfolio views alongside rewards if the task also involves holdings or actions.',
243
+ ],
244
+ pitfalls: [
245
+ 'Net balance delta is not pure reward if transfers happened during the interval.',
246
+ 'Historical queries can fail on non-archive RPC providers.',
247
+ ],
248
+ example_prompts: [
249
+ 'Show my Lido rewards context on Ethereum.',
250
+ 'Show my Lido net position delta since block 22000000 on Ethereum.',
251
+ ],
252
+ },
253
+ governance: {
254
+ objective: 'Inspect and, with explicit human approval, act on Lido DAO governance proposals.',
255
+ asset_flow: 'LDO voting power at proposal snapshot -> Aragon vote or execution on Ethereum',
256
+ recommended_tools: ['lido_get_governance_proposals', 'lido_preflight_write_action', 'lido_vote_on_proposal', 'lido_execute_proposal'],
257
+ required_inputs: ['vote_id for writes and support for voting'],
258
+ workflow: [
259
+ 'Call lido_get_governance_proposals to inspect recent votes and optional wallet eligibility.',
260
+ 'Preflight action=vote_on_proposal or action=execute_proposal before any governance write.',
261
+ 'Use the write tool with dry_run=true before live voting or execution.',
262
+ ],
263
+ pitfalls: [
264
+ 'Governance writes require an explicit human instruction.',
265
+ 'A wallet may hold LDO now but still be ineligible for a specific proposal snapshot.',
266
+ ],
267
+ example_prompts: [
268
+ 'Show the 5 most recent Lido governance proposals and whether my wallet can vote.',
269
+ 'Preflight voting yes on Lido proposal 123.',
270
+ ],
271
+ },
272
+ swap: {
273
+ objective: 'Use Uniswap routing to swap into or out of Lido-related assets with explicit approval and route checks first.',
274
+ asset_flow: 'source token on source chain -> Uniswap-routed swap -> destination token on destination chain',
275
+ recommended_tools: ['lido_preflight_uniswap_route', 'lido_execute_uniswap_route', 'lido_get_uniswap_route_status'],
276
+ required_inputs: ['token_in_chain', 'token_out_chain', 'token_in', 'token_out', 'amount'],
277
+ workflow: [
278
+ 'Preflight the swap route first so the agent can inspect approval requirements and routing type.',
279
+ 'Run lido_execute_uniswap_route with dry_run=true to inspect the approval and swap calldata.',
280
+ 'Only execute live after explicit human confirmation and only if the route is supported by the server.',
281
+ ],
282
+ pitfalls: [
283
+ 'This server uses a direct approval-then-swap flow with x-permit2-disabled=true, not a Permit2 signature flow.',
284
+ 'Some quotes may return UniswapX or chained routings which this server currently surfaces in preflight but does not execute live.',
285
+ ],
286
+ example_prompts: [
287
+ 'Preflight a Uniswap route from ETH on Ethereum into wstETH on Ethereum.',
288
+ 'Dry run a Uniswap route from wstETH on Base into native ETH on Base.',
289
+ ],
290
+ },
291
+ bridge: {
292
+ objective: 'Use Uniswap to bridge supported assets across the server-supported chains with explicit route validation and status tracking.',
293
+ asset_flow: 'source-chain token -> bridge transaction on source chain -> destination-chain asset arrival',
294
+ recommended_tools: ['lido_get_uniswap_bridgable_tokens', 'lido_preflight_uniswap_route', 'lido_execute_uniswap_route', 'lido_get_uniswap_route_status'],
295
+ required_inputs: ['token_in_chain', 'token_out_chain', 'token_in', 'token_out', 'amount'],
296
+ workflow: [
297
+ 'Optionally inspect bridgable destinations first with lido_get_uniswap_bridgable_tokens.',
298
+ 'Preflight the bridge route to inspect approval requirements, route type, and blockers.',
299
+ 'Dry run the bridge transaction, then execute live only after explicit human confirmation.',
300
+ 'Track the bridge transaction with lido_get_uniswap_route_status using the source-chain transaction hash.',
301
+ ],
302
+ pitfalls: [
303
+ 'A bridge route is still initiated by a source-chain transaction that can require ERC-20 approval first.',
304
+ 'Cross-chain quote quality and executability can vary by token pair and destination. Preflight should always be treated as authoritative for the current attempt.',
305
+ ],
306
+ example_prompts: [
307
+ 'Show bridgable destinations for wstETH on Ethereum.',
308
+ 'Preflight a Uniswap route from wstETH on Ethereum into wstETH on Base.',
309
+ ],
310
+ },
311
+ };
312
+ export function getSetupSummary() {
313
+ let walletAddress = null;
314
+ let walletError = null;
315
+ if (hasPrivateKeyConfigured()) {
316
+ try {
317
+ walletAddress = getAccount().address;
318
+ }
319
+ catch (error) {
320
+ walletError = error instanceof Error ? error.message : String(error);
321
+ }
322
+ }
323
+ return {
324
+ wallet_configured: hasPrivateKeyConfigured(),
325
+ wallet_address: walletAddress,
326
+ wallet_error: walletError,
327
+ writable_networks: SUPPORTED_NETWORKS.filter((network) => getNetworkConfig(network).supportsStake || getNetworkConfig(network).supportsGovernance),
328
+ readable_networks: [...SUPPORTED_NETWORKS],
329
+ supported_write_actions: [...WRITE_ACTIONS],
330
+ uniswap: getUniswapSetupSummary(),
331
+ rpc_status: SUPPORTED_NETWORKS.map((network) => {
332
+ const config = getNetworkConfig(network);
333
+ return {
334
+ network: config.key,
335
+ rpc_env: config.rpcEnv,
336
+ configured: Boolean(getOptionalEnv(config.rpcEnv)),
337
+ fallback_rpc_available: Boolean(config.chain.rpcUrls.default.http[0]),
338
+ };
339
+ }),
340
+ developer_artifacts: {
341
+ skill_file: 'lido.skill.md',
342
+ demo_guide: 'DEMO.md',
343
+ recommended_first_tools: ['lido_get_setup', 'lido_get_agent_guide', 'lido_get_portfolio_summary', 'lido_preflight_write_action', 'lido_preflight_uniswap_route'],
344
+ },
345
+ safety_defaults: {
346
+ write_tools_default_to_dry_run: true,
347
+ lido_core_write_execution_networks: ['ethereum'],
348
+ uniswap_source_execution_networks: [...SUPPORTED_NETWORKS],
349
+ recommended_sequence: [...SAFE_OPERATING_SEQUENCE],
350
+ },
351
+ quickstart_prompts: [...QUICKSTART_PROMPTS],
352
+ };
353
+ }
354
+ export function getAgentGuide(topic = 'overview') {
355
+ return {
356
+ topic,
357
+ server: {
358
+ name: 'lido-mcp-server',
359
+ transport: 'stdio',
360
+ goal: 'Make real on-chain Lido staking, position management, and governance actions callable by an AI agent without custom integration code.',
361
+ },
362
+ mental_model: {
363
+ steth: 'stETH is the canonical rebasing Ethereum staking receipt. Displayed token balance changes as rewards and penalties are reported.',
364
+ wsteth: 'wstETH is the wrapped, non-rebasing form of stETH. Token balance stays fixed while redeemable stETH per token grows over time.',
365
+ withdrawal_queue: 'Unstaking is a two-phase queue workflow: request withdrawal first, then claim ETH only after finalization.',
366
+ governance: 'Lido DAO governance runs through Aragon voting on Ethereum and should only be acted on with explicit human approval.',
367
+ },
368
+ network_boundaries: {
369
+ ethereum: 'Canonical execution layer for staking, wrapping, withdrawal queue actions, rewards context, and governance.',
370
+ base: 'Read visibility for bridged Lido assets such as wstETH. Not native staking or native governance execution.',
371
+ optimism: 'Read visibility for bridged Lido assets such as wstETH. Not native staking or native governance execution.',
372
+ arbitrum: 'Read visibility for bridged Lido assets such as wstETH. Not native staking or native governance execution.',
373
+ },
374
+ safe_operating_sequence: [...SAFE_OPERATING_SEQUENCE],
375
+ tool_map: TOOL_MAP,
376
+ focused_guidance: FOCUSED_GUIDANCE[topic],
377
+ };
378
+ }
@@ -0,0 +1,185 @@
1
+ import { erc20Abi, formatUnits, parseEther, zeroAddress } from 'viem';
2
+ import { STETH_ABI, WSTETH_ABI } from './abis.js';
3
+ import { getWalletContext, normalizeAddress } from './clients.js';
4
+ import { ensureBalanceAtLeast, getAllowance, parseTokenAmount } from './utils.js';
5
+ async function simulateApprove(network, token, spender, amount, account) {
6
+ const { publicClient } = getWalletContext(network);
7
+ return publicClient.simulateContract({
8
+ account,
9
+ address: token,
10
+ abi: erc20Abi,
11
+ functionName: 'approve',
12
+ args: [spender, amount],
13
+ });
14
+ }
15
+ export async function stakeEth(params) {
16
+ const { network, amountEth, referral, dryRun } = params;
17
+ const { account, publicClient, walletClient, config } = getWalletContext(network);
18
+ const value = parseEther(amountEth);
19
+ const referralAddress = referral ? normalizeAddress(referral) : zeroAddress;
20
+ const simulation = await publicClient.simulateContract({
21
+ account: account.address,
22
+ address: config.lido,
23
+ abi: STETH_ABI,
24
+ functionName: 'submit',
25
+ args: [referralAddress],
26
+ value,
27
+ });
28
+ if (dryRun) {
29
+ return {
30
+ mode: 'dry_run',
31
+ network,
32
+ account: account.address,
33
+ contract: config.lido,
34
+ function: 'submit',
35
+ amount_eth: amountEth,
36
+ referral: referralAddress,
37
+ request: {
38
+ to: simulation.request.address,
39
+ value: simulation.request.value,
40
+ },
41
+ };
42
+ }
43
+ const hash = await walletClient.writeContract({ ...simulation.request, account });
44
+ const receipt = await publicClient.waitForTransactionReceipt({ hash });
45
+ return {
46
+ mode: 'executed',
47
+ network,
48
+ account: account.address,
49
+ amount_eth: amountEth,
50
+ transaction_hash: hash,
51
+ block_number: receipt.blockNumber,
52
+ status: receipt.status,
53
+ };
54
+ }
55
+ export async function wrapSteth(params) {
56
+ const { network, amountSteth, approveIfNeeded, dryRun } = params;
57
+ const { account, publicClient, walletClient, config } = getWalletContext(network);
58
+ const amount = parseTokenAmount(amountSteth, 'stETH');
59
+ const [balance, allowance, expectedWstethOut] = await Promise.all([
60
+ ensureBalanceAtLeast(publicClient, config.steth, account.address, amount, 'stETH'),
61
+ getAllowance(publicClient, config.steth, account.address, config.wsteth),
62
+ publicClient.readContract({
63
+ address: config.wsteth,
64
+ abi: WSTETH_ABI,
65
+ functionName: 'getWstETHByStETH',
66
+ args: [amount],
67
+ }),
68
+ ]);
69
+ const needsApproval = allowance < amount;
70
+ if (needsApproval && !approveIfNeeded) {
71
+ throw new Error('stETH allowance is insufficient for wrapping and approve_if_needed is false.');
72
+ }
73
+ let approvalPreview = null;
74
+ if (needsApproval) {
75
+ const approvalSimulation = await simulateApprove(network, config.steth, config.wsteth, amount, account.address);
76
+ approvalPreview = {
77
+ to: approvalSimulation.request.address,
78
+ amount,
79
+ };
80
+ }
81
+ if (dryRun) {
82
+ const wrapRequest = !needsApproval
83
+ ? await publicClient.simulateContract({
84
+ account: account.address,
85
+ address: config.wsteth,
86
+ abi: WSTETH_ABI,
87
+ functionName: 'wrap',
88
+ args: [amount],
89
+ })
90
+ : null;
91
+ return {
92
+ mode: 'dry_run',
93
+ network,
94
+ account: account.address,
95
+ amount_steth: amountSteth,
96
+ expected_wsteth_out: formatUnits(expectedWstethOut, 18),
97
+ current_steth_balance: formatUnits(balance, 18),
98
+ current_allowance: formatUnits(allowance, 18),
99
+ needs_approval: needsApproval,
100
+ approval_request: approvalPreview,
101
+ wrap_request: wrapRequest
102
+ ? {
103
+ to: wrapRequest.request.address,
104
+ function: 'wrap',
105
+ }
106
+ : null,
107
+ notes: needsApproval
108
+ ? 'An approval transaction must be mined before the wrap call can be simulated against current chain state.'
109
+ : 'Wrap call is ready to execute.',
110
+ };
111
+ }
112
+ let approvalHash = null;
113
+ if (needsApproval) {
114
+ const approvalSimulation = await simulateApprove(network, config.steth, config.wsteth, amount, account.address);
115
+ approvalHash = await walletClient.writeContract({ ...approvalSimulation.request, account });
116
+ await publicClient.waitForTransactionReceipt({ hash: approvalHash });
117
+ }
118
+ const wrapSimulation = await publicClient.simulateContract({
119
+ account: account.address,
120
+ address: config.wsteth,
121
+ abi: WSTETH_ABI,
122
+ functionName: 'wrap',
123
+ args: [amount],
124
+ });
125
+ const wrapHash = await walletClient.writeContract({ ...wrapSimulation.request, account });
126
+ const receipt = await publicClient.waitForTransactionReceipt({ hash: wrapHash });
127
+ return {
128
+ mode: 'executed',
129
+ network,
130
+ account: account.address,
131
+ amount_steth: amountSteth,
132
+ expected_wsteth_out: formatUnits(expectedWstethOut, 18),
133
+ approval_transaction_hash: approvalHash,
134
+ wrap_transaction_hash: wrapHash,
135
+ block_number: receipt.blockNumber,
136
+ status: receipt.status,
137
+ };
138
+ }
139
+ export async function unwrapWsteth(params) {
140
+ const { network, amountWsteth, dryRun } = params;
141
+ const { account, publicClient, walletClient, config } = getWalletContext(network);
142
+ const amount = parseTokenAmount(amountWsteth, 'wstETH');
143
+ const [balance, expectedStethOut] = await Promise.all([
144
+ ensureBalanceAtLeast(publicClient, config.wsteth, account.address, amount, 'wstETH'),
145
+ publicClient.readContract({
146
+ address: config.wsteth,
147
+ abi: WSTETH_ABI,
148
+ functionName: 'getStETHByWstETH',
149
+ args: [amount],
150
+ }),
151
+ ]);
152
+ const simulation = await publicClient.simulateContract({
153
+ account: account.address,
154
+ address: config.wsteth,
155
+ abi: WSTETH_ABI,
156
+ functionName: 'unwrap',
157
+ args: [amount],
158
+ });
159
+ if (dryRun) {
160
+ return {
161
+ mode: 'dry_run',
162
+ network,
163
+ account: account.address,
164
+ amount_wsteth: amountWsteth,
165
+ current_wsteth_balance: formatUnits(balance, 18),
166
+ expected_steth_out: formatUnits(expectedStethOut, 18),
167
+ request: {
168
+ to: simulation.request.address,
169
+ function: 'unwrap',
170
+ },
171
+ };
172
+ }
173
+ const hash = await walletClient.writeContract({ ...simulation.request, account });
174
+ const receipt = await publicClient.waitForTransactionReceipt({ hash });
175
+ return {
176
+ mode: 'executed',
177
+ network,
178
+ account: account.address,
179
+ amount_wsteth: amountWsteth,
180
+ expected_steth_out: formatUnits(expectedStethOut, 18),
181
+ transaction_hash: hash,
182
+ block_number: receipt.blockNumber,
183
+ status: receipt.status,
184
+ };
185
+ }
@@ -0,0 +1,11 @@
1
+ export const SUPPORTED_NETWORKS = ['ethereum', 'base', 'optimism', 'arbitrum'];
2
+ export const WRITABLE_NETWORKS = ['ethereum'];
3
+ export const WRITE_ACTIONS = [
4
+ 'stake',
5
+ 'wrap',
6
+ 'unwrap',
7
+ 'request_unstake',
8
+ 'claim_withdrawals',
9
+ 'vote_on_proposal',
10
+ 'execute_proposal',
11
+ ];
@@ -0,0 +1,54 @@
1
+ import { erc20Abi, formatUnits, getAddress, parseUnits } from 'viem';
2
+ export function parseTokenAmount(amount, symbol) {
3
+ try {
4
+ return parseUnits(amount, 18);
5
+ }
6
+ catch {
7
+ throw new Error(`Invalid ${symbol} amount: ${amount}`);
8
+ }
9
+ }
10
+ export function toBigIntIds(ids) {
11
+ return ids.map((id) => {
12
+ const asString = String(id);
13
+ if (!/^\d+$/.test(asString)) {
14
+ throw new Error(`Invalid numeric id: ${asString}`);
15
+ }
16
+ return BigInt(asString);
17
+ });
18
+ }
19
+ export function formatPct(value) {
20
+ return (Number(value) / 1e16).toFixed(2);
21
+ }
22
+ export function mapVoterState(value) {
23
+ const state = Number(value);
24
+ if (state === 1) {
25
+ return 'yea';
26
+ }
27
+ if (state === 2) {
28
+ return 'nay';
29
+ }
30
+ return 'absent';
31
+ }
32
+ export async function ensureBalanceAtLeast(publicClient, token, owner, required, symbol) {
33
+ const balance = await publicClient.readContract({
34
+ address: token,
35
+ abi: erc20Abi,
36
+ functionName: 'balanceOf',
37
+ args: [owner],
38
+ });
39
+ if (balance < required) {
40
+ throw new Error(`Insufficient ${symbol} balance. Required ${formatUnits(required, 18)}, current ${formatUnits(balance, 18)}.`);
41
+ }
42
+ return balance;
43
+ }
44
+ export async function getAllowance(publicClient, token, owner, spender) {
45
+ return publicClient.readContract({
46
+ address: token,
47
+ abi: erc20Abi,
48
+ functionName: 'allowance',
49
+ args: [owner, spender],
50
+ });
51
+ }
52
+ export function normalizeAddress(value) {
53
+ return getAddress(value);
54
+ }