@xpr-agents/openclaw 0.3.1 → 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.
- package/README.md +51 -10
- package/openclaw.plugin.json +15 -1
- package/package.json +7 -4
- package/skills/code-sandbox/SKILL.md +30 -0
- package/skills/code-sandbox/dist/index.js +188 -0
- package/skills/code-sandbox/skill.json +13 -0
- package/skills/code-sandbox/src/index.ts +212 -0
- package/skills/creative/SKILL.md +32 -0
- package/skills/creative/dist/index.js +667 -0
- package/skills/creative/skill.json +13 -0
- package/skills/creative/src/index.ts +679 -0
- package/skills/defi/SKILL.md +123 -0
- package/skills/defi/dist/index.js +1745 -0
- package/skills/defi/skill.json +44 -0
- package/skills/defi/src/index.ts +1788 -0
- package/skills/defi/test-read.mjs +281 -0
- package/skills/governance/SKILL.md +69 -0
- package/skills/governance/dist/index.js +632 -0
- package/skills/governance/skill.json +21 -0
- package/skills/governance/src/index.ts +656 -0
- package/skills/governance/test-read.mjs +176 -0
- package/skills/lending/SKILL.md +63 -0
- package/skills/lending/dist/index.js +1039 -0
- package/skills/lending/skill.json +29 -0
- package/skills/lending/src/index.ts +1105 -0
- package/skills/lending/test-read.mjs +156 -0
- package/skills/nft/SKILL.md +95 -0
- package/skills/nft/dist/index.js +1520 -0
- package/skills/nft/skill.json +37 -0
- package/skills/nft/src/index.ts +1539 -0
- package/skills/shellbook/SKILL.md +59 -0
- package/skills/shellbook/dist/index.js +381 -0
- package/skills/shellbook/skill.json +29 -0
- package/skills/shellbook/src/index.ts +391 -0
- package/skills/shellbook/tsconfig.json +14 -0
- package/skills/smart-contracts/SKILL.md +128 -0
- package/skills/smart-contracts/dist/index.js +1225 -0
- package/skills/smart-contracts/skill.json +25 -0
- package/skills/smart-contracts/src/index.ts +1327 -0
- package/skills/smart-contracts/tsconfig.json +14 -0
- package/skills/structured-data/SKILL.md +36 -0
- package/skills/structured-data/dist/index.js +501 -0
- package/skills/structured-data/skill.json +13 -0
- package/skills/structured-data/src/index.ts +597 -0
- package/skills/tax/SKILL.md +109 -0
- package/skills/tax/dist/index.js +1749 -0
- package/skills/tax/skill.json +20 -0
- package/skills/tax/src/index.ts +1985 -0
- package/skills/web-scraping/SKILL.md +29 -0
- package/skills/web-scraping/dist/index.js +311 -0
- package/skills/web-scraping/skill.json +13 -0
- package/skills/web-scraping/src/index.ts +371 -0
- package/skills/xmd/SKILL.md +52 -0
- package/skills/xmd/dist/index.js +596 -0
- package/skills/xmd/skill.json +22 -0
- package/skills/xmd/src/index.ts +635 -0
- package/skills/xmd/test-read.mjs +178 -0
|
@@ -0,0 +1,1788 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeFi Skill — DEX trading, AMM swaps, OTC escrow, yield farming, and Msig proposals
|
|
3
|
+
*
|
|
4
|
+
* 30 tools total:
|
|
5
|
+
* Read-only (14): token price, markets, swap rate, pools, OHLCV, orderbook,
|
|
6
|
+
* recent trades, open orders, order/trade history, DEX balances,
|
|
7
|
+
* OTC offers, farm list, farm stakes
|
|
8
|
+
* Write (16): place/cancel DEX orders, withdraw DEX, AMM swap, add/remove liquidity,
|
|
9
|
+
* create/fill/cancel OTC, farm stake/unstake/claim,
|
|
10
|
+
* msig propose/approve/cancel, msig list
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ── Types ────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
interface ToolDef {
|
|
16
|
+
name: string;
|
|
17
|
+
description: string;
|
|
18
|
+
parameters: { type: 'object'; required?: string[]; properties: Record<string, unknown> };
|
|
19
|
+
handler: (params: any) => Promise<unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SkillApi {
|
|
23
|
+
registerTool(tool: ToolDef): void;
|
|
24
|
+
getConfig(): Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── RPC Helpers ──────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const RPC_TIMEOUT = 15000;
|
|
30
|
+
|
|
31
|
+
async function rpcPost(endpoint: string, path: string, body: unknown): Promise<any> {
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => controller.abort(), RPC_TIMEOUT);
|
|
34
|
+
try {
|
|
35
|
+
const resp = await fetch(`${endpoint}${path}`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38
|
+
body: JSON.stringify(body),
|
|
39
|
+
signal: controller.signal,
|
|
40
|
+
});
|
|
41
|
+
if (!resp.ok) {
|
|
42
|
+
const text = await resp.text().catch(() => '');
|
|
43
|
+
throw new Error(`RPC ${path} failed (${resp.status}): ${text.slice(0, 200)}`);
|
|
44
|
+
}
|
|
45
|
+
return await resp.json();
|
|
46
|
+
} finally {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function getTableRows(endpoint: string, opts: {
|
|
52
|
+
code: string; scope: string; table: string;
|
|
53
|
+
lower_bound?: string | number; upper_bound?: string | number;
|
|
54
|
+
limit?: number; key_type?: string; index_position?: string;
|
|
55
|
+
json?: boolean; reverse?: boolean;
|
|
56
|
+
}): Promise<any[]> {
|
|
57
|
+
const result = await rpcPost(endpoint, '/v1/chain/get_table_rows', {
|
|
58
|
+
json: opts.json !== false,
|
|
59
|
+
code: opts.code,
|
|
60
|
+
scope: opts.scope,
|
|
61
|
+
table: opts.table,
|
|
62
|
+
lower_bound: opts.lower_bound,
|
|
63
|
+
upper_bound: opts.upper_bound,
|
|
64
|
+
limit: opts.limit || 100,
|
|
65
|
+
key_type: opts.key_type,
|
|
66
|
+
index_position: opts.index_position,
|
|
67
|
+
reverse: opts.reverse || false,
|
|
68
|
+
});
|
|
69
|
+
return result.rows || [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Metal X API Helpers ─────────────────────────
|
|
73
|
+
|
|
74
|
+
function getMetalXBaseUrl(network: string): string {
|
|
75
|
+
return network === 'mainnet'
|
|
76
|
+
? 'https://dex.api.mainnet.metalx.com'
|
|
77
|
+
: 'https://dex.api.testnet.metalx.com';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function metalXGet(baseUrl: string, path: string): Promise<any> {
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const timer = setTimeout(() => controller.abort(), RPC_TIMEOUT);
|
|
83
|
+
try {
|
|
84
|
+
const resp = await fetch(`${baseUrl}${path}`, { signal: controller.signal });
|
|
85
|
+
if (!resp.ok) {
|
|
86
|
+
const text = await resp.text().catch(() => '');
|
|
87
|
+
throw new Error(`Metal X ${path} failed (${resp.status}): ${text.slice(0, 200)}`);
|
|
88
|
+
}
|
|
89
|
+
return await resp.json();
|
|
90
|
+
} finally {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Swap Math ────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
function parseTokenSpec(spec: string): { precision: number; symbol: string; contract: string } | null {
|
|
98
|
+
const parts = spec.split(',');
|
|
99
|
+
if (parts.length !== 3) return null;
|
|
100
|
+
const precision = parseInt(parts[0]);
|
|
101
|
+
if (isNaN(precision) || precision < 0 || precision > 18) return null;
|
|
102
|
+
return { precision, symbol: parts[1].trim(), contract: parts[2].trim() };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getPoolFee(pool: any): number {
|
|
106
|
+
if (typeof pool.fee === 'number') return pool.fee;
|
|
107
|
+
if (pool.fee && typeof pool.fee.exchange_fee === 'number') return pool.fee.exchange_fee;
|
|
108
|
+
return 30;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function calcConstantProduct(
|
|
112
|
+
amountIn: number, reserveIn: number, reserveOut: number, feeBps: number,
|
|
113
|
+
): { output: number; priceImpactPct: number } {
|
|
114
|
+
if (reserveIn <= 0 || reserveOut <= 0 || amountIn <= 0) {
|
|
115
|
+
return { output: 0, priceImpactPct: 0 };
|
|
116
|
+
}
|
|
117
|
+
const inputWithFee = amountIn * (10000 - feeBps);
|
|
118
|
+
const output = (reserveOut * inputWithFee) / (reserveIn * 10000 + inputWithFee);
|
|
119
|
+
const spotRate = reserveOut / reserveIn;
|
|
120
|
+
const effectiveRate = output / amountIn;
|
|
121
|
+
const priceImpactPct = Math.max(0, (1 - effectiveRate / spotRate) * 100);
|
|
122
|
+
return { output, priceImpactPct };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── EOSIO Name Encoding ──────────────────────────
|
|
126
|
+
|
|
127
|
+
function charToValue(c: string): number {
|
|
128
|
+
if (c === '.') return 0;
|
|
129
|
+
if (c >= '1' && c <= '5') return c.charCodeAt(0) - '1'.charCodeAt(0) + 1;
|
|
130
|
+
if (c >= 'a' && c <= 'z') return c.charCodeAt(0) - 'a'.charCodeAt(0) + 6;
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function nameToU64(name: string): string {
|
|
135
|
+
let value = BigInt(0);
|
|
136
|
+
const n = Math.min(name.length, 12);
|
|
137
|
+
for (let i = 0; i < n; i++) {
|
|
138
|
+
const c = BigInt(charToValue(name[i]));
|
|
139
|
+
if (i < 12) {
|
|
140
|
+
value |= (c & BigInt(0x1f)) << BigInt(64 - 5 * (i + 1));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (name.length > 12) {
|
|
144
|
+
const c = BigInt(charToValue(name[12]));
|
|
145
|
+
value |= c & BigInt(0x0f);
|
|
146
|
+
}
|
|
147
|
+
return value.toString();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isValidEosioName(name: string): boolean {
|
|
151
|
+
if (!name || name.length > 12) return false;
|
|
152
|
+
return /^[a-z1-5.]+$/.test(name);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Asset Formatting ─────────────────────────────
|
|
156
|
+
|
|
157
|
+
function formatAsset(amount: number, precision: number, symbol: string): string {
|
|
158
|
+
return `${amount.toFixed(precision)} ${symbol}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseAssetString(s: string): { amount: number; symbol: string; precision: number } | null {
|
|
162
|
+
const parts = s.trim().split(' ');
|
|
163
|
+
if (parts.length !== 2) return null;
|
|
164
|
+
const amount = parseFloat(parts[0]);
|
|
165
|
+
if (isNaN(amount)) return null;
|
|
166
|
+
const dotIdx = parts[0].indexOf('.');
|
|
167
|
+
const precision = dotIdx >= 0 ? parts[0].length - dotIdx - 1 : 0;
|
|
168
|
+
return { amount, symbol: parts[1], precision };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Session Factory ──────────────────────────────
|
|
172
|
+
// Backed by the proton CLI. The agent process never holds a private key —
|
|
173
|
+
// the CLI signs every transaction internally via its encrypted keychain.
|
|
174
|
+
|
|
175
|
+
let cachedSession: { api: any; account: string; permission: string } | null = null;
|
|
176
|
+
|
|
177
|
+
async function getSession(): Promise<{ api: any; account: string; permission: string }> {
|
|
178
|
+
if (cachedSession) return cachedSession;
|
|
179
|
+
|
|
180
|
+
const account = process.env.XPR_ACCOUNT;
|
|
181
|
+
const permission = process.env.XPR_PERMISSION || 'active';
|
|
182
|
+
const rpcEndpoint = process.env.XPR_RPC_ENDPOINT;
|
|
183
|
+
|
|
184
|
+
if (!account) throw new Error('XPR_ACCOUNT is required for write operations');
|
|
185
|
+
|
|
186
|
+
// @ts-ignore — provided by host at runtime; not resolvable when building skills inside the openclaw package
|
|
187
|
+
|
|
188
|
+
const { createCliApi } = await import('@xpr-agents/openclaw');
|
|
189
|
+
cachedSession = createCliApi({ account, permission, rpcEndpoint });
|
|
190
|
+
return cachedSession;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── DEX Market Cache ─────────────────────────────
|
|
194
|
+
|
|
195
|
+
let marketsCache: { data: any[]; ts: number } | null = null;
|
|
196
|
+
const MARKETS_CACHE_TTL = 60000; // 1 minute
|
|
197
|
+
|
|
198
|
+
async function fetchMarkets(metalXBase: string): Promise<any[]> {
|
|
199
|
+
if (marketsCache && Date.now() - marketsCache.ts < MARKETS_CACHE_TTL) {
|
|
200
|
+
return marketsCache.data;
|
|
201
|
+
}
|
|
202
|
+
const result = await metalXGet(metalXBase, '/dex/v1/markets/all');
|
|
203
|
+
const markets = Array.isArray(result) ? result : (result.data || []);
|
|
204
|
+
marketsCache = { data: markets, ts: Date.now() };
|
|
205
|
+
return markets;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function findMarket(metalXBase: string, symbol: string): Promise<any | null> {
|
|
209
|
+
const markets = await fetchMarkets(metalXBase);
|
|
210
|
+
const normalized = symbol.toUpperCase().replace(/[-\/]/g, '_');
|
|
211
|
+
return markets.find((m: any) => (m.symbol || '').toUpperCase() === normalized) || null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Skill Entry Point ────────────────────────────
|
|
215
|
+
|
|
216
|
+
export default function defiSkill(api: SkillApi): void {
|
|
217
|
+
const config = api.getConfig();
|
|
218
|
+
const rpcEndpoint = (config.rpcEndpoint as string) || process.env.XPR_RPC_ENDPOINT || '';
|
|
219
|
+
const network = (config.network as string) || process.env.XPR_NETWORK || 'testnet';
|
|
220
|
+
const metalXBase = getMetalXBaseUrl(network);
|
|
221
|
+
|
|
222
|
+
// ════════════════════════════════════════════════
|
|
223
|
+
// READ-ONLY DEX TOOLS
|
|
224
|
+
// ════════════════════════════════════════════════
|
|
225
|
+
|
|
226
|
+
// ── 1. defi_get_token_price ──
|
|
227
|
+
api.registerTool({
|
|
228
|
+
name: 'defi_get_token_price',
|
|
229
|
+
description: 'Get 24h price data for a trading pair on Metal X DEX. Returns open/high/low/close, volume, and percentage change. Symbol format: "BASE_QUOTE" e.g. "XPR_XMD", "XBTC_XMD".',
|
|
230
|
+
parameters: {
|
|
231
|
+
type: 'object',
|
|
232
|
+
required: ['symbol'],
|
|
233
|
+
properties: {
|
|
234
|
+
symbol: { type: 'string', description: 'Trading pair symbol, e.g. "XPR_XMD"' },
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
handler: async ({ symbol }: { symbol: string }) => {
|
|
238
|
+
if (!symbol || typeof symbol !== 'string') {
|
|
239
|
+
return { error: 'symbol parameter is required (e.g. "XPR_XMD")' };
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const data = await metalXGet(metalXBase, '/dex/v1/trades/daily');
|
|
243
|
+
const markets: any[] = Array.isArray(data) ? data : (data.data || []);
|
|
244
|
+
const normalized = symbol.toUpperCase().replace(/[-\/]/g, '_');
|
|
245
|
+
const match = markets.find((m: any) => (m.symbol || '').toUpperCase() === normalized);
|
|
246
|
+
if (!match) {
|
|
247
|
+
return { error: `Market "${symbol}" not found. Use defi_list_markets to see available pairs.` };
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
symbol: match.symbol,
|
|
251
|
+
open: match.open,
|
|
252
|
+
high: match.high,
|
|
253
|
+
low: match.low,
|
|
254
|
+
close: match.close,
|
|
255
|
+
volume_bid: match.volume_bid,
|
|
256
|
+
volume_ask: match.volume_ask,
|
|
257
|
+
change_24h_pct: match.change_percentage,
|
|
258
|
+
};
|
|
259
|
+
} catch (err: any) {
|
|
260
|
+
return { error: `Failed to fetch price: ${err.message}` };
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ── 2. defi_list_markets ──
|
|
266
|
+
api.registerTool({
|
|
267
|
+
name: 'defi_list_markets',
|
|
268
|
+
description: 'List all trading pairs on Metal X DEX with fees and token info.',
|
|
269
|
+
parameters: {
|
|
270
|
+
type: 'object',
|
|
271
|
+
properties: {},
|
|
272
|
+
},
|
|
273
|
+
handler: async () => {
|
|
274
|
+
try {
|
|
275
|
+
const markets = await fetchMarkets(metalXBase);
|
|
276
|
+
return {
|
|
277
|
+
markets: markets.map((m: any) => ({
|
|
278
|
+
market_id: m.market_id,
|
|
279
|
+
symbol: m.symbol,
|
|
280
|
+
type: m.type || 'spot',
|
|
281
|
+
maker_fee_pct: m.maker_fee,
|
|
282
|
+
taker_fee_pct: m.taker_fee,
|
|
283
|
+
order_min: m.order_min,
|
|
284
|
+
bid_token: m.bid_token,
|
|
285
|
+
ask_token: m.ask_token,
|
|
286
|
+
})),
|
|
287
|
+
total: markets.length,
|
|
288
|
+
};
|
|
289
|
+
} catch (err: any) {
|
|
290
|
+
return { error: `Failed to list markets: ${err.message}` };
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ── 3. defi_get_swap_rate ──
|
|
296
|
+
api.registerTool({
|
|
297
|
+
name: 'defi_get_swap_rate',
|
|
298
|
+
description: 'Calculate AMM swap rate on proton.swaps WITHOUT executing. Token format: "PRECISION,SYMBOL,CONTRACT" (e.g. "4,XPR,eosio.token", "6,XUSDC,xtokens"). Returns expected output, rate, and price impact.',
|
|
299
|
+
parameters: {
|
|
300
|
+
type: 'object',
|
|
301
|
+
required: ['from_token', 'to_token', 'amount'],
|
|
302
|
+
properties: {
|
|
303
|
+
from_token: { type: 'string', description: 'Input token: "PRECISION,SYMBOL,CONTRACT"' },
|
|
304
|
+
to_token: { type: 'string', description: 'Output token: "PRECISION,SYMBOL,CONTRACT"' },
|
|
305
|
+
amount: { type: 'number', description: 'Amount of input token to swap' },
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
handler: async ({ from_token, to_token, amount }: {
|
|
309
|
+
from_token: string; to_token: string; amount: number;
|
|
310
|
+
}) => {
|
|
311
|
+
const fromSpec = parseTokenSpec(from_token);
|
|
312
|
+
const toSpec = parseTokenSpec(to_token);
|
|
313
|
+
if (!fromSpec) return { error: 'Invalid from_token. Use "PRECISION,SYMBOL,CONTRACT"' };
|
|
314
|
+
if (!toSpec) return { error: 'Invalid to_token. Use "PRECISION,SYMBOL,CONTRACT"' };
|
|
315
|
+
if (!amount || amount <= 0) return { error: 'amount must be positive' };
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const pools = await getTableRows(rpcEndpoint, {
|
|
319
|
+
code: 'proton.swaps', scope: 'proton.swaps', table: 'pools', limit: 200,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const matchPool = pools.find((p: any) => {
|
|
323
|
+
const sym1 = ((p.pool1?.quantity || '') as string).split(' ')[1] || '';
|
|
324
|
+
const sym2 = ((p.pool2?.quantity || '') as string).split(' ')[1] || '';
|
|
325
|
+
const c1 = p.pool1?.contract || '';
|
|
326
|
+
const c2 = p.pool2?.contract || '';
|
|
327
|
+
return (
|
|
328
|
+
(sym1 === fromSpec.symbol && c1 === fromSpec.contract &&
|
|
329
|
+
sym2 === toSpec.symbol && c2 === toSpec.contract) ||
|
|
330
|
+
(sym2 === fromSpec.symbol && c2 === fromSpec.contract &&
|
|
331
|
+
sym1 === toSpec.symbol && c1 === toSpec.contract)
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
if (!matchPool) {
|
|
336
|
+
return { error: `No pool for ${fromSpec.symbol}/${toSpec.symbol}. Use defi_list_pools.` };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const sym1 = ((matchPool.pool1?.quantity || '') as string).split(' ')[1] || '';
|
|
340
|
+
const isForward = sym1 === fromSpec.symbol;
|
|
341
|
+
const reserveIn = parseFloat((isForward ? matchPool.pool1 : matchPool.pool2)?.quantity || '0');
|
|
342
|
+
const reserveOut = parseFloat((isForward ? matchPool.pool2 : matchPool.pool1)?.quantity || '0');
|
|
343
|
+
const feeBps = getPoolFee(matchPool);
|
|
344
|
+
const { output, priceImpactPct } = calcConstantProduct(amount, reserveIn, reserveOut, feeBps);
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
input: `${amount} ${fromSpec.symbol}`,
|
|
348
|
+
output: `${output.toFixed(toSpec.precision)} ${toSpec.symbol}`,
|
|
349
|
+
rate: output > 0 ? (output / amount).toFixed(8) : '0',
|
|
350
|
+
price_impact_pct: priceImpactPct.toFixed(4),
|
|
351
|
+
pool: matchPool.lt_symbol || `${fromSpec.symbol}/${toSpec.symbol}`,
|
|
352
|
+
fee_pct: (feeBps / 100).toFixed(2),
|
|
353
|
+
amplifier: matchPool.amplifier || 0,
|
|
354
|
+
note: matchPool.amplifier > 0 ? 'StableSwap pool — actual output may differ from constant-product estimate' : undefined,
|
|
355
|
+
reserve_in: reserveIn,
|
|
356
|
+
reserve_out: reserveOut,
|
|
357
|
+
};
|
|
358
|
+
} catch (err: any) {
|
|
359
|
+
return { error: `Failed to calc swap rate: ${err.message}` };
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ── 4. defi_list_pools ──
|
|
365
|
+
api.registerTool({
|
|
366
|
+
name: 'defi_list_pools',
|
|
367
|
+
description: 'List AMM liquidity pools on proton.swaps with reserves, fees, and pool type.',
|
|
368
|
+
parameters: {
|
|
369
|
+
type: 'object',
|
|
370
|
+
properties: {
|
|
371
|
+
active_only: { type: 'boolean', description: 'Only active pools (default true)' },
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
handler: async ({ active_only }: { active_only?: boolean }) => {
|
|
375
|
+
try {
|
|
376
|
+
const pools = await getTableRows(rpcEndpoint, {
|
|
377
|
+
code: 'proton.swaps', scope: 'proton.swaps', table: 'pools', limit: 200,
|
|
378
|
+
});
|
|
379
|
+
const result = pools
|
|
380
|
+
.filter((p: any) => active_only === false || p.active !== false)
|
|
381
|
+
.map((p: any) => ({
|
|
382
|
+
lt_symbol: p.lt_symbol || null,
|
|
383
|
+
memo: p.memo || null,
|
|
384
|
+
token1: { quantity: p.pool1?.quantity || '0', contract: p.pool1?.contract || '' },
|
|
385
|
+
token2: { quantity: p.pool2?.quantity || '0', contract: p.pool2?.contract || '' },
|
|
386
|
+
fee_pct: (getPoolFee(p) / 100).toFixed(2),
|
|
387
|
+
amplifier: p.amplifier || 0,
|
|
388
|
+
pool_type: (p.amplifier || 0) > 0 ? 'stableswap' : 'constant-product',
|
|
389
|
+
}));
|
|
390
|
+
return { pools: result, total: result.length };
|
|
391
|
+
} catch (err: any) {
|
|
392
|
+
return { error: `Failed to list pools: ${err.message}` };
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ── 5. defi_get_ohlcv ──
|
|
398
|
+
api.registerTool({
|
|
399
|
+
name: 'defi_get_ohlcv',
|
|
400
|
+
description: 'Get OHLCV candlestick data for a trading pair. Intervals: "15", "30", "60" (minutes), "1D", "1W", "1M".',
|
|
401
|
+
parameters: {
|
|
402
|
+
type: 'object',
|
|
403
|
+
required: ['symbol', 'interval'],
|
|
404
|
+
properties: {
|
|
405
|
+
symbol: { type: 'string', description: 'Trading pair, e.g. "XPR_XMD"' },
|
|
406
|
+
interval: { type: 'string', description: 'Candle interval: "15","30","60","1D","1W","1M"' },
|
|
407
|
+
from: { type: 'string', description: 'Start date ISO (default: 30 days ago)' },
|
|
408
|
+
to: { type: 'string', description: 'End date ISO (default: now)' },
|
|
409
|
+
limit: { type: 'number', description: 'Max candles (default 100, max 500)' },
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
handler: async ({ symbol, interval, from, to, limit }: {
|
|
413
|
+
symbol: string; interval: string; from?: string; to?: string; limit?: number;
|
|
414
|
+
}) => {
|
|
415
|
+
if (!symbol) return { error: 'symbol is required' };
|
|
416
|
+
if (!interval) return { error: 'interval is required (15, 30, 60, 1D, 1W, 1M)' };
|
|
417
|
+
try {
|
|
418
|
+
const now = new Date();
|
|
419
|
+
const defaultFrom = new Date(now.getTime() - 30 * 86400000);
|
|
420
|
+
const fromStr = from || defaultFrom.toISOString();
|
|
421
|
+
const toStr = to || now.toISOString();
|
|
422
|
+
const lim = Math.min(limit || 100, 500);
|
|
423
|
+
|
|
424
|
+
const params = `symbol=${encodeURIComponent(symbol)}&interval=${encodeURIComponent(interval)}&from=${encodeURIComponent(fromStr)}&to=${encodeURIComponent(toStr)}&limit=${lim}`;
|
|
425
|
+
const data = await metalXGet(metalXBase, `/dex/v1/chart/ohlcv?${params}`);
|
|
426
|
+
const candles: any[] = Array.isArray(data) ? data : (data.data || []);
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
symbol,
|
|
430
|
+
interval,
|
|
431
|
+
candles: candles.map((c: any) => ({
|
|
432
|
+
time: c.time,
|
|
433
|
+
open: c.open,
|
|
434
|
+
high: c.high,
|
|
435
|
+
low: c.low,
|
|
436
|
+
close: c.close,
|
|
437
|
+
volume: c.volume,
|
|
438
|
+
volume_bid: c.volume_bid,
|
|
439
|
+
count: c.count,
|
|
440
|
+
})),
|
|
441
|
+
total: candles.length,
|
|
442
|
+
};
|
|
443
|
+
} catch (err: any) {
|
|
444
|
+
return { error: `Failed to fetch OHLCV: ${err.message}` };
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// ── 6. defi_get_orderbook ──
|
|
450
|
+
api.registerTool({
|
|
451
|
+
name: 'defi_get_orderbook',
|
|
452
|
+
description: 'Get orderbook depth (bids and asks) for a trading pair. The step parameter controls price grouping precision.',
|
|
453
|
+
parameters: {
|
|
454
|
+
type: 'object',
|
|
455
|
+
required: ['symbol'],
|
|
456
|
+
properties: {
|
|
457
|
+
symbol: { type: 'string', description: 'Trading pair, e.g. "XPR_XMD"' },
|
|
458
|
+
step: { type: 'number', description: 'Price grouping step (1/precision). E.g. 1000 for 0.001 precision. Default: 10000' },
|
|
459
|
+
limit: { type: 'number', description: 'Max depth levels (default 20)' },
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
handler: async ({ symbol, step, limit }: { symbol: string; step?: number; limit?: number }) => {
|
|
463
|
+
if (!symbol) return { error: 'symbol is required' };
|
|
464
|
+
try {
|
|
465
|
+
const s = step || 10000;
|
|
466
|
+
const l = limit || 20;
|
|
467
|
+
const params = `symbol=${encodeURIComponent(symbol)}&step=${s}&limit=${l}`;
|
|
468
|
+
const data = await metalXGet(metalXBase, `/dex/v1/orders/depth?${params}`);
|
|
469
|
+
const depth = data.data || data;
|
|
470
|
+
return {
|
|
471
|
+
symbol,
|
|
472
|
+
step: s,
|
|
473
|
+
bids: (depth.bids || []).slice(0, l),
|
|
474
|
+
asks: (depth.asks || []).slice(0, l),
|
|
475
|
+
};
|
|
476
|
+
} catch (err: any) {
|
|
477
|
+
return { error: `Failed to fetch orderbook: ${err.message}` };
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// ── 7. defi_get_recent_trades ──
|
|
483
|
+
api.registerTool({
|
|
484
|
+
name: 'defi_get_recent_trades',
|
|
485
|
+
description: 'Get recent trades for a trading pair on Metal X DEX.',
|
|
486
|
+
parameters: {
|
|
487
|
+
type: 'object',
|
|
488
|
+
required: ['symbol'],
|
|
489
|
+
properties: {
|
|
490
|
+
symbol: { type: 'string', description: 'Trading pair, e.g. "XPR_XMD"' },
|
|
491
|
+
limit: { type: 'number', description: 'Max trades (default 20)' },
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
handler: async ({ symbol, limit }: { symbol: string; limit?: number }) => {
|
|
495
|
+
if (!symbol) return { error: 'symbol is required' };
|
|
496
|
+
try {
|
|
497
|
+
const l = limit || 20;
|
|
498
|
+
const params = `symbol=${encodeURIComponent(symbol)}&limit=${l}`;
|
|
499
|
+
const data = await metalXGet(metalXBase, `/dex/v1/trades/recent?${params}`);
|
|
500
|
+
const trades: any[] = Array.isArray(data) ? data : (data.data || []);
|
|
501
|
+
return {
|
|
502
|
+
symbol,
|
|
503
|
+
trades: trades.map((t: any) => ({
|
|
504
|
+
trade_id: t.trade_id,
|
|
505
|
+
price: t.price,
|
|
506
|
+
bid_amount: t.bid_total,
|
|
507
|
+
ask_amount: t.ask_total,
|
|
508
|
+
bid_user: t.bid_user,
|
|
509
|
+
ask_user: t.ask_user,
|
|
510
|
+
side: t.order_side === 1 ? 'buy' : 'sell',
|
|
511
|
+
time: t.block_time,
|
|
512
|
+
trx_id: t.trx_id,
|
|
513
|
+
})),
|
|
514
|
+
total: trades.length,
|
|
515
|
+
};
|
|
516
|
+
} catch (err: any) {
|
|
517
|
+
return { error: `Failed to fetch recent trades: ${err.message}` };
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// ── 8. defi_get_open_orders ──
|
|
523
|
+
api.registerTool({
|
|
524
|
+
name: 'defi_get_open_orders',
|
|
525
|
+
description: 'Get open orders on Metal X DEX for a specific account.',
|
|
526
|
+
parameters: {
|
|
527
|
+
type: 'object',
|
|
528
|
+
required: ['account'],
|
|
529
|
+
properties: {
|
|
530
|
+
account: { type: 'string', description: 'Account name' },
|
|
531
|
+
symbol: { type: 'string', description: 'Filter by trading pair (optional)' },
|
|
532
|
+
limit: { type: 'number', description: 'Max results (default 50)' },
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
handler: async ({ account, symbol, limit }: { account: string; symbol?: string; limit?: number }) => {
|
|
536
|
+
if (!account) return { error: 'account is required' };
|
|
537
|
+
try {
|
|
538
|
+
let params = `account=${encodeURIComponent(account)}&limit=${limit || 50}`;
|
|
539
|
+
if (symbol) params += `&symbol=${encodeURIComponent(symbol)}`;
|
|
540
|
+
const data = await metalXGet(metalXBase, `/dex/v1/orders/open?${params}`);
|
|
541
|
+
const orders: any[] = Array.isArray(data) ? data : (data.data || []);
|
|
542
|
+
return {
|
|
543
|
+
account,
|
|
544
|
+
orders: orders.map((o: any) => ({
|
|
545
|
+
order_id: o.order_id,
|
|
546
|
+
ordinal_order_id: o.ordinal_order_id,
|
|
547
|
+
market_id: o.market_id,
|
|
548
|
+
side: o.order_side === 1 ? 'buy' : 'sell',
|
|
549
|
+
type: o.order_type === 1 ? 'limit' : o.order_type === 2 ? 'stop_loss' : o.order_type === 3 ? 'take_profit' : `type_${o.order_type}`,
|
|
550
|
+
price: o.price,
|
|
551
|
+
quantity_init: o.quantity_init,
|
|
552
|
+
quantity_curr: o.quantity_curr,
|
|
553
|
+
filled_total: o.filled_total,
|
|
554
|
+
filled_amount: o.filled_amount,
|
|
555
|
+
filled_fee: o.filled_fee,
|
|
556
|
+
fill_type: o.fill_type === 0 ? 'GTC' : o.fill_type === 1 ? 'IOC' : 'POST_ONLY',
|
|
557
|
+
status: o.status,
|
|
558
|
+
created_at: o.created_at,
|
|
559
|
+
})),
|
|
560
|
+
total: data.count || orders.length,
|
|
561
|
+
};
|
|
562
|
+
} catch (err: any) {
|
|
563
|
+
return { error: `Failed to fetch open orders: ${err.message}` };
|
|
564
|
+
}
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
// ── 9. defi_get_order_history ──
|
|
569
|
+
api.registerTool({
|
|
570
|
+
name: 'defi_get_order_history',
|
|
571
|
+
description: 'Get order history on Metal X DEX for a specific account.',
|
|
572
|
+
parameters: {
|
|
573
|
+
type: 'object',
|
|
574
|
+
required: ['account'],
|
|
575
|
+
properties: {
|
|
576
|
+
account: { type: 'string', description: 'Account name' },
|
|
577
|
+
symbol: { type: 'string', description: 'Filter by trading pair (optional)' },
|
|
578
|
+
status: { type: 'string', description: 'Filter by status: "create","fill","pfill","cancel" (optional)' },
|
|
579
|
+
limit: { type: 'number', description: 'Max results (default 50)' },
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
handler: async ({ account, symbol, status, limit }: {
|
|
583
|
+
account: string; symbol?: string; status?: string; limit?: number;
|
|
584
|
+
}) => {
|
|
585
|
+
if (!account) return { error: 'account is required' };
|
|
586
|
+
try {
|
|
587
|
+
let params = `account=${encodeURIComponent(account)}&limit=${limit || 50}`;
|
|
588
|
+
if (symbol) params += `&symbol=${encodeURIComponent(symbol)}`;
|
|
589
|
+
if (status) params += `&status=${encodeURIComponent(status)}`;
|
|
590
|
+
const data = await metalXGet(metalXBase, `/dex/v1/orders/history?${params}`);
|
|
591
|
+
const orders: any[] = Array.isArray(data) ? data : (data.data || []);
|
|
592
|
+
return {
|
|
593
|
+
account,
|
|
594
|
+
orders: orders.map((o: any) => ({
|
|
595
|
+
order_id: o.order_id,
|
|
596
|
+
market_id: o.market_id,
|
|
597
|
+
side: o.order_side === 1 ? 'buy' : 'sell',
|
|
598
|
+
type: o.order_type === 1 ? 'limit' : o.order_type === 2 ? 'stop_loss' : o.order_type === 3 ? 'take_profit' : `type_${o.order_type}`,
|
|
599
|
+
price: o.price,
|
|
600
|
+
quantity_init: o.quantity_init,
|
|
601
|
+
quantity_curr: o.quantity_curr,
|
|
602
|
+
filled_total: o.filled_total,
|
|
603
|
+
filled_amount: o.filled_amount,
|
|
604
|
+
filled_fee: o.filled_fee,
|
|
605
|
+
status: o.status,
|
|
606
|
+
trx_id: o.trx_id,
|
|
607
|
+
block_time: o.block_time,
|
|
608
|
+
})),
|
|
609
|
+
total: data.count || orders.length,
|
|
610
|
+
};
|
|
611
|
+
} catch (err: any) {
|
|
612
|
+
return { error: `Failed to fetch order history: ${err.message}` };
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// ── 10. defi_get_trade_history ──
|
|
618
|
+
api.registerTool({
|
|
619
|
+
name: 'defi_get_trade_history',
|
|
620
|
+
description: 'Get trade (fill) history on Metal X DEX for a specific account.',
|
|
621
|
+
parameters: {
|
|
622
|
+
type: 'object',
|
|
623
|
+
required: ['account'],
|
|
624
|
+
properties: {
|
|
625
|
+
account: { type: 'string', description: 'Account name' },
|
|
626
|
+
symbol: { type: 'string', description: 'Filter by trading pair (optional)' },
|
|
627
|
+
limit: { type: 'number', description: 'Max results (default 50)' },
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
handler: async ({ account, symbol, limit }: { account: string; symbol?: string; limit?: number }) => {
|
|
631
|
+
if (!account) return { error: 'account is required' };
|
|
632
|
+
try {
|
|
633
|
+
let params = `account=${encodeURIComponent(account)}&limit=${limit || 50}`;
|
|
634
|
+
if (symbol) params += `&symbol=${encodeURIComponent(symbol)}`;
|
|
635
|
+
const data = await metalXGet(metalXBase, `/dex/v1/trades/history?${params}`);
|
|
636
|
+
const trades: any[] = Array.isArray(data) ? data : (data.data || []);
|
|
637
|
+
return {
|
|
638
|
+
account,
|
|
639
|
+
trades: trades.map((t: any) => ({
|
|
640
|
+
trade_id: t.trade_id,
|
|
641
|
+
market_id: t.market_id,
|
|
642
|
+
price: t.price,
|
|
643
|
+
side: t.order_side === 1 ? 'buy' : 'sell',
|
|
644
|
+
bid_total: t.bid_total,
|
|
645
|
+
bid_amount: t.bid_amount,
|
|
646
|
+
bid_fee: t.bid_fee,
|
|
647
|
+
ask_total: t.ask_total,
|
|
648
|
+
ask_amount: t.ask_amount,
|
|
649
|
+
ask_fee: t.ask_fee,
|
|
650
|
+
trx_id: t.trx_id,
|
|
651
|
+
block_time: t.block_time,
|
|
652
|
+
})),
|
|
653
|
+
total: data.count || trades.length,
|
|
654
|
+
};
|
|
655
|
+
} catch (err: any) {
|
|
656
|
+
return { error: `Failed to fetch trade history: ${err.message}` };
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
// ── 11. defi_get_dex_balances ──
|
|
662
|
+
api.registerTool({
|
|
663
|
+
name: 'defi_get_dex_balances',
|
|
664
|
+
description: 'Get DEX exchange balances for an account (tokens deposited on Metal X for trading).',
|
|
665
|
+
parameters: {
|
|
666
|
+
type: 'object',
|
|
667
|
+
required: ['account'],
|
|
668
|
+
properties: {
|
|
669
|
+
account: { type: 'string', description: 'Account name' },
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
handler: async ({ account }: { account: string }) => {
|
|
673
|
+
if (!account) return { error: 'account is required' };
|
|
674
|
+
try {
|
|
675
|
+
const data = await metalXGet(metalXBase, `/dex/v1/account/balances?account=${encodeURIComponent(account)}`);
|
|
676
|
+
const balances = Array.isArray(data) ? data : (data.data || []);
|
|
677
|
+
return { account, balances, total: balances.length };
|
|
678
|
+
} catch (err: any) {
|
|
679
|
+
return { error: `Failed to fetch DEX balances: ${err.message}` };
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// ── 12. defi_list_otc_offers ──
|
|
685
|
+
api.registerTool({
|
|
686
|
+
name: 'defi_list_otc_offers',
|
|
687
|
+
description: 'List OTC (peer-to-peer) escrow offers on token.escrow. Shows recent offers with token pairs and expiry.',
|
|
688
|
+
parameters: {
|
|
689
|
+
type: 'object',
|
|
690
|
+
properties: {
|
|
691
|
+
limit: { type: 'number', description: 'Max results (default 20)' },
|
|
692
|
+
recent_first: { type: 'boolean', description: 'Show newest first (default true)' },
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
handler: async ({ limit, recent_first }: { limit?: number; recent_first?: boolean }) => {
|
|
696
|
+
try {
|
|
697
|
+
const rows = await getTableRows(rpcEndpoint, {
|
|
698
|
+
code: 'token.escrow', scope: 'token.escrow', table: 'escrows',
|
|
699
|
+
limit: limit || 20,
|
|
700
|
+
reverse: recent_first !== false,
|
|
701
|
+
});
|
|
702
|
+
return {
|
|
703
|
+
offers: rows.map((e: any) => ({
|
|
704
|
+
id: e.id,
|
|
705
|
+
from: e.from,
|
|
706
|
+
to: e.to || '(open — anyone can fill)',
|
|
707
|
+
from_tokens: e.fromTokens || [],
|
|
708
|
+
from_nfts: e.fromNfts || [],
|
|
709
|
+
to_tokens: e.toTokens || [],
|
|
710
|
+
to_nfts: e.toNfts || [],
|
|
711
|
+
expiry: e.expiry ? new Date(e.expiry * 1000).toISOString() : null,
|
|
712
|
+
})),
|
|
713
|
+
total: rows.length,
|
|
714
|
+
};
|
|
715
|
+
} catch (err: any) {
|
|
716
|
+
return { error: `Failed to list OTC offers: ${err.message}` };
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// ════════════════════════════════════════════════
|
|
722
|
+
// WRITE DEX TOOLS
|
|
723
|
+
// ════════════════════════════════════════════════
|
|
724
|
+
|
|
725
|
+
// ── 13. defi_place_order ──
|
|
726
|
+
api.registerTool({
|
|
727
|
+
name: 'defi_place_order',
|
|
728
|
+
description: 'Place a limit order on Metal X DEX. Deposits tokens to DEX and places the order in one transaction. Order types: limit, stop_loss, take_profit. Fill types: GTC (good-til-cancelled), IOC (immediate-or-cancel), POST_ONLY.',
|
|
729
|
+
parameters: {
|
|
730
|
+
type: 'object',
|
|
731
|
+
required: ['symbol', 'side', 'amount', 'price', 'confirmed'],
|
|
732
|
+
properties: {
|
|
733
|
+
symbol: { type: 'string', description: 'Market pair, e.g. "XPR_XMD"' },
|
|
734
|
+
side: { type: 'string', description: '"buy" or "sell"' },
|
|
735
|
+
amount: { type: 'number', description: 'Amount of bid (base) token' },
|
|
736
|
+
price: { type: 'number', description: 'Price in ask (quote) token per 1 bid token' },
|
|
737
|
+
order_type: { type: 'string', description: '"limit" (default), "stop_loss", "take_profit"' },
|
|
738
|
+
trigger_price: { type: 'number', description: 'Trigger price for stop_loss/take_profit' },
|
|
739
|
+
fill_type: { type: 'string', description: '"GTC" (default), "IOC", "POST_ONLY"' },
|
|
740
|
+
confirmed: { type: 'boolean', description: 'Must be true to execute' },
|
|
741
|
+
},
|
|
742
|
+
},
|
|
743
|
+
handler: async (params: {
|
|
744
|
+
symbol: string; side: string; amount: number; price: number;
|
|
745
|
+
order_type?: string; trigger_price?: number; fill_type?: string; confirmed?: boolean;
|
|
746
|
+
}) => {
|
|
747
|
+
if (!params.confirmed) {
|
|
748
|
+
return {
|
|
749
|
+
error: 'Confirmation required. Set confirmed=true to place this order.',
|
|
750
|
+
preview: { symbol: params.symbol, side: params.side, amount: params.amount, price: params.price },
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
if (!params.symbol) return { error: 'symbol is required' };
|
|
754
|
+
if (!params.side || !['buy', 'sell'].includes(params.side.toLowerCase())) {
|
|
755
|
+
return { error: 'side must be "buy" or "sell"' };
|
|
756
|
+
}
|
|
757
|
+
if (!params.amount || params.amount <= 0) return { error: 'amount must be positive' };
|
|
758
|
+
if (!params.price || params.price <= 0) return { error: 'price must be positive' };
|
|
759
|
+
|
|
760
|
+
try {
|
|
761
|
+
const market = await findMarket(metalXBase, params.symbol);
|
|
762
|
+
if (!market) return { error: `Market "${params.symbol}" not found` };
|
|
763
|
+
|
|
764
|
+
const bidToken = market.bid_token;
|
|
765
|
+
const askToken = market.ask_token;
|
|
766
|
+
const bidMult = bidToken.multiplier || Math.pow(10, bidToken.precision);
|
|
767
|
+
const askMult = askToken.multiplier || Math.pow(10, askToken.precision);
|
|
768
|
+
|
|
769
|
+
const orderSide = params.side.toLowerCase() === 'buy' ? 1 : 2;
|
|
770
|
+
const orderTypeMap: Record<string, number> = { limit: 1, stop_loss: 2, take_profit: 3 };
|
|
771
|
+
const orderType = orderTypeMap[(params.order_type || 'limit').toLowerCase()] || 1;
|
|
772
|
+
const fillTypeMap: Record<string, number> = { gtc: 0, ioc: 1, post_only: 2 };
|
|
773
|
+
const fillType = fillTypeMap[(params.fill_type || 'gtc').toLowerCase()] || 0;
|
|
774
|
+
|
|
775
|
+
const rawQuantity = Math.round(params.amount * bidMult);
|
|
776
|
+
const rawPrice = Math.round(params.price * askMult);
|
|
777
|
+
const triggerPrice = params.trigger_price ? Math.round(params.trigger_price * askMult) : 0;
|
|
778
|
+
|
|
779
|
+
// Calculate deposit needed
|
|
780
|
+
let depositQuantity: string;
|
|
781
|
+
let depositContract: string;
|
|
782
|
+
if (orderSide === 1) {
|
|
783
|
+
// BUY: deposit ask token (quote). Total cost = amount * price
|
|
784
|
+
const totalCost = params.amount * params.price;
|
|
785
|
+
depositQuantity = formatAsset(totalCost, askToken.precision, askToken.code);
|
|
786
|
+
depositContract = askToken.contract;
|
|
787
|
+
} else {
|
|
788
|
+
// SELL: deposit bid token (base)
|
|
789
|
+
depositQuantity = formatAsset(params.amount, bidToken.precision, bidToken.code);
|
|
790
|
+
depositContract = bidToken.contract;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const { api: eosApi, account, permission } = await getSession();
|
|
794
|
+
|
|
795
|
+
const actions: any[] = [
|
|
796
|
+
// 1. Deposit tokens to DEX
|
|
797
|
+
{
|
|
798
|
+
account: depositContract,
|
|
799
|
+
name: 'transfer',
|
|
800
|
+
authorization: [{ actor: account, permission }],
|
|
801
|
+
data: { from: account, to: 'dex', quantity: depositQuantity, memo: '' },
|
|
802
|
+
},
|
|
803
|
+
// 2. Place the order
|
|
804
|
+
{
|
|
805
|
+
account: 'dex',
|
|
806
|
+
name: 'placeorder',
|
|
807
|
+
authorization: [{ actor: account, permission }],
|
|
808
|
+
data: {
|
|
809
|
+
market_id: market.market_id,
|
|
810
|
+
account,
|
|
811
|
+
order_type: orderType,
|
|
812
|
+
order_side: orderSide,
|
|
813
|
+
quantity: rawQuantity,
|
|
814
|
+
price: rawPrice,
|
|
815
|
+
bid_symbol: { sym: `${bidToken.precision},${bidToken.code}`, contract: bidToken.contract },
|
|
816
|
+
ask_symbol: { sym: `${askToken.precision},${askToken.code}`, contract: askToken.contract },
|
|
817
|
+
trigger_price: triggerPrice,
|
|
818
|
+
fill_type: fillType,
|
|
819
|
+
},
|
|
820
|
+
},
|
|
821
|
+
];
|
|
822
|
+
|
|
823
|
+
const result = await eosApi.transact({ actions }, { blocksBehind: 3, expireSeconds: 30 });
|
|
824
|
+
return {
|
|
825
|
+
transaction_id: result.transaction_id || result.processed?.id,
|
|
826
|
+
order: {
|
|
827
|
+
market: params.symbol,
|
|
828
|
+
side: params.side,
|
|
829
|
+
amount: params.amount,
|
|
830
|
+
price: params.price,
|
|
831
|
+
type: params.order_type || 'limit',
|
|
832
|
+
fill_type: params.fill_type || 'GTC',
|
|
833
|
+
deposit: depositQuantity,
|
|
834
|
+
},
|
|
835
|
+
};
|
|
836
|
+
} catch (err: any) {
|
|
837
|
+
return { error: `Failed to place order: ${err.message}` };
|
|
838
|
+
}
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
// ── 14. defi_cancel_order ──
|
|
843
|
+
api.registerTool({
|
|
844
|
+
name: 'defi_cancel_order',
|
|
845
|
+
description: 'Cancel an open order on Metal X DEX.',
|
|
846
|
+
parameters: {
|
|
847
|
+
type: 'object',
|
|
848
|
+
required: ['order_id', 'confirmed'],
|
|
849
|
+
properties: {
|
|
850
|
+
order_id: { type: 'number', description: 'The order ID to cancel' },
|
|
851
|
+
confirmed: { type: 'boolean', description: 'Must be true to execute' },
|
|
852
|
+
},
|
|
853
|
+
},
|
|
854
|
+
handler: async ({ order_id, confirmed }: { order_id: number; confirmed?: boolean }) => {
|
|
855
|
+
if (!confirmed) return { error: 'Confirmation required. Set confirmed=true.', order_id };
|
|
856
|
+
if (!order_id) return { error: 'order_id is required' };
|
|
857
|
+
try {
|
|
858
|
+
const { api: eosApi, account, permission } = await getSession();
|
|
859
|
+
const result = await eosApi.transact({
|
|
860
|
+
actions: [{
|
|
861
|
+
account: 'dex',
|
|
862
|
+
name: 'cancelorder',
|
|
863
|
+
authorization: [{ actor: account, permission }],
|
|
864
|
+
data: { account, order_id },
|
|
865
|
+
}],
|
|
866
|
+
}, { blocksBehind: 3, expireSeconds: 30 });
|
|
867
|
+
return { transaction_id: result.transaction_id || result.processed?.id, cancelled_order_id: order_id };
|
|
868
|
+
} catch (err: any) {
|
|
869
|
+
return { error: `Failed to cancel order: ${err.message}` };
|
|
870
|
+
}
|
|
871
|
+
},
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
// ── 15. defi_withdraw_dex ──
|
|
875
|
+
api.registerTool({
|
|
876
|
+
name: 'defi_withdraw_dex',
|
|
877
|
+
description: 'Withdraw all tokens from your Metal X DEX balance back to your wallet.',
|
|
878
|
+
parameters: {
|
|
879
|
+
type: 'object',
|
|
880
|
+
required: ['confirmed'],
|
|
881
|
+
properties: {
|
|
882
|
+
confirmed: { type: 'boolean', description: 'Must be true to execute' },
|
|
883
|
+
},
|
|
884
|
+
},
|
|
885
|
+
handler: async ({ confirmed }: { confirmed?: boolean }) => {
|
|
886
|
+
if (!confirmed) return { error: 'Confirmation required. Set confirmed=true.' };
|
|
887
|
+
try {
|
|
888
|
+
const { api: eosApi, account, permission } = await getSession();
|
|
889
|
+
const result = await eosApi.transact({
|
|
890
|
+
actions: [{
|
|
891
|
+
account: 'dex',
|
|
892
|
+
name: 'withdrawall',
|
|
893
|
+
authorization: [{ actor: account, permission }],
|
|
894
|
+
data: { account },
|
|
895
|
+
}],
|
|
896
|
+
}, { blocksBehind: 3, expireSeconds: 30 });
|
|
897
|
+
return { transaction_id: result.transaction_id || result.processed?.id, withdrawn: true };
|
|
898
|
+
} catch (err: any) {
|
|
899
|
+
return { error: `Failed to withdraw: ${err.message}` };
|
|
900
|
+
}
|
|
901
|
+
},
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
// ── 16. defi_swap ──
|
|
905
|
+
api.registerTool({
|
|
906
|
+
name: 'defi_swap',
|
|
907
|
+
description: 'Execute an AMM swap on proton.swaps. Deposits input token, swaps via the pool, and withdraws output in one transaction. Use defi_get_swap_rate first to preview.',
|
|
908
|
+
parameters: {
|
|
909
|
+
type: 'object',
|
|
910
|
+
required: ['from_token', 'to_token', 'amount', 'min_output', 'confirmed'],
|
|
911
|
+
properties: {
|
|
912
|
+
from_token: { type: 'string', description: 'Input token: "PRECISION,SYMBOL,CONTRACT"' },
|
|
913
|
+
to_token: { type: 'string', description: 'Output token: "PRECISION,SYMBOL,CONTRACT"' },
|
|
914
|
+
amount: { type: 'number', description: 'Amount of input token to swap' },
|
|
915
|
+
min_output: { type: 'number', description: 'Minimum output (slippage protection)' },
|
|
916
|
+
confirmed: { type: 'boolean', description: 'Must be true to execute' },
|
|
917
|
+
},
|
|
918
|
+
},
|
|
919
|
+
handler: async (params: {
|
|
920
|
+
from_token: string; to_token: string; amount: number; min_output: number; confirmed?: boolean;
|
|
921
|
+
}) => {
|
|
922
|
+
if (!params.confirmed) {
|
|
923
|
+
return {
|
|
924
|
+
error: 'Confirmation required. Set confirmed=true. Use defi_get_swap_rate to preview first.',
|
|
925
|
+
preview: { from: params.from_token, to: params.to_token, amount: params.amount, min_output: params.min_output },
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
const fromSpec = parseTokenSpec(params.from_token);
|
|
929
|
+
const toSpec = parseTokenSpec(params.to_token);
|
|
930
|
+
if (!fromSpec) return { error: 'Invalid from_token. Use "PRECISION,SYMBOL,CONTRACT"' };
|
|
931
|
+
if (!toSpec) return { error: 'Invalid to_token. Use "PRECISION,SYMBOL,CONTRACT"' };
|
|
932
|
+
if (!params.amount || params.amount <= 0) return { error: 'amount must be positive' };
|
|
933
|
+
if (!params.min_output || params.min_output <= 0) return { error: 'min_output must be positive' };
|
|
934
|
+
|
|
935
|
+
try {
|
|
936
|
+
const { api: eosApi, account, permission } = await getSession();
|
|
937
|
+
|
|
938
|
+
const fromQty = formatAsset(params.amount, fromSpec.precision, fromSpec.symbol);
|
|
939
|
+
const minOutQty = formatAsset(params.min_output, toSpec.precision, toSpec.symbol);
|
|
940
|
+
const fromExtSym = { sym: `${fromSpec.precision},${fromSpec.symbol}`, contract: fromSpec.contract };
|
|
941
|
+
const toExtSym = { sym: `${toSpec.precision},${toSpec.symbol}`, contract: toSpec.contract };
|
|
942
|
+
|
|
943
|
+
const actions = [
|
|
944
|
+
// 1. Prepare deposit slots
|
|
945
|
+
{
|
|
946
|
+
account: 'proton.swaps',
|
|
947
|
+
name: 'depositprep',
|
|
948
|
+
authorization: [{ actor: account, permission }],
|
|
949
|
+
data: { owner: account, symbols: [fromExtSym, toExtSym] },
|
|
950
|
+
},
|
|
951
|
+
// 2. Deposit input token
|
|
952
|
+
{
|
|
953
|
+
account: fromSpec.contract,
|
|
954
|
+
name: 'transfer',
|
|
955
|
+
authorization: [{ actor: account, permission }],
|
|
956
|
+
data: { from: account, to: 'proton.swaps', quantity: fromQty, memo: '' },
|
|
957
|
+
},
|
|
958
|
+
// 3. Execute swap
|
|
959
|
+
{
|
|
960
|
+
account: 'proton.swaps',
|
|
961
|
+
name: 'makeorder1',
|
|
962
|
+
authorization: [{ actor: account, permission }],
|
|
963
|
+
data: {
|
|
964
|
+
maker: account,
|
|
965
|
+
maker_in: { quantity: fromQty, contract: fromSpec.contract },
|
|
966
|
+
maker_out_min: { quantity: minOutQty, contract: toSpec.contract },
|
|
967
|
+
allow_partial: true,
|
|
968
|
+
deadline_secs: 300,
|
|
969
|
+
},
|
|
970
|
+
},
|
|
971
|
+
// 4. Withdraw all output
|
|
972
|
+
{
|
|
973
|
+
account: 'proton.swaps',
|
|
974
|
+
name: 'withdrawall',
|
|
975
|
+
authorization: [{ actor: account, permission }],
|
|
976
|
+
data: { owner: account },
|
|
977
|
+
},
|
|
978
|
+
];
|
|
979
|
+
|
|
980
|
+
const result = await eosApi.transact({ actions }, { blocksBehind: 3, expireSeconds: 30 });
|
|
981
|
+
return {
|
|
982
|
+
transaction_id: result.transaction_id || result.processed?.id,
|
|
983
|
+
swap: { input: fromQty, min_output: minOutQty },
|
|
984
|
+
};
|
|
985
|
+
} catch (err: any) {
|
|
986
|
+
return { error: `Failed to swap: ${err.message}` };
|
|
987
|
+
}
|
|
988
|
+
},
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
// ── 17. defi_add_liquidity ──
|
|
992
|
+
api.registerTool({
|
|
993
|
+
name: 'defi_add_liquidity',
|
|
994
|
+
description: 'Add liquidity to an AMM pool on proton.swaps. Deposits both tokens and adds to the pool in one transaction. Use defi_list_pools to find pool lt_symbol.',
|
|
995
|
+
parameters: {
|
|
996
|
+
type: 'object',
|
|
997
|
+
required: ['lt_symbol', 'token1', 'token2', 'confirmed'],
|
|
998
|
+
properties: {
|
|
999
|
+
lt_symbol: { type: 'string', description: 'LP token symbol, e.g. "XPRBTC" (from defi_list_pools)' },
|
|
1000
|
+
token1: { type: 'string', description: 'Token 1 deposit: "AMOUNT SYMBOL" e.g. "1000.0000 XPR"' },
|
|
1001
|
+
token1_contract: { type: 'string', description: 'Token 1 contract, e.g. "eosio.token"' },
|
|
1002
|
+
token2: { type: 'string', description: 'Token 2 deposit: "AMOUNT SYMBOL" e.g. "0.00100000 XBTC"' },
|
|
1003
|
+
token2_contract: { type: 'string', description: 'Token 2 contract, e.g. "xtokens"' },
|
|
1004
|
+
slippage_pct: { type: 'number', description: 'Max slippage percentage (default 1.0)' },
|
|
1005
|
+
confirmed: { type: 'boolean', description: 'Must be true to execute' },
|
|
1006
|
+
},
|
|
1007
|
+
},
|
|
1008
|
+
handler: async (params: {
|
|
1009
|
+
lt_symbol: string; token1: string; token1_contract: string;
|
|
1010
|
+
token2: string; token2_contract: string; slippage_pct?: number; confirmed?: boolean;
|
|
1011
|
+
}) => {
|
|
1012
|
+
if (!params.confirmed) {
|
|
1013
|
+
return { error: 'Confirmation required. Set confirmed=true.', preview: params };
|
|
1014
|
+
}
|
|
1015
|
+
const t1 = parseAssetString(params.token1);
|
|
1016
|
+
const t2 = parseAssetString(params.token2);
|
|
1017
|
+
if (!t1) return { error: 'Invalid token1 format. Use "AMOUNT SYMBOL" e.g. "1000.0000 XPR"' };
|
|
1018
|
+
if (!t2) return { error: 'Invalid token2 format. Use "AMOUNT SYMBOL" e.g. "0.01000000 XBTC"' };
|
|
1019
|
+
if (!params.token1_contract) return { error: 'token1_contract is required' };
|
|
1020
|
+
if (!params.token2_contract) return { error: 'token2_contract is required' };
|
|
1021
|
+
|
|
1022
|
+
try {
|
|
1023
|
+
const { api: eosApi, account, permission } = await getSession();
|
|
1024
|
+
const slip = (params.slippage_pct || 1.0) / 100;
|
|
1025
|
+
const min1 = formatAsset(t1.amount * (1 - slip), t1.precision, t1.symbol);
|
|
1026
|
+
const min2 = formatAsset(t2.amount * (1 - slip), t2.precision, t2.symbol);
|
|
1027
|
+
|
|
1028
|
+
// Parse lt_symbol to get precision
|
|
1029
|
+
const ltParts = params.lt_symbol.match(/^(\d+),(.+)$/);
|
|
1030
|
+
const ltSym = ltParts ? params.lt_symbol : `8,${params.lt_symbol}`;
|
|
1031
|
+
|
|
1032
|
+
const ext1 = { sym: `${t1.precision},${t1.symbol}`, contract: params.token1_contract };
|
|
1033
|
+
const ext2 = { sym: `${t2.precision},${t2.symbol}`, contract: params.token2_contract };
|
|
1034
|
+
|
|
1035
|
+
const actions = [
|
|
1036
|
+
// 1. Prepare deposit slots
|
|
1037
|
+
{
|
|
1038
|
+
account: 'proton.swaps',
|
|
1039
|
+
name: 'depositprep',
|
|
1040
|
+
authorization: [{ actor: account, permission }],
|
|
1041
|
+
data: { owner: account, symbols: [ext1, ext2] },
|
|
1042
|
+
},
|
|
1043
|
+
// 2. Deposit token 1
|
|
1044
|
+
{
|
|
1045
|
+
account: params.token1_contract,
|
|
1046
|
+
name: 'transfer',
|
|
1047
|
+
authorization: [{ actor: account, permission }],
|
|
1048
|
+
data: { from: account, to: 'proton.swaps', quantity: params.token1, memo: '' },
|
|
1049
|
+
},
|
|
1050
|
+
// 3. Deposit token 2
|
|
1051
|
+
{
|
|
1052
|
+
account: params.token2_contract,
|
|
1053
|
+
name: 'transfer',
|
|
1054
|
+
authorization: [{ actor: account, permission }],
|
|
1055
|
+
data: { from: account, to: 'proton.swaps', quantity: params.token2, memo: '' },
|
|
1056
|
+
},
|
|
1057
|
+
// 4. Add liquidity
|
|
1058
|
+
{
|
|
1059
|
+
account: 'proton.swaps',
|
|
1060
|
+
name: 'liquidityadd',
|
|
1061
|
+
authorization: [{ actor: account, permission }],
|
|
1062
|
+
data: {
|
|
1063
|
+
owner: account,
|
|
1064
|
+
lt_symbol: ltSym,
|
|
1065
|
+
add_token1: { quantity: params.token1, contract: params.token1_contract },
|
|
1066
|
+
add_token2: { quantity: params.token2, contract: params.token2_contract },
|
|
1067
|
+
add_token1_min: { quantity: min1, contract: params.token1_contract },
|
|
1068
|
+
add_token2_min: { quantity: min2, contract: params.token2_contract },
|
|
1069
|
+
},
|
|
1070
|
+
},
|
|
1071
|
+
];
|
|
1072
|
+
|
|
1073
|
+
const result = await eosApi.transact({ actions }, { blocksBehind: 3, expireSeconds: 30 });
|
|
1074
|
+
return {
|
|
1075
|
+
transaction_id: result.transaction_id || result.processed?.id,
|
|
1076
|
+
added: { token1: params.token1, token2: params.token2, pool: params.lt_symbol },
|
|
1077
|
+
};
|
|
1078
|
+
} catch (err: any) {
|
|
1079
|
+
return { error: `Failed to add liquidity: ${err.message}` };
|
|
1080
|
+
}
|
|
1081
|
+
},
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
// ── 18. defi_remove_liquidity ──
|
|
1085
|
+
api.registerTool({
|
|
1086
|
+
name: 'defi_remove_liquidity',
|
|
1087
|
+
description: 'Remove liquidity from an AMM pool on proton.swaps by burning LP tokens. Returns both underlying tokens.',
|
|
1088
|
+
parameters: {
|
|
1089
|
+
type: 'object',
|
|
1090
|
+
required: ['lp_amount', 'confirmed'],
|
|
1091
|
+
properties: {
|
|
1092
|
+
lp_amount: { type: 'string', description: 'LP tokens to burn: "AMOUNT SYMBOL" e.g. "100.00000000 XPRBTC"' },
|
|
1093
|
+
confirmed: { type: 'boolean', description: 'Must be true to execute' },
|
|
1094
|
+
},
|
|
1095
|
+
},
|
|
1096
|
+
handler: async ({ lp_amount, confirmed }: { lp_amount: string; confirmed?: boolean }) => {
|
|
1097
|
+
if (!confirmed) return { error: 'Confirmation required. Set confirmed=true.', lp_amount };
|
|
1098
|
+
const lp = parseAssetString(lp_amount);
|
|
1099
|
+
if (!lp) return { error: 'Invalid lp_amount. Use "AMOUNT SYMBOL" e.g. "100.00000000 XPRBTC"' };
|
|
1100
|
+
try {
|
|
1101
|
+
const { api: eosApi, account, permission } = await getSession();
|
|
1102
|
+
const actions = [
|
|
1103
|
+
{
|
|
1104
|
+
account: 'proton.swaps',
|
|
1105
|
+
name: 'liquidityrmv',
|
|
1106
|
+
authorization: [{ actor: account, permission }],
|
|
1107
|
+
data: { owner: account, lt: lp_amount },
|
|
1108
|
+
},
|
|
1109
|
+
{
|
|
1110
|
+
account: 'proton.swaps',
|
|
1111
|
+
name: 'withdrawall',
|
|
1112
|
+
authorization: [{ actor: account, permission }],
|
|
1113
|
+
data: { owner: account },
|
|
1114
|
+
},
|
|
1115
|
+
];
|
|
1116
|
+
const result = await eosApi.transact({ actions }, { blocksBehind: 3, expireSeconds: 30 });
|
|
1117
|
+
return {
|
|
1118
|
+
transaction_id: result.transaction_id || result.processed?.id,
|
|
1119
|
+
removed: lp_amount,
|
|
1120
|
+
};
|
|
1121
|
+
} catch (err: any) {
|
|
1122
|
+
return { error: `Failed to remove liquidity: ${err.message}` };
|
|
1123
|
+
}
|
|
1124
|
+
},
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
// ════════════════════════════════════════════════
|
|
1128
|
+
// OTC ESCROW TOOLS
|
|
1129
|
+
// ════════════════════════════════════════════════
|
|
1130
|
+
|
|
1131
|
+
// ── 19. defi_create_otc ──
|
|
1132
|
+
api.registerTool({
|
|
1133
|
+
name: 'defi_create_otc',
|
|
1134
|
+
description: 'Create a P2P OTC escrow offer on token.escrow. You send fromTokens and receive toTokens when counterparty fills. Leave "to" empty for an open offer anyone can fill.',
|
|
1135
|
+
parameters: {
|
|
1136
|
+
type: 'object',
|
|
1137
|
+
required: ['from_tokens', 'to_tokens', 'confirmed'],
|
|
1138
|
+
properties: {
|
|
1139
|
+
to: { type: 'string', description: 'Counterparty account (empty string = open offer anyone can fill)' },
|
|
1140
|
+
from_tokens: {
|
|
1141
|
+
type: 'array',
|
|
1142
|
+
description: 'Tokens you send: [{ "quantity": "100.0000 XPR", "contract": "eosio.token" }]',
|
|
1143
|
+
},
|
|
1144
|
+
to_tokens: {
|
|
1145
|
+
type: 'array',
|
|
1146
|
+
description: 'Tokens you receive: [{ "quantity": "0.250000 XUSDC", "contract": "xtokens" }]',
|
|
1147
|
+
},
|
|
1148
|
+
expiry_hours: { type: 'number', description: 'Hours until expiry (default 72)' },
|
|
1149
|
+
confirmed: { type: 'boolean', description: 'Must be true to execute' },
|
|
1150
|
+
},
|
|
1151
|
+
},
|
|
1152
|
+
handler: async (params: {
|
|
1153
|
+
to?: string; from_tokens: any[]; to_tokens: any[];
|
|
1154
|
+
expiry_hours?: number; confirmed?: boolean;
|
|
1155
|
+
}) => {
|
|
1156
|
+
if (!params.confirmed) {
|
|
1157
|
+
return { error: 'Confirmation required. Set confirmed=true.', preview: params };
|
|
1158
|
+
}
|
|
1159
|
+
if (!Array.isArray(params.from_tokens) || params.from_tokens.length === 0) {
|
|
1160
|
+
return { error: 'from_tokens must be non-empty array of { quantity, contract }' };
|
|
1161
|
+
}
|
|
1162
|
+
if (!Array.isArray(params.to_tokens) || params.to_tokens.length === 0) {
|
|
1163
|
+
return { error: 'to_tokens must be non-empty array of { quantity, contract }' };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
try {
|
|
1167
|
+
const { api: eosApi, account, permission } = await getSession();
|
|
1168
|
+
const expiryHours = params.expiry_hours || 72;
|
|
1169
|
+
const expiry = Math.floor(Date.now() / 1000) + expiryHours * 3600;
|
|
1170
|
+
const toAccount = params.to || '';
|
|
1171
|
+
|
|
1172
|
+
// Build deposit actions for all from_tokens
|
|
1173
|
+
const depositActions = params.from_tokens.map((t: any) => ({
|
|
1174
|
+
account: t.contract,
|
|
1175
|
+
name: 'transfer',
|
|
1176
|
+
authorization: [{ actor: account, permission }],
|
|
1177
|
+
data: { from: account, to: 'token.escrow', quantity: t.quantity, memo: '' },
|
|
1178
|
+
}));
|
|
1179
|
+
|
|
1180
|
+
const actions = [
|
|
1181
|
+
...depositActions,
|
|
1182
|
+
{
|
|
1183
|
+
account: 'token.escrow',
|
|
1184
|
+
name: 'startescrow',
|
|
1185
|
+
authorization: [{ actor: account, permission }],
|
|
1186
|
+
data: {
|
|
1187
|
+
from: account,
|
|
1188
|
+
to: toAccount,
|
|
1189
|
+
fromTokens: params.from_tokens,
|
|
1190
|
+
fromNfts: [],
|
|
1191
|
+
toTokens: params.to_tokens,
|
|
1192
|
+
toNfts: [],
|
|
1193
|
+
expiry,
|
|
1194
|
+
},
|
|
1195
|
+
},
|
|
1196
|
+
];
|
|
1197
|
+
|
|
1198
|
+
const result = await eosApi.transact({ actions }, { blocksBehind: 3, expireSeconds: 30 });
|
|
1199
|
+
return {
|
|
1200
|
+
transaction_id: result.transaction_id || result.processed?.id,
|
|
1201
|
+
escrow: {
|
|
1202
|
+
from: account,
|
|
1203
|
+
to: toAccount || '(open offer)',
|
|
1204
|
+
from_tokens: params.from_tokens,
|
|
1205
|
+
to_tokens: params.to_tokens,
|
|
1206
|
+
expiry: new Date(expiry * 1000).toISOString(),
|
|
1207
|
+
},
|
|
1208
|
+
};
|
|
1209
|
+
} catch (err: any) {
|
|
1210
|
+
return { error: `Failed to create OTC: ${err.message}` };
|
|
1211
|
+
}
|
|
1212
|
+
},
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
// ── 20. defi_fill_otc ──
|
|
1216
|
+
api.registerTool({
|
|
1217
|
+
name: 'defi_fill_otc',
|
|
1218
|
+
description: 'Fill an OTC escrow offer. You deposit the required toTokens and receive the fromTokens. Use defi_list_otc_offers to find offers.',
|
|
1219
|
+
parameters: {
|
|
1220
|
+
type: 'object',
|
|
1221
|
+
required: ['escrow_id', 'confirmed'],
|
|
1222
|
+
properties: {
|
|
1223
|
+
escrow_id: { type: 'number', description: 'Escrow ID to fill' },
|
|
1224
|
+
confirmed: { type: 'boolean', description: 'Must be true to execute' },
|
|
1225
|
+
},
|
|
1226
|
+
},
|
|
1227
|
+
handler: async ({ escrow_id, confirmed }: { escrow_id: number; confirmed?: boolean }) => {
|
|
1228
|
+
if (!confirmed) return { error: 'Confirmation required. Set confirmed=true.', escrow_id };
|
|
1229
|
+
if (escrow_id === undefined || escrow_id === null) return { error: 'escrow_id is required' };
|
|
1230
|
+
|
|
1231
|
+
try {
|
|
1232
|
+
const { api: eosApi, account, permission } = await getSession();
|
|
1233
|
+
|
|
1234
|
+
// Fetch the escrow to know what tokens to deposit
|
|
1235
|
+
const rows = await getTableRows(rpcEndpoint, {
|
|
1236
|
+
code: 'token.escrow', scope: 'token.escrow', table: 'escrows',
|
|
1237
|
+
lower_bound: escrow_id, upper_bound: escrow_id, limit: 1,
|
|
1238
|
+
});
|
|
1239
|
+
if (rows.length === 0) return { error: `Escrow ${escrow_id} not found` };
|
|
1240
|
+
|
|
1241
|
+
const escrow = rows[0];
|
|
1242
|
+
const toTokens: any[] = escrow.toTokens || [];
|
|
1243
|
+
|
|
1244
|
+
// Deposit the required toTokens
|
|
1245
|
+
const depositActions = toTokens.map((t: any) => ({
|
|
1246
|
+
account: t.contract,
|
|
1247
|
+
name: 'transfer',
|
|
1248
|
+
authorization: [{ actor: account, permission }],
|
|
1249
|
+
data: { from: account, to: 'token.escrow', quantity: t.quantity, memo: '' },
|
|
1250
|
+
}));
|
|
1251
|
+
|
|
1252
|
+
const actions = [
|
|
1253
|
+
...depositActions,
|
|
1254
|
+
{
|
|
1255
|
+
account: 'token.escrow',
|
|
1256
|
+
name: 'fillescrow',
|
|
1257
|
+
authorization: [{ actor: account, permission }],
|
|
1258
|
+
data: { actor: account, id: escrow_id },
|
|
1259
|
+
},
|
|
1260
|
+
];
|
|
1261
|
+
|
|
1262
|
+
const result = await eosApi.transact({ actions }, { blocksBehind: 3, expireSeconds: 30 });
|
|
1263
|
+
return {
|
|
1264
|
+
transaction_id: result.transaction_id || result.processed?.id,
|
|
1265
|
+
filled_escrow: escrow_id,
|
|
1266
|
+
you_sent: toTokens,
|
|
1267
|
+
you_received: escrow.fromTokens || [],
|
|
1268
|
+
};
|
|
1269
|
+
} catch (err: any) {
|
|
1270
|
+
return { error: `Failed to fill OTC: ${err.message}` };
|
|
1271
|
+
}
|
|
1272
|
+
},
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
// ── 21. defi_cancel_otc ──
|
|
1276
|
+
api.registerTool({
|
|
1277
|
+
name: 'defi_cancel_otc',
|
|
1278
|
+
description: 'Cancel your OTC escrow offer and withdraw deposited tokens.',
|
|
1279
|
+
parameters: {
|
|
1280
|
+
type: 'object',
|
|
1281
|
+
required: ['escrow_id', 'confirmed'],
|
|
1282
|
+
properties: {
|
|
1283
|
+
escrow_id: { type: 'number', description: 'Escrow ID to cancel' },
|
|
1284
|
+
confirmed: { type: 'boolean', description: 'Must be true to execute' },
|
|
1285
|
+
},
|
|
1286
|
+
},
|
|
1287
|
+
handler: async ({ escrow_id, confirmed }: { escrow_id: number; confirmed?: boolean }) => {
|
|
1288
|
+
if (!confirmed) return { error: 'Confirmation required. Set confirmed=true.', escrow_id };
|
|
1289
|
+
if (escrow_id === undefined || escrow_id === null) return { error: 'escrow_id is required' };
|
|
1290
|
+
try {
|
|
1291
|
+
const { api: eosApi, account, permission } = await getSession();
|
|
1292
|
+
const result = await eosApi.transact({
|
|
1293
|
+
actions: [{
|
|
1294
|
+
account: 'token.escrow',
|
|
1295
|
+
name: 'cancelescrow',
|
|
1296
|
+
authorization: [{ actor: account, permission }],
|
|
1297
|
+
data: { actor: account, id: escrow_id },
|
|
1298
|
+
}],
|
|
1299
|
+
}, { blocksBehind: 3, expireSeconds: 30 });
|
|
1300
|
+
return {
|
|
1301
|
+
transaction_id: result.transaction_id || result.processed?.id,
|
|
1302
|
+
cancelled_escrow: escrow_id,
|
|
1303
|
+
};
|
|
1304
|
+
} catch (err: any) {
|
|
1305
|
+
return { error: `Failed to cancel OTC: ${err.message}` };
|
|
1306
|
+
}
|
|
1307
|
+
},
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
// ════════════════════════════════════════════════
|
|
1311
|
+
// YIELD FARMING TOOLS (yield.farms)
|
|
1312
|
+
// ════════════════════════════════════════════════
|
|
1313
|
+
|
|
1314
|
+
// ── defi_list_farms ──
|
|
1315
|
+
api.registerTool({
|
|
1316
|
+
name: 'defi_list_farms',
|
|
1317
|
+
description: 'List yield farms on yield.farms with staking token, total staked, and reward emission rates. Active farms earn rewards per half-second.',
|
|
1318
|
+
parameters: {
|
|
1319
|
+
type: 'object',
|
|
1320
|
+
properties: {
|
|
1321
|
+
active_only: { type: 'boolean', description: 'Only show active farms with nonzero rewards (default true)' },
|
|
1322
|
+
},
|
|
1323
|
+
},
|
|
1324
|
+
handler: async ({ active_only }: { active_only?: boolean }) => {
|
|
1325
|
+
try {
|
|
1326
|
+
const rows = await getTableRows(rpcEndpoint, {
|
|
1327
|
+
code: 'yield.farms', scope: 'yield.farms', table: 'rewards.cfg', limit: 50,
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
const farms = rows
|
|
1331
|
+
.filter((r: any) => {
|
|
1332
|
+
if (active_only === false) return true;
|
|
1333
|
+
// Active = has nonzero reward emission
|
|
1334
|
+
const rewards = r.rewards_per_half_second || [];
|
|
1335
|
+
return rewards.some((rw: any) => {
|
|
1336
|
+
const qty = parseFloat((rw.quantity || '0').split(' ')[0]);
|
|
1337
|
+
return qty > 0;
|
|
1338
|
+
});
|
|
1339
|
+
})
|
|
1340
|
+
.map((r: any) => {
|
|
1341
|
+
// total_staked is extended_asset: { quantity: "123.0000 SYMBOL", contract: "..." }
|
|
1342
|
+
const stakeQty = r.total_staked?.quantity || '0';
|
|
1343
|
+
const stakeContract = r.total_staked?.contract || '';
|
|
1344
|
+
const parsed = parseAssetString(stakeQty);
|
|
1345
|
+
const symbol = parsed?.symbol || '';
|
|
1346
|
+
const precision = parsed?.precision || 0;
|
|
1347
|
+
const totalStake = parsed?.amount || 0;
|
|
1348
|
+
|
|
1349
|
+
const rewardsPerHalfSec = (r.rewards_per_half_second || []).map((rw: any) => {
|
|
1350
|
+
const amt = parseFloat((rw.quantity || '0').split(' ')[0]);
|
|
1351
|
+
const rwSym = (rw.quantity || '').split(' ')[1] || '';
|
|
1352
|
+
return {
|
|
1353
|
+
token: rwSym,
|
|
1354
|
+
contract: rw.contract,
|
|
1355
|
+
per_half_second: amt,
|
|
1356
|
+
per_day: +(amt * 2 * 86400).toFixed(8),
|
|
1357
|
+
per_year: +(amt * 2 * 86400 * 365).toFixed(4),
|
|
1358
|
+
};
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
return {
|
|
1362
|
+
stake_symbol: symbol,
|
|
1363
|
+
stake_contract: stakeContract,
|
|
1364
|
+
stake_precision: precision,
|
|
1365
|
+
total_staked: totalStake,
|
|
1366
|
+
rewards: rewardsPerHalfSec,
|
|
1367
|
+
last_update: r.reward_time,
|
|
1368
|
+
};
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
return { farms, total: farms.length };
|
|
1372
|
+
} catch (err: any) {
|
|
1373
|
+
return { error: `Failed to list farms: ${err.message}` };
|
|
1374
|
+
}
|
|
1375
|
+
},
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
// ── defi_get_farm_stakes ──
|
|
1379
|
+
api.registerTool({
|
|
1380
|
+
name: 'defi_get_farm_stakes',
|
|
1381
|
+
description: 'Get a user\'s staked positions and pending rewards on yield.farms.',
|
|
1382
|
+
parameters: {
|
|
1383
|
+
type: 'object',
|
|
1384
|
+
required: ['account'],
|
|
1385
|
+
properties: {
|
|
1386
|
+
account: { type: 'string', description: 'Account name' },
|
|
1387
|
+
},
|
|
1388
|
+
},
|
|
1389
|
+
handler: async ({ account }: { account: string }) => {
|
|
1390
|
+
if (!account || !isValidEosioName(account)) return { error: 'Valid account name is required' };
|
|
1391
|
+
try {
|
|
1392
|
+
const accountU64 = nameToU64(account);
|
|
1393
|
+
const rows = await getTableRows(rpcEndpoint, {
|
|
1394
|
+
code: 'yield.farms', scope: 'yield.farms', table: 'rewards',
|
|
1395
|
+
lower_bound: accountU64, upper_bound: accountU64, limit: 1,
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
if (rows.length === 0) {
|
|
1399
|
+
return { account, stakes: [], total: 0, note: 'No farming positions found' };
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const userRow = rows[0];
|
|
1403
|
+
const stakes = (userRow.stakes || []).map((entry: any) => {
|
|
1404
|
+
const sym = entry.key?.sym || '';
|
|
1405
|
+
const contract = entry.key?.contract || '';
|
|
1406
|
+
const parts = sym.split(',');
|
|
1407
|
+
const precision = parts.length === 2 ? parseInt(parts[0]) : 0;
|
|
1408
|
+
const symbol = parts.length === 2 ? parts[1] : sym;
|
|
1409
|
+
|
|
1410
|
+
const balanceRaw = entry.value?.balance || 0;
|
|
1411
|
+
const balance = precision > 0 ? balanceRaw / Math.pow(10, precision) : balanceRaw;
|
|
1412
|
+
|
|
1413
|
+
const accruedRewards = entry.value?.accrued_rewards || [];
|
|
1414
|
+
|
|
1415
|
+
return {
|
|
1416
|
+
symbol,
|
|
1417
|
+
contract,
|
|
1418
|
+
precision,
|
|
1419
|
+
balance,
|
|
1420
|
+
balance_raw: balanceRaw,
|
|
1421
|
+
accrued_rewards_raw: accruedRewards,
|
|
1422
|
+
staked: balanceRaw > 0,
|
|
1423
|
+
};
|
|
1424
|
+
}).filter((s: any) => s.balance_raw > 0 || s.accrued_rewards_raw.some((r: number) => r > 0));
|
|
1425
|
+
|
|
1426
|
+
return { account, stakes, total: stakes.length };
|
|
1427
|
+
} catch (err: any) {
|
|
1428
|
+
return { error: `Failed to get farm stakes: ${err.message}` };
|
|
1429
|
+
}
|
|
1430
|
+
},
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
// ── defi_farm_stake ──
|
|
1434
|
+
api.registerTool({
|
|
1435
|
+
name: 'defi_farm_stake',
|
|
1436
|
+
description: 'Stake LP tokens into a yield farm on yield.farms. Opens a farming position (if not already open) and transfers LP tokens to the farm. Get LP tokens first using defi_add_liquidity.',
|
|
1437
|
+
parameters: {
|
|
1438
|
+
type: 'object',
|
|
1439
|
+
required: ['lp_amount', 'lp_contract', 'confirmed'],
|
|
1440
|
+
properties: {
|
|
1441
|
+
lp_amount: { type: 'string', description: 'LP tokens to stake: "AMOUNT SYMBOL" e.g. "100.00000000 METAXMD"' },
|
|
1442
|
+
lp_contract: { type: 'string', description: 'LP token contract (usually "proton.swaps")' },
|
|
1443
|
+
confirmed: { type: 'boolean', description: 'Must be true to execute' },
|
|
1444
|
+
},
|
|
1445
|
+
},
|
|
1446
|
+
handler: async ({ lp_amount, lp_contract, confirmed }: {
|
|
1447
|
+
lp_amount: string; lp_contract: string; confirmed?: boolean;
|
|
1448
|
+
}) => {
|
|
1449
|
+
if (!confirmed) return { error: 'Confirmation required. Set confirmed=true.', lp_amount };
|
|
1450
|
+
const lp = parseAssetString(lp_amount);
|
|
1451
|
+
if (!lp) return { error: 'Invalid lp_amount. Use "AMOUNT SYMBOL" e.g. "100.00000000 METAXMD"' };
|
|
1452
|
+
if (!lp_contract) return { error: 'lp_contract is required (usually "proton.swaps")' };
|
|
1453
|
+
|
|
1454
|
+
try {
|
|
1455
|
+
const { api: eosApi, account, permission } = await getSession();
|
|
1456
|
+
|
|
1457
|
+
const actions = [
|
|
1458
|
+
// 1. Open farming position (idempotent — safe if already open)
|
|
1459
|
+
{
|
|
1460
|
+
account: 'yield.farms',
|
|
1461
|
+
name: 'open',
|
|
1462
|
+
authorization: [{ actor: account, permission }],
|
|
1463
|
+
data: { user: account, stakes: [lp.symbol] },
|
|
1464
|
+
},
|
|
1465
|
+
// 2. Transfer LP tokens to farm
|
|
1466
|
+
{
|
|
1467
|
+
account: lp_contract,
|
|
1468
|
+
name: 'transfer',
|
|
1469
|
+
authorization: [{ actor: account, permission }],
|
|
1470
|
+
data: { from: account, to: 'yield.farms', quantity: lp_amount, memo: '' },
|
|
1471
|
+
},
|
|
1472
|
+
];
|
|
1473
|
+
|
|
1474
|
+
const result = await eosApi.transact({ actions }, { blocksBehind: 3, expireSeconds: 30 });
|
|
1475
|
+
return {
|
|
1476
|
+
transaction_id: result.transaction_id || result.processed?.id,
|
|
1477
|
+
staked: lp_amount,
|
|
1478
|
+
farm: lp.symbol,
|
|
1479
|
+
};
|
|
1480
|
+
} catch (err: any) {
|
|
1481
|
+
return { error: `Failed to stake: ${err.message}` };
|
|
1482
|
+
}
|
|
1483
|
+
},
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
// ── defi_farm_unstake ──
|
|
1487
|
+
api.registerTool({
|
|
1488
|
+
name: 'defi_farm_unstake',
|
|
1489
|
+
description: 'Unstake (withdraw) LP tokens from a yield farm. Also claims any pending rewards.',
|
|
1490
|
+
parameters: {
|
|
1491
|
+
type: 'object',
|
|
1492
|
+
required: ['lp_amount', 'lp_contract', 'confirmed'],
|
|
1493
|
+
properties: {
|
|
1494
|
+
lp_amount: { type: 'string', description: 'LP tokens to withdraw: "AMOUNT SYMBOL" e.g. "100.00000000 METAXMD"' },
|
|
1495
|
+
lp_contract: { type: 'string', description: 'LP token contract (usually "proton.swaps")' },
|
|
1496
|
+
confirmed: { type: 'boolean', description: 'Must be true to execute' },
|
|
1497
|
+
},
|
|
1498
|
+
},
|
|
1499
|
+
handler: async ({ lp_amount, lp_contract, confirmed }: {
|
|
1500
|
+
lp_amount: string; lp_contract: string; confirmed?: boolean;
|
|
1501
|
+
}) => {
|
|
1502
|
+
if (!confirmed) return { error: 'Confirmation required. Set confirmed=true.', lp_amount };
|
|
1503
|
+
const lp = parseAssetString(lp_amount);
|
|
1504
|
+
if (!lp) return { error: 'Invalid lp_amount. Use "AMOUNT SYMBOL" e.g. "100.00000000 METAXMD"' };
|
|
1505
|
+
if (!lp_contract) return { error: 'lp_contract is required (usually "proton.swaps")' };
|
|
1506
|
+
|
|
1507
|
+
try {
|
|
1508
|
+
const { api: eosApi, account, permission } = await getSession();
|
|
1509
|
+
|
|
1510
|
+
const actions = [
|
|
1511
|
+
{
|
|
1512
|
+
account: 'yield.farms',
|
|
1513
|
+
name: 'withdraw',
|
|
1514
|
+
authorization: [{ actor: account, permission }],
|
|
1515
|
+
data: {
|
|
1516
|
+
withdrawer: account,
|
|
1517
|
+
token: { quantity: lp_amount, contract: lp_contract },
|
|
1518
|
+
},
|
|
1519
|
+
},
|
|
1520
|
+
];
|
|
1521
|
+
|
|
1522
|
+
const result = await eosApi.transact({ actions }, { blocksBehind: 3, expireSeconds: 30 });
|
|
1523
|
+
return {
|
|
1524
|
+
transaction_id: result.transaction_id || result.processed?.id,
|
|
1525
|
+
unstaked: lp_amount,
|
|
1526
|
+
};
|
|
1527
|
+
} catch (err: any) {
|
|
1528
|
+
return { error: `Failed to unstake: ${err.message}` };
|
|
1529
|
+
}
|
|
1530
|
+
},
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
// ── defi_farm_claim ──
|
|
1534
|
+
api.registerTool({
|
|
1535
|
+
name: 'defi_farm_claim',
|
|
1536
|
+
description: 'Claim accrued yield farming rewards. Specify which farm(s) to claim from by LP symbol.',
|
|
1537
|
+
parameters: {
|
|
1538
|
+
type: 'object',
|
|
1539
|
+
required: ['stakes', 'confirmed'],
|
|
1540
|
+
properties: {
|
|
1541
|
+
stakes: {
|
|
1542
|
+
type: 'array',
|
|
1543
|
+
description: 'LP symbols to claim rewards for, e.g. ["METAXMD", "XPRUSDC"]',
|
|
1544
|
+
},
|
|
1545
|
+
confirmed: { type: 'boolean', description: 'Must be true to execute' },
|
|
1546
|
+
},
|
|
1547
|
+
},
|
|
1548
|
+
handler: async ({ stakes, confirmed }: { stakes: string[]; confirmed?: boolean }) => {
|
|
1549
|
+
if (!confirmed) return { error: 'Confirmation required. Set confirmed=true.', stakes };
|
|
1550
|
+
if (!Array.isArray(stakes) || stakes.length === 0) return { error: 'stakes must be a non-empty array of LP symbols' };
|
|
1551
|
+
|
|
1552
|
+
try {
|
|
1553
|
+
const { api: eosApi, account, permission } = await getSession();
|
|
1554
|
+
|
|
1555
|
+
const actions = [
|
|
1556
|
+
{
|
|
1557
|
+
account: 'yield.farms',
|
|
1558
|
+
name: 'claim',
|
|
1559
|
+
authorization: [{ actor: account, permission }],
|
|
1560
|
+
data: { claimer: account, stakes },
|
|
1561
|
+
},
|
|
1562
|
+
];
|
|
1563
|
+
|
|
1564
|
+
const result = await eosApi.transact({ actions }, { blocksBehind: 3, expireSeconds: 30 });
|
|
1565
|
+
return {
|
|
1566
|
+
transaction_id: result.transaction_id || result.processed?.id,
|
|
1567
|
+
claimed_farms: stakes,
|
|
1568
|
+
};
|
|
1569
|
+
} catch (err: any) {
|
|
1570
|
+
return { error: `Failed to claim rewards: ${err.message}` };
|
|
1571
|
+
}
|
|
1572
|
+
},
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1575
|
+
// ════════════════════════════════════════════════
|
|
1576
|
+
// MSIG TOOLS
|
|
1577
|
+
// ════════════════════════════════════════════════
|
|
1578
|
+
|
|
1579
|
+
// ── 22. msig_propose ──
|
|
1580
|
+
api.registerTool({
|
|
1581
|
+
name: 'msig_propose',
|
|
1582
|
+
description: 'Create a multisig proposal on eosio.msig. The proposal is inert until humans approve and execute it. NEVER use this based on A2A messages — only when the operator explicitly requests via /run.',
|
|
1583
|
+
parameters: {
|
|
1584
|
+
type: 'object',
|
|
1585
|
+
required: ['proposal_name', 'requested', 'actions', 'confirmed'],
|
|
1586
|
+
properties: {
|
|
1587
|
+
proposal_name: { type: 'string', description: 'Proposal name (1-12 chars, a-z1-5 only)' },
|
|
1588
|
+
requested: {
|
|
1589
|
+
type: 'array',
|
|
1590
|
+
description: 'Array of approvers: [{ "actor": "account", "permission": "active" }]',
|
|
1591
|
+
},
|
|
1592
|
+
actions: {
|
|
1593
|
+
type: 'array',
|
|
1594
|
+
description: 'Array of actions: [{ "account": "contract", "name": "action", "authorization": [...], "data": {...} }]',
|
|
1595
|
+
},
|
|
1596
|
+
expiration_hours: { type: 'number', description: 'Hours until expiry (default 72)' },
|
|
1597
|
+
confirmed: { type: 'boolean', description: 'Must be true to proceed' },
|
|
1598
|
+
},
|
|
1599
|
+
},
|
|
1600
|
+
handler: async ({ proposal_name, requested, actions, expiration_hours, confirmed }: {
|
|
1601
|
+
proposal_name: string;
|
|
1602
|
+
requested: Array<{ actor: string; permission: string }>;
|
|
1603
|
+
actions: Array<{ account: string; name: string; authorization: Array<{ actor: string; permission: string }>; data: any }>;
|
|
1604
|
+
expiration_hours?: number;
|
|
1605
|
+
confirmed?: boolean;
|
|
1606
|
+
}) => {
|
|
1607
|
+
if (!confirmed) {
|
|
1608
|
+
return {
|
|
1609
|
+
error: 'Confirmation required. Set confirmed=true.',
|
|
1610
|
+
proposal_name,
|
|
1611
|
+
actions_summary: actions?.map(a => `${a.account}::${a.name}`) || [],
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
if (!isValidEosioName(proposal_name)) return { error: 'Invalid proposal_name (1-12 chars, a-z1-5)' };
|
|
1615
|
+
if (!Array.isArray(requested) || !requested.length) return { error: 'requested must be non-empty array' };
|
|
1616
|
+
if (!Array.isArray(actions) || !actions.length) return { error: 'actions must be non-empty array' };
|
|
1617
|
+
|
|
1618
|
+
try {
|
|
1619
|
+
const { api: eosApi, account, permission } = await getSession();
|
|
1620
|
+
const expirationSec = (expiration_hours || 72) * 3600;
|
|
1621
|
+
|
|
1622
|
+
const serializedActions = [];
|
|
1623
|
+
for (const action of actions) {
|
|
1624
|
+
if (!action.account || !action.name || !action.authorization) {
|
|
1625
|
+
return { error: 'Each action must have account, name, and authorization' };
|
|
1626
|
+
}
|
|
1627
|
+
try {
|
|
1628
|
+
const sa = await eosApi.serializeActions([{
|
|
1629
|
+
account: action.account, name: action.name,
|
|
1630
|
+
authorization: action.authorization, data: action.data || {},
|
|
1631
|
+
}]);
|
|
1632
|
+
serializedActions.push({
|
|
1633
|
+
account: action.account, name: action.name,
|
|
1634
|
+
authorization: action.authorization, data: sa[0].data,
|
|
1635
|
+
});
|
|
1636
|
+
} catch (err: any) {
|
|
1637
|
+
return { error: `Failed to serialize ${action.account}::${action.name}: ${err.message}` };
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
const info = await rpcPost(rpcEndpoint, '/v1/chain/get_info', {});
|
|
1642
|
+
const headBlockTime = new Date(info.head_block_time + 'Z');
|
|
1643
|
+
const expiration = new Date(headBlockTime.getTime() + expirationSec * 1000);
|
|
1644
|
+
|
|
1645
|
+
const trx = {
|
|
1646
|
+
expiration: expiration.toISOString().slice(0, -1),
|
|
1647
|
+
ref_block_num: info.last_irreversible_block_num & 0xffff,
|
|
1648
|
+
ref_block_prefix: info.last_irreversible_block_id
|
|
1649
|
+
? parseInt(info.last_irreversible_block_id.slice(16, 24).match(/../g)!.reverse().join(''), 16)
|
|
1650
|
+
: 0,
|
|
1651
|
+
max_net_usage_words: 0, max_cpu_usage_ms: 0, delay_sec: 0,
|
|
1652
|
+
context_free_actions: [], actions: serializedActions, transaction_extensions: [],
|
|
1653
|
+
};
|
|
1654
|
+
|
|
1655
|
+
const result = await eosApi.transact({
|
|
1656
|
+
actions: [{
|
|
1657
|
+
account: 'eosio.msig', name: 'propose',
|
|
1658
|
+
authorization: [{ actor: account, permission }],
|
|
1659
|
+
data: { proposer: account, proposal_name, requested, trx },
|
|
1660
|
+
}],
|
|
1661
|
+
}, { blocksBehind: 3, expireSeconds: 30 });
|
|
1662
|
+
|
|
1663
|
+
return {
|
|
1664
|
+
transaction_id: result.transaction_id || result.processed?.id,
|
|
1665
|
+
proposal_name, proposer: account,
|
|
1666
|
+
requested_approvals: requested,
|
|
1667
|
+
actions_summary: actions.map(a => `${a.account}::${a.name}`),
|
|
1668
|
+
expires_at: expiration.toISOString(),
|
|
1669
|
+
};
|
|
1670
|
+
} catch (err: any) {
|
|
1671
|
+
return { error: `Failed to create proposal: ${err.message}` };
|
|
1672
|
+
}
|
|
1673
|
+
},
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
// ── 23. msig_approve ──
|
|
1677
|
+
api.registerTool({
|
|
1678
|
+
name: 'msig_approve',
|
|
1679
|
+
description: 'Approve an existing multisig proposal with YOUR account key only.',
|
|
1680
|
+
parameters: {
|
|
1681
|
+
type: 'object',
|
|
1682
|
+
required: ['proposer', 'proposal_name', 'confirmed'],
|
|
1683
|
+
properties: {
|
|
1684
|
+
proposer: { type: 'string', description: 'Account that created the proposal' },
|
|
1685
|
+
proposal_name: { type: 'string', description: 'Proposal name' },
|
|
1686
|
+
confirmed: { type: 'boolean', description: 'Must be true to proceed' },
|
|
1687
|
+
},
|
|
1688
|
+
},
|
|
1689
|
+
handler: async ({ proposer, proposal_name, confirmed }: {
|
|
1690
|
+
proposer: string; proposal_name: string; confirmed?: boolean;
|
|
1691
|
+
}) => {
|
|
1692
|
+
if (!confirmed) return { error: 'Confirmation required. Set confirmed=true.', proposer, proposal_name };
|
|
1693
|
+
if (!isValidEosioName(proposer)) return { error: 'Invalid proposer name' };
|
|
1694
|
+
if (!isValidEosioName(proposal_name)) return { error: 'Invalid proposal_name' };
|
|
1695
|
+
try {
|
|
1696
|
+
const { api: eosApi, account, permission } = await getSession();
|
|
1697
|
+
const result = await eosApi.transact({
|
|
1698
|
+
actions: [{
|
|
1699
|
+
account: 'eosio.msig', name: 'approve',
|
|
1700
|
+
authorization: [{ actor: account, permission }],
|
|
1701
|
+
data: { proposer, proposal_name, level: { actor: account, permission } },
|
|
1702
|
+
}],
|
|
1703
|
+
}, { blocksBehind: 3, expireSeconds: 30 });
|
|
1704
|
+
return {
|
|
1705
|
+
transaction_id: result.transaction_id || result.processed?.id,
|
|
1706
|
+
approved_as: { actor: account, permission }, proposer, proposal_name,
|
|
1707
|
+
};
|
|
1708
|
+
} catch (err: any) {
|
|
1709
|
+
return { error: `Failed to approve: ${err.message}` };
|
|
1710
|
+
}
|
|
1711
|
+
},
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
// ── 24. msig_cancel ──
|
|
1715
|
+
api.registerTool({
|
|
1716
|
+
name: 'msig_cancel',
|
|
1717
|
+
description: 'Cancel a multisig proposal you created.',
|
|
1718
|
+
parameters: {
|
|
1719
|
+
type: 'object',
|
|
1720
|
+
required: ['proposal_name'],
|
|
1721
|
+
properties: {
|
|
1722
|
+
proposal_name: { type: 'string', description: 'Proposal name to cancel' },
|
|
1723
|
+
},
|
|
1724
|
+
},
|
|
1725
|
+
handler: async ({ proposal_name }: { proposal_name: string }) => {
|
|
1726
|
+
if (!isValidEosioName(proposal_name)) return { error: 'Invalid proposal_name' };
|
|
1727
|
+
try {
|
|
1728
|
+
const { api: eosApi, account, permission } = await getSession();
|
|
1729
|
+
const result = await eosApi.transact({
|
|
1730
|
+
actions: [{
|
|
1731
|
+
account: 'eosio.msig', name: 'cancel',
|
|
1732
|
+
authorization: [{ actor: account, permission }],
|
|
1733
|
+
data: { proposer: account, proposal_name, canceler: account },
|
|
1734
|
+
}],
|
|
1735
|
+
}, { blocksBehind: 3, expireSeconds: 30 });
|
|
1736
|
+
return { transaction_id: result.transaction_id || result.processed?.id, cancelled: true, proposal_name };
|
|
1737
|
+
} catch (err: any) {
|
|
1738
|
+
return { error: `Failed to cancel: ${err.message}` };
|
|
1739
|
+
}
|
|
1740
|
+
},
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
// ── 25. msig_list_proposals ──
|
|
1744
|
+
api.registerTool({
|
|
1745
|
+
name: 'msig_list_proposals',
|
|
1746
|
+
description: 'List active multisig proposals for an account. Read-only.',
|
|
1747
|
+
parameters: {
|
|
1748
|
+
type: 'object',
|
|
1749
|
+
required: ['proposer'],
|
|
1750
|
+
properties: {
|
|
1751
|
+
proposer: { type: 'string', description: 'Account to list proposals for' },
|
|
1752
|
+
},
|
|
1753
|
+
},
|
|
1754
|
+
handler: async ({ proposer }: { proposer: string }) => {
|
|
1755
|
+
if (!isValidEosioName(proposer)) return { error: 'Invalid proposer name' };
|
|
1756
|
+
try {
|
|
1757
|
+
const proposals = await getTableRows(rpcEndpoint, {
|
|
1758
|
+
code: 'eosio.msig', scope: proposer, table: 'proposal', limit: 50,
|
|
1759
|
+
});
|
|
1760
|
+
const approvals = await getTableRows(rpcEndpoint, {
|
|
1761
|
+
code: 'eosio.msig', scope: proposer, table: 'approvals2', limit: 50,
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
const approvalMap = new Map<string, { requested: any[]; provided: any[] }>();
|
|
1765
|
+
for (const a of approvals) {
|
|
1766
|
+
approvalMap.set(a.proposal_name, {
|
|
1767
|
+
requested: a.requested_approvals || [],
|
|
1768
|
+
provided: a.provided_approvals || [],
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
const result = proposals.map((p: any) => {
|
|
1773
|
+
const ad = approvalMap.get(p.proposal_name);
|
|
1774
|
+
return {
|
|
1775
|
+
proposal_name: p.proposal_name,
|
|
1776
|
+
packed_transaction: p.packed_transaction ? `${(p.packed_transaction as string).length / 2} bytes` : null,
|
|
1777
|
+
requested_approvals: ad?.requested.map((r: any) => r.level || r) || [],
|
|
1778
|
+
provided_approvals: ad?.provided.map((r: any) => r.level || r) || [],
|
|
1779
|
+
};
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
return { proposals: result, total: result.length, proposer };
|
|
1783
|
+
} catch (err: any) {
|
|
1784
|
+
return { error: `Failed to list proposals: ${err.message}` };
|
|
1785
|
+
}
|
|
1786
|
+
},
|
|
1787
|
+
});
|
|
1788
|
+
}
|