@xpr-agents/openclaw 0.3.2 → 0.4.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.
Files changed (53) hide show
  1. package/README.md +31 -5
  2. package/openclaw.plugin.json +15 -1
  3. package/package.json +7 -4
  4. package/skills/code-sandbox/SKILL.md +30 -0
  5. package/skills/code-sandbox/skill.json +13 -0
  6. package/skills/code-sandbox/src/index.ts +212 -0
  7. package/skills/creative/SKILL.md +32 -0
  8. package/skills/creative/skill.json +13 -0
  9. package/skills/creative/src/index.ts +679 -0
  10. package/skills/defi/SKILL.md +123 -0
  11. package/skills/defi/dist/index.js +1 -0
  12. package/skills/defi/skill.json +44 -0
  13. package/skills/defi/src/index.ts +1788 -0
  14. package/skills/defi/test-read.mjs +281 -0
  15. package/skills/governance/SKILL.md +69 -0
  16. package/skills/governance/dist/index.js +632 -0
  17. package/skills/governance/skill.json +21 -0
  18. package/skills/governance/src/index.ts +656 -0
  19. package/skills/governance/test-read.mjs +176 -0
  20. package/skills/lending/SKILL.md +63 -0
  21. package/skills/lending/dist/index.js +1039 -0
  22. package/skills/lending/skill.json +29 -0
  23. package/skills/lending/src/index.ts +1105 -0
  24. package/skills/lending/test-read.mjs +156 -0
  25. package/skills/nft/SKILL.md +95 -0
  26. package/skills/nft/dist/index.js +4 -10
  27. package/skills/nft/skill.json +37 -0
  28. package/skills/nft/src/index.ts +1539 -0
  29. package/skills/shellbook/SKILL.md +59 -0
  30. package/skills/shellbook/skill.json +29 -0
  31. package/skills/shellbook/src/index.ts +391 -0
  32. package/skills/shellbook/tsconfig.json +14 -0
  33. package/skills/smart-contracts/SKILL.md +128 -0
  34. package/skills/smart-contracts/skill.json +25 -0
  35. package/skills/smart-contracts/src/index.ts +1327 -0
  36. package/skills/smart-contracts/tsconfig.json +14 -0
  37. package/skills/structured-data/SKILL.md +36 -0
  38. package/skills/structured-data/dist/index.js +501 -0
  39. package/skills/structured-data/skill.json +13 -0
  40. package/skills/structured-data/src/index.ts +597 -0
  41. package/skills/tax/SKILL.md +109 -0
  42. package/skills/tax/dist/index.js +216 -32
  43. package/skills/tax/skill.json +20 -0
  44. package/skills/tax/src/index.ts +1985 -0
  45. package/skills/web-scraping/SKILL.md +29 -0
  46. package/skills/web-scraping/dist/index.js +311 -0
  47. package/skills/web-scraping/skill.json +13 -0
  48. package/skills/web-scraping/src/index.ts +371 -0
  49. package/skills/xmd/SKILL.md +52 -0
  50. package/skills/xmd/dist/index.js +596 -0
  51. package/skills/xmd/skill.json +22 -0
  52. package/skills/xmd/src/index.ts +635 -0
  53. package/skills/xmd/test-read.mjs +178 -0
