@moly-mcp/lido 1.0.6 → 1.0.7

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.
@@ -1,32 +1,64 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ getSettings,
4
+ stakeEth,
5
+ updateSettings
6
+ } from "../chunk-CQ6ZSCMZ.js";
2
7
  import {
3
8
  castVote,
4
9
  claimWithdrawals,
5
- getBalance,
6
- getConversionRate,
7
10
  getProposal,
8
11
  getProposals,
9
- getRewards,
10
- getSettings,
11
12
  getWithdrawalRequests,
12
13
  getWithdrawalStatus,
13
- requestWithdrawal,
14
- stakeEth,
14
+ requestWithdrawal
15
+ } from "../chunk-EQYEWCQO.js";
16
+ import {
17
+ configureAlertChannels,
18
+ listAlerts,
19
+ removeAlertById,
20
+ setAlert
21
+ } from "../chunk-6UIRFWG4.js";
22
+ import "../chunk-6F64RPQQ.js";
23
+ import {
24
+ bridgeToEthereum,
25
+ getBridgeQuote,
26
+ getBridgeStatus,
27
+ getL2Balance,
28
+ getTotalPosition
29
+ } from "../chunk-GL6TLHSF.js";
30
+ import {
31
+ getBalance,
32
+ getConversionRate,
33
+ getRewards,
15
34
  unwrapWsteth,
16
- updateSettings,
17
35
  wrapSteth
18
- } from "../chunk-RE3UIDLV.js";
36
+ } from "../chunk-EKZFGIVK.js";
19
37
  import {
20
38
  loadConfig
21
- } from "../chunk-PIFEXJ56.js";
39
+ } from "../chunk-P6VFMSPM.js";
40
+ import {
41
+ loadBounds,
42
+ saveBounds
43
+ } from "../chunk-CH4MXPWS.js";
44
+ import {
45
+ initLedger,
46
+ ledgerStats,
47
+ queryLedger
48
+ } from "../chunk-RR74UAKD.js";
49
+ import "../chunk-PDX44BCA.js";
22
50
 
23
51
  // src/server/index.ts
24
52
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
25
53
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
26
54
  import { z } from "zod";
55
+ try {
56
+ initLedger();
57
+ } catch {
58
+ }
27
59
  var cfg = loadConfig();
28
- var modeNote = cfg.mode === "simulation" ? "\u{1F7E1} SIMULATION \u2014 dry_run true by default, no real transactions" : "\u{1F534} LIVE \u2014 real transactions on " + (cfg.network === "mainnet" ? "Ethereum Mainnet" : "Hoodi Testnet");
29
- var server = new McpServer({ name: "@moly/lido", version: "1.0.0" });
60
+ var modeNote = cfg.mode === "simulation" ? "SIMULATION \u2014 dry_run true by default, no real transactions" : "LIVE \u2014 real transactions on " + (cfg.network === "mainnet" ? "Ethereum Mainnet" : "Hoodi Testnet");
61
+ var server = new McpServer({ name: "@moly-mcp/lido", version: "1.0.0" });
30
62
  server.tool(
31
63
  "get_balance",
32
64
  `Get ETH, stETH, and wstETH balances for an address. ${modeNote}`,
@@ -174,7 +206,153 @@ server.tool(
174
206
  content: [{ type: "text", text: JSON.stringify(updateSettings({ network, mode, rpc, model }), null, 2) }]
175
207
  })
176
208
  );
