@agether/agether 2.13.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/index.ts +277 -255
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agether/agether",
3
- "version": "2.13.0",
3
+ "version": "3.0.1",
4
4
  "description": "OpenClaw plugin for Agether — onchain credit for AI agents on Ethereum & Base",
5
5
  "main": "src/index.ts",
6
6
  "openclaw": {
@@ -9,7 +9,7 @@
9
9
  ]
10
10
  },
11
11
  "dependencies": {
12
- "@agether/sdk": "^2.15.0",
12
+ "@agether/sdk": "^2.16.1",
13
13
  "axios": "^1.6.0",
14
14
  "ethers": "^6.9.0"
15
15
  },
package/src/index.ts CHANGED
@@ -99,25 +99,155 @@ interface PluginConfig {
99
99
 
100
100
  function txLink(hash: string): string {
101
101
  if (!hash) return "";
102
- const explorer = CHAIN_DEFAULTS[activeChainId]?.explorer ?? CHAIN_DEFAULTS[ChainId.Ethereum].explorer;
102
+ const explorer = CHAIN_DEFAULTS[state.activeChainId]?.explorer ?? CHAIN_DEFAULTS[ChainId.Ethereum].explorer;
103
103
  return `${explorer}/${hash}`;
104
104
  }
105
105
 
106
+
107
+
108
+ // ─── Unified State Management ─────────────────────────────
109
+ // Phase 1 Fix: Replace scattered module-level state with single class.
110
+ // Eliminates race conditions between memory cache, config, and disk persistence.
111
+
112
+ class AgetherState {
113
+ private agentsByChain: Record<number, string> = {};
114
+ private _activeChainId: ChainId = ChainId.Ethereum;
115
+ private _chainExplicitlySet: boolean = false;
116
+ private configPath: string;
117
+
118
+ constructor() {
119
+ const home = process.env.HOME || process.env.USERPROFILE || "/root";
120
+ this.configPath = path.join(home, ".openclaw", "openclaw.json");
121
+ this.restore();
122
+ }
123
+
124
+ /** Restore state from openclaw.json on startup */
125
+ private restore(): void {
126
+ try {
127
+ if (!fs.existsSync(this.configPath)) return;
128
+ const raw = fs.readFileSync(this.configPath, "utf-8");
129
+ const json = JSON.parse(raw);
130
+ const cfg = json?.plugins?.entries?.agether?.config;
131
+ if (!cfg) return;
132
+
133
+ if (cfg.chain !== undefined && cfg.chain !== null && cfg.chain !== "") {
134
+ this._activeChainId = cfg.chain as ChainId;
135
+ this._chainExplicitlySet = true;
136
+ }
137
+ if (cfg.agentId) {
138
+ this.agentsByChain[this._activeChainId] = String(cfg.agentId);
139
+ }
140
+ if (cfg.agentsByChain) {
141
+ for (const [k, v] of Object.entries(cfg.agentsByChain)) {
142
+ this.agentsByChain[Number(k)] = String(v);
143
+ }
144
+ }
145
+ } catch {
146
+ // Silently fail on restore — fresh state is fine
147
+ }
148
+ }
149
+
150
+ /** Persist current state to openclaw.json atomically */
151
+ private persist(): void {
152
+ try {
153
+ let json: any = {};
154
+ if (fs.existsSync(this.configPath)) {
155
+ json = JSON.parse(fs.readFileSync(this.configPath, "utf-8"));
156
+ }
157
+ if (!json.plugins) json.plugins = {};
158
+ if (!json.plugins.entries) json.plugins.entries = {};
159
+ if (!json.plugins.entries.agether) json.plugins.entries.agether = {};
160
+ if (!json.plugins.entries.agether.config) json.plugins.entries.agether.config = {};
161
+
162
+ const cfg = json.plugins.entries.agether.config;
163
+ cfg.chain = this._activeChainId;
164
+ cfg.agentId = this.agentsByChain[this._activeChainId] || cfg.agentId;
165
+ cfg.agentsByChain = { ...this.agentsByChain };
166
+
167
+ fs.writeFileSync(this.configPath, JSON.stringify(json, null, 2));
168
+ } catch (e) {
169
+ console.warn("[agether] state persist failed:", e instanceof Error ? e.message : String(e));
170
+ }
171
+ }
172
+
173
+ get activeChainId(): ChainId { return this._activeChainId; }
174
+ get chainConfigured(): boolean { return this._chainExplicitlySet; }
175
+
176
+ getAgentId(chainId?: ChainId): string | undefined {
177
+ return this.agentsByChain[chainId ?? this._activeChainId];
178
+ }
179
+
180
+ setChain(chainId: ChainId): string {
181
+ this._activeChainId = chainId;
182
+ this._chainExplicitlySet = true;
183
+ this.persist();
184
+ return "saved";
185
+ }
186
+
187
+ setAgentId(agentId: string, chainId?: ChainId): string {
188
+ const chain = chainId ?? this._activeChainId;
189
+ this.agentsByChain[chain] = agentId;
190
+ this.persist();
191
+ return "saved";
192
+ }
193
+ }
194
+
195
+ // Single global state instance — replaces scattered cachedAgentIds, activeChainId, chainOverride
196
+ const state = new AgetherState();
197
+
106
198
  function ok(text: string) {
199
+ // Enhanced: Extract transaction links and key info for user visibility
200
+ try {
201
+ const data = JSON.parse(text);
202
+ let enhanced = "";
203
+
204
+ // Show transaction link if present
205
+ if (data.tx) {
206
+ enhanced += `🔗 **Transaction:** ${txLink(data.tx)}\n`;
207
+ }
208
+
209
+ // Show agent ID if present
210
+ if (data.agentId && data.agentId !== '?') {
211
+ enhanced += `🤖 **Agent:** ${data.agentId}\n`;
212
+ }
213
+
214
+ // Show balances if present
215
+ if (data.eth || data.usdc) {
216
+ enhanced += `💰 **EOA:** ${data.eth || '0'} ETH, $${data.usdc || '0'} USDC\n`;
217
+ }
218
+
219
+ if (enhanced) {
220
+ return { content: [{ type: "text" as const, text: enhanced + "\n```json\n" + text + "\n```" }] };
221
+ }
222
+ } catch {
223
+ // Not JSON, just return as-is
224
+ }
225
+
107
226
  return { content: [{ type: "text" as const, text }] };
108
227
  }
109
228
 
110
229
  function fail(err: unknown) {
111
230
  let msg = err instanceof Error ? err.message : String(err);
231
+
232
+ // Add helpful suggestions for common errors
233
+ let suggestion = "";
112
234
  if (msg.includes("0xda04aecc")) {
113
235
  msg = "ExceedsMaxLtv — collateral too low for this borrow amount";
236
+ suggestion = "\n💡 Try: Reduce borrow amount or check morpho_max_borrowable";
114
237
  } else if (msg.includes("0xfeca99cb")) {
115
238
  msg = "ExecutionFailed — the inner contract call reverted. Likely ExceedsMaxLtv or insufficient collateral.";
239
+ suggestion = "\n💡 Try: Check morpho_status and agether_balance";
116
240
  } else if (msg.includes("0xa920ef9f")) {
117
241
  msg = "PositionNotActive — no collateral deposited for this token";
242
+ suggestion = "\n💡 Try: morpho_deposit first, then borrow";
243
+ } else if (msg.includes("agentId not set")) {
244
+ suggestion = "\n💡 Try: agether_register() first";
245
+ } else if (msg.includes("Chain not configured")) {
246
+ suggestion = "\n💡 Try: agether_set_chain('base') or agether_set_chain('ethereum')";
118
247
  }
248
+
119
249
  if (msg.length > 300) msg = msg.slice(0, 250) + "…";
120
- return { content: [{ type: "text" as const, text: `❌ ${msg}` }], isError: true };
250
+ return { content: [{ type: "text" as const, text: `❌ ${msg}${suggestion}` }], isError: true };
121
251
  }
122
252
 
123
253
  // ─── Secrets-first config resolution ──────────────────────
@@ -163,26 +293,22 @@ function resolvePrivateKey(): string {
163
293
  }
164
294
 
165
295
  function getConfig(api: any): PluginConfig {
166
- const cfg = api.config?.plugins?.entries?.["agether"]?.config ?? {};
167
- // chainOverride (set by agether_set_chain at runtime) takes priority over
168
- // api.config which is a stale in-memory snapshot that never refreshes.
169
- const rawChain = chainOverride ?? cfg.chain;
170
- const chainConfigured = rawChain !== undefined && rawChain !== null && rawChain !== "";
171
- const chainId = (rawChain as ChainId) || ChainId.Ethereum;
172
- activeChainId = chainId; // Update module-level for txLink
296
+ // Phase 1 Fix: Use unified AgetherState instead of scattered module variables.
297
+ // State reads from single source of truth (state object) which is
298
+ // initialized from openclaw.json and updated atomically on mutations.
299
+ const chainId = state.activeChainId;
173
300
  return {
174
301
  privateKey: resolvePrivateKey(),
175
- agentId: cachedAgentIds[chainId] || cfg.agentId,
302
+ agentId: state.getAgentId(chainId),
176
303
  rpcUrl: resolveRpcUrl(chainId),
177
304
  chainId,
178
- chainConfigured,
305
+ chainConfigured: state.chainConfigured,
179
306
  };
180
307
  }
181
308
 
182
- // Module-level cache
183
- const cachedAgentIds: Record<number, string> = {}; // chainId agentId (chain-specific!)
184
- let activeChainId: ChainId = ChainId.Ethereum;
185
- let chainOverride: ChainId | undefined; // set by agether_set_chain, survives stale api.config
309
+ // Legacy module-level aliases — kept for backward compatibility with txLink()
310
+ // These now delegate to state object instead of being independent variables.
311
+ function get_activeChainId(): ChainId { return state.activeChainId; }
186
312
 
187
313
  /**
188
314
  * Hard guardrail: refuse to proceed if chain was never explicitly configured.
@@ -204,7 +330,7 @@ function requireChain(cfg: PluginConfig): void {
204
330
  * client (only register() and getBalances() available).
205
331
  */
206
332
  function createAgetherClient(cfg: PluginConfig): AgetherClient {
207
- const agentId = cachedAgentIds[cfg.chainId] || cfg.agentId;
333
+ const agentId = state.getAgentId(cfg.chainId) || cfg.agentId;
208
334
  if (agentId) {
209
335
  return AgetherClient.fromPrivateKey(cfg.privateKey, BigInt(agentId), cfg.chainId);
210
336
  }
@@ -216,7 +342,7 @@ function createAgetherClient(cfg: PluginConfig): AgetherClient {
216
342
  * Requires agentId — use AgetherClient.register() first.
217
343
  */
218
344
  function createMorphoClient(cfg: PluginConfig): MorphoClient {
219
- const agentId = cachedAgentIds[cfg.chainId] || cfg.agentId;
345
+ const agentId = state.getAgentId(cfg.chainId) || cfg.agentId;
220
346
  if (!agentId) {
221
347
  throw new Error("agentId not set. Register first with agether_register, then try again.");
222
348
  }
@@ -233,51 +359,14 @@ function createClient(cfg: PluginConfig): MorphoClient {
233
359
  return createMorphoClient(cfg);
234
360
  }
235
361
 
236
- /** Persist agentId to openclaw.json so it survives restarts. */
362
+ /** Persist agentId — now delegates to AgetherState for atomic updates. */
237
363
  function persistAgentId(agentId: string): string {
238
- cachedAgentIds[activeChainId] = agentId;
239
- try {
240
- const home = process.env.HOME || process.env.USERPROFILE || "/root";
241
- const cfgPath = path.join(home, ".openclaw", "openclaw.json");
242
- const raw = fs.readFileSync(cfgPath, "utf-8");
243
- const json = JSON.parse(raw);
244
-
245
- // Ensure config path exists
246
- if (!json.plugins) json.plugins = {};
247
- if (!json.plugins.entries) json.plugins.entries = {};
248
- if (!json.plugins.entries.agether) json.plugins.entries.agether = {};
249
- if (!json.plugins.entries.agether.config) json.plugins.entries.agether.config = {};
250
-
251
- json.plugins.entries.agether.config.agentId = agentId;
252
- fs.writeFileSync(cfgPath, JSON.stringify(json, null, 2));
253
- return "saved";
254
- } catch (e) {
255
- return `write failed: ${e instanceof Error ? e.message : String(e)}`;
256
- }
364
+ return state.setAgentId(agentId);
257
365
  }
258
366
 
259
- /** Persist chain to openclaw.json so it survives restarts. */
367
+ /** Persist chain — now delegates to AgetherState for atomic updates. */
260
368
  function persistChainId(chainId: ChainId): string {
261
- activeChainId = chainId;
262
- chainOverride = chainId; // In-memory override so getConfig() picks it up immediately
263
- try {
264
- const home = process.env.HOME || process.env.USERPROFILE || "/root";
265
- const cfgPath = path.join(home, ".openclaw", "openclaw.json");
266
- const raw = fs.readFileSync(cfgPath, "utf-8");
267
- const json = JSON.parse(raw);
268
-
269
- // Ensure config path exists
270
- if (!json.plugins) json.plugins = {};
271
- if (!json.plugins.entries) json.plugins.entries = {};
272
- if (!json.plugins.entries.agether) json.plugins.entries.agether = {};
273
- if (!json.plugins.entries.agether.config) json.plugins.entries.agether.config = {};
274
-
275
- json.plugins.entries.agether.config.chain = chainId;
276
- fs.writeFileSync(cfgPath, JSON.stringify(json, null, 2));
277
- return "saved";
278
- } catch (e) {
279
- return `write failed: ${e instanceof Error ? e.message : String(e)}`;
280
- }
369
+ return state.setChain(chainId);
281
370
  }
282
371
 
283
372
  // ─── Plugin Entry ─────────────────────────────────────────
@@ -288,8 +377,7 @@ export default function register(api: any) {
288
377
  // ═══════════════════════════════════════════════════════
289
378
  api.registerTool({
290
379
  name: "agether_balance",
291
- description:
292
- "Check ETH and USDC balances for the agent's EOA wallet and AgentAccount.",
380
+ description: "Check all token balances (ETH, USDC, collateral) for EOA and AgentAccount.",
293
381
  parameters: { type: "object", properties: {}, required: [] },
294
382
  async execute() {
295
383
  try {
@@ -306,9 +394,7 @@ export default function register(api: any) {
306
394
  // ═══════════════════════════════════════════════════════
307
395
  api.registerTool({
308
396
  name: "agether_register",
309
- description:
310
- "Register a new ERC-8004 agent identity and create a Safe smart account (via Safe7579). " +
311
- "REQUIRES chain to be configured first — call agether_set_chain before this tool. Returns the new agentId.",
397
+ description: "Register new agent identity (ERC-8004) and deploy Safe account. Set chain first via agether_set_chain.",
312
398
  parameters: {
313
399
  type: "object",
314
400
  properties: {
@@ -321,6 +407,29 @@ export default function register(api: any) {
321
407
  const cfg = getConfig(api);
322
408
  requireChain(cfg);
323
409
  const client = createAgetherClient(cfg);
410
+
411
+ // Phase 1 Fix: Warn if agentId already cached for this chain
412
+ // The SDK's register() handles double-reg gracefully (returns alreadyRegistered=true),
413
+ // but this gives the user an earlier, clearer signal.
414
+ const existingAgentId = state.getAgentId(cfg.chainId);
415
+ if (existingAgentId) {
416
+ // Agent already registered on this chain — verify before re-registering
417
+ try {
418
+ const verifyClient = AgetherClient.fromPrivateKey(cfg.privateKey, BigInt(existingAgentId), cfg.chainId);
419
+ const acctAddr = await verifyClient.getAccountAddress();
420
+ if (acctAddr) {
421
+ return ok(JSON.stringify({
422
+ status: "already_registered",
423
+ agentId: existingAgentId,
424
+ agentAccount: acctAddr,
425
+ warning: "Agent already registered on this chain. Use agether_set_agent if you need to change agentId.",
426
+ }));
427
+ }
428
+ } catch {
429
+ // Verification failed — maybe stale cache, allow re-registration
430
+ }
431
+ }
432
+
324
433
  const result = await client.register({ name: _params.name });
325
434
  const persistStatus = persistAgentId(result.agentId);
326
435
  const kyaMessage = result.kyaRequired
@@ -563,9 +672,8 @@ export default function register(api: any) {
563
672
  // TOOL: morpho_deposit
564
673
  // ═══════════════════════════════════════════════════════
565
674
  api.registerTool({
566
- name: "morpho_deposit",
567
- description:
568
- "Deposit collateral into Morpho Blue via AgentAccount. Enables borrowing against it. Token is auto-discovered from available markets.",
675
+ name: "morpho_deposit_collateral",
676
+ description: "Deposit collateral into Morpho Blue to enable borrowing. Validates balance first.",
569
677
  parameters: {
570
678
  type: "object",
571
679
  properties: {
@@ -579,6 +687,26 @@ export default function register(api: any) {
579
687
  const cfg = getConfig(api);
580
688
  requireChain(cfg);
581
689
  const client = createMorphoClient(cfg);
690
+
691
+ // Phase 1 Fix: Validate balance before attempting deposit
692
+ try {
693
+ const agether = createAgetherClient(cfg);
694
+ const balances = await agether.getBalances();
695
+ const tokenBalance = balances.agentAccount?.collateral?.[params.token.toUpperCase()]
696
+ || balances.collateral?.[params.token.toUpperCase()]
697
+ || "0";
698
+ const available = parseFloat(tokenBalance);
699
+ const requested = parseFloat(params.amount);
700
+ if (requested > available && available >= 0) {
701
+ return fail(new Error(
702
+ `Insufficient ${params.token} balance. Have: ${available}, Need: ${requested}. ` +
703
+ `Fund your wallet with ${params.token} first, or use wallet_fund_token.`
704
+ ));
705
+ }
706
+ } catch {
707
+ // Balance check failed — proceed anyway, on-chain will validate
708
+ }
709
+
582
710
  const result = await client.supplyCollateral(params.token, params.amount);
583
711
  return ok(JSON.stringify({
584
712
  status: "deposited",
@@ -595,35 +723,32 @@ export default function register(api: any) {
595
723
  // ═══════════════════════════════════════════════════════
596
724
  api.registerTool({
597
725
  name: "morpho_deposit_and_borrow",
598
- description:
599
- "Deposit collateral AND borrow in one batched transaction via AgentAccount (ERC-7579 batch mode). " +
600
- "Deposits collateral from EOA into Morpho, then borrows loan token. All in one tx. " +
601
- "Supports any loan token (USDC, WETH, etc.).",
726
+ description: "Deposit collateral and borrow in one atomic transaction. Gas-efficient batch operation.",
602
727
  parameters: {
603
728
  type: "object",
604
729
  properties: {
605
- collateralAmount: { type: "string", description: "Amount of collateral (e.g. '0.05')" },
730
+ amount: { type: "string", description: "Collateral amount (e.g. '0.05')" },
606
731
  token: { type: "string", description: "Collateral token" },
607
732
  borrowAmount: { type: "string", description: "Amount to borrow (e.g. '50')" },
608
733
  loanToken: { type: "string", description: "Loan token to borrow (e.g. 'USDC', 'WETH'). Optional — defaults to most liquid market." },
609
734
  },
610
- required: ["collateralAmount", "token", "borrowAmount"],
735
+ required: ["amount", "token", "borrowAmount"],
611
736
  },
612
- async execute(_id: string, params: { collateralAmount: string; token: string; borrowAmount: string; loanToken?: string }) {
737
+ async execute(_id: string, params: { amount: string; token: string; borrowAmount: string; loanToken?: string }) {
613
738
  try {
614
739
  const cfg = getConfig(api);
615
740
  requireChain(cfg);
616
741
  const client = createMorphoClient(cfg);
617
742
  const result = await client.depositAndBorrow(
618
743
  params.token,
619
- params.collateralAmount,
744
+ params.amount,
620
745
  params.borrowAmount,
621
746
  undefined,
622
747
  params.loanToken,
623
748
  );
624
749
  return ok(JSON.stringify({
625
750
  status: "deposited_and_borrowed",
626
- collateral: `${params.collateralAmount} ${params.token}`,
751
+ collateral: `${params.amount} ${params.token}`,
627
752
  borrowed: `${params.borrowAmount}`,
628
753
  agentAccount: result.agentAccount,
629
754
  tx: txLink(result.tx),
@@ -632,56 +757,13 @@ export default function register(api: any) {
632
757
  },
633
758
  });
634
759
 
635
- // ═══════════════════════════════════════════════════════
636
- // TOOL: morpho_sponsor
637
- // ═══════════════════════════════════════════════════════
638
- api.registerTool({
639
- name: "agether_sponsor",
640
- description:
641
- "Transfer tokens to another agent's Safe account (or any address). " +
642
- "The target agent can then supply the tokens to Morpho Blue or use them directly.",
643
- parameters: {
644
- type: "object",
645
- properties: {
646
- agentId: { type: "string", description: "Target agent's ERC-8004 ID (e.g. '17676')" },
647
- agentAddress: { type: "string", description: "Target account address (alternative to agentId)" },
648
- amount: { type: "string", description: "Token amount (e.g. '0.05')" },
649
- token: { type: "string", description: "Token to send (e.g. 'WETH', 'USDC')" },
650
- },
651
- required: ["amount", "token"],
652
- },
653
- async execute(_id: string, params: { agentId?: string; agentAddress?: string; amount: string; token: string }) {
654
- try {
655
- if (!params.agentId && !params.agentAddress) return fail("Provide either agentId or agentAddress");
656
- const cfg = getConfig(api);
657
- requireChain(cfg);
658
- const client = createAgetherClient(cfg);
659
-
660
- const target = params.agentId
661
- ? { agentId: params.agentId }
662
- : { address: params.agentAddress! };
663
-
664
- const result = await client.sponsor(target, params.token, params.amount);
665
- return ok(JSON.stringify({
666
- status: "sponsored",
667
- target: result.targetAccount,
668
- targetAgentId: result.targetAgentId || "by address",
669
- amount: `${params.amount} ${params.token}`,
670
- tx: txLink(result.tx),
671
- }));
672
- } catch (e) { return fail(e); }
673
- },
674
- });
675
760
 
676
761
  // ═══════════════════════════════════════════════════════
677
762
  // TOOL: morpho_borrow
678
763
  // ═══════════════════════════════════════════════════════
679
764
  api.registerTool({
680
765
  name: "morpho_borrow",
681
- description:
682
- "Borrow loan token against deposited collateral via Morpho Blue. " +
683
- "Borrowed tokens land in AgentAccount. Requires collateral deposited first. " +
684
- "Supports any loan token (USDC, WETH, etc.).",
766
+ description: "Borrow against deposited collateral. Pre-checks health factor and max borrowable.",
685
767
  parameters: {
686
768
  type: "object",
687
769
  properties: {
@@ -696,14 +778,59 @@ export default function register(api: any) {
696
778
  const cfg = getConfig(api);
697
779
  requireChain(cfg);
698
780
  const client = createMorphoClient(cfg);
781
+
782
+ // Phase 1 Fix: Pre-check health factor before borrow
783
+ // Warns if resulting HF would be dangerously low
784
+ let healthWarning = "";
785
+ try {
786
+ const [status, maxBorrow] = await Promise.all([
787
+ client.getStatus(),
788
+ client.getMaxBorrowable(),
789
+ ]);
790
+ const maxBorrowUsd = Number(maxBorrow.total) / 1e6;
791
+ const requestedAmount = parseFloat(params.amount);
792
+
793
+ if (requestedAmount > maxBorrowUsd && maxBorrowUsd > 0) {
794
+ return fail(new Error(
795
+ `Borrow amount $${requestedAmount} exceeds max borrowable $${maxBorrowUsd.toFixed(2)}. ` +
796
+ `Reduce amount or deposit more collateral first.`
797
+ ));
798
+ }
799
+
800
+ // Estimate resulting health factor
801
+ if (status.positions.length > 0) {
802
+ const currentDebt = parseFloat(status.totalDebt);
803
+ const totalCollateralValue = maxBorrow.byMarket.reduce(
804
+ (sum: number, m: any) => sum + Number(m.collateralValue) / 1e6, 0
805
+ );
806
+ const newDebt = currentDebt + requestedAmount;
807
+ const lltv = 0.80; // Approximate LLTV
808
+ const estimatedHF = totalCollateralValue > 0 ? (totalCollateralValue * lltv) / newDebt : Infinity;
809
+
810
+ if (estimatedHF < 1.1 && estimatedHF !== Infinity) {
811
+ return fail(new Error(
812
+ `⚠️ This borrow would result in estimated Health Factor of ${estimatedHF.toFixed(2)} — DANGEROUSLY CLOSE to liquidation (HF < 1.0). ` +
813
+ `Current debt: $${currentDebt.toFixed(2)}, Collateral value: $${totalCollateralValue.toFixed(2)}. ` +
814
+ `Reduce borrow amount or add more collateral.`
815
+ ));
816
+ } else if (estimatedHF < 1.5 && estimatedHF !== Infinity) {
817
+ healthWarning = `⚠️ Warning: Estimated HF after borrow: ${estimatedHF.toFixed(2)} (below safe threshold of 1.5). Consider a smaller amount.`;
818
+ }
819
+ }
820
+ } catch {
821
+ // Pre-check failed — proceed with borrow anyway, on-chain will catch errors
822
+ }
823
+
699
824
  const result = await client.borrow(params.amount, params.token, undefined, params.loanToken);
700
- return ok(JSON.stringify({
825
+ const response: any = {
701
826
  status: "borrowed",
702
827
  amount: params.amount,
703
828
  collateral: result.collateralToken,
704
829
  agentAccount: result.agentAccount,
705
830
  tx: txLink(result.tx),
706
- }));
831
+ };
832
+ if (healthWarning) response.healthWarning = healthWarning;
833
+ return ok(JSON.stringify(response));
707
834
  } catch (e) { return fail(e); }
708
835
  },
709
836
  });
@@ -713,10 +840,7 @@ export default function register(api: any) {
713
840
  // ═══════════════════════════════════════════════════════
714
841
  api.registerTool({
715
842
  name: "morpho_repay",
716
- description:
717
- "Repay borrowed loan token back to Morpho Blue from AgentAccount. Reduces debt. " +
718
- "Use amount 'all' to repay full debt and clear dust shares. " +
719
- "Supports any loan token (USDC, WETH, etc.).",
843
+ description: "Repay debt to Morpho Blue. Use amount='all' for full repayment including dust.",
720
844
  parameters: {
721
845
  type: "object",
722
846
  properties: {
@@ -784,7 +908,7 @@ export default function register(api: any) {
784
908
  // TOOL: morpho_supply (lender-side — earn yield)
785
909
  // ═══════════════════════════════════════════════════════
786
910
  api.registerTool({
787
- name: "morpho_supply",
911
+ name: "morpho_lend",
788
912
  description:
789
913
  "Supply a loan token (USDC, WETH, etc.) to a Morpho Blue market as a LENDER to earn yield. " +
790
914
  "This is the lending side — your tokens earn interest from borrowers. " +
@@ -828,7 +952,7 @@ export default function register(api: any) {
828
952
  // TOOL: morpho_supply_status (supply positions + yield)
829
953
  // ═══════════════════════════════════════════════════════
830
954
  api.registerTool({
831
- name: "morpho_supply_status",
955
+ name: "morpho_lending_status",
832
956
  description:
833
957
  "Show all supply (lending) positions with earned yield. " +
834
958
  "Tracks yield WITHOUT any database — reads Morpho events directly from blockchain. " +
@@ -881,7 +1005,7 @@ export default function register(api: any) {
881
1005
  // TOOL: morpho_withdraw_supply (withdraw lent tokens)
882
1006
  // ═══════════════════════════════════════════════════════
883
1007
  api.registerTool({
884
- name: "morpho_withdraw_supply",
1008
+ name: "morpho_withdraw_lending",
885
1009
  description:
886
1010
  "Withdraw supplied tokens (principal + earned interest) from a Morpho Blue lending position. " +
887
1011
  "By default keeps tokens in AgentAccount. Set toEoa=true to send to EOA wallet. " +
@@ -923,9 +1047,7 @@ export default function register(api: any) {
923
1047
  // ═══════════════════════════════════════════════════════
924
1048
  api.registerTool({
925
1049
  name: "morpho_markets",
926
- description:
927
- "List available Morpho Blue markets — liquidity, supply/borrow APY, utilization, LLTV. " +
928
- "Supports all loan tokens (USDC, WETH, etc.). Optionally filter by collateral or loan token.",
1050
+ description: "List Morpho Blue markets — collateral/loan pairs, APY rates, liquidity.",
929
1051
  parameters: {
930
1052
  type: "object",
931
1053
  properties: {
@@ -1060,7 +1182,7 @@ export default function register(api: any) {
1060
1182
  // TOOL: morpho_supply_options (find lending opportunities)
1061
1183
  // ═══════════════════════════════════════════════════════
1062
1184
  api.registerTool({
1063
- name: "morpho_supply_options",
1185
+ name: "morpho_lending_options",
1064
1186
  description:
1065
1187
  "Find supply/lending opportunities for a specific loan token. " +
1066
1188
  "Use when user asks 'what can I supply to earn WETH?', 'where can I lend USDC?', " +
@@ -1133,9 +1255,7 @@ export default function register(api: any) {
1133
1255
  // ═══════════════════════════════════════════════════════
1134
1256
  api.registerTool({
1135
1257
  name: "agether_score",
1136
- description:
1137
- "Get the agent's current onchain credit score. " +
1138
- "Use 'refresh' param to request a fresh score computation (costs USDC via x402).",
1258
+ description: "Get credit score. Set refresh=true to recompute (costs USDC via x402). Checks staleness first.",
1139
1259
  parameters: {
1140
1260
  type: "object",
1141
1261
  properties: {
@@ -1151,6 +1271,25 @@ export default function register(api: any) {
1151
1271
  const agentId = client.getAgentId().toString();
1152
1272
 
1153
1273
  if (params.refresh) {
1274
+ // Phase 1 Fix: Check if current score is still fresh before paying for refresh
1275
+ try {
1276
+ const scoring0 = new ScoringClient({ endpoint: BACKEND_URL, chainId: cfg.chainId });
1277
+ const current = await scoring0.getCurrentScore(agentId);
1278
+ if (current && current.fresh) {
1279
+ return ok(JSON.stringify({
1280
+ status: "score_still_fresh",
1281
+ warning: "Current score is still fresh — paying for a new score is unnecessary.",
1282
+ currentScore: current.score,
1283
+ scoreAge: current.age,
1284
+ suggestion: "Score will need refresh when age exceeds freshness threshold. " +
1285
+ "To force refresh anyway, call agether_score with refresh=true again.",
1286
+ ...current,
1287
+ }));
1288
+ }
1289
+ } catch {
1290
+ // If staleness check fails, proceed with refresh anyway
1291
+ }
1292
+
1154
1293
  // x402-gated fresh score via ScoringClient
1155
1294
  const accountAddress = await client.getAccountAddress();
1156
1295
  const contracts = getContractAddresses(cfg.chainId);
@@ -1185,10 +1324,7 @@ export default function register(api: any) {
1185
1324
  // ═══════════════════════════════════════════════════════
1186
1325
  api.registerTool({
1187
1326
  name: "wallet_withdraw_token",
1188
- description:
1189
- "Withdraw (transfer) a token from AgentAccount to EOA wallet. " +
1190
- "Works with any ERC-20 token (USDC, WETH, wstETH, cbBTC, etc.). " +
1191
- "Use amount 'all' to withdraw the full balance.",
1327
+ description: "Withdraw ERC-20 token from AgentAccount to EOA. Use amount='all' for full balance.",
1192
1328
  parameters: {
1193
1329
  type: "object",
1194
1330
  properties: {
@@ -1219,9 +1355,7 @@ export default function register(api: any) {
1219
1355
  // ═══════════════════════════════════════════════════════
1220
1356
  api.registerTool({
1221
1357
  name: "wallet_withdraw_eth",
1222
- description:
1223
- "Withdraw ETH from AgentAccount to EOA wallet. " +
1224
- "Use amount 'all' to withdraw the full ETH balance.",
1358
+ description: "Withdraw ETH from AgentAccount to EOA. Use amount='all' for full balance.",
1225
1359
  parameters: {
1226
1360
  type: "object",
1227
1361
  properties: {
@@ -1676,120 +1810,8 @@ export default function register(api: any) {
1676
1810
  // SLASH COMMANDS (no AI needed)
1677
1811
  // ═══════════════════════════════════════════════════════
1678
1812
 
1679
- api.registerCommand({
1680
- name: "balance",
1681
- description: "Show agent wallet balances (no AI)",
1682
- handler: async () => {
1683
- try {
1684
- const cfg = getConfig(api);
1685
- const client = createAgetherClient(cfg);
1686
- const b = await client.getBalances();
1687
-
1688
- const nz = (v: string) => parseFloat(v) > 0;
1689
-
1690
- let text = `💰 Agent #${b.agentId}\n`;
1691
- text += `Address: ${b.address}`;
1692
- if (nz(b.eth)) text += `\nETH: ${parseFloat(b.eth).toFixed(6)}`;
1693
- if (nz(b.usdc)) text += `\nUSDC: $${b.usdc}`;
1694
- for (const [sym, val] of Object.entries(b.collateral ?? {})) {
1695
- if (nz(val)) text += `\n${sym}: ${parseFloat(val).toFixed(6)}`;
1696
- }
1697
-
1698
- if (b.agentAccount) {
1699
- const a = b.agentAccount;
1700
- const hasAny = nz(a.eth) || nz(a.usdc) ||
1701
- Object.values(a.collateral ?? {}).some(nz);
1702
- if (hasAny) {
1703
- text += `\n\n🏦 AgentAccount: ${a.address}`;
1704
- if (nz(a.eth)) text += `\nETH: ${parseFloat(a.eth).toFixed(6)}`;
1705
- if (nz(a.usdc)) text += `\nUSDC: $${a.usdc}`;
1706
- for (const [sym, val] of Object.entries(a.collateral ?? {})) {
1707
- if (nz(val)) text += `\n${sym}: ${parseFloat(val).toFixed(6)}`;
1708
- }
1709
- } else {
1710
- text += `\n\n🏦 AgentAccount: ${a.address}\n(empty)`;
1711
- }
1712
- }
1713
-
1714
- return { text };
1715
- } catch (e: any) {
1716
- return { text: `❌ ${e.message}` };
1717
- }
1718
- },
1719
- });
1720
-
1721
- api.registerCommand({
1722
- name: "morpho",
1723
- description: "Show Morpho positions with Health Factor (no AI)",
1724
- handler: async () => {
1725
- try {
1726
- const cfg = getConfig(api);
1727
- const client = createMorphoClient(cfg);
1728
- const [s, maxBorrow] = await Promise.all([
1729
- client.getStatus(),
1730
- client.getMaxBorrowable(),
1731
- ]);
1732
-
1733
- let text = `📊 Morpho\nAccount: ${s.agentAccount}\nTotal debt: $${s.totalDebt}\n`;
1734
- for (const p of s.positions) {
1735
- const mm = maxBorrow.byMarket.find((m: any) => m.collateralToken === p.collateralToken);
1736
- const cv = mm ? Number(mm.collateralValue) / 1e6 : 0;
1737
- const debt = parseFloat(p.debt);
1738
- const ltv = cv > 0 ? debt / cv : 0;
1739
- const hf = ltv > 0 ? 0.80 / ltv : Infinity;
1740
- const hfStr = debt === 0 ? "∞" : hf.toFixed(2);
1741
- const icon = debt === 0 ? "🟢" : hf <= 1.0 ? "🔴" : hf <= 1.15 ? "🟡" : "🟢";
1742
- text += `\n${icon} ${p.collateralToken}: ${p.collateral} col, $${p.debt} debt, HF ${hfStr}`;
1743
- }
1744
- if (s.positions.length === 0) text += "\nNo active positions.";
1745
- return { text };
1746
- } catch (e: any) {
1747
- return { text: `❌ ${e.message}` };
1748
- }
1749
- },
1750
- });
1751
1813
 
1752
- api.registerCommand({
1753
- name: "health",
1754
- description: "Quick position health check — LTV, liquidation risk, balances (no AI)",
1755
- handler: async () => {
1756
- try {
1757
- const cfg = getConfig(api);
1758
- const agetherCfg = api.config?.plugins?.entries?.agether?.config ?? {};
1759
- const healthThreshold = agetherCfg.healthAlertThreshold ?? 70;
1760
- const agether = createAgetherClient(cfg);
1761
- const morpho = createMorphoClient(cfg);
1762
-
1763
- const [balances, status, maxBorrow] = await Promise.all([
1764
- agether.getBalances(),
1765
- morpho.getStatus(),
1766
- morpho.getMaxBorrowable(),
1767
- ]);
1768
-
1769
- let text = `🏥 Health — Agent #${balances.agentId}\n`;
1770
- text += `EOA: ${balances.eth} ETH, $${balances.usdc} USDC\n`;
1771
- if (balances.agentAccount) {
1772
- text += `Safe: $${balances.agentAccount.usdc} USDC\n`;
1773
- }
1774
- text += `Total debt: $${status.totalDebt}\n`;
1775
- text += `Headroom: $${(Number(maxBorrow.total) / 1e6).toFixed(2)}\n`;
1776
1814
 
1777
- for (const p of status.positions) {
1778
- const mm = maxBorrow.byMarket.find((m: any) => m.collateralToken === p.collateralToken);
1779
- const cv = mm ? Number(mm.collateralValue) / 1e6 : 0;
1780
- const debt = parseFloat(p.debt);
1781
- const ltv = cv > 0 ? (debt / cv) * 100 : 0;
1782
- const icon = debt === 0 ? "🟢" : ltv >= 80 ? "🔴" : ltv >= healthThreshold ? "🟡" : "🟢";
1783
- text += `\n${icon} ${p.collateralToken}: ${p.collateral} col, $${p.debt} debt, LTV ${ltv.toFixed(1)}%`;
1784
- }
1785
- if (status.positions.length === 0) text += "\nNo active positions.";
1786
-
1787
- return { text };
1788
- } catch (e: any) {
1789
- return { text: `❌ ${e.message}` };
1790
- }
1791
- },
1792
- });
1793
1815
 
1794
1816
  api.registerCommand({
1795
1817
  name: "rates",
@@ -1828,7 +1850,7 @@ export default function register(api: any) {
1828
1850
  const balances = await client.getBalances();
1829
1851
  const agentId = balances.agentId ?? "?";
1830
1852
  const safeUsdc = balances.agentAccount?.usdc ?? "0";
1831
- const chainName = CHAIN_DEFAULTS[cfg.chainId]?.chainName ?? `Chain ${cfg.chainId}`;
1853
+ const chainName = CHAIN_DEFAULTS[state.activeChainId]?.chainName ?? `Chain ${state.activeChainId}`;
1832
1854
  api.logger?.info?.(
1833
1855
  `[agether] Session start — ${chainName}, Agent #${agentId}, EOA: ${balances.eth} ETH / $${balances.usdc} USDC, Safe: $${safeUsdc} USDC`,
1834
1856
  );