suprafx-agent-sdk 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +452 -0
- package/dist/bin/suprafx-mcp.d.ts +15 -0
- package/dist/bin/suprafx-mcp.js +238 -0
- package/dist/bin/suprafx-mcp.js.map +1 -0
- package/dist/src/asset-registry.d.ts +61 -0
- package/dist/src/asset-registry.js +118 -0
- package/dist/src/asset-registry.js.map +1 -0
- package/dist/src/client.d.ts +227 -0
- package/dist/src/client.js +282 -0
- package/dist/src/client.js.map +1 -0
- package/dist/src/derive-ids.d.ts +112 -0
- package/dist/src/derive-ids.js +361 -0
- package/dist/src/derive-ids.js.map +1 -0
- package/dist/src/event-bcs.d.ts +341 -0
- package/dist/src/event-bcs.js +767 -0
- package/dist/src/event-bcs.js.map +1 -0
- package/dist/src/index.d.ts +26 -0
- package/dist/src/index.js +26 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/mcp/config.d.ts +32 -0
- package/dist/src/mcp/config.js +101 -0
- package/dist/src/mcp/config.js.map +1 -0
- package/dist/src/mcp/lifecycle.d.ts +109 -0
- package/dist/src/mcp/lifecycle.js +170 -0
- package/dist/src/mcp/lifecycle.js.map +1 -0
- package/dist/src/mcp/preflight.d.ts +36 -0
- package/dist/src/mcp/preflight.js +291 -0
- package/dist/src/mcp/preflight.js.map +1 -0
- package/dist/src/mcp/server.d.ts +20 -0
- package/dist/src/mcp/server.js +235 -0
- package/dist/src/mcp/server.js.map +1 -0
- package/dist/src/mcp/tools.d.ts +51 -0
- package/dist/src/mcp/tools.js +1022 -0
- package/dist/src/mcp/tools.js.map +1 -0
- package/dist/src/sign-event.d.ts +185 -0
- package/dist/src/sign-event.js +331 -0
- package/dist/src/sign-event.js.map +1 -0
- package/dist/src/signer.d.ts +89 -0
- package/dist/src/signer.js +226 -0
- package/dist/src/signer.js.map +1 -0
- package/package.json +65 -0
|
@@ -0,0 +1,1022 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool definitions + handlers for SupraFX.
|
|
3
|
+
*
|
|
4
|
+
* DESIGN RULE: everything an agent needs in order to trade safely lives
|
|
5
|
+
* in the TOOL CONTRACT — the description it reads before calling, the
|
|
6
|
+
* lifecycle field it gets back, the structured error it gets on failure.
|
|
7
|
+
* Nothing important is left to prose in a doc the agent may never fetch.
|
|
8
|
+
*
|
|
9
|
+
* Tools are split into three classes, selectable at launch
|
|
10
|
+
* (`--tools=read,cancel`):
|
|
11
|
+
* read — always available, cannot move money
|
|
12
|
+
* trade — opens or fills a position (submit_rfq, place_quote, accept_quote)
|
|
13
|
+
* cancel — releases a position (cancel_rfq, withdraw_quote)
|
|
14
|
+
*
|
|
15
|
+
* Reference: docs/INTEGRATING-AGENTS.md
|
|
16
|
+
*/
|
|
17
|
+
import { deriveAssetId, derivePairIdFromTokens, canonicalChain, } from "../derive-ids.js";
|
|
18
|
+
import { toMicroUnits, toRateBFT } from "../asset-registry.js";
|
|
19
|
+
import { withLifecycle, findRfq, findQuote, sameId, } from "./lifecycle.js";
|
|
20
|
+
import { runPreflight, ORACLE_STALE_MS } from "./preflight.js";
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
// ─── Shared description fragments ──────────────────────────────
|
|
25
|
+
//
|
|
26
|
+
// The known failure modes, annotated AT THE TOOL. An agent reads the
|
|
27
|
+
// trap here instead of paying for it once and writing it in a notebook
|
|
28
|
+
// nobody else can see.
|
|
29
|
+
const OK_IS_NOT_COMMITTED = "OUTCOME: this tool returns a `lifecycle` field — `applied` (a state " +
|
|
30
|
+
"read confirmed it landed), `rejected` (ingress refused it), or " +
|
|
31
|
+
"`unknown` (ingress accepted it but the confirming read did not see it " +
|
|
32
|
+
"in time). `ok:true` on its own NEVER means committed. Treat `unknown` " +
|
|
33
|
+
"as 'I do not know yet': do NOT retry blindly, read state back.";
|
|
34
|
+
const ACK_NOTE = "GUARDED MODE: this tool moves real money and requires `acknowledged: true` " +
|
|
35
|
+
"on every call. Launch the server with `--allow-dangerous` (or " +
|
|
36
|
+
"SUPRAFX_ALLOW_DANGEROUS=1) for an autonomous loop that should not stop " +
|
|
37
|
+
"to acknowledge each write.";
|
|
38
|
+
const GHOST_LOCK_NOTE = "TRAP — ghost locks: collateral can stay locked with no order visibly " +
|
|
39
|
+
"holding it (expiry and other-maker-accepted paths do not always release). " +
|
|
40
|
+
"`list_my_open_orders` shows every order of yours that is still holding " +
|
|
41
|
+
"funds, which is what tells a real lock apart from a ghost one.";
|
|
42
|
+
const PRECONDITIONS = "Preconditions: delegate configured, active on-chain policy, and " +
|
|
43
|
+
"sufficient available master balance — verify with `get_setup_status` " +
|
|
44
|
+
"and `preflight` first.";
|
|
45
|
+
const ACK_PROPERTY = {
|
|
46
|
+
acknowledged: {
|
|
47
|
+
type: "boolean",
|
|
48
|
+
description: "Required in guarded mode (the default). Set true to confirm you intend " +
|
|
49
|
+
"this real-money write. Not required when the server runs with " +
|
|
50
|
+
"--allow-dangerous / SUPRAFX_ALLOW_DANGEROUS=1.",
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
// ─── Read tools ────────────────────────────────────────────────
|
|
54
|
+
const readTools = [
|
|
55
|
+
{
|
|
56
|
+
name: "get_setup_status",
|
|
57
|
+
description: "Check whether this MCP server is ready to trade: local config, delegate " +
|
|
58
|
+
"identity, chain connectivity, on-chain policy, sequence, and master balances. " +
|
|
59
|
+
"Read-only and always available; run this before any write tool.",
|
|
60
|
+
inputSchema: { type: "object", properties: {} },
|
|
61
|
+
requiresSigner: false,
|
|
62
|
+
handler: async (_args, ctx) => await getSetupStatus(ctx),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "get_chain_info",
|
|
66
|
+
description: "Return the SupraFX chain identifier hash, threshold, and chain ID. " +
|
|
67
|
+
"Use to verify which chain you're connected to.",
|
|
68
|
+
inputSchema: { type: "object", properties: {} },
|
|
69
|
+
requiresSigner: false,
|
|
70
|
+
handler: async (_args, ctx) => await ctx.client.getChainInfo(),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "get_current_batch",
|
|
74
|
+
description: "Return the current committed batch height. Useful as a chain " +
|
|
75
|
+
"health probe (advancing = chain alive) and for `expires_at_batch` " +
|
|
76
|
+
"math when bootstrapping delegate sessions.",
|
|
77
|
+
inputSchema: { type: "object", properties: {} },
|
|
78
|
+
requiresSigner: false,
|
|
79
|
+
handler: async (_args, ctx) => ({ current_batch: await ctx.client.getCurrentBatch() }),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "get_sequence_number",
|
|
83
|
+
description: "Return the next strictly-monotonic sequence number an account " +
|
|
84
|
+
"must use for its next signed event. Pass the master's address " +
|
|
85
|
+
"for master events, the delegate's address for trade events.",
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: "object",
|
|
88
|
+
properties: {
|
|
89
|
+
address: {
|
|
90
|
+
type: "string",
|
|
91
|
+
description: "0x-prefixed 32-byte Supra address (master or delegate)",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
required: ["address"],
|
|
95
|
+
},
|
|
96
|
+
requiresSigner: false,
|
|
97
|
+
handler: async (args, ctx) => ({
|
|
98
|
+
next_sequence_number: await ctx.client.getSequenceNumber(args.address),
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "list_assets",
|
|
103
|
+
description: "List all supported assets on the chain. Returns chain_id, " +
|
|
104
|
+
"asset_symbol, contract address (null for native), and decimals.",
|
|
105
|
+
inputSchema: { type: "object", properties: {} },
|
|
106
|
+
requiresSigner: false,
|
|
107
|
+
handler: async (_args, ctx) => ({ assets: await ctx.client.listAssets() }),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: "get_balances",
|
|
111
|
+
description: "Return the MASTER's available + locked balances per asset. Delegates " +
|
|
112
|
+
"have no balances of their own. `address` is optional when a master " +
|
|
113
|
+
"address is configured (see `get_master_address`). " +
|
|
114
|
+
"`locked_in_rfq` is trading collateral, `locked_in_orders` is quote-side; " +
|
|
115
|
+
"locked is shared across ALL your open orders, so locked>0 with no " +
|
|
116
|
+
"matching order of yours is the ghost-lock signal — check " +
|
|
117
|
+
"`list_my_open_orders` first. " +
|
|
118
|
+
"This is the TIE-BREAKER read: whenever a write returns `unknown`, come " +
|
|
119
|
+
"here rather than guessing or retrying.",
|
|
120
|
+
inputSchema: {
|
|
121
|
+
type: "object",
|
|
122
|
+
properties: {
|
|
123
|
+
address: {
|
|
124
|
+
type: "string",
|
|
125
|
+
description: "0x-prefixed 32-byte MASTER Supra address. Omit to use the configured master.",
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
requiresSigner: false,
|
|
130
|
+
group: "read",
|
|
131
|
+
handler: async (args, ctx) => {
|
|
132
|
+
const address = args.address ?? ctx.masterAddress;
|
|
133
|
+
if (!address) {
|
|
134
|
+
throw new ToolError("NO_MASTER_ADDRESS", "get_balances needs a master address and none is configured", "ask the operator for their master StarKey address and set " +
|
|
135
|
+
"SUPRAFX_MASTER_ADDRESS so it survives a restart");
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
address,
|
|
139
|
+
source: args.address ? "argument" : "configured",
|
|
140
|
+
balances: await ctx.client.getBalances(address),
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: "get_orderbook",
|
|
146
|
+
description: "Return the current public orderbook of open RFQs. Filter by " +
|
|
147
|
+
"pair (e.g. 'ETH/USDC') or status. Returns up to `limit` rows.",
|
|
148
|
+
inputSchema: {
|
|
149
|
+
type: "object",
|
|
150
|
+
properties: {
|
|
151
|
+
pair: { type: "string", description: "Pair filter, e.g. 'ETH/USDC'" },
|
|
152
|
+
status: {
|
|
153
|
+
type: "string",
|
|
154
|
+
description: "Status filter. Default 'open' — also accepts 'matched', 'cancelled', 'expired'",
|
|
155
|
+
},
|
|
156
|
+
limit: {
|
|
157
|
+
type: "number",
|
|
158
|
+
description: "Max rows to return (default 50, cap 200)",
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
requiresSigner: false,
|
|
163
|
+
handler: async (args, ctx) => ({
|
|
164
|
+
rfqs: await ctx.client.getOrderbook({
|
|
165
|
+
pair: args.pair,
|
|
166
|
+
status: args.status ?? "open",
|
|
167
|
+
limit: args.limit ?? 50,
|
|
168
|
+
}),
|
|
169
|
+
}),
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "get_my_identity",
|
|
173
|
+
description: "Return the delegate address this MCP server is signing as. " +
|
|
174
|
+
"Available only when a delegate key is configured.",
|
|
175
|
+
inputSchema: { type: "object", properties: {} },
|
|
176
|
+
requiresSigner: false, // read-only but informational
|
|
177
|
+
handler: async (_args, ctx) => {
|
|
178
|
+
if (!ctx.signer) {
|
|
179
|
+
return { delegate_address: null, configured: false };
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
delegate_address: ctx.signer.addressHex,
|
|
183
|
+
next_sequence_number: ctx.signer.getNextSeq().toString(),
|
|
184
|
+
configured: true,
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: "get_master_address",
|
|
190
|
+
description: "Return the operator's MASTER address — the account that holds the " +
|
|
191
|
+
"funds, as opposed to the delegate this server signs as. " +
|
|
192
|
+
"Balance, lock and open-order reads all key on the master, so without " +
|
|
193
|
+
"it verification breaks on any context reset. If it is not configured " +
|
|
194
|
+
"this returns `configured:false` and the exact fix — ask the operator, " +
|
|
195
|
+
"never guess an address.",
|
|
196
|
+
inputSchema: { type: "object", properties: {} },
|
|
197
|
+
requiresSigner: false,
|
|
198
|
+
group: "read",
|
|
199
|
+
handler: async (_args, ctx) => {
|
|
200
|
+
if (!ctx.masterAddress) {
|
|
201
|
+
return {
|
|
202
|
+
master_address: null,
|
|
203
|
+
configured: false,
|
|
204
|
+
how_to_fix: "Ask the operator for the StarKey address they connected to " +
|
|
205
|
+
"suprafx.ai with, then export SUPRAFX_MASTER_ADDRESS=0x… or add " +
|
|
206
|
+
'"masterAddress" to ~/.suprafx/config.json and reconnect. ' +
|
|
207
|
+
"`suprafx-mcp init` also asks for it.",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return { master_address: ctx.masterAddress, configured: true };
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: "get_deposit_status",
|
|
215
|
+
description: "Is a deposit still crediting, credited, or failed? Pass `chain` + " +
|
|
216
|
+
"`tx_hash` for one deposit, or nothing to list every claim of the " +
|
|
217
|
+
"configured master. Returns `state` — `pending` | `credited` | " +
|
|
218
|
+
"`rejected` | `expired` — plus `stale` and the one `next_step`. " +
|
|
219
|
+
"TRAP — fresh wallets: a brand-new wallet's first deposit can take 15+ " +
|
|
220
|
+
"minutes; that reads as `pending` with `stale: true`, which is NOT a " +
|
|
221
|
+
"failure. Wait and re-read; never re-send the deposit, never loop. Only " +
|
|
222
|
+
"`rejected` or `expired` means the credit will not land on its own. " +
|
|
223
|
+
"`found: false` means no claim was recorded for that transaction — the " +
|
|
224
|
+
"deposit can still credit (the bridge does not need the claim); read " +
|
|
225
|
+
"`get_balances` instead. " +
|
|
226
|
+
"TRAP — the claim record is not the last word: the venue's credit can " +
|
|
227
|
+
"land BEFORE the record exists, leaving the record saying `pending` for " +
|
|
228
|
+
"ever on money that arrived (one real deposit read stuck for 57 days). " +
|
|
229
|
+
"The venue now checks its ledger, so `reconciled_from_ledger: true` " +
|
|
230
|
+
"means `state` came from the ledger and `status` is the stale record. " +
|
|
231
|
+
"Trust `state`, and never re-send a deposit on a `pending` record alone " +
|
|
232
|
+
"— check `get_balances` first.",
|
|
233
|
+
inputSchema: {
|
|
234
|
+
type: "object",
|
|
235
|
+
properties: {
|
|
236
|
+
chain: {
|
|
237
|
+
type: "string",
|
|
238
|
+
description: "Chain the deposit was sent on, e.g. `supra`, `ethereum`. Required with `tx_hash`.",
|
|
239
|
+
},
|
|
240
|
+
tx_hash: {
|
|
241
|
+
type: "string",
|
|
242
|
+
description: "The L1 transaction hash of the deposit. Required with `chain`.",
|
|
243
|
+
},
|
|
244
|
+
address: {
|
|
245
|
+
type: "string",
|
|
246
|
+
description: "MASTER address whose claims to list. Omit to use the configured master.",
|
|
247
|
+
},
|
|
248
|
+
limit: {
|
|
249
|
+
type: "integer",
|
|
250
|
+
description: "List form only. Newest first; the venue caps this at 50.",
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
requiresSigner: false,
|
|
255
|
+
group: "read",
|
|
256
|
+
handler: async (args, ctx) => {
|
|
257
|
+
const chain = typeof args.chain === "string" ? args.chain.trim() : "";
|
|
258
|
+
const txHash = typeof args.tx_hash === "string" ? args.tx_hash.trim() : "";
|
|
259
|
+
if (chain && txHash) {
|
|
260
|
+
return await ctx.client.getDepositStatus(chain, txHash);
|
|
261
|
+
}
|
|
262
|
+
if (chain || txHash) {
|
|
263
|
+
throw new ToolError("INVALID_ARGS", "get_deposit_status needs BOTH `chain` and `tx_hash` for a single deposit", "pass both, or pass neither to list every claim of the configured master");
|
|
264
|
+
}
|
|
265
|
+
const address = (args.address ?? ctx.masterAddress);
|
|
266
|
+
if (!address) {
|
|
267
|
+
throw new ToolError("NO_MASTER_ADDRESS", "get_deposit_status needs a master address to list claims and none is configured", "pass `chain` + `tx_hash`, set SUPRAFX_MASTER_ADDRESS, or pass `address` — see `get_master_address`");
|
|
268
|
+
}
|
|
269
|
+
const limit = Number.isInteger(args.limit) && args.limit > 0 ? Number(args.limit) : 20;
|
|
270
|
+
return await ctx.client.listDepositClaims(address, limit);
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
name: "list_my_open_orders",
|
|
275
|
+
description: "EVERY order of yours that is still holding locked funds — RFQs you " +
|
|
276
|
+
"took, quotes you made — each with the action that releases it. " +
|
|
277
|
+
"This is the answer to 'where did my money go'. Run it before treating " +
|
|
278
|
+
"any lock as a ghost-lock, and after every cancel to confirm release. " +
|
|
279
|
+
"Uses the configured master address unless you pass one.",
|
|
280
|
+
inputSchema: {
|
|
281
|
+
type: "object",
|
|
282
|
+
properties: {
|
|
283
|
+
address: {
|
|
284
|
+
type: "string",
|
|
285
|
+
description: "MASTER address. Omit to use the configured master.",
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
requiresSigner: false,
|
|
290
|
+
group: "read",
|
|
291
|
+
handler: async (args, ctx) => {
|
|
292
|
+
const address = (args.address ?? ctx.masterAddress);
|
|
293
|
+
if (!address) {
|
|
294
|
+
throw new ToolError("NO_MASTER_ADDRESS", "list_my_open_orders needs a master address and none is configured", "set SUPRAFX_MASTER_ADDRESS, or pass `address` — see `get_master_address`");
|
|
295
|
+
}
|
|
296
|
+
const me = address.toLowerCase();
|
|
297
|
+
const rows = [
|
|
298
|
+
...(await ctx.client.getOrderbook({ status: "open", limit: 200 })),
|
|
299
|
+
...(await ctx.client.getOrderbook({ status: "expired", limit: 200 })),
|
|
300
|
+
];
|
|
301
|
+
const myRfqs = [];
|
|
302
|
+
const myQuotes = [];
|
|
303
|
+
for (const r of rows) {
|
|
304
|
+
const expired = r.expires_at ? Date.parse(r.expires_at) < Date.now() : false;
|
|
305
|
+
if (String(r.taker_address ?? "").toLowerCase() === me) {
|
|
306
|
+
myRfqs.push({
|
|
307
|
+
rfq_id: r.id,
|
|
308
|
+
role: "taker",
|
|
309
|
+
pair: r.pair,
|
|
310
|
+
size: r.size,
|
|
311
|
+
remaining_size: r.remaining_size,
|
|
312
|
+
status: r.status,
|
|
313
|
+
expires_at: r.expires_at,
|
|
314
|
+
expired,
|
|
315
|
+
holds_collateral: true,
|
|
316
|
+
release_with: `cancel_rfq({ rfq_id: "${r.id}", acknowledged: true })`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
for (const q of (r.quotes ?? [])) {
|
|
320
|
+
if (String(q.maker_address ?? "").toLowerCase() !== me)
|
|
321
|
+
continue;
|
|
322
|
+
if (q.status && !["open", "pending", "active"].includes(String(q.status)))
|
|
323
|
+
continue;
|
|
324
|
+
myQuotes.push({
|
|
325
|
+
quote_id: q.id,
|
|
326
|
+
role: "maker",
|
|
327
|
+
on_rfq: r.id,
|
|
328
|
+
pair: r.pair,
|
|
329
|
+
rate: q.rate,
|
|
330
|
+
status: q.status,
|
|
331
|
+
parent_rfq_status: r.status,
|
|
332
|
+
parent_rfq_expired: expired,
|
|
333
|
+
holds_collateral: true,
|
|
334
|
+
release_with: `withdraw_quote({ quote_id: "${q.id}", acknowledged: true })`,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const balances = await ctx.client.getBalances(address).catch(() => []);
|
|
339
|
+
const total = myRfqs.length + myQuotes.length;
|
|
340
|
+
return {
|
|
341
|
+
address,
|
|
342
|
+
open_rfqs_as_taker: myRfqs,
|
|
343
|
+
open_quotes_as_maker: myQuotes,
|
|
344
|
+
total_open: total,
|
|
345
|
+
locked_balances: balances
|
|
346
|
+
.filter((b) => (b.locked_in_rfq ?? 0) > 0 || (b.locked_in_orders ?? 0) > 0)
|
|
347
|
+
.map((b) => ({
|
|
348
|
+
asset: b.asset,
|
|
349
|
+
available: b.available,
|
|
350
|
+
locked_in_rfq: b.locked_in_rfq,
|
|
351
|
+
locked_in_orders: b.locked_in_orders,
|
|
352
|
+
})),
|
|
353
|
+
note: total === 0
|
|
354
|
+
? "No open orders of yours. If balances still show locked funds, that " +
|
|
355
|
+
"is a GHOST LOCK — you cannot clear it yourself; report it to the venue."
|
|
356
|
+
: "Each row above holds collateral. Release it with the named call, " +
|
|
357
|
+
"then re-read get_balances to confirm the funds returned to available.",
|
|
358
|
+
};
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
name: "get_oracle_price",
|
|
363
|
+
description: "Venue fair value for a pair, with the quote's AGE. Quote against this, " +
|
|
364
|
+
`never an external price. A quote older than ${ORACLE_STALE_MS / 1000}s is ` +
|
|
365
|
+
"unusable — `stale:true` means do not quote.",
|
|
366
|
+
inputSchema: {
|
|
367
|
+
type: "object",
|
|
368
|
+
properties: { pair: { type: "string", description: "e.g. 'ETH/USDC'" } },
|
|
369
|
+
required: ["pair"],
|
|
370
|
+
},
|
|
371
|
+
requiresSigner: false,
|
|
372
|
+
group: "read",
|
|
373
|
+
handler: async (args, ctx) => {
|
|
374
|
+
const o = await ctx.client.getOracle(args.pair);
|
|
375
|
+
return {
|
|
376
|
+
...o,
|
|
377
|
+
stale: o.ageMs == null ? null : o.ageMs > ORACLE_STALE_MS,
|
|
378
|
+
stale_limit_ms: ORACLE_STALE_MS,
|
|
379
|
+
};
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
name: "preflight",
|
|
384
|
+
description: "Run every check that decides whether it is safe to trade right now, " +
|
|
385
|
+
"each with the ACTION that clears it: venue reachable, venue batch " +
|
|
386
|
+
"actually advancing (not just the L1), venue assets resolving to " +
|
|
387
|
+
"registered ids, oracle freshness, custody, sequence drift, funding, " +
|
|
388
|
+
"and stale own-RFQs still holding collateral. Run on connect and after " +
|
|
389
|
+
"any surprise. `ready_to_trade:false` means stop and read `checks`.",
|
|
390
|
+
inputSchema: {
|
|
391
|
+
type: "object",
|
|
392
|
+
properties: {
|
|
393
|
+
pair: {
|
|
394
|
+
type: "string",
|
|
395
|
+
description: "Pair to check oracle freshness against, e.g. 'ETH/USDC'",
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
requiresSigner: false,
|
|
400
|
+
group: "read",
|
|
401
|
+
handler: async (args, ctx) => await runPreflight({
|
|
402
|
+
client: ctx.client,
|
|
403
|
+
signer: ctx.signer,
|
|
404
|
+
masterAddress: ctx.masterAddress ?? null,
|
|
405
|
+
mode: ctx.signer ? (ctx.mode ?? "guarded") : "read_only",
|
|
406
|
+
pair: args.pair,
|
|
407
|
+
}),
|
|
408
|
+
},
|
|
409
|
+
];
|
|
410
|
+
// ─── Write tools ───────────────────────────────────────────────
|
|
411
|
+
const writeTools = [
|
|
412
|
+
{
|
|
413
|
+
name: "submit_rfq",
|
|
414
|
+
description: "Sign and submit a SubmitRfq — become the taker on a new RFQ. " +
|
|
415
|
+
"LOCKS `size` of `sell_token` from the master's available balance " +
|
|
416
|
+
"until it matches, expires (30 min default), or you cancel it. " +
|
|
417
|
+
`${OK_IS_NOT_COMMITTED} ` +
|
|
418
|
+
"Confirmed by reading the RFQ back off the orderbook. " +
|
|
419
|
+
"TRAP — an RFQ with an already-past expiry, or min_fill_size > size, " +
|
|
420
|
+
"can commit and then sit dead while holding your collateral; both are " +
|
|
421
|
+
"refused here before they can lock anything. " +
|
|
422
|
+
`${GHOST_LOCK_NOTE} ${ACK_NOTE} ${PRECONDITIONS}`,
|
|
423
|
+
inputSchema: {
|
|
424
|
+
type: "object",
|
|
425
|
+
properties: {
|
|
426
|
+
sell_chain: {
|
|
427
|
+
type: "string",
|
|
428
|
+
description: "Chain of the asset you're selling. EITHER spelling works: " +
|
|
429
|
+
"'ethereum' (as list_assets returns it) or 'eth-mainnet' (canonical).",
|
|
430
|
+
},
|
|
431
|
+
sell_token: {
|
|
432
|
+
type: "string",
|
|
433
|
+
description: "Symbol of the asset you're selling (e.g. 'ETH', 'USDC')",
|
|
434
|
+
},
|
|
435
|
+
buy_chain: {
|
|
436
|
+
type: "string",
|
|
437
|
+
description: "Chain of the asset you want. Either spelling works.",
|
|
438
|
+
},
|
|
439
|
+
buy_token: { type: "string", description: "Symbol of the asset you want" },
|
|
440
|
+
size: {
|
|
441
|
+
type: "number",
|
|
442
|
+
description: "Amount of sell_token to give (human units, e.g. 0.5 for 0.5 ETH)",
|
|
443
|
+
},
|
|
444
|
+
reference_price: {
|
|
445
|
+
type: "number",
|
|
446
|
+
description: "Reference rate as buy_token per 1 sell_token (e.g. 2400 for ETH/USDC at $2400)",
|
|
447
|
+
},
|
|
448
|
+
settlement_mode: {
|
|
449
|
+
type: "string",
|
|
450
|
+
enum: ["Platform", "OnChain"],
|
|
451
|
+
description: "Platform (recommended) for fast internal settle, OnChain for L1 settle",
|
|
452
|
+
},
|
|
453
|
+
expires_in_minutes: {
|
|
454
|
+
type: "number",
|
|
455
|
+
description: "Minutes until the RFQ expires (default 30)",
|
|
456
|
+
},
|
|
457
|
+
allow_partial_fills: { type: "boolean", description: "Default false" },
|
|
458
|
+
min_fill_size: {
|
|
459
|
+
type: "number",
|
|
460
|
+
description: "Required if allow_partial_fills=true; minimum acceptable partial fill",
|
|
461
|
+
},
|
|
462
|
+
auto_accept: {
|
|
463
|
+
type: "boolean",
|
|
464
|
+
description: "If true, auto-accept the first quote at or better than auto_accept_target_rate",
|
|
465
|
+
},
|
|
466
|
+
auto_accept_target_rate: { type: "number", description: "Required if auto_accept=true" },
|
|
467
|
+
...ACK_PROPERTY,
|
|
468
|
+
},
|
|
469
|
+
required: [
|
|
470
|
+
"sell_chain",
|
|
471
|
+
"sell_token",
|
|
472
|
+
"buy_chain",
|
|
473
|
+
"buy_token",
|
|
474
|
+
"size",
|
|
475
|
+
"reference_price",
|
|
476
|
+
],
|
|
477
|
+
},
|
|
478
|
+
requiresSigner: true,
|
|
479
|
+
group: "trade",
|
|
480
|
+
dangerous: true,
|
|
481
|
+
handler: async (args, ctx) => {
|
|
482
|
+
const signer = requireSigner(ctx);
|
|
483
|
+
requireAck(args, ctx, "submit_rfq");
|
|
484
|
+
const expiresInMinutes = assertRfqIsFillable(args);
|
|
485
|
+
const assets = await ctx.client.listAssets();
|
|
486
|
+
const baseDec = assetDecimals(assets, args.sell_chain, args.sell_token);
|
|
487
|
+
const quoteDec = assetDecimals(assets, args.buy_chain, args.buy_token);
|
|
488
|
+
const baseAsset = deriveAssetId(args.sell_chain, args.sell_token);
|
|
489
|
+
const quoteAsset = deriveAssetId(args.buy_chain, args.buy_token);
|
|
490
|
+
const pair = derivePairIdFromTokens(args.sell_chain, args.sell_token, args.buy_chain, args.buy_token);
|
|
491
|
+
const currentBatch = BigInt(await ctx.client.getCurrentBatch());
|
|
492
|
+
const clockOffsetMs = await ctx.client.getVenueClockOffsetMs();
|
|
493
|
+
warnIfClockSkewed(clockOffsetMs);
|
|
494
|
+
const expiresAtMs = BigInt(Date.now() + clockOffsetMs + expiresInMinutes * 60 * 1000);
|
|
495
|
+
const allowPartial = !!args.allow_partial_fills;
|
|
496
|
+
const rfqIdBytes = randomBytes16();
|
|
497
|
+
const rfqUuid = bytes16ToUuid(rfqIdBytes);
|
|
498
|
+
const res = await signer.submitRfq({
|
|
499
|
+
pair,
|
|
500
|
+
base_asset: baseAsset,
|
|
501
|
+
quote_asset: quoteAsset,
|
|
502
|
+
size: toMicroUnits(args.size, baseDec),
|
|
503
|
+
reference_price: toRateBFT(args.reference_price, baseDec, quoteDec),
|
|
504
|
+
auto_accept: !!args.auto_accept,
|
|
505
|
+
auto_accept_target_rate: args.auto_accept
|
|
506
|
+
? toRateBFT(args.auto_accept_target_rate, baseDec, quoteDec)
|
|
507
|
+
: BigInt(0),
|
|
508
|
+
allow_partial_fills: allowPartial,
|
|
509
|
+
min_fill_size: allowPartial
|
|
510
|
+
? toMicroUnits(args.min_fill_size ?? 0, baseDec)
|
|
511
|
+
: BigInt(0),
|
|
512
|
+
expires_at_ms: expiresAtMs,
|
|
513
|
+
rfq_id: rfqIdBytes,
|
|
514
|
+
settlement_mode: args.settlement_mode === "OnChain" ? "OnChain" : "Platform",
|
|
515
|
+
});
|
|
516
|
+
const commit = await withLifecycle(res, {
|
|
517
|
+
verifiedBy: `get_orderbook for rfq_id ${rfqUuid}`,
|
|
518
|
+
tieBreaker: `get_balances, and list_my_open_orders for rfq_id ${rfqUuid}`,
|
|
519
|
+
check: async () => (await findRfq(ctx.client, rfqUuid)) != null,
|
|
520
|
+
});
|
|
521
|
+
return { rfq_id: rfqUuid, ...commit };
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
name: "place_quote",
|
|
526
|
+
description: "Sign and submit a PlaceQuote on an open RFQ — become the maker. " +
|
|
527
|
+
"LOCKS `total_payment` of the RFQ's quote_asset from your master balance " +
|
|
528
|
+
"until the taker accepts, the RFQ dies, or you withdraw_quote. " +
|
|
529
|
+
`${OK_IS_NOT_COMMITTED} ` +
|
|
530
|
+
"Confirmed by reading the quote back off the parent RFQ. " +
|
|
531
|
+
"TRAP — a quote's lock is NOT shown anywhere on the taker-side " +
|
|
532
|
+
"orderbook; use `list_my_open_orders` to see it. " +
|
|
533
|
+
`${ACK_NOTE} ${PRECONDITIONS}`,
|
|
534
|
+
inputSchema: {
|
|
535
|
+
type: "object",
|
|
536
|
+
properties: {
|
|
537
|
+
rfq_id: {
|
|
538
|
+
type: "string",
|
|
539
|
+
description: "UUID (with dashes) of the parent RFQ from the orderbook",
|
|
540
|
+
},
|
|
541
|
+
fill_size: {
|
|
542
|
+
type: "number",
|
|
543
|
+
description: "Amount of the RFQ's base_asset you're offering to fill (human units). For a full quote, match rfq.size",
|
|
544
|
+
},
|
|
545
|
+
total_payment: {
|
|
546
|
+
type: "number",
|
|
547
|
+
description: "Total amount of quote_asset you'll pay across this fill (human units). E.g. 1200 USDC for 0.5 ETH at $2400.",
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
required: ["rfq_id", "fill_size", "total_payment"],
|
|
551
|
+
},
|
|
552
|
+
requiresSigner: true,
|
|
553
|
+
group: "trade",
|
|
554
|
+
dangerous: true,
|
|
555
|
+
handler: async (args, ctx) => {
|
|
556
|
+
const signer = requireSigner(ctx);
|
|
557
|
+
requireAck(args, ctx, "place_quote");
|
|
558
|
+
if (!(args.fill_size > 0) || !(args.total_payment > 0)) {
|
|
559
|
+
throw new ToolError("INVALID_QUOTE", "fill_size and total_payment must both be > 0", "pass positive values for both");
|
|
560
|
+
}
|
|
561
|
+
// Fetch the parent rfq so we know the pair + decimals.
|
|
562
|
+
const orderbook = await ctx.client.getOrderbook({ status: "open", limit: 200 });
|
|
563
|
+
const parent = orderbook.find((r) => sameId(r.id, args.rfq_id));
|
|
564
|
+
if (!parent) {
|
|
565
|
+
throw new ToolError("RFQ_NOT_OPEN", `rfq ${args.rfq_id} is not in the open orderbook`, "it may have matched, expired or been cancelled — re-read `get_orderbook`");
|
|
566
|
+
}
|
|
567
|
+
// pair is "BASE/QUOTE" e.g. "ETH/USDC". RFQ rows carry CANONICAL
|
|
568
|
+
// chain ids ("eth-mainnet") while /api/assets carries SHORT ones
|
|
569
|
+
// ("ethereum") — comparing them raw never matched, so this threw on
|
|
570
|
+
// every call. canonicalChain folds both sides.
|
|
571
|
+
const [baseSym, quoteSym] = parent.pair.split("/");
|
|
572
|
+
const assets = await ctx.client.listAssets();
|
|
573
|
+
const findDec = (sym, chain) => assets.find((a) => a.asset_symbol.toUpperCase() === sym.toUpperCase() &&
|
|
574
|
+
canonicalChain(a.chain_id) === canonicalChain(chain))?.decimals;
|
|
575
|
+
const baseDec = findDec(baseSym, parent.source_chain);
|
|
576
|
+
const quoteDec = findDec(quoteSym, parent.dest_chain);
|
|
577
|
+
if (baseDec == null || quoteDec == null) {
|
|
578
|
+
throw new ToolError("UNRESOLVED_DECIMALS", `could not resolve decimals for ${parent.pair} (${parent.source_chain} / ${parent.dest_chain})`, "the venue lists an asset this SDK does not know — `npm update -g suprafx-agent-sdk` and report");
|
|
579
|
+
}
|
|
580
|
+
const quoteIdBytes = randomBytes16();
|
|
581
|
+
const quoteUuid = bytes16ToUuid(quoteIdBytes);
|
|
582
|
+
const impliedRate = args.total_payment / args.fill_size;
|
|
583
|
+
const res = await signer.placeQuote({
|
|
584
|
+
rfq_id: uuidToBytes16(parent.id),
|
|
585
|
+
quote_id: quoteIdBytes,
|
|
586
|
+
rate: toRateBFT(impliedRate, baseDec, quoteDec),
|
|
587
|
+
fill_size: toMicroUnits(args.fill_size, baseDec),
|
|
588
|
+
});
|
|
589
|
+
const commit = await withLifecycle(res, {
|
|
590
|
+
verifiedBy: `get_orderbook — quote ${quoteUuid} on rfq ${parent.id}`,
|
|
591
|
+
tieBreaker: "get_balances and list_my_open_orders",
|
|
592
|
+
check: async () => (await findQuote(ctx.client, quoteUuid)) != null,
|
|
593
|
+
});
|
|
594
|
+
return { quote_id: quoteUuid, rfq_id: parent.id, implied_rate: impliedRate, ...commit };
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
name: "cancel_rfq",
|
|
599
|
+
description: "Sign and submit a CancelRfq — withdraw an open RFQ you took, releasing " +
|
|
600
|
+
"its locked collateral back to available. " +
|
|
601
|
+
`${OK_IS_NOT_COMMITTED} ` +
|
|
602
|
+
"Confirmed by reading the RFQ's status off the orderbook. " +
|
|
603
|
+
"ALWAYS re-read `get_balances` after this: confirming the RFQ closed is " +
|
|
604
|
+
"NOT the same as confirming the funds came back. " +
|
|
605
|
+
"NOTE — consumes no sequence number, so it cannot desync your counter. " +
|
|
606
|
+
`${ACK_NOTE} ${PRECONDITIONS}`,
|
|
607
|
+
inputSchema: {
|
|
608
|
+
type: "object",
|
|
609
|
+
properties: {
|
|
610
|
+
rfq_id: { type: "string", description: "UUID of the RFQ to cancel" },
|
|
611
|
+
reason: {
|
|
612
|
+
type: "string",
|
|
613
|
+
description: "Optional human-readable reason (logged on chain)",
|
|
614
|
+
},
|
|
615
|
+
...ACK_PROPERTY,
|
|
616
|
+
},
|
|
617
|
+
required: ["rfq_id"],
|
|
618
|
+
},
|
|
619
|
+
requiresSigner: true,
|
|
620
|
+
group: "cancel",
|
|
621
|
+
dangerous: true,
|
|
622
|
+
handler: async (args, ctx) => {
|
|
623
|
+
const signer = requireSigner(ctx);
|
|
624
|
+
requireAck(args, ctx, "cancel_rfq");
|
|
625
|
+
const res = await signer.cancelRfq({
|
|
626
|
+
rfq_id: uuidToBytes16(args.rfq_id),
|
|
627
|
+
// The chain mempool TxId hashes the full event. An identical retry is
|
|
628
|
+
// deduped forever if the first submission was silently dropped, so a
|
|
629
|
+
// generated reason must be unique on every call.
|
|
630
|
+
reason: args.reason ?? `agent_cancel-${shortUniqueSuffix()}`,
|
|
631
|
+
});
|
|
632
|
+
const commit = await withLifecycle(res, {
|
|
633
|
+
verifiedBy: `get_orderbook — status of rfq ${args.rfq_id}`,
|
|
634
|
+
tieBreaker: "get_balances — confirm the collateral returned to available",
|
|
635
|
+
check: async () => {
|
|
636
|
+
const found = await findRfq(ctx.client, args.rfq_id);
|
|
637
|
+
if (!found)
|
|
638
|
+
return false;
|
|
639
|
+
return String(found.status ?? "").toLowerCase() !== "open";
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
return {
|
|
643
|
+
rfq_id: args.rfq_id,
|
|
644
|
+
...commit,
|
|
645
|
+
next_step: "Re-read get_balances and confirm the locked amount returned to " +
|
|
646
|
+
"available. Locks do not always release on their own.",
|
|
647
|
+
};
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
name: "accept_quote",
|
|
652
|
+
description: "Sign and submit an AcceptQuote — as the taker of the parent RFQ, accept " +
|
|
653
|
+
"a maker's quote and trigger settlement. THIS SPENDS: it is the point of " +
|
|
654
|
+
"no return for the trade. " +
|
|
655
|
+
`${OK_IS_NOT_COMMITTED} ` +
|
|
656
|
+
"Confirmed by reading the quote's status back off the orderbook. " +
|
|
657
|
+
"trade_id is generated client-side if not supplied. " +
|
|
658
|
+
`${ACK_NOTE} ${PRECONDITIONS}`,
|
|
659
|
+
inputSchema: {
|
|
660
|
+
type: "object",
|
|
661
|
+
properties: {
|
|
662
|
+
quote_id: { type: "string", description: "UUID of the quote to accept" },
|
|
663
|
+
trade_id: {
|
|
664
|
+
type: "string",
|
|
665
|
+
description: "(Optional) UUID for the resulting trade. Omit to auto-generate",
|
|
666
|
+
},
|
|
667
|
+
...ACK_PROPERTY,
|
|
668
|
+
},
|
|
669
|
+
required: ["quote_id"],
|
|
670
|
+
},
|
|
671
|
+
requiresSigner: true,
|
|
672
|
+
group: "trade",
|
|
673
|
+
dangerous: true,
|
|
674
|
+
handler: async (args, ctx) => {
|
|
675
|
+
const signer = requireSigner(ctx);
|
|
676
|
+
requireAck(args, ctx, "accept_quote");
|
|
677
|
+
const res = await signer.acceptQuote({
|
|
678
|
+
quote_id: uuidToBytes16(args.quote_id),
|
|
679
|
+
trade_id: args.trade_id ? uuidToBytes16(args.trade_id) : randomBytes16(),
|
|
680
|
+
});
|
|
681
|
+
const commit = await withLifecycle(res, {
|
|
682
|
+
verifiedBy: `get_orderbook — status of quote ${args.quote_id}`,
|
|
683
|
+
tieBreaker: "get_balances (the trade should have moved both assets)",
|
|
684
|
+
check: async () => {
|
|
685
|
+
const found = await findQuote(ctx.client, args.quote_id);
|
|
686
|
+
if (!found)
|
|
687
|
+
return false;
|
|
688
|
+
const st = String(found.quote.status ?? "").toLowerCase();
|
|
689
|
+
return st === "accepted" || found.rfqStatus === "matched";
|
|
690
|
+
},
|
|
691
|
+
});
|
|
692
|
+
return { quote_id: args.quote_id, ...commit };
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
name: "withdraw_quote",
|
|
697
|
+
description: "Sign and submit a WithdrawQuote — as the maker, pull a pending quote off " +
|
|
698
|
+
"the orderbook before it is accepted, releasing its lock. " +
|
|
699
|
+
"THIS IS NOT A FUND WITHDRAWAL: it does not move money off SupraFX. " +
|
|
700
|
+
"Getting funds OUT is a master-signed dApp action, and this delegate key " +
|
|
701
|
+
"cannot do it. " +
|
|
702
|
+
`${OK_IS_NOT_COMMITTED} ` +
|
|
703
|
+
"ALWAYS re-read `get_balances` after this. " +
|
|
704
|
+
"NOTE — consumes no sequence number. " +
|
|
705
|
+
`${ACK_NOTE} ${PRECONDITIONS}`,
|
|
706
|
+
inputSchema: {
|
|
707
|
+
type: "object",
|
|
708
|
+
properties: {
|
|
709
|
+
quote_id: { type: "string", description: "UUID of the quote to withdraw" },
|
|
710
|
+
...ACK_PROPERTY,
|
|
711
|
+
},
|
|
712
|
+
required: ["quote_id"],
|
|
713
|
+
},
|
|
714
|
+
requiresSigner: true,
|
|
715
|
+
group: "cancel",
|
|
716
|
+
dangerous: true,
|
|
717
|
+
handler: async (args, ctx) => {
|
|
718
|
+
const signer = requireSigner(ctx);
|
|
719
|
+
requireAck(args, ctx, "withdraw_quote");
|
|
720
|
+
const res = await signer.withdrawQuote({
|
|
721
|
+
quote_id: uuidToBytes16(args.quote_id),
|
|
722
|
+
});
|
|
723
|
+
const commit = await withLifecycle(res, {
|
|
724
|
+
verifiedBy: `get_orderbook — status of quote ${args.quote_id}`,
|
|
725
|
+
tieBreaker: "get_balances — confirm the lock returned to available",
|
|
726
|
+
check: async () => {
|
|
727
|
+
const found = await findQuote(ctx.client, args.quote_id);
|
|
728
|
+
if (!found)
|
|
729
|
+
return true; // gone from the book = pulled
|
|
730
|
+
const st = String(found.quote.status ?? "").toLowerCase();
|
|
731
|
+
return st !== "open" && st !== "pending" && st !== "active";
|
|
732
|
+
},
|
|
733
|
+
});
|
|
734
|
+
return {
|
|
735
|
+
quote_id: args.quote_id,
|
|
736
|
+
...commit,
|
|
737
|
+
next_step: "Re-read get_balances and confirm the lock returned to available.",
|
|
738
|
+
};
|
|
739
|
+
},
|
|
740
|
+
},
|
|
741
|
+
];
|
|
742
|
+
/** `read` is never withheld; omitting `groups` exposes every class. */
|
|
743
|
+
function inGroups(t, groups) {
|
|
744
|
+
if (!groups)
|
|
745
|
+
return true;
|
|
746
|
+
return groups.has(t.group ?? "read");
|
|
747
|
+
}
|
|
748
|
+
export function allTools(hasSigner, groups) {
|
|
749
|
+
const reads = readTools.filter((t) => inGroups(t, groups));
|
|
750
|
+
if (hasSigner)
|
|
751
|
+
return [...reads, ...writeTools.filter((t) => inGroups(t, groups))];
|
|
752
|
+
return reads;
|
|
753
|
+
}
|
|
754
|
+
export function findTool(name, hasSigner, groups) {
|
|
755
|
+
// A client may call a previously-discovered write tool after its key is
|
|
756
|
+
// removed. Keep lookup available so it receives NO_DELEGATE_CONFIGURED
|
|
757
|
+
// rather than a confusing "unknown tool".
|
|
758
|
+
//
|
|
759
|
+
// A tool excluded by `--tools=` is a DIFFERENT case: the operator chose
|
|
760
|
+
// not to expose it, so it must not be callable at all.
|
|
761
|
+
return [...readTools, ...writeTools]
|
|
762
|
+
.filter((t) => inGroups(t, groups))
|
|
763
|
+
.find((t) => t.name === name);
|
|
764
|
+
}
|
|
765
|
+
// ─── helpers ──────────────────────────────────────────────────
|
|
766
|
+
function requireSigner(ctx) {
|
|
767
|
+
if (!ctx.signer) {
|
|
768
|
+
throw new ToolError("NO_DELEGATE_CONFIGURED", "run `suprafx-mcp init` or set SUPRAFX_DELEGATE_PRIV_HEX, then retry", "suprafx-mcp init");
|
|
769
|
+
}
|
|
770
|
+
return ctx.signer;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Decimals for `(chain, symbol)`, comparing chain ids in CANONICAL form.
|
|
774
|
+
*
|
|
775
|
+
* This used to fold both sides to the short DB form. That worked for
|
|
776
|
+
* `/api/assets` (which returns `"ethereum"`) but NOT for RFQ rows (which
|
|
777
|
+
* return `"eth-mainnet"`), so `place_quote` compared `"ethereum"` against
|
|
778
|
+
* `"eth-mainnet"`, never matched, and threw on every single call.
|
|
779
|
+
* `canonicalChain` folds both directions, so either spelling works.
|
|
780
|
+
*/
|
|
781
|
+
function assetDecimals(assets, chain, symbol) {
|
|
782
|
+
const want = canonicalChain(chain);
|
|
783
|
+
const a = assets.find((r) => r.asset_symbol.toUpperCase() === symbol.toUpperCase() &&
|
|
784
|
+
canonicalChain(r.chain_id) === want);
|
|
785
|
+
if (!a) {
|
|
786
|
+
throw new ToolError("UNSUPPORTED_ASSET", `${symbol}@${chain} is not a supported asset`, "call `list_assets` and use a chain_id and asset_symbol from that list");
|
|
787
|
+
}
|
|
788
|
+
return a.decimals;
|
|
789
|
+
}
|
|
790
|
+
function uuidToBytes16(uuid) {
|
|
791
|
+
// Lower-cased: the venue has returned ids in mixed casing, and an
|
|
792
|
+
// upper-case id must encode to the same 16 bytes.
|
|
793
|
+
const hex = uuid.replace(/-/g, "").trim().toLowerCase();
|
|
794
|
+
if (!/^[0-9a-f]{32}$/.test(hex)) {
|
|
795
|
+
throw new ToolError("BAD_UUID", `not a uuid: ${uuid}`, "pass the id exactly as the orderbook returned it");
|
|
796
|
+
}
|
|
797
|
+
const out = new Uint8Array(16);
|
|
798
|
+
for (let i = 0; i < 16; i++)
|
|
799
|
+
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
800
|
+
return out;
|
|
801
|
+
}
|
|
802
|
+
function randomBytes16() {
|
|
803
|
+
const out = new Uint8Array(16);
|
|
804
|
+
crypto.getRandomValues(out);
|
|
805
|
+
return out;
|
|
806
|
+
}
|
|
807
|
+
/** Render 16 bytes as a dashed UUID so the id we return matches the one
|
|
808
|
+
* the orderbook will show — the only way apply-verification can compare. */
|
|
809
|
+
function bytes16ToUuid(b) {
|
|
810
|
+
const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
811
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* The guarded gate. Key-presence alone is not a stop: once a key loads,
|
|
815
|
+
* `accept_quote` was as ungated as `get_orderbook`. In guarded mode every
|
|
816
|
+
* money tool needs an explicit per-call `acknowledged:true`; an autonomous
|
|
817
|
+
* loop opts out once, at launch, in the open.
|
|
818
|
+
*/
|
|
819
|
+
function requireAck(args, ctx, toolName) {
|
|
820
|
+
if ((ctx.mode ?? "guarded") === "autonomous")
|
|
821
|
+
return;
|
|
822
|
+
if (args?.acknowledged === true)
|
|
823
|
+
return;
|
|
824
|
+
throw new ToolError("NEEDS_ACKNOWLEDGEMENT", `${toolName} moves real money and this server is in GUARDED mode`, "re-send the identical call with `acknowledged: true`; for an autonomous " +
|
|
825
|
+
"loop the OPERATOR relaunches with --allow-dangerous — do not work around this");
|
|
826
|
+
}
|
|
827
|
+
/** Reject an RFQ that could never fill but would still lock collateral. */
|
|
828
|
+
function assertRfqIsFillable(args) {
|
|
829
|
+
const expiresInMinutes = args.expires_in_minutes ?? 30;
|
|
830
|
+
if (!(expiresInMinutes > 0)) {
|
|
831
|
+
throw new ToolError("RFQ_DEAD_ON_ARRIVAL", `expires_in_minutes must be > 0 (got ${expiresInMinutes})`, "an already-expired RFQ can commit and lock collateral nobody can fill — pass a positive expiry");
|
|
832
|
+
}
|
|
833
|
+
if (!(args.size > 0)) {
|
|
834
|
+
throw new ToolError("RFQ_DEAD_ON_ARRIVAL", `size must be > 0 (got ${args.size})`, "pass a positive size");
|
|
835
|
+
}
|
|
836
|
+
if (args.allow_partial_fills && (args.min_fill_size ?? 0) > args.size) {
|
|
837
|
+
throw new ToolError("RFQ_DEAD_ON_ARRIVAL", `min_fill_size (${args.min_fill_size}) exceeds size (${args.size})`, "no fill could satisfy it, but the RFQ would still lock funds — lower min_fill_size");
|
|
838
|
+
}
|
|
839
|
+
return expiresInMinutes;
|
|
840
|
+
}
|
|
841
|
+
let uniqueReasonCounter = 0;
|
|
842
|
+
function shortUniqueSuffix() {
|
|
843
|
+
uniqueReasonCounter = (uniqueReasonCounter + 1) & 0xfffff;
|
|
844
|
+
return `${Date.now().toString(36)}-${uniqueReasonCounter.toString(36)}-${Array.from(crypto.getRandomValues(new Uint8Array(3)), (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
845
|
+
}
|
|
846
|
+
export class ToolError extends Error {
|
|
847
|
+
code;
|
|
848
|
+
detail;
|
|
849
|
+
remedy;
|
|
850
|
+
constructor(code, detail, remedy) {
|
|
851
|
+
super(detail);
|
|
852
|
+
this.code = code;
|
|
853
|
+
this.detail = detail;
|
|
854
|
+
this.remedy = remedy;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
const CLOCK_SKEW_WARNING_MS = 60_000;
|
|
858
|
+
function clockSkewSeconds(offsetMs) {
|
|
859
|
+
return Math.sign(offsetMs) * Math.ceil(Math.abs(offsetMs) / 1000);
|
|
860
|
+
}
|
|
861
|
+
function warnIfClockSkewed(offsetMs) {
|
|
862
|
+
if (Math.abs(offsetMs) > CLOCK_SKEW_WARNING_MS) {
|
|
863
|
+
console.error(`local clock skewed by ${clockSkewSeconds(offsetMs)}s vs venue; using server-aligned expiry`);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
async function getSetupStatus(ctx) {
|
|
867
|
+
const configPath = join(homedir(), ".suprafx", "config.json");
|
|
868
|
+
const configPresent = existsSync(configPath);
|
|
869
|
+
const report = {
|
|
870
|
+
config_file: configPresent
|
|
871
|
+
? { status: "ok", detail: `config file present at ${configPath}`, remedy: "suprafx-mcp" }
|
|
872
|
+
: {
|
|
873
|
+
status: ctx.signer ? "warn" : "fail",
|
|
874
|
+
detail: ctx.signer
|
|
875
|
+
? "config file absent; delegate key was loaded from the environment"
|
|
876
|
+
: `config file absent at ${configPath}`,
|
|
877
|
+
remedy: "suprafx-mcp init",
|
|
878
|
+
},
|
|
879
|
+
delegate_key: ctx.signer
|
|
880
|
+
? { status: "ok", detail: `delegate address ${ctx.signer.addressHex}`, remedy: "suprafx-mcp" }
|
|
881
|
+
: {
|
|
882
|
+
status: "fail",
|
|
883
|
+
detail: "no delegate key is loaded",
|
|
884
|
+
remedy: "suprafx-mcp init",
|
|
885
|
+
},
|
|
886
|
+
chain_reachable: {
|
|
887
|
+
status: "unknown",
|
|
888
|
+
detail: "chain connectivity not checked yet",
|
|
889
|
+
remedy: "suprafx-mcp",
|
|
890
|
+
},
|
|
891
|
+
clock_skew: {
|
|
892
|
+
status: "unknown",
|
|
893
|
+
detail: "venue clock offset not checked yet",
|
|
894
|
+
remedy: "sync your system clock (NTP)",
|
|
895
|
+
},
|
|
896
|
+
delegate_policy: {
|
|
897
|
+
status: "unknown",
|
|
898
|
+
detail: "requires a loaded delegate address",
|
|
899
|
+
remedy: "suprafx-mcp init",
|
|
900
|
+
},
|
|
901
|
+
sequence_number: {
|
|
902
|
+
status: "unknown",
|
|
903
|
+
detail: "requires a loaded delegate address",
|
|
904
|
+
remedy: "suprafx-mcp init",
|
|
905
|
+
},
|
|
906
|
+
master_balances: {
|
|
907
|
+
status: "unknown",
|
|
908
|
+
detail: "requires a master address returned by the delegate-policy endpoint",
|
|
909
|
+
remedy: "suprafx-mcp init",
|
|
910
|
+
},
|
|
911
|
+
};
|
|
912
|
+
try {
|
|
913
|
+
const info = await ctx.client.getChainInfo();
|
|
914
|
+
report.chain_reachable = {
|
|
915
|
+
status: "ok",
|
|
916
|
+
detail: `chain reachable (${info.chainId})`,
|
|
917
|
+
remedy: "suprafx-mcp",
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
catch (e) {
|
|
921
|
+
report.chain_reachable = {
|
|
922
|
+
status: "fail",
|
|
923
|
+
detail: errorMessage(e),
|
|
924
|
+
remedy: "curl -f https://suprafx.ai/api/council/chain-info",
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
const clockOffsetMs = await ctx.client.getVenueClockOffsetMs();
|
|
928
|
+
const skewSeconds = clockSkewSeconds(clockOffsetMs);
|
|
929
|
+
report.clock_skew = Math.abs(clockOffsetMs) > CLOCK_SKEW_WARNING_MS
|
|
930
|
+
? {
|
|
931
|
+
status: "warn",
|
|
932
|
+
detail: `local clock is skewed by ${skewSeconds}s versus the venue`,
|
|
933
|
+
remedy: "sync your system clock (NTP)",
|
|
934
|
+
}
|
|
935
|
+
: {
|
|
936
|
+
status: "ok",
|
|
937
|
+
detail: `local clock skew is ${skewSeconds}s versus the venue`,
|
|
938
|
+
remedy: "sync your system clock (NTP)",
|
|
939
|
+
};
|
|
940
|
+
if (!ctx.signer)
|
|
941
|
+
return report;
|
|
942
|
+
const delegate = ctx.signer.addressHex;
|
|
943
|
+
try {
|
|
944
|
+
const next = await ctx.client.getSequenceNumber(delegate);
|
|
945
|
+
report.sequence_number = {
|
|
946
|
+
status: "ok",
|
|
947
|
+
detail: `next sequence number ${next}`,
|
|
948
|
+
remedy: `curl -f 'https://suprafx.ai/api/council/sequence-number?address=${delegate}'`,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
catch (e) {
|
|
952
|
+
report.sequence_number = {
|
|
953
|
+
status: "unknown",
|
|
954
|
+
detail: errorMessage(e),
|
|
955
|
+
remedy: `curl -f 'https://suprafx.ai/api/council/sequence-number?address=${delegate}'`,
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
try {
|
|
959
|
+
const policy = await ctx.client.getDelegatePolicy(delegate);
|
|
960
|
+
if (!policy) {
|
|
961
|
+
report.delegate_policy = {
|
|
962
|
+
status: "fail",
|
|
963
|
+
detail: "no on-chain delegate policy found",
|
|
964
|
+
remedy: "open https://suprafx.ai/profile and create or reactivate this delegate",
|
|
965
|
+
};
|
|
966
|
+
return report;
|
|
967
|
+
}
|
|
968
|
+
report.delegate_policy = policy.active === false
|
|
969
|
+
? {
|
|
970
|
+
status: "fail",
|
|
971
|
+
detail: "delegate policy exists but is inactive",
|
|
972
|
+
remedy: "open https://suprafx.ai/profile and create or reactivate this delegate",
|
|
973
|
+
}
|
|
974
|
+
: {
|
|
975
|
+
status: policy.active === true ? "ok" : "warn",
|
|
976
|
+
detail: policy.active === true
|
|
977
|
+
? "active on-chain delegate policy found"
|
|
978
|
+
: "delegate policy found, but the response did not state whether it is active",
|
|
979
|
+
remedy: `curl -f 'https://suprafx.ai/api/delegate-policy?delegate=${delegate}'`,
|
|
980
|
+
};
|
|
981
|
+
const master = typeof policy.master_address === "string"
|
|
982
|
+
? policy.master_address
|
|
983
|
+
: typeof policy.master === "string" ? policy.master : null;
|
|
984
|
+
if (!master) {
|
|
985
|
+
report.master_balances = {
|
|
986
|
+
status: "unknown",
|
|
987
|
+
detail: "delegate policy response did not expose a master address",
|
|
988
|
+
remedy: `curl -f 'https://suprafx.ai/api/delegate-policy?delegate=${delegate}'`,
|
|
989
|
+
};
|
|
990
|
+
return report;
|
|
991
|
+
}
|
|
992
|
+
try {
|
|
993
|
+
const balances = await ctx.client.getBalances(master);
|
|
994
|
+
report.master_balances = {
|
|
995
|
+
status: balances.length > 0 ? "ok" : "warn",
|
|
996
|
+
detail: balances.length > 0
|
|
997
|
+
? `master ${master} has ${balances.length} balance row(s): ${JSON.stringify(balances)}`
|
|
998
|
+
: `master ${master} has no balance rows`,
|
|
999
|
+
remedy: `curl -f 'https://suprafx.ai/api/platform/balances?address=${master}'`,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
catch (e) {
|
|
1003
|
+
report.master_balances = {
|
|
1004
|
+
status: "unknown",
|
|
1005
|
+
detail: errorMessage(e),
|
|
1006
|
+
remedy: `curl -f 'https://suprafx.ai/api/platform/balances?address=${master}'`,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
catch (e) {
|
|
1011
|
+
report.delegate_policy = {
|
|
1012
|
+
status: "unknown",
|
|
1013
|
+
detail: errorMessage(e),
|
|
1014
|
+
remedy: `curl -f 'https://suprafx.ai/api/delegate-policy?delegate=${delegate}'`,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
return report;
|
|
1018
|
+
}
|
|
1019
|
+
function errorMessage(e) {
|
|
1020
|
+
return e instanceof Error ? e.message : String(e);
|
|
1021
|
+
}
|
|
1022
|
+
//# sourceMappingURL=tools.js.map
|