209
+ server.tool(
210
+ "set_alert",
211
+ "Create a new alert. Conditions: balance_below, balance_above, reward_rate_below, reward_rate_above, withdrawal_ready, proposal_new, conversion_rate_above, conversion_rate_below. Default channel: telegram.",
212
+ {
213
+ condition: z.string().describe("Alert condition type"),
214
+ threshold: z.number().optional().describe("Numeric threshold (required for _above/_below conditions)"),
215
+ channel: z.enum(["telegram", "webhook"]).optional().default("telegram").describe("Notification channel")
216
+ },
217
+ async ({ condition, threshold, channel }) => ({
218
+ content: [{ type: "text", text: JSON.stringify(setAlert({ condition, threshold, channel }), null, 2) }]
219
+ })
220
+ );
221
+ server.tool(
222
+ "list_alerts",
223
+ "List all configured alerts.",
224
+ {},
225
+ async () => ({
226
+ content: [{ type: "text", text: JSON.stringify(listAlerts(), null, 2) }]
227
+ })
228
+ );
229
+ server.tool(
230
+ "remove_alert",
231
+ "Remove an alert by ID.",
232
+ { id: z.string().describe("Alert ID to remove") },
233
+ async ({ id }) => ({
234
+ content: [{ type: "text", text: JSON.stringify(removeAlertById(id), null, 2) }]
235
+ })
236
+ );
237
+ server.tool(
238
+ "configure_alert_channels",
239
+ "Configure Telegram and/or webhook notification channels for alerts.",
240
+ {
241
+ telegram_token: z.string().optional().describe("Telegram bot token"),
242
+ telegram_chat_id: z.string().optional().describe("Telegram chat ID"),
243
+ webhook_url: z.string().optional().describe("Webhook URL for HTTP POST notifications")
244
+ },
245
+ async ({ telegram_token, telegram_chat_id, webhook_url }) => ({
246
+ content: [{ type: "text", text: JSON.stringify(configureAlertChannels({ telegram_token, telegram_chat_id, webhook_url }), null, 2) }]
247
+ })
248
+ );
249
+ server.tool(
250
+ "get_total_position",
251
+ "Aggregated cross-chain position: ETH, stETH, wstETH across Ethereum + Base + Arbitrum, converted to ETH equivalent.",
252
+ { address: z.string().optional().describe("Ethereum address (defaults to configured wallet)") },
253
+ async ({ address }) => ({
254
+ content: [{ type: "text", text: JSON.stringify(await getTotalPosition(address), null, 2) }]
255
+ })
256
+ );
257
+ server.tool(
258
+ "get_bounds",
259
+ "Get current policy bounds (max stake per tx, daily limit, min ETH reserve, governance auto-vote).",
260
+ {},
261
+ async () => ({
262
+ content: [{ type: "text", text: JSON.stringify(loadBounds(), null, 2) }]
263
+ })
264
+ );
265
+ server.tool(
266
+ "set_bounds",
267
+ "Update policy bounds that gate write operations.",
268
+ {
269
+ maxStakePerTx: z.number().optional().describe("Max ETH per single stake"),
270
+ maxDailyStake: z.number().optional().describe("Max ETH staked per day"),
271
+ minEthReserve: z.number().optional().describe("Min ETH to keep unstaked for gas"),
272
+ autoRestakeThreshold: z.number().optional().describe("Auto-restake rewards threshold"),
273
+ governanceAutoVote: z.boolean().optional().describe("Allow agent to auto-vote")
274
+ },
275
+ async (patch) => {
276
+ const current = loadBounds();
277
+ if (patch.maxStakePerTx !== void 0) current.maxStakePerTx = patch.maxStakePerTx;
278
+ if (patch.maxDailyStake !== void 0) current.maxDailyStake = patch.maxDailyStake;
279
+ if (patch.minEthReserve !== void 0) current.minEthReserve = patch.minEthReserve;
280
+ if (patch.autoRestakeThreshold !== void 0) current.autoRestakeThreshold = patch.autoRestakeThreshold;
281
+ if (patch.governanceAutoVote !== void 0) current.governanceAutoVote = patch.governanceAutoVote;
282
+ saveBounds(current);
283
+ return { content: [{ type: "text", text: JSON.stringify(current, null, 2) }] };
284
+ }
285
+ );
286
+ server.tool(
287
+ "get_l2_balance",
288
+ "Get ETH and wstETH balances on Base or Arbitrum. Mainnet only. Use this before bridging to check available funds.",
289
+ {
290
+ source_chain: z.enum(["base", "arbitrum"]).describe("L2 chain to query"),
291
+ address: z.string().optional().describe("Address to check (defaults to configured wallet)")
292
+ },
293
+ async ({ source_chain, address }) => ({
294
+ content: [{ type: "text", text: JSON.stringify(await getL2Balance(source_chain, address), null, 2) }]
295
+ })
296
+ );
297
+ server.tool(
298
+ "get_bridge_quote",
299
+ "Get a quote for bridging ETH or wstETH from an L2 to Ethereum L1 via LI.FI. Mainnet only. Requires a configured wallet address.",
300
+ {
301
+ source_chain: z.enum(["base", "arbitrum"]).describe("L2 to bridge from"),
302
+ token: z.enum(["ETH", "wstETH"]).describe("Token to bridge"),
303
+ amount: z.string().describe('Amount to bridge (e.g. "0.1")'),
304
+ to_token: z.enum(["ETH", "wstETH"]).optional().describe("Token to receive on L1 (default ETH)")
305
+ },
306
+ async ({ source_chain, token, amount, to_token }) => ({
307
+ content: [{ type: "text", text: JSON.stringify(await getBridgeQuote(source_chain, token, amount, to_token), null, 2) }]
308
+ })
309
+ );
310
+ server.tool(
311
+ "bridge_to_ethereum",
312
+ "Bridge ETH or wstETH from Base/Arbitrum to Ethereum L1 via LI.FI. Mainnet only. Requires a private key. In simulation mode this returns a quote without broadcasting.",
313
+ {
314
+ source_chain: z.enum(["base", "arbitrum"]).describe("L2 to bridge from"),
315
+ token: z.enum(["ETH", "wstETH"]).describe("Token to bridge"),
316
+ amount: z.string().describe("Amount to bridge"),
317
+ to_token: z.enum(["ETH", "wstETH"]).optional().describe("Token to receive on L1 (default ETH)"),
318
+ dry_run: z.boolean().optional().describe("Simulate without broadcasting")
319
+ },
320
+ async ({ source_chain, token, amount, to_token, dry_run }) => ({
321
+ content: [{ type: "text", text: JSON.stringify(await bridgeToEthereum(source_chain, token, amount, to_token, dry_run), null, 2) }]
322
+ })
323
+ );
324
+ server.tool(
325
+ "get_bridge_status",
326
+ "Check the status of an in-progress bridge transaction. Mainnet only. Use the tx hash returned by bridge_to_ethereum.",
327
+ {
328
+ tx_hash: z.string().describe("Bridge transaction hash on the L2"),
329
+ source_chain: z.enum(["base", "arbitrum"]).describe("L2 the bridge was sent from")
330
+ },
331
+ async ({ tx_hash, source_chain }) => ({
332
+ content: [{ type: "text", text: JSON.stringify(await getBridgeStatus(tx_hash, source_chain), null, 2) }]
333
+ })
334
+ );
335
+ server.tool(
336
+ "get_trade_history",
337
+ "Query the activity ledger with filters.",
338
+ {
339
+ tool: z.string().optional().describe("Filter by tool name (e.g. stake_eth)"),
340
+ since: z.string().optional().describe("ISO date to filter from"),
341
+ limit: z.number().int().optional().default(50).describe("Max results")
342
+ },
343
+ async (opts) => ({
344
+ content: [{ type: "text", text: JSON.stringify(queryLedger(opts), null, 2) }]
345
+ })
346
+ );
347
+ server.tool(
348
+ "get_staking_summary",
349
+ "Aggregate stats from the activity ledger: total operations, staked ETH, errors.",
350
+ { since: z.string().optional().describe("ISO date to filter from") },
351
+ async ({ since }) => ({
352
+ content: [{ type: "text", text: JSON.stringify(ledgerStats(since), null, 2) }]
353
+ })
354
+ );
177
355
  var transport = new StdioServerTransport();