@@ -0,0 +1,1105 @@
1
+ /**
2
+ * Lending Skill — LOAN Protocol (lending.loan) on XPR Network
3
+ *
4
+ * Read-only tools use fetch-based RPC helpers (no signing).
5
+ * Write tools create a session from env vars for signing transactions.
6
+ *
7
+ * IMPORTANT: LOAN Protocol is mainnet only.
8
+ */
9
+
10
+ // ── Types ────────────────────────────────────────
11
+
12
+ interface ToolDef {
13
+ name: string;
14
+ description: string;
15
+ parameters: { type: 'object'; required?: string[]; properties: Record<string, unknown> };
16
+ handler: (params: any) => Promise<unknown>;
17
+ }
18
+
19
+ interface SkillApi {
20
+ registerTool(tool: ToolDef): void;
21
+ getConfig(): Record<string, unknown>;
22
+ }
23
+
24
+ // ── Constants ────────────────────────────────────
25
+
26
+ const LENDING_CONTRACT = 'lending.loan';
27
+ const LOAN_TOKEN_CONTRACT = 'loan.token';
28
+ const LOAN_TOKEN_SYMBOL = 'LOAN';
29
+
30
+ // Mainnet RPC endpoints (LOAN Protocol is mainnet only)
31
+ const MAINNET_RPC = 'https://xpr-mainnet-rpc.saltant.io';
32
+
33
+ // Metal X API for APY/TVL stats
34
+ const METALX_LOAN_API = 'https://identity.api.prod.metalx.com/v1/loan/stats';
35
+ const VALID_DAYS = [7, 30, 90];
36
+
37
+ // ── RPC Helper ───────────────────────────────────
38
+
39
+ const RPC_TIMEOUT = 15000;
40
+
41
+ async function rpcPost(endpoint: string, path: string, body: unknown): Promise<any> {
42
+ const controller = new AbortController();
43
+ const timer = setTimeout(() => controller.abort(), RPC_TIMEOUT);
44
+ try {
45
+ const resp = await fetch(`${endpoint}${path}`, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify(body),
49
+ signal: controller.signal,
50
+ });
51
+ if (!resp.ok) {
52
+ const text = await resp.text().catch(() => '');
53
+ throw new Error(`RPC ${path} failed (${resp.status}): ${text.slice(0, 200)}`);
54
+ }
55
+ return await resp.json();
56
+ } finally {
57
+ clearTimeout(timer);
58
+ }
59
+ }
60
+
61
+ async function getTableRows(endpoint: string, opts: {
62
+ code: string; scope: string; table: string;
63
+ lower_bound?: string | number; upper_bound?: string | number;
64
+ limit?: number; key_type?: string; index_position?: string;
65
+ json?: boolean;
66
+ }): Promise<any[]> {
67
+ const result = await rpcPost(endpoint, '/v1/chain/get_table_rows', {
68
+ json: opts.json !== false,
69
+ code: opts.code,
70
+ scope: opts.scope,
71
+ table: opts.table,
72
+ lower_bound: opts.lower_bound,
73
+ upper_bound: opts.upper_bound,
74
+ limit: opts.limit || 100,
75
+ key_type: opts.key_type,
76
+ index_position: opts.index_position,
77
+ });
78
+ return result.rows || [];
79
+ }
80
+
81
+ // ── Metal X API Helper ───────────────────────────
82
+
83
+ async function metalXLoanGet(path: string): Promise<any> {
84
+ const controller = new AbortController();
85
+ const timer = setTimeout(() => controller.abort(), RPC_TIMEOUT);
86
+ try {
87
+ const resp = await fetch(`${METALX_LOAN_API}${path}`, {
88
+ signal: controller.signal,
89
+ headers: { 'Accept': 'application/json' },
90
+ });
91
+ if (!resp.ok) {
92
+ const text = await resp.text().catch(() => '');
93
+ throw new Error(`Metal X loan API failed (${resp.status}): ${text.slice(0, 200)}`);
94
+ }
95
+ return await resp.json();
96
+ } finally {
97
+ clearTimeout(timer);
98
+ }
99
+ }
100
+
101
+ // ── Session Factory ──────────────────────────────
102
+ // Backed by the proton CLI — agent process never holds a private key.
103
+
104
+ let cachedSession: { api: any; account: string; permission: string } | null = null;
105
+
106
+ async function getLendingSession(): Promise<{ api: any; account: string; permission: string }> {
107
+ if (cachedSession) return cachedSession;
108
+
109
+ const account = process.env.XPR_ACCOUNT;
110
+ const permission = process.env.XPR_PERMISSION || 'active';
111
+
112
+ if (!account) throw new Error('XPR_ACCOUNT is required for lending write operations');
113
+
114
+ // @ts-ignore — provided by host at runtime; not resolvable when building skills inside the openclaw package
115
+
116
+ const { createCliApi } = await import('@xpr-agents/openclaw');
117
+ cachedSession = createCliApi({ account, permission, rpcEndpoint: MAINNET_RPC });
118
+ return cachedSession;
119
+ }
120
+
121
+ // ── Helper: Parse extended_symbol ────────────────
122
+
123
+ function parseExtSym(sym: any): { precision: number; symbol: string; contract: string } | null {
124
+ if (!sym) return null;
125
+ // Format from chain: { sym: "8,LBTC", contract: "shares.loan" }
126
+ const symStr = sym.sym || sym.symbol || '';
127
+ const parts = symStr.split(',');
128
+ if (parts.length !== 2) return null;
129
+ return {
130
+ precision: parseInt(parts[0]) || 0,
131
+ symbol: parts[1].trim(),
132
+ contract: sym.contract || '',
133
+ };
134
+ }
135
+
136
+ // ── Helper: Format asset ─────────────────────────
137
+
138
+ function formatAsset(amount: number | string, precision: number, symbol: string): string {
139
+ const num = typeof amount === 'string' ? parseFloat(amount) : amount;
140
+ return `${num.toFixed(precision)} ${symbol}`;
141
+ }
142
+
143
+ // ── Helper: Parse quantity string ────────────────
144
+
145
+ function parseQuantity(qty: string): { amount: number; symbol: string } | null {
146
+ const parts = qty.trim().split(' ');
147
+ if (parts.length !== 2) return null;
148
+ return { amount: parseFloat(parts[0]), symbol: parts[1] };
149
+ }
150
+
151
+ // ── Skill Entry Point ────────────────────────────
152
+
153
+ export default function lendingSkill(api: SkillApi): void {
154
+ const config = api.getConfig();
155
+ // LOAN Protocol is mainnet only — always use mainnet RPC
156
+ const rpcEndpoint = MAINNET_RPC;
157
+
158
+ // ════════════════════════════════════════════════
159
+ // READ-ONLY TOOLS
160
+ // ════════════════════════════════════════════════
161
+
162
+ // ── 1. loan_list_markets ──
163
+ api.registerTool({
164
+ name: 'loan_list_markets',
165
+ description: 'List all LOAN Protocol lending markets with interest models, collateral factors, utilization, and reserves. Mainnet only.',
166
+ parameters: {
167
+ type: 'object',
168
+ properties: {},
169
+ },
170
+ handler: async () => {
171
+ try {
172
+ const markets = await getTableRows(rpcEndpoint, {
173
+ code: LENDING_CONTRACT, scope: LENDING_CONTRACT, table: 'markets', limit: 50,
174
+ });
175
+
176
+ return {
177
+ markets: markets.map((m: any) => {
178
+ const share = parseExtSym(m.share_symbol);
179
+ const underlying = parseExtSym(m.underlying_symbol);
180
+
181
+ // Calculate utilization from borrows and cash
182
+ const totalVarBorrows = m.total_variable_borrows
183
+ ? parseFloat((m.total_variable_borrows.quantity || '0').split(' ')[0])
184
+ : 0;
185
+ const totalStableBorrows = m.total_stable_borrows
186
+ ? parseFloat((m.total_stable_borrows.quantity || '0').split(' ')[0])
187
+ : 0;
188
+ const totalReserves = m.total_reserves
189
+ ? parseFloat((m.total_reserves.quantity || '0').split(' ')[0])
190
+ : 0;
191
+
192
+ return {
193
+ market_symbol: share?.symbol || 'unknown',
194
+ underlying_symbol: underlying?.symbol || 'unknown',
195
+ share_contract: share?.contract || '',
196
+ underlying_contract: underlying?.contract || '',
197
+ precision: underlying?.precision || 0,
198
+ collateral_factor: m.collateral_factor,
199
+ reserve_factor: m.reserve_factor,
200
+ borrow_index: m.borrow_index,
201
+ stable_loans_enabled: m.stable_loans_enabled,
202
+ total_variable_borrows: m.total_variable_borrows?.quantity || '0',
203
+ total_stable_borrows: m.total_stable_borrows?.quantity || '0',
204
+ total_reserves: m.total_reserves?.quantity || '0',
205
+ average_stable_rate: m.average_stable_rate,
206
+ variable_interest_model: m.variable_interest_model,
207
+ oracle_feed_index: m.oracle_feed_index,
208
+ };
209
+ }),
210
+ total: markets.length,
211
+ note: 'LOAN Protocol is mainnet only. Collateral factors indicate max borrow percentage of collateral value.',
212
+ };
213
+ } catch (err: any) {
214
+ return { error: `Failed to list markets: ${err.message}` };
215
+ }
216
+ },
217
+ });
218
+
219
+ // ── 2. loan_get_market ──
220
+ api.registerTool({
221
+ name: 'loan_get_market',
222
+ description: 'Get detailed info for a specific LOAN Protocol lending market by L-token symbol (e.g. "LBTC", "LUSDC"). Returns interest model, collateral factor, reserves, and utilization.',
223
+ parameters: {
224
+ type: 'object',
225
+ required: ['market_symbol'],
226
+ properties: {
227
+ market_symbol: { type: 'string', description: 'L-token symbol e.g. "LBTC", "LUSDC", "LXPR"' },
228
+ },
229
+ },
230
+ handler: async ({ market_symbol }: { market_symbol: string }) => {
231
+ if (!market_symbol) return { error: 'market_symbol is required (e.g. "LBTC")' };
232
+ const sym = market_symbol.toUpperCase();
233
+
234
+ try {
235
+ const markets = await getTableRows(rpcEndpoint, {
236
+ code: LENDING_CONTRACT, scope: LENDING_CONTRACT, table: 'markets', limit: 50,
237
+ });
238
+
239
+ const market = markets.find((m: any) => {
240
+ const share = parseExtSym(m.share_symbol);
241
+ return share?.symbol === sym;
242
+ });
243
+
244
+ if (!market) {
245
+ const available = markets.map((m: any) => parseExtSym(m.share_symbol)?.symbol).filter(Boolean);
246
+ return { error: `Market "${sym}" not found. Available: ${available.join(', ')}` };
247
+ }
248
+
249
+ const share = parseExtSym(market.share_symbol);
250
+ const underlying = parseExtSym(market.underlying_symbol);
251
+
252
+ // Also fetch reward config for this market
253
+ let rewardConfig: any = null;
254
+ try {
255
+ const rewardsCfg = await getTableRows(rpcEndpoint, {
256
+ code: LENDING_CONTRACT, scope: LENDING_CONTRACT, table: 'rewards.cfg', limit: 50,
257
+ });
258
+ rewardConfig = rewardsCfg.find((r: any) => r.market_symbol === sym);
259
+ } catch { /* rewards.cfg table may not exist */ }
260
+
261
+ return {
262
+ market_symbol: share?.symbol,
263
+ underlying_symbol: underlying?.symbol,
264
+ share_contract: share?.contract,
265
+ underlying_contract: underlying?.contract,
266
+ precision: underlying?.precision,
267
+ collateral_factor: market.collateral_factor,
268
+ collateral_factor_pct: `${((market.collateral_factor || 0) * 100).toFixed(0)}%`,
269
+ reserve_factor: market.reserve_factor,
270
+ reserve_factor_pct: `${((market.reserve_factor || 0) * 100).toFixed(0)}%`,
271
+ borrow_index: market.borrow_index,
272
+ stable_loans_enabled: market.stable_loans_enabled,
273
+ max_stable_borrow_percentage: market.max_stable_borrow_percentage,
274
+ total_variable_borrows: market.total_variable_borrows?.quantity || '0',
275
+ total_stable_borrows: market.total_stable_borrows?.quantity || '0',
276
+ total_reserves: market.total_reserves?.quantity || '0',
277
+ average_stable_rate: market.average_stable_rate,
278
+ variable_interest_model: market.variable_interest_model,
279
+ stable_interest_model: market.stable_interest_model,
280
+ oracle_feed_index: market.oracle_feed_index,
281
+ variable_accrual_time: market.variable_accrual_time,
282
+ stable_accrual_time: market.stable_accrual_time,
283
+ rewards: rewardConfig ? {
284
+ supplier_rewards_per_half_second: rewardConfig.supplier_rewards_per_half_second,
285
+ borrower_rewards_per_half_second: rewardConfig.borrower_rewards_per_half_second,
286
+ supply_index: rewardConfig.supply_index,
287
+ borrow_index: rewardConfig.borrow_index,
288
+ } : null,
289
+ };
290
+ } catch (err: any) {
291
+ return { error: `Failed to get market: ${err.message}` };
292
+ }
293
+ },
294
+ });
295
+
296
+ // ── 3. loan_get_user_positions ──
297
+ api.registerTool({
298
+ name: 'loan_get_user_positions',
299
+ description: 'Get a user\'s supply (L-token shares) and borrow positions across all LOAN Protocol markets.',
300
+ parameters: {
301
+ type: 'object',
302
+ required: ['account'],
303
+ properties: {
304
+ account: { type: 'string', description: 'XPR Network account name' },
305
+ },
306
+ },
307
+ handler: async ({ account }: { account: string }) => {
308
+ if (!account) return { error: 'account is required' };
309
+
310
+ try {
311
+ // All lending tables are scoped by lending.loan, keyed by account name
312
+ const shares = await getTableRows(rpcEndpoint, {
313
+ code: LENDING_CONTRACT, scope: LENDING_CONTRACT, table: 'shares',
314
+ lower_bound: account, upper_bound: account, limit: 1, key_type: 'name',
315
+ });
316
+
317
+ const borrows = await getTableRows(rpcEndpoint, {
318
+ code: LENDING_CONTRACT, scope: LENDING_CONTRACT, table: 'borrows',
319
+ lower_bound: account, upper_bound: account, limit: 1, key_type: 'name',
320
+ });
321
+
322
+ // Parse share positions
323
+ const supplyPositions = shares.flatMap((row: any) => {
324
+ if (!row.tokens || !Array.isArray(row.tokens)) return [];
325
+ return row.tokens.map((t: any) => {
326
+ const sym = parseExtSym(t.key);
327
+ return {
328
+ market_symbol: sym?.symbol || 'unknown',
329
+ contract: sym?.contract || '',
330
+ balance_raw: t.value,
331
+ balance: sym ? formatAsset(t.value / Math.pow(10, sym.precision), sym.precision, sym.symbol) : String(t.value),
332
+ };
333
+ });
334
+ });
335
+
336
+ // Parse borrow positions
337
+ const borrowPositions = borrows.flatMap((row: any) => {
338
+ if (!row.tokens || !Array.isArray(row.tokens)) return [];
339
+ return row.tokens.map((t: any) => {
340
+ const sym = parseExtSym(t.key);
341
+ const snapshot = t.value || {};
342
+ return {
343
+ underlying_symbol: sym?.symbol || 'unknown',
344
+ contract: sym?.contract || '',
345
+ variable_principal_raw: snapshot.variable_principal || 0,
346
+ variable_principal: sym
347
+ ? formatAsset((snapshot.variable_principal || 0) / Math.pow(10, sym.precision), sym.precision, sym.symbol)
348
+ : String(snapshot.variable_principal || 0),
349
+ stable_principal_raw: snapshot.stable_principal || 0,
350
+ stable_principal: sym
351
+ ? formatAsset((snapshot.stable_principal || 0) / Math.pow(10, sym.precision), sym.precision, sym.symbol)
352
+ : String(snapshot.stable_principal || 0),
353
+ stable_rate: snapshot.stable_rate,
354
+ last_stable_update: snapshot.last_stable_update,
355
+ variable_interest_index: snapshot.variable_interest_index,
356
+ };
357
+ });
358
+ });
359
+
360
+ return {
361
+ account,
362
+ supply_positions: supplyPositions,
363
+ borrow_positions: borrowPositions,
364
+ has_supply: supplyPositions.length > 0,
365
+ has_borrows: borrowPositions.length > 0,
366
+ };
367
+ } catch (err: any) {
368
+ return { error: `Failed to get user positions: ${err.message}` };
369
+ }
370
+ },
371
+ });
372
+
373
+ // ── 4. loan_get_user_rewards ──
374
+ api.registerTool({
375
+ name: 'loan_get_user_rewards',
376
+ description: 'Get a user\'s unclaimed LOAN token rewards per market. Call loan_claim_rewards to claim them.',
377
+ parameters: {
378
+ type: 'object',
379
+ required: ['account'],
380
+ properties: {
381
+ account: { type: 'string', description: 'XPR Network account name' },
382
+ },
383
+ },
384
+ handler: async ({ account }: { account: string }) => {
385
+ if (!account) return { error: 'account is required' };
386
+
387
+ try {
388
+ // User rewards — scoped by lending.loan, keyed by account name
389
+ const rewards = await getTableRows(rpcEndpoint, {
390
+ code: LENDING_CONTRACT, scope: LENDING_CONTRACT, table: 'rewards',
391
+ lower_bound: account, upper_bound: account, limit: 1, key_type: 'name',
392
+ });
393
+
394
+ // Global reward config per market
395
+ const globalRewards = await getTableRows(rpcEndpoint, {
396
+ code: LENDING_CONTRACT, scope: LENDING_CONTRACT, table: 'rewards.cfg', limit: 50,
397
+ });
398
+
399
+ const rewardPositions = rewards.flatMap((row: any) => {
400
+ if (!row.markets || !Array.isArray(row.markets)) return [];
401
+ return row.markets.map((m: any) => {
402
+ const snapshot = m.value || {};
403
+ // LOAN token has precision 4
404
+ const accruedFormatted = formatAsset((snapshot.accrued_amount || 0) / 10000, 4, LOAN_TOKEN_SYMBOL);
405
+ return {
406
+ market_symbol: m.key,
407
+ accrued_amount_raw: snapshot.accrued_amount || 0,
408
+ accrued_amount: accruedFormatted,
409
+ borrower_index: snapshot.borrower_index,
410
+ supplier_index: snapshot.supplier_index,
411
+ };
412
+ });
413
+ });
414
+
415
+ const totalAccrued = rewardPositions.reduce(
416
+ (sum: number, p: any) => sum + (p.accrued_amount_raw || 0),
417
+ 0,
418
+ );
419
+
420
+ return {
421
+ account,
422
+ rewards: rewardPositions,
423
+ total_unclaimed: formatAsset(totalAccrued / 10000, 4, LOAN_TOKEN_SYMBOL),
424
+ total_unclaimed_raw: totalAccrued,
425
+ note: 'Call loan_claim_rewards to claim. Combine with update.user action for up-to-date amounts.',
426
+ };
427
+ } catch (err: any) {
428
+ return { error: `Failed to get user rewards: ${err.message}` };
429
+ }
430
+ },
431
+ });
432
+
433
+ // ── 5. loan_get_config ──
434
+ api.registerTool({
435
+ name: 'loan_get_config',
436
+ description: 'Get global LOAN Protocol lending configuration (oracle contract, close factor for liquidations, liquidation incentive, reward token).',
437
+ parameters: {
438
+ type: 'object',
439
+ properties: {},
440
+ },
441
+ handler: async () => {
442
+ try {
443
+ const globals = await getTableRows(rpcEndpoint, {
444
+ code: LENDING_CONTRACT, scope: LENDING_CONTRACT, table: 'globals.cfg', limit: 1,
445
+ });
446
+
447
+ if (globals.length === 0) {
448
+ return { error: 'Global config not found' };
449
+ }
450
+
451
+ const cfg = globals[0];
452
+ return {
453
+ oracle_contract: cfg.oracle_contract,
454
+ close_factor: cfg.close_factor,
455
+ close_factor_pct: `${((cfg.close_factor || 0) * 100).toFixed(1)}%`,
456
+ liquidation_incentive: cfg.liquidation_incentive,
457
+ liquidation_incentive_pct: `${((cfg.liquidation_incentive || 0) * 100).toFixed(1)}%`,
458
+ reward_symbol: cfg.reward_symbol,
459
+ note: `Close factor = max % of debt repayable per liquidation. Liquidation incentive = discount liquidators get on seized collateral.`,
460
+ };
461
+ } catch (err: any) {
462
+ return { error: `Failed to get config: ${err.message}` };
463
+ }
464
+ },
465
+ });
466
+
467
+ // ── 6. loan_get_market_apy ──
468
+ api.registerTool({
469
+ name: 'loan_get_market_apy',
470
+ description: 'Get historical APY (annual percentage yield) for a lending market. Returns deposit and borrow APYs including LOAN token rewards. Data from Metal X API.',
471
+ parameters: {
472
+ type: 'object',
473
+ required: ['underlying_symbol'],
474
+ properties: {
475
+ underlying_symbol: { type: 'string', description: 'Underlying token symbol e.g. "XBTC", "XUSDC", "XPR"' },
476
+ days: { type: 'number', description: 'Time period: 7, 30, or 90 days (default 7)' },
477
+ },
478
+ },
479
+ handler: async ({ underlying_symbol, days }: { underlying_symbol: string; days?: number }) => {
480
+ if (!underlying_symbol) return { error: 'underlying_symbol is required (e.g. "XBTC")' };
481
+ const d = VALID_DAYS.includes(days || 0) ? days! : 7;
482
+
483
+ try {
484
+ const data = await metalXLoanGet(`/apy?token_symbol=${encodeURIComponent(underlying_symbol.toUpperCase())}&days=${d}`);
485
+
486
+ return {
487
+ token: data.tokenSymbol || underlying_symbol.toUpperCase(),
488
+ days: data.days || d,
489
+ avg_deposit_apy_pct: `${((data.avgDepositApy || 0) * 100).toFixed(2)}%`,
490
+ avg_deposit_with_loan_apy_pct: `${((data.avgDepositLoanApy || 0) * 100).toFixed(2)}%`,
491
+ avg_borrow_apy_pct: `${((data.avgBorrowApy || 0) * 100).toFixed(2)}%`,
492
+ avg_borrow_with_loan_apy_pct: `${((data.avgBorrowLoanApy || 0) * 100).toFixed(2)}%`,
493
+ net_borrow_apy_pct: `${(((data.avgBorrowLoanApy || 0) - (data.avgBorrowApy || 0)) * 100).toFixed(2)}%`,
494
+ chart_data: (data.chartData || []).slice(-7).map((p: any) => ({
495
+ date: p.date,
496
+ deposit_apy_pct: `${((p.depositApy || 0) * 100).toFixed(2)}%`,
497
+ borrow_apy_pct: `${((p.borrowApy || 0) * 100).toFixed(2)}%`,
498
+ })),
499
+ note: 'APY includes interest. "with_loan" APYs include LOAN token rewards. Net borrow APY = LOAN rewards - interest cost (positive = earning while borrowing).',
500
+ };
501
+ } catch (err: any) {
502
+ return { error: `Failed to get APY: ${err.message}` };
503
+ }
504
+ },
505
+ });
506
+
507
+ // ── 7. loan_get_market_tvl ──
508
+ api.registerTool({
509
+ name: 'loan_get_market_tvl',
510
+ description: 'Get historical TVL (total value locked) for a lending market in USD. Returns deposit and borrow TVL. Data from Metal X API.',
511
+ parameters: {
512
+ type: 'object',
513
+ required: ['underlying_symbol'],
514
+ properties: {
515
+ underlying_symbol: { type: 'string', description: 'Underlying token symbol e.g. "XBTC", "XUSDC", "XPR"' },
516
+ days: { type: 'number', description: 'Time period: 7, 30, or 90 days (default 7)' },
517
+ },
518
+ },
519
+ handler: async ({ underlying_symbol, days }: { underlying_symbol: string; days?: number }) => {
520
+ if (!underlying_symbol) return { error: 'underlying_symbol is required (e.g. "XBTC")' };
521
+ const d = VALID_DAYS.includes(days || 0) ? days! : 7;
522
+
523
+ try {
524
+ const data = await metalXLoanGet(`/tvl?token_symbol=${encodeURIComponent(underlying_symbol.toUpperCase())}&days=${d}`);
525
+
526
+ return {
527
+ token: data.tokenSymbol || underlying_symbol.toUpperCase(),
528
+ days: data.days || d,
529
+ avg_deposit_tvl_usd: `$${((data.avgDepositTvl || 0)).toLocaleString('en-US', { maximumFractionDigits: 0 })}`,
530
+ avg_borrow_tvl_usd: `$${((data.avgBorrowTvl || 0)).toLocaleString('en-US', { maximumFractionDigits: 0 })}`,
531
+ utilization_pct: data.avgDepositTvl > 0
532
+ ? `${((data.avgBorrowTvl / data.avgDepositTvl) * 100).toFixed(1)}%`
533
+ : '0%',
534
+ chart_data: (data.chartData || []).slice(-7).map((p: any) => ({
535
+ date: p.date,
536
+ deposit_tvl_usd: Math.round(p.depositTvl || 0),
537
+ borrow_tvl_usd: Math.round(p.borrowTvl || 0),
538
+ })),
539
+ };
540
+ } catch (err: any) {
541
+ return { error: `Failed to get TVL: ${err.message}` };
542
+ }
543
+ },
544
+ });
545
+
546
+ // ════════════════════════════════════════════════
547
+ // WRITE TOOLS (require confirmation)
548
+ // ════════════════════════════════════════════════
549
+
550
+ // ── 8. loan_enter_markets ──
551
+ api.registerTool({
552
+ name: 'loan_enter_markets',
553
+ description: 'Enter lending markets to enable supply/borrow. Must enter a market before interacting with it. No-op if already entered.',
554
+ parameters: {
555
+ type: 'object',
556
+ required: ['markets', 'confirmed'],
557
+ properties: {
558
+ markets: {
559
+ type: 'array',
560
+ description: 'Array of L-token market symbols to enter, e.g. ["LBTC", "LUSDC"]',
561
+ },
562
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
563
+ },
564
+ },
565
+ handler: async ({ markets, confirmed }: { markets: string[]; confirmed?: boolean }) => {
566
+ if (!confirmed) {
567
+ return { error: 'Confirmation required. Set confirmed=true to enter these markets.', markets };
568
+ }
569
+ if (!Array.isArray(markets) || markets.length === 0) {
570
+ return { error: 'markets must be a non-empty array of market symbols (e.g. ["LBTC"])' };
571
+ }
572
+
573
+ try {
574
+ const { api: eosApi, account, permission } = await getLendingSession();
575
+
576
+ const result = await eosApi.transact({
577
+ actions: [{
578
+ account: LENDING_CONTRACT,
579
+ name: 'entermarkets',
580
+ authorization: [{ actor: account, permission }],
581
+ data: {
582
+ payer: account,
583
+ user: account,
584
+ markets: markets.map(m => m.toUpperCase()),
585
+ },
586
+ }],
587
+ }, { blocksBehind: 3, expireSeconds: 30 });
588
+
589
+ return {
590
+ transaction_id: result.transaction_id || result.processed?.id,
591
+ entered_markets: markets.map(m => m.toUpperCase()),
592
+ account,
593
+ };
594
+ } catch (err: any) {
595
+ return { error: `Failed to enter markets: ${err.message}` };
596
+ }
597
+ },
598
+ });
599
+
600
+ // ── 7. loan_exit_markets ──
601
+ api.registerTool({
602
+ name: 'loan_exit_markets',
603
+ description: 'Exit lending markets. User must not have outstanding rewards, collateral, or borrows in these markets.',
604
+ parameters: {
605
+ type: 'object',
606
+ required: ['markets', 'confirmed'],
607
+ properties: {
608
+ markets: {
609
+ type: 'array',
610
+ description: 'Array of L-token market symbols to exit, e.g. ["LBTC"]',
611
+ },
612
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
613
+ },
614
+ },
615
+ handler: async ({ markets, confirmed }: { markets: string[]; confirmed?: boolean }) => {
616
+ if (!confirmed) {
617
+ return { error: 'Confirmation required. Set confirmed=true to exit these markets.', markets };
618
+ }
619
+ if (!Array.isArray(markets) || markets.length === 0) {
620
+ return { error: 'markets must be a non-empty array of market symbols' };
621
+ }
622
+
623
+ try {
624
+ const { api: eosApi, account, permission } = await getLendingSession();
625
+
626
+ const result = await eosApi.transact({
627
+ actions: [{
628
+ account: LENDING_CONTRACT,
629
+ name: 'exitmarkets',
630
+ authorization: [{ actor: account, permission }],
631
+ data: {
632
+ user: account,
633
+ markets: markets.map(m => m.toUpperCase()),
634
+ },
635
+ }],
636
+ }, { blocksBehind: 3, expireSeconds: 30 });
637
+
638
+ return {
639
+ transaction_id: result.transaction_id || result.processed?.id,
640
+ exited_markets: markets.map(m => m.toUpperCase()),
641
+ account,
642
+ };
643
+ } catch (err: any) {
644
+ return { error: `Failed to exit markets: ${err.message}` };
645
+ }
646
+ },
647
+ });
648
+
649
+ // ── 8. loan_supply ──
650
+ api.registerTool({
651
+ name: 'loan_supply',
652
+ description: 'Supply underlying tokens to LOAN Protocol to earn interest. Transfers tokens to lending.loan with "mint" memo, which mints L-tokens and deposits them as collateral. You must enter the market first.',
653
+ parameters: {
654
+ type: 'object',
655
+ required: ['market_symbol', 'amount', 'confirmed'],
656
+ properties: {
657
+ market_symbol: { type: 'string', description: 'L-token market symbol, e.g. "LBTC"' },
658
+ amount: { type: 'number', description: 'Amount of underlying tokens to supply' },
659
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
660
+ },
661
+ },
662
+ handler: async ({ market_symbol, amount, confirmed }: {
663
+ market_symbol: string; amount: number; confirmed?: boolean;
664
+ }) => {
665
+ if (!confirmed) {
666
+ return {
667
+ error: 'Confirmation required. Set confirmed=true to supply tokens.',
668
+ market_symbol, amount,
669
+ };
670
+ }
671
+ if (!market_symbol) return { error: 'market_symbol is required (e.g. "LBTC")' };
672
+ if (!amount || amount <= 0) return { error: 'amount must be positive' };
673
+
674
+ try {
675
+ // Look up market to find underlying token contract and precision
676
+ const markets = await getTableRows(rpcEndpoint, {
677
+ code: LENDING_CONTRACT, scope: LENDING_CONTRACT, table: 'markets', limit: 50,
678
+ });
679
+
680
+ const market = markets.find((m: any) => {
681
+ const share = parseExtSym(m.share_symbol);
682
+ return share?.symbol === market_symbol.toUpperCase();
683
+ });
684
+
685
+ if (!market) {
686
+ const available = markets.map((m: any) => parseExtSym(m.share_symbol)?.symbol).filter(Boolean);
687
+ return { error: `Market "${market_symbol}" not found. Available: ${available.join(', ')}` };
688
+ }
689
+
690
+ const underlying = parseExtSym(market.underlying_symbol);
691
+ if (!underlying) return { error: 'Could not parse underlying token info from market' };
692
+
693
+ const quantity = formatAsset(amount, underlying.precision, underlying.symbol);
694
+
695
+ const { api: eosApi, account, permission } = await getLendingSession();
696
+
697
+ const result = await eosApi.transact({
698
+ actions: [{
699
+ account: underlying.contract,
700
+ name: 'transfer',
701
+ authorization: [{ actor: account, permission }],
702
+ data: {
703
+ from: account,
704
+ to: LENDING_CONTRACT,
705
+ quantity,
706
+ memo: 'mint',
707
+ },
708
+ }],
709
+ }, { blocksBehind: 3, expireSeconds: 30 });
710
+
711
+ return {
712
+ transaction_id: result.transaction_id || result.processed?.id,
713
+ action: 'supply (mint)',
714
+ market: market_symbol.toUpperCase(),
715
+ quantity,
716
+ underlying_contract: underlying.contract,
717
+ account,
718
+ note: 'L-tokens minted and deposited as collateral automatically.',
719
+ };
720
+ } catch (err: any) {
721
+ return { error: `Failed to supply: ${err.message}` };
722
+ }
723
+ },
724
+ });
725
+
726
+ // ── 9. loan_borrow ──
727
+ api.registerTool({
728
+ name: 'loan_borrow',
729
+ description: 'Borrow underlying tokens from LOAN Protocol against deposited collateral. Ensure your collateral value covers the new borrow — borrowing at the limit risks liquidation.',
730
+ parameters: {
731
+ type: 'object',
732
+ required: ['market_symbol', 'amount', 'confirmed'],
733
+ properties: {
734
+ market_symbol: { type: 'string', description: 'L-token market symbol to borrow from, e.g. "LUSDC"' },
735
+ amount: { type: 'number', description: 'Amount of underlying tokens to borrow' },
736
+ use_stable_rate: { type: 'boolean', description: 'Use stable (fixed) rate loan. Default false (variable rate).' },
737
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
738
+ },
739
+ },
740
+ handler: async ({ market_symbol, amount, use_stable_rate, confirmed }: {
741
+ market_symbol: string; amount: number; use_stable_rate?: boolean; confirmed?: boolean;
742
+ }) => {
743
+ if (!confirmed) {
744
+ return {
745
+ error: 'Confirmation required. Set confirmed=true to borrow. WARNING: Borrowing close to the collateral factor risks liquidation.',
746
+ market_symbol, amount, use_stable_rate: use_stable_rate || false,
747
+ };
748
+ }
749
+ if (!market_symbol) return { error: 'market_symbol is required (e.g. "LUSDC")' };
750
+ if (!amount || amount <= 0) return { error: 'amount must be positive' };
751
+
752
+ try {
753
+ const markets = await getTableRows(rpcEndpoint, {
754
+ code: LENDING_CONTRACT, scope: LENDING_CONTRACT, table: 'markets', limit: 50,
755
+ });
756
+
757
+ const market = markets.find((m: any) => {
758
+ const share = parseExtSym(m.share_symbol);
759
+ return share?.symbol === market_symbol.toUpperCase();
760
+ });
761
+
762
+ if (!market) {
763
+ const available = markets.map((m: any) => parseExtSym(m.share_symbol)?.symbol).filter(Boolean);
764
+ return { error: `Market "${market_symbol}" not found. Available: ${available.join(', ')}` };
765
+ }
766
+
767
+ const underlying = parseExtSym(market.underlying_symbol);
768
+ if (!underlying) return { error: 'Could not parse underlying token info' };
769
+
770
+ if (use_stable_rate && !market.stable_loans_enabled) {
771
+ return { error: `Stable loans are not enabled for market ${market_symbol}. Use variable rate.` };
772
+ }
773
+
774
+ const quantity = formatAsset(amount, underlying.precision, underlying.symbol);
775
+
776
+ const { api: eosApi, account, permission } = await getLendingSession();
777
+
778
+ const result = await eosApi.transact({
779
+ actions: [{
780
+ account: LENDING_CONTRACT,
781
+ name: 'borrow',
782
+ authorization: [{ actor: account, permission }],
783
+ data: {
784
+ borrower: account,
785
+ underlying: {
786
+ quantity,
787
+ contract: underlying.contract,
788
+ },
789
+ use_stable_rate: use_stable_rate || false,
790
+ },
791
+ }],
792
+ }, { blocksBehind: 3, expireSeconds: 30 });
793
+
794
+ return {
795
+ transaction_id: result.transaction_id || result.processed?.id,
796
+ action: 'borrow',
797
+ market: market_symbol.toUpperCase(),
798
+ quantity,
799
+ rate_type: use_stable_rate ? 'stable' : 'variable',
800
+ account,
801
+ warning: 'Monitor your collateral ratio. If it drops below the collateral factor, you may be liquidated.',
802
+ };
803
+ } catch (err: any) {
804
+ return { error: `Failed to borrow: ${err.message}` };
805
+ }
806
+ },
807
+ });
808
+
809
+ // ── 10. loan_repay ──
810
+ api.registerTool({
811
+ name: 'loan_repay',
812
+ description: 'Repay borrowed tokens on LOAN Protocol. Transfers underlying tokens to lending.loan with "repay" memo. Can optionally repay on behalf of another borrower.',
813
+ parameters: {
814
+ type: 'object',
815
+ required: ['market_symbol', 'amount', 'rate_type', 'confirmed'],
816
+ properties: {
817
+ market_symbol: { type: 'string', description: 'L-token market symbol, e.g. "LUSDC"' },
818
+ amount: { type: 'number', description: 'Amount of underlying tokens to repay. Overpayment is refunded.' },
819
+ rate_type: { type: 'string', description: '"variable" or "stable" — which borrow to repay' },
820
+ borrower: { type: 'string', description: 'Optional: repay on behalf of another account' },
821
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
822
+ },
823
+ },
824
+ handler: async ({ market_symbol, amount, rate_type, borrower, confirmed }: {
825
+ market_symbol: string; amount: number; rate_type: string; borrower?: string; confirmed?: boolean;
826
+ }) => {
827
+ if (!confirmed) {
828
+ return {
829
+ error: 'Confirmation required. Set confirmed=true to repay.',
830
+ market_symbol, amount, rate_type, borrower,
831
+ };
832
+ }
833
+ if (!market_symbol) return { error: 'market_symbol is required' };
834
+ if (!amount || amount <= 0) return { error: 'amount must be positive' };
835
+ if (rate_type !== 'variable' && rate_type !== 'stable') {
836
+ return { error: 'rate_type must be "variable" or "stable"' };
837
+ }
838
+
839
+ try {
840
+ const markets = await getTableRows(rpcEndpoint, {
841
+ code: LENDING_CONTRACT, scope: LENDING_CONTRACT, table: 'markets', limit: 50,
842
+ });
843
+
844
+ const market = markets.find((m: any) => {
845
+ const share = parseExtSym(m.share_symbol);
846
+ return share?.symbol === market_symbol.toUpperCase();
847
+ });
848
+
849
+ if (!market) {
850
+ const available = markets.map((m: any) => parseExtSym(m.share_symbol)?.symbol).filter(Boolean);
851
+ return { error: `Market "${market_symbol}" not found. Available: ${available.join(', ')}` };
852
+ }
853
+
854
+ const underlying = parseExtSym(market.underlying_symbol);
855
+ if (!underlying) return { error: 'Could not parse underlying token info' };
856
+
857
+ const quantity = formatAsset(amount, underlying.precision, underlying.symbol);
858
+ let memo = `repay,${rate_type}`;
859
+ if (borrower) memo += `,${borrower}`;
860
+
861
+ const { api: eosApi, account, permission } = await getLendingSession();
862
+
863
+ const result = await eosApi.transact({
864
+ actions: [{
865
+ account: underlying.contract,
866
+ name: 'transfer',
867
+ authorization: [{ actor: account, permission }],
868
+ data: {
869
+ from: account,
870
+ to: LENDING_CONTRACT,
871
+ quantity,
872
+ memo,
873
+ },
874
+ }],
875
+ }, { blocksBehind: 3, expireSeconds: 30 });
876
+
877
+ return {
878
+ transaction_id: result.transaction_id || result.processed?.id,
879
+ action: 'repay',
880
+ market: market_symbol.toUpperCase(),
881
+ quantity,
882
+ rate_type,
883
+ borrower: borrower || account,
884
+ account,
885
+ note: 'Any overpayment is automatically refunded.',
886
+ };
887
+ } catch (err: any) {
888
+ return { error: `Failed to repay: ${err.message}` };
889
+ }
890
+ },
891
+ });
892
+
893
+ // ── 11. loan_redeem ──
894
+ api.registerTool({
895
+ name: 'loan_redeem',
896
+ description: 'Redeem deposited L-tokens for underlying tokens. Burns L-tokens and returns the equivalent underlying amount at the current exchange rate. Collateral must still cover outstanding borrows after redemption.',
897
+ parameters: {
898
+ type: 'object',
899
+ required: ['market_symbol', 'amount', 'confirmed'],
900
+ properties: {
901
+ market_symbol: { type: 'string', description: 'L-token market symbol, e.g. "LBTC"' },
902
+ amount: { type: 'number', description: 'Amount of L-tokens to redeem' },
903
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
904
+ },
905
+ },
906
+ handler: async ({ market_symbol, amount, confirmed }: {
907
+ market_symbol: string; amount: number; confirmed?: boolean;
908
+ }) => {
909
+ if (!confirmed) {
910
+ return {
911
+ error: 'Confirmation required. Set confirmed=true to redeem L-tokens.',
912
+ market_symbol, amount,
913
+ };
914
+ }
915
+ if (!market_symbol) return { error: 'market_symbol is required' };
916
+ if (!amount || amount <= 0) return { error: 'amount must be positive' };
917
+
918
+ try {
919
+ const markets = await getTableRows(rpcEndpoint, {
920
+ code: LENDING_CONTRACT, scope: LENDING_CONTRACT, table: 'markets', limit: 50,
921
+ });
922
+
923
+ const market = markets.find((m: any) => {
924
+ const share = parseExtSym(m.share_symbol);
925
+ return share?.symbol === market_symbol.toUpperCase();
926
+ });
927
+
928
+ if (!market) {
929
+ const available = markets.map((m: any) => parseExtSym(m.share_symbol)?.symbol).filter(Boolean);
930
+ return { error: `Market "${market_symbol}" not found. Available: ${available.join(', ')}` };
931
+ }
932
+
933
+ const share = parseExtSym(market.share_symbol);
934
+ if (!share) return { error: 'Could not parse share token info' };
935
+
936
+ const quantity = formatAsset(amount, share.precision, share.symbol);
937
+
938
+ const { api: eosApi, account, permission } = await getLendingSession();
939
+
940
+ const result = await eosApi.transact({
941
+ actions: [{
942
+ account: LENDING_CONTRACT,
943
+ name: 'redeem',
944
+ authorization: [{ actor: account, permission }],
945
+ data: {
946
+ redeemer: account,
947
+ token: {
948
+ quantity,
949
+ contract: share.contract,
950
+ },
951
+ },
952
+ }],
953
+ }, { blocksBehind: 3, expireSeconds: 30 });
954
+
955
+ return {
956
+ transaction_id: result.transaction_id || result.processed?.id,
957
+ action: 'redeem',
958
+ market: market_symbol.toUpperCase(),
959
+ quantity_redeemed: quantity,
960
+ account,
961
+ note: 'Underlying tokens returned at current exchange rate. Interest earned is included.',
962
+ };
963
+ } catch (err: any) {
964
+ return { error: `Failed to redeem: ${err.message}` };
965
+ }
966
+ },
967
+ });
968
+
969
+ // ── 12. loan_withdraw_collateral ──
970
+ api.registerTool({
971
+ name: 'loan_withdraw_collateral',
972
+ description: 'Withdraw L-tokens from collateral without burning them. Reduces borrowing capacity. Collateral must still cover outstanding borrows after withdrawal.',
973
+ parameters: {
974
+ type: 'object',
975
+ required: ['market_symbol', 'amount', 'confirmed'],
976
+ properties: {
977
+ market_symbol: { type: 'string', description: 'L-token market symbol, e.g. "LBTC"' },
978
+ amount: { type: 'number', description: 'Amount of L-tokens to withdraw from collateral' },
979
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
980
+ },
981
+ },
982
+ handler: async ({ market_symbol, amount, confirmed }: {
983
+ market_symbol: string; amount: number; confirmed?: boolean;
984
+ }) => {
985
+ if (!confirmed) {
986
+ return {
987
+ error: 'Confirmation required. Set confirmed=true to withdraw collateral.',
988
+ market_symbol, amount,
989
+ };
990
+ }
991
+ if (!market_symbol) return { error: 'market_symbol is required' };
992
+ if (!amount || amount <= 0) return { error: 'amount must be positive' };
993
+
994
+ try {
995
+ const markets = await getTableRows(rpcEndpoint, {
996
+ code: LENDING_CONTRACT, scope: LENDING_CONTRACT, table: 'markets', limit: 50,
997
+ });
998
+
999
+ const market = markets.find((m: any) => {
1000
+ const share = parseExtSym(m.share_symbol);
1001
+ return share?.symbol === market_symbol.toUpperCase();
1002
+ });
1003
+
1004
+ if (!market) {
1005
+ const available = markets.map((m: any) => parseExtSym(m.share_symbol)?.symbol).filter(Boolean);
1006
+ return { error: `Market "${market_symbol}" not found. Available: ${available.join(', ')}` };
1007
+ }
1008
+
1009
+ const share = parseExtSym(market.share_symbol);
1010
+ if (!share) return { error: 'Could not parse share token info' };
1011
+
1012
+ const quantity = formatAsset(amount, share.precision, share.symbol);
1013
+
1014
+ const { api: eosApi, account, permission } = await getLendingSession();
1015
+
1016
+ const result = await eosApi.transact({
1017
+ actions: [{
1018
+ account: LENDING_CONTRACT,
1019
+ name: 'withdraw',
1020
+ authorization: [{ actor: account, permission }],
1021
+ data: {
1022
+ withdrawer: account,
1023
+ token: {
1024
+ quantity,
1025
+ contract: share.contract,
1026
+ },
1027
+ },
1028
+ }],
1029
+ }, { blocksBehind: 3, expireSeconds: 30 });
1030
+
1031
+ return {
1032
+ transaction_id: result.transaction_id || result.processed?.id,
1033
+ action: 'withdraw_collateral',
1034
+ market: market_symbol.toUpperCase(),
1035
+ quantity_withdrawn: quantity,
1036
+ account,
1037
+ note: 'L-tokens withdrawn from collateral to your wallet. Use redeem to convert to underlying.',
1038
+ };
1039
+ } catch (err: any) {
1040
+ return { error: `Failed to withdraw collateral: ${err.message}` };
1041
+ }
1042
+ },
1043
+ });
1044
+
1045
+ // ── 13. loan_claim_rewards ──
1046
+ api.registerTool({
1047
+ name: 'loan_claim_rewards',
1048
+ description: 'Claim accrued LOAN token rewards from LOAN Protocol lending markets. Combines update.user (to accrue latest rewards) with claim action.',
1049
+ parameters: {
1050
+ type: 'object',
1051
+ required: ['markets', 'confirmed'],
1052
+ properties: {
1053
+ markets: {
1054
+ type: 'array',
1055
+ description: 'Array of L-token market symbols to claim from, e.g. ["LBTC", "LUSDC"]',
1056
+ },
1057
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
1058
+ },
1059
+ },
1060
+ handler: async ({ markets, confirmed }: { markets: string[]; confirmed?: boolean }) => {
1061
+ if (!confirmed) {
1062
+ return { error: 'Confirmation required. Set confirmed=true to claim rewards.', markets };
1063
+ }
1064
+ if (!Array.isArray(markets) || markets.length === 0) {
1065
+ return { error: 'markets must be a non-empty array of market symbols' };
1066
+ }
1067
+
1068
+ try {
1069
+ const { api: eosApi, account, permission } = await getLendingSession();
1070
+ const marketSymbols = markets.map(m => m.toUpperCase());
1071
+
1072
+ // Combine update.user + claim in one transaction for up-to-date rewards
1073
+ const result = await eosApi.transact({
1074
+ actions: [
1075
+ {
1076
+ account: LENDING_CONTRACT,
1077
+ name: 'update.user',
1078
+ authorization: [{ actor: account, permission }],
1079
+ data: { user: account },
1080
+ },
1081
+ {
1082
+ account: LENDING_CONTRACT,
1083
+ name: 'claim',
1084
+ authorization: [{ actor: account, permission }],
1085
+ data: {
1086
+ user: account,
1087
+ markets: marketSymbols,
1088
+ },
1089
+ },
1090
+ ],
1091
+ }, { blocksBehind: 3, expireSeconds: 30 });
1092
+
1093
+ return {
1094
+ transaction_id: result.transaction_id || result.processed?.id,
1095
+ action: 'claim_rewards',
1096
+ markets_claimed: marketSymbols,
1097
+ account,
1098
+ note: 'LOAN rewards sent to your account. User state updated before claiming for accurate amounts.',
1099
+ };
1100
+ } catch (err: any) {
1101
+ return { error: `Failed to claim rewards: ${err.message}` };
1102
+ }
1103
+ },
1104
+ });
1105
+ }