178
356
  await server.connect(transport);
179
- process.stderr.write(`@moly/lido MCP server started \u2014 ${modeNote}
357
+ process.stderr.write(`@moly-mcp/lido MCP server started \u2014 ${modeNote}
180
358
  `);
@@ -1,27 +1,57 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ getSettings,
4
+ stakeEth,
5
+ updateSettings
6
+ } from "./chunk-CQ6ZSCMZ.js";
2
7
  import {
3
8
  castVote,
4
9
  claimWithdrawals,
5
- getBalance,
6
- getConversionRate,
7
10
  getProposal,
8
11
  getProposals,
9
- getRewards,
10
- getSettings,
11
12
  getWithdrawalRequests,
12
13
  getWithdrawalStatus,
13
- requestWithdrawal,
14
- stakeEth,
14
+ requestWithdrawal
15
+ } from "./chunk-EQYEWCQO.js";
16
+ import {
17
+ configureAlertChannels,
18
+ listAlerts,
19
+ removeAlertById,
20
+ setAlert
21
+ } from "./chunk-6UIRFWG4.js";
22
+ import "./chunk-6F64RPQQ.js";
23
+ import {
24
+ bridgeToEthereum,
25
+ getBridgeQuote,
26
+ getBridgeStatus,
27
+ getL2Balance,
28
+ getTotalPosition
29
+ } from "./chunk-GL6TLHSF.js";
30
+ import {
31
+ getBalance,
32
+ getConversionRate,
33
+ getRewards,
15
34
  unwrapWsteth,
16
- updateSettings,
17
35
  wrapSteth
18
- } from "./chunk-RE3UIDLV.js";
19
- import "./chunk-PIFEXJ56.js";
36
+ } from "./chunk-EKZFGIVK.js";
37
+ import "./chunk-P6VFMSPM.js";
38
+ import {
39
+ loadBounds,
40
+ saveBounds
41
+ } from "./chunk-CH4MXPWS.js";
42
+ import {
43
+ initLedger,
44
+ ledgerStats,
45
+ logEntry,
46
+ queryLedger
47
+ } from "./chunk-RR74UAKD.js";
48
+ import "./chunk-PDX44BCA.js";
20
49
 
21
50
  // src/chat/session.ts
22
51
  import * as readline from "readline";
23
52
  import * as fs from "fs";
24
53
  import * as path from "path";
54
+ import { fileURLToPath } from "url";
25
55
 
26
56
  // src/chat/providers.ts
27
57
  async function callAnthropic(apiKey, model, messages, tools) {
@@ -325,14 +355,158 @@ var TOOL_DEFS = [
325
355
  },
326
356
  {
327
357
  name: "update_settings",
328
- description: "Change mode, network, or RPC.",
358
+ description: "Change mode, network, RPC, or chain scope.",
329
359
  parameters: {
330
360
  type: "object",
331
361
  properties: {
332
362
  network: { type: "string", enum: ["hoodi", "mainnet"] },
333
363
  mode: { type: "string", enum: ["simulation", "live"] },
334
364
  rpc: { type: "string", nullable: true },
335
- model: { type: "string" }
365
+ model: { type: "string" },
366
+ chain_scope: { type: "string", enum: ["ethereum", "all"], description: "ethereum = L1 only, all = L1 + Base/Arbitrum bridging" }
367
+ }
368
+ }
369
+ },
370
+ {
371
+ name: "get_l2_balance",
372
+ description: "Get ETH and wstETH balances on Base or Arbitrum L2. Mainnet only.",
373
+ parameters: {
374
+ type: "object",
375
+ required: ["source_chain"],
376
+ properties: {
377
+ source_chain: { type: "string", enum: ["base", "arbitrum"], description: "L2 chain to query" },
378
+ address: { type: "string", description: "Address to check (optional, defaults to wallet)" }
379
+ }
380
+ }
381
+ },
382
+ {
383
+ name: "get_bridge_quote",
384
+ description: "Get a quote for bridging ETH or wstETH from an L2 to Ethereum L1 via LI.FI. Mainnet only.",
385
+ parameters: {
386
+ type: "object",
387
+ required: ["source_chain", "token", "amount"],
388
+ properties: {
389
+ source_chain: { type: "string", enum: ["base", "arbitrum"] },
390
+ token: { type: "string", enum: ["ETH", "wstETH"], description: "Token to bridge" },
391
+ amount: { type: "string", description: 'Amount to bridge (e.g. "0.1")' },
392
+ to_token: { type: "string", enum: ["ETH", "wstETH"], description: "Token to receive on L1 (default ETH)" }
393
+ }
394
+ }
395
+ },
396
+ {
397
+ name: "bridge_to_ethereum",
398
+ description: "Bridge ETH or wstETH from Base/Arbitrum to Ethereum L1 via LI.FI. Mainnet only.",
399
+ parameters: {
400
+ type: "object",
401
+ required: ["source_chain", "token", "amount"],
402
+ properties: {
403
+ source_chain: { type: "string", enum: ["base", "arbitrum"] },
404
+ token: { type: "string", enum: ["ETH", "wstETH"] },
405
+ amount: { type: "string", description: "Amount to bridge" },
406
+ to_token: { type: "string", enum: ["ETH", "wstETH"] },
407
+ dry_run: { type: "boolean", description: "Simulate without broadcasting" }
408
+ }
409
+ }
410
+ },
411
+ {
412
+ name: "get_bridge_status",
413
+ description: "Check the status of an in-progress bridge transaction. Mainnet only.",
414
+ parameters: {
415
+ type: "object",
416
+ required: ["tx_hash", "source_chain"],
417
+ properties: {
418
+ tx_hash: { type: "string", description: "Bridge transaction hash on the L2" },
419
+ source_chain: { type: "string", enum: ["base", "arbitrum"] }
420
+ }
421
+ }
422
+ },
423
+ {
424
+ name: "set_alert",
425
+ description: "Create a new alert. Conditions: balance_below, balance_above, reward_rate_below, reward_rate_above, withdrawal_ready, proposal_new, conversion_rate_above, conversion_rate_below.",
426
+ parameters: {
427
+ type: "object",
428
+ required: ["condition"],
429
+ properties: {
430
+ condition: { type: "string", description: "Alert condition type" },
431
+ threshold: { type: "number", description: "Numeric threshold (required for _above/_below conditions)" },
432
+ channel: { type: "string", enum: ["telegram", "webhook"], description: "Notification channel (default: telegram)" }
433
+ }
434
+ }
435
+ },
436
+ {
437
+ name: "list_alerts",
438
+ description: "List all configured alerts.",
439
+ parameters: { type: "object", properties: {} }
440
+ },
441
+ {
442
+ name: "remove_alert",
443
+ description: "Remove an alert by ID.",
444
+ parameters: {
445
+ type: "object",
446
+ required: ["id"],
447
+ properties: { id: { type: "string", description: "Alert ID to remove" } }
448
+ }
449
+ },
450
+ {
451
+ name: "configure_alert_channels",
452
+ description: "Configure Telegram and/or webhook notification channels for alerts.",
453
+ parameters: {
454
+ type: "object",
455
+ properties: {
456
+ telegram_token: { type: "string", description: "Telegram bot token" },
457
+ telegram_chat_id: { type: "string", description: "Telegram chat ID" },
458
+ webhook_url: { type: "string", description: "Webhook URL for HTTP POST notifications" }
459
+ }
460
+ }
461
+ },
462
+ {
463
+ name: "get_total_position",
464
+ description: "Aggregated cross-chain position: ETH, stETH, wstETH across Ethereum + Base + Arbitrum, all converted to ETH equivalent.",
465
+ parameters: {
466
+ type: "object",
467
+ properties: {
468
+ address: { type: "string", description: "Ethereum address (optional)" }
469
+ }
470
+ }
471
+ },
472
+ {
473
+ name: "get_bounds",
474
+ description: "Get current policy bounds (max stake per tx, daily limit, min ETH reserve, governance auto-vote).",
475
+ parameters: { type: "object", properties: {} }
476
+ },
477
+ {
478
+ name: "set_bounds",
479
+ description: "Update policy bounds that gate write operations.",
480
+ parameters: {
481
+ type: "object",
482
+ properties: {
483
+ maxStakePerTx: { type: "number", description: "Max ETH per single stake" },
484
+ maxDailyStake: { type: "number", description: "Max ETH staked per day" },
485
+ minEthReserve: { type: "number", description: "Min ETH to keep unstaked for gas" },
486
+ autoRestakeThreshold: { type: "number", description: "Auto-restake rewards threshold in ETH" },
487
+ governanceAutoVote: { type: "boolean", description: "Allow agent to auto-vote on proposals" }
488
+ }
489
+ }
490
+ },
491
+ {
492
+ name: "get_trade_history",
493
+ description: "Query the activity ledger with filters.",
494
+ parameters: {
495
+ type: "object",
496
+ properties: {
497
+ tool: { type: "string", description: "Filter by tool name (e.g. stake_eth)" },
498
+ since: { type: "string", description: "ISO date to filter from (e.g. 2026-01-01)" },
499
+ limit: { type: "number", description: "Max results (default 50)" }
500
+ }
501
+ }
502
+ },
503
+ {
504
+ name: "get_staking_summary",
505
+ description: "Aggregate stats from the activity ledger: total operations, staked ETH, errors.",
506
+ parameters: {
507
+ type: "object",
508
+ properties: {
509
+ since: { type: "string", description: "ISO date to filter from (optional)" }
336
510
  }
337
511
  }
338
512
  }
@@ -386,6 +560,54 @@ async function executeTool(name, args) {
386
560
  case "update_settings":
387
561
  result = updateSettings(args);
388
562
  break;
563
+ case "get_l2_balance":
564
+ result = await getL2Balance(args.source_chain, args.address);
565
+ break;
566
+ case "get_bridge_quote":
567
+ result = await getBridgeQuote(args.source_chain, args.token, args.amount, args.to_token);
568
+ break;
569
+ case "bridge_to_ethereum":
570
+ result = await bridgeToEthereum(args.source_chain, args.token, args.amount, args.to_token, args.dry_run);
571
+ break;
572
+ case "get_bridge_status":
573
+ result = await getBridgeStatus(args.tx_hash, args.source_chain);
574
+ break;
575
+ case "set_alert":
576
+ result = setAlert(args);
577
+ break;
578
+ case "list_alerts":
579
+ result = listAlerts();
580
+ break;
581
+ case "remove_alert":
582
+ result = removeAlertById(args.id);
583
+ break;
584
+ case "configure_alert_channels":
585
+ result = configureAlertChannels(args);
586
+ break;
587
+ case "get_total_position":
588
+ result = await getTotalPosition(args.address);
589
+ break;
590
+ case "get_bounds":
591
+ result = loadBounds();
592
+ break;
593
+ case "set_bounds": {
594
+ const current = loadBounds();
595
+ const patch = args;
596
+ if (patch.maxStakePerTx !== void 0) current.maxStakePerTx = patch.maxStakePerTx;
597
+ if (patch.maxDailyStake !== void 0) current.maxDailyStake = patch.maxDailyStake;
598
+ if (patch.minEthReserve !== void 0) current.minEthReserve = patch.minEthReserve;
599
+ if (patch.autoRestakeThreshold !== void 0) current.autoRestakeThreshold = patch.autoRestakeThreshold;
600
+ if (patch.governanceAutoVote !== void 0) current.governanceAutoVote = patch.governanceAutoVote;
601
+ saveBounds(current);
602
+ result = current;
603
+ break;
604
+ }
605
+ case "get_trade_history":
606
+ result = queryLedger({ tool: args.tool, since: args.since, limit: args.limit });
607
+ break;
608
+ case "get_staking_summary":
609
+ result = ledgerStats(args.since);
610
+ break;
389
611
  default:
390
612
  return `Unknown tool: ${name}`;
391
613
  }
@@ -395,6 +617,50 @@ async function executeTool(name, args) {
395
617
  }
396
618
  }
397
619
 
620
+ // src/bounds/enforce.ts
621
+ var STAKE_TOOLS = /* @__PURE__ */ new Set(["stake_eth", "bridge_to_ethereum"]);
622
+ async function checkBounds(toolName, args) {
623
+ const bounds = loadBounds();
624
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
625
+ if (bounds.lastResetDate !== today) {
626
+ bounds.dailyStaked = 0;
627
+ bounds.lastResetDate = today;
628
+ saveBounds(bounds);
629
+ }
630
+ if (toolName === "cast_vote" && !bounds.governanceAutoVote) {
631
+ return { allowed: false, reason: `Governance auto-vote is disabled. Set bounds: governanceAutoVote = true` };
632
+ }
633
+ if (STAKE_TOOLS.has(toolName)) {
634
+ const amount = parseFloat(args.amount_eth ?? args.amount ?? "0");
635
+ if (isNaN(amount) || amount <= 0) return { allowed: true };
636
+ if (amount > bounds.maxStakePerTx) {
637
+ return { allowed: false, reason: `Amount ${amount} ETH exceeds max per tx (${bounds.maxStakePerTx} ETH)` };
638
+ }
639
+ if (bounds.dailyStaked + amount > bounds.maxDailyStake) {
640
+ return { allowed: false, reason: `Would exceed daily limit: staked today ${bounds.dailyStaked.toFixed(4)} + ${amount} > ${bounds.maxDailyStake} ETH` };
641
+ }
642
+ try {
643
+ const bal = await getBalance();
644
+ const ethBal = parseFloat(bal.balances.eth);
645
+ if (ethBal - amount < bounds.minEthReserve) {
646
+ return { allowed: false, reason: `Would leave only ${(ethBal - amount).toFixed(4)} ETH, below reserve of ${bounds.minEthReserve} ETH` };
647
+ }
648
+ } catch {
649
+ }
650
+ }
651
+ return { allowed: true };
652
+ }
653
+ function recordStake(amount) {
654
+ const bounds = loadBounds();
655
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
656
+ if (bounds.lastResetDate !== today) {
657
+ bounds.dailyStaked = 0;
658
+ bounds.lastResetDate = today;
659
+ }
660
+ bounds.dailyStaked += amount;
661
+ saveBounds(bounds);
662
+ }
663
+
398
664
  // src/chat/session.ts
399
665
  var R = "\x1B[0m";
400
666
  var B = "\x1B[1m";
@@ -412,30 +678,29 @@ ${CY}${B} \u2588\u2588\u2554\u2588\u2588\u2588\u2588\u2554\u2588\u2588\u2551\u2
412
678
  ${CY}${B} \u2588\u2588\u2551\u255A\u2588\u2588\u2554\u255D\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u255A\u2588\u2588\u2554\u255D ${R}
413
679
  ${CY}${B} \u2588\u2588\u2551 \u255A\u2550\u255D \u2588\u2588\u2551\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 ${R}
414
680
  ${CY}${B} \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D ${R}
415
- ${D} powered by Lido \u2B21${R}
681
+ ${D} powered by Lido${R}
416
682
  `;
417
683
  function ln(text = "") {
418
684
  process.stdout.write(text + "\n");
419
685
  }
420
686
  function saveTrade(toolName, args, result) {
421
687
  try {
422
- const tradesDir = path.join(process.cwd(), "trades");
423
- if (!fs.existsSync(tradesDir)) fs.mkdirSync(tradesDir, { recursive: true });
424
- const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
425
- const file = path.join(tradesDir, `${today}.jsonl`);
426
- const record = JSON.stringify({
427
- ts: (/* @__PURE__ */ new Date()).toISOString(),
688
+ let txHash;
689
+ let amount;
690
+ try {
691
+ const parsed = JSON.parse(result);
692
+ txHash = parsed.txHash ?? parsed.tx_hash;
693
+ amount = args.amount_eth ?? args.amount_steth ?? args.amount;
694
+ } catch {
695
+ }
696
+ logEntry({
428
697
  tool: toolName,
429
698
  args,
430
- result: (() => {
431
- try {
432
- return JSON.parse(result);
433
- } catch {
434
- return result;
435
- }
436
- })()
699
+ result,
700
+ tx_hash: txHash,
701
+ amount,
702
+ status: "ok"
437
703
  });
438
- fs.appendFileSync(file, record + "\n");
439
704
  } catch {
440
705
  }
441
706
  }
@@ -453,18 +718,31 @@ var WRITE_TOOLS = /* @__PURE__ */ new Set([
453
718
  "claim_withdrawals",
454
719
  "wrap_steth",
455
720
  "unwrap_wsteth",
456
- "cast_vote"
721
+ "cast_vote",
722
+ "bridge_to_ethereum"
457
723
  ]);
458
724
  async function startChatSession(cfg) {
459
725
  if (!cfg.ai) {
460
726
  ln(`${RE}No AI provider configured. Run: moly setup${R}`);
461
727
  process.exit(1);
462
728
  }
729
+ try {
730
+ initLedger();
731
+ } catch {
732
+ }
463
733
  const { provider, apiKey, model } = cfg.ai;
734
+ let skillContext = "";
735
+ try {
736
+ const skillPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../lido.skill.md");
737
+ if (fs.existsSync(skillPath)) {
738
+ skillContext = fs.readFileSync(skillPath, "utf-8") + "\n\n";
739
+ }
740
+ } catch {
741
+ }
464
742
  const messages = [
465
743
  {
466
744
  role: "user",
467
- content: `You are Moly, a terminal assistant for Lido Finance on ${cfg.network}. Mode: ${cfg.mode} (${cfg.mode === "simulation" ? "dry-run, nothing broadcast" : "LIVE - real on-chain transactions"}). You can only do what your tools support: staking ETH, withdrawals, wrap/unwrap stETH/wstETH, balances, rewards, and Lido DAO governance. If asked about anything outside those tools (e.g. Lido Vaults, validators, node operators, DeFi integrations), say clearly and briefly that it is not supported. IMPORTANT: This is a terminal. Never use markdown. No **bold**, no bullet points, no headers, no backticks. Plain text only. Be concise. For live transactions always confirm first.`
745
+ content: skillContext + `You are Moly, a terminal assistant for Lido Finance on ${cfg.network}. Mode: ${cfg.mode} (${cfg.mode === "simulation" ? "dry-run, nothing broadcast" : "LIVE - real on-chain transactions"}). Chain scope: ${cfg.chainScope ?? "ethereum"}. You can only do what your tools support: staking ETH, withdrawals, wrap/unwrap stETH/wstETH, balances, rewards, Lido DAO governance${cfg.chainScope === "all" ? ", and L2 bridging from Base/Arbitrum to Ethereum via LI.FI" : ""}. ${cfg.chainScope === "all" ? "If the user wants to stake ETH from Base or Arbitrum, first check their L2 balance with get_l2_balance, then bridge to Ethereum with bridge_to_ethereum, then after bridging completes use stake_eth. Bridge takes 1-20 min, tell user to check with get_bridge_status. " : ""}If asked about anything outside those tools (e.g. Lido Vaults, validators, node operators, DeFi integrations), say clearly and briefly that it is not supported. IMPORTANT: This is a terminal. Never use markdown. No **bold**, no bullet points, no headers, no backticks. Plain text only. Be concise. For live transactions always confirm first.`
468
746
  },
469
747
  {
470
748
  role: "assistant",
@@ -473,8 +751,8 @@ async function startChatSession(cfg) {
473
751
  ];
474
752
  printBanner(cfg);
475
753
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
476
- const prompt = () => new Promise((resolve, reject) => {
477
- rl.question(`${B}${BL}you${R} \u203A `, resolve);
754
+ const prompt = () => new Promise((resolve2, reject) => {
755
+ rl.question(`${B}${BL}you${R} \u203A `, resolve2);
478
756
  rl.once("close", () => reject(new Error("closed")));
479
757
  });
480
758
  while (true) {
@@ -500,10 +778,27 @@ async function startChatSession(cfg) {
500
778
  const toolResults = [];
501
779
  for (const tc of response.toolCalls) {
502
780
  ln(`${D} \u21B3 ${MA}${tc.name}${R}${D} ${JSON.stringify(tc.args)}${R}`);
781
+ if (WRITE_TOOLS.has(tc.name)) {
782
+ try {
783
+ const check = await checkBounds(tc.name, tc.args);
784
+ if (!check.allowed) {
785
+ ln(`${RE} \u2715 BLOCKED: ${check.reason}${R}`);
786
+ toolResults.push(makeToolResultMessage(provider, tc.id, tc.name, JSON.stringify({ blocked: true, reason: check.reason })));
787
+ continue;
788
+ }
789
+ } catch {
790
+ }
791
+ }
503
792
  const result = await executeTool(tc.name, tc.args);
504
793
  ln(`${D} ${result.slice(0, 300)}${result.length > 300 ? "\u2026" : ""}${R}`);
505
794
  if (WRITE_TOOLS.has(tc.name)) {
506
795
  saveTrade(tc.name, tc.args, result);
796
+ if (tc.name === "stake_eth" && tc.args.amount_eth) {
797
+ try {
798
+ recordStake(parseFloat(tc.args.amount_eth));
799
+ } catch {
800
+ }
801
+ }
507
802
  }
508
803
  toolResults.push(makeToolResultMessage(provider, tc.id, tc.name, result));
509
804
  }