everyblock-mcp 0.2.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/LICENSE.md +75 -0
- package/README.md +92 -0
- package/dist/abi.js +1720 -0
- package/dist/chain.js +202 -0
- package/dist/main.js +18 -0
- package/dist/orders.js +178 -0
- package/dist/server.js +638 -0
- package/package.json +49 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
// The Everyblock MCP server: tools an AI agent uses to read, quote and trade Everyblock markets.
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { isAddress } from "viem";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { everyblockAbi } from "./abi.js";
|
|
6
|
+
import { FEED_NAMES, feeBps, MONAD_MAINNET, now, read, readToken, reason, send, serialize, writeBlocker, } from "./chain.js";
|
|
7
|
+
import { centsToUsd, clearBatch, describeKind, escrowOf, feeOn, GRACE, isBid, kindOf, KINDS, marketStatus, meanPrice, OUTCOMES, ownPriceAt, priceOf, scenarios, tickOf, usd, WINDOW, } from "./orders.js";
|
|
8
|
+
export const INSTRUCTIONS = `Everyblock runs short crypto UP/DOWN markets (e.g. "is BTC/USD higher at the close than at the open?") on Monad.
|
|
9
|
+
|
|
10
|
+
How it works:
|
|
11
|
+
- Each market has an open and a close time (5 minutes on the live schedule). The strike is the mean of 12 Pyth prices over the 60 s before the open; the closing price is the mean of 12 Pyth prices over the 60 s from the close. UP wins if the closing mean is above the strike, DOWN if below; a tie, or no proof within an hour, voids the market and every share redeems for $0.50.
|
|
12
|
+
- A winning share pays $1.00. Prices are in cents (1-99): buying UP at 60 means paying at most $0.60 per share.
|
|
13
|
+
- There is no continuous order book. All orders sent in the same block form one batch that clears at ONE uniform price after the block ends, so being first or fast in a block gives no advantage. Orders better than the clearing price fill fully, orders at it may fill pro rata, worse ones do not fill. Buyers never pay more than their limit and get back any price improvement.
|
|
14
|
+
- Orders live for one block. After it, call claim with the order's nonce to collect the fill, the refund of anything unfilled and the price improvement. Claiming is required even if the order did not fill (that returns the escrow).
|
|
15
|
+
- After a market resolves, call redeem to turn winning shares into collateral. Claim open orders first.
|
|
16
|
+
- split turns $1 of collateral into 1 UP + 1 DOWN (needed before you can sell shares you do not own); merge does the reverse.
|
|
17
|
+
|
|
18
|
+
Typical flow: list_markets -> preview_order -> place_order -> (next block) claim -> (after resolution) get_positions -> redeem.
|
|
19
|
+
Amounts are shown in dollars (strings) and in raw collateral units (6 decimals). Writes are capped per call by the server's limits and refused on chains not in its allowlist.`;
|
|
20
|
+
const MAX_ORDERS_SCANNED = 50n;
|
|
21
|
+
// ------------------------------------------------------------------------------------------------
|
|
22
|
+
// Output helpers
|
|
23
|
+
// ------------------------------------------------------------------------------------------------
|
|
24
|
+
const json = (v) => JSON.stringify(v, (_, x) => (typeof x === "bigint" ? x.toString() : x), 2);
|
|
25
|
+
const ok = (v) => ({ content: [{ type: "text", text: json(v) }] });
|
|
26
|
+
const fail = (msg) => ({ content: [{ type: "text", text: `Error: ${msg}` }], isError: true });
|
|
27
|
+
function guarded(fn) {
|
|
28
|
+
return async (args) => {
|
|
29
|
+
try {
|
|
30
|
+
return await fn(args);
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
return fail(reason(e));
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const iso = (t) => new Date(Number(t) * 1000).toISOString();
|
|
38
|
+
const feedName = (id) => FEED_NAMES[id.toLowerCase()] ?? id;
|
|
39
|
+
const tx = (s) => ({ txHash: s.txHash, blockNumber: s.blockNumber, gasUsed: s.gasUsed });
|
|
40
|
+
const eventArgs = (s, name) => s.events.find((e) => e.name === name)?.args;
|
|
41
|
+
async function lastClearing(ctx, marketId, latest) {
|
|
42
|
+
const stop = latest > ctx.logLookback ? latest - ctx.logLookback : 0n;
|
|
43
|
+
for (let to = latest; to >= stop;) {
|
|
44
|
+
const from = to - ctx.logRange + 1n > stop ? to - ctx.logRange + 1n : stop;
|
|
45
|
+
const logs = await ctx.pub.getContractEvents({
|
|
46
|
+
address: ctx.everyblock,
|
|
47
|
+
abi: everyblockAbi,
|
|
48
|
+
eventName: "BatchCleared",
|
|
49
|
+
args: { marketId },
|
|
50
|
+
fromBlock: from,
|
|
51
|
+
toBlock: to,
|
|
52
|
+
});
|
|
53
|
+
const traded = logs.filter((l) => l.args.clearingTick !== 0);
|
|
54
|
+
const last = traded.at(-1);
|
|
55
|
+
if (last) {
|
|
56
|
+
const a = last.args;
|
|
57
|
+
return { batchId: a.batchId, priceCents: a.clearingTick, priceUsd: centsToUsd(a.clearingTick), volume: a.volume };
|
|
58
|
+
}
|
|
59
|
+
if (from === 0n)
|
|
60
|
+
break;
|
|
61
|
+
to = from - 1n;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
async function marketView(ctx, marketId, t, latestBlock) {
|
|
66
|
+
const [m, outcome, fee] = await Promise.all([
|
|
67
|
+
read(ctx, "getMarket", [marketId]),
|
|
68
|
+
read(ctx, "outcome", [marketId]),
|
|
69
|
+
feeBps(ctx, marketId),
|
|
70
|
+
]);
|
|
71
|
+
const status = marketStatus(t, m.openTime, m.closeTime, outcome);
|
|
72
|
+
const deadline = m.closeTime + WINDOW + GRACE;
|
|
73
|
+
return {
|
|
74
|
+
marketId,
|
|
75
|
+
feed: { id: m.feedId, name: feedName(m.feedId) },
|
|
76
|
+
openTime: m.openTime,
|
|
77
|
+
closeTime: m.closeTime,
|
|
78
|
+
open: iso(m.openTime),
|
|
79
|
+
close: iso(m.closeTime),
|
|
80
|
+
status,
|
|
81
|
+
secondsToOpen: status === "upcoming" ? m.openTime - t : 0n,
|
|
82
|
+
secondsToClose: status === "trading" || status === "upcoming" ? m.closeTime - t : 0n,
|
|
83
|
+
strike: meanPrice(m.strikeSum, m.expo, m.strikeSet),
|
|
84
|
+
closePrice: m.outcome !== 0 ? meanPrice(m.closeSum, m.expo, true) : null,
|
|
85
|
+
outcome: OUTCOMES[outcome],
|
|
86
|
+
settlementDeadline: iso(deadline),
|
|
87
|
+
pendingBatch: m.pendingBatch === 0n ? null : m.pendingBatch,
|
|
88
|
+
fee: { bps: fee.bps, source: fee.source },
|
|
89
|
+
lastClearing: latestBlock === null ? undefined : await lastClearing(ctx, marketId, latestBlock),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
async function batchView(ctx, marketId, batchId) {
|
|
93
|
+
const [b, cleared] = await Promise.all([
|
|
94
|
+
read(ctx, "getBatch", [marketId, batchId]),
|
|
95
|
+
read(ctx, "isCleared", [marketId, batchId]),
|
|
96
|
+
]);
|
|
97
|
+
const [bidBits, askBits, clearingTick, volume, bidFill, askFill] = b;
|
|
98
|
+
const bits = bidBits | askBits;
|
|
99
|
+
const ticks = [];
|
|
100
|
+
for (let t = 1; t <= 99; t++)
|
|
101
|
+
if ((bits >> BigInt(t)) & 1n)
|
|
102
|
+
ticks.push(t);
|
|
103
|
+
const levels = await Promise.all(ticks.map(async (tick) => {
|
|
104
|
+
const [bid, ask] = (await read(ctx, "getLevel", [marketId, batchId, tick]));
|
|
105
|
+
return { tick, bid, ask };
|
|
106
|
+
}));
|
|
107
|
+
const shown = levels.map((l) => ({ tick: l.tick, priceUsd: centsToUsd(l.tick), bidShares: l.bid, askShares: l.ask }));
|
|
108
|
+
const result = (c) => c.tick === 0
|
|
109
|
+
? { traded: false }
|
|
110
|
+
: {
|
|
111
|
+
traded: true,
|
|
112
|
+
priceCents: c.tick,
|
|
113
|
+
upPriceUsd: centsToUsd(c.tick),
|
|
114
|
+
downPriceUsd: centsToUsd(100 - c.tick),
|
|
115
|
+
volume: c.volume,
|
|
116
|
+
bidFillAtTick: c.bidFillAtTick,
|
|
117
|
+
askFillAtTick: c.askFillAtTick,
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
marketId,
|
|
121
|
+
batchId,
|
|
122
|
+
cleared,
|
|
123
|
+
note: "Prices are cents of UP: a bid is a Buy UP (or a Sell DOWN at 100 - tick), an ask a Sell UP (or a Buy DOWN at 100 - tick).",
|
|
124
|
+
levels: shown,
|
|
125
|
+
clearing: cleared ? result({ tick: clearingTick, volume, bidFillAtTick: bidFill, askFillAtTick: askFill }) : null,
|
|
126
|
+
indicative: cleared
|
|
127
|
+
? undefined
|
|
128
|
+
: { ...result(clearBatch(levels)), note: "Not cleared yet: this is what it would clear at with the orders so far." },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async function currentAndNext(ctx, feeds, t) {
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const feedId of feeds) {
|
|
134
|
+
const d = (await read(ctx, "feedDuration", [feedId]));
|
|
135
|
+
if (d === 0n)
|
|
136
|
+
continue;
|
|
137
|
+
const cur = (t / d) * d;
|
|
138
|
+
for (const openTime of [cur, cur + d]) {
|
|
139
|
+
out.push({ feedId, duration: d, openTime, marketId: (await read(ctx, "marketOf", [feedId, openTime])) });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
function resolveFeeds(ctx, feed) {
|
|
145
|
+
if (!feed)
|
|
146
|
+
return ctx.feeds;
|
|
147
|
+
const f = feed.toLowerCase();
|
|
148
|
+
const match = ctx.feeds.filter((id) => id.toLowerCase() === f || feedName(id).toLowerCase().startsWith(f));
|
|
149
|
+
if (match.length === 0)
|
|
150
|
+
throw new Error(`unknown feed ${feed}; configured: ${ctx.feeds.map(feedName).join(", ")}`);
|
|
151
|
+
return match;
|
|
152
|
+
}
|
|
153
|
+
async function quote(ctx, a) {
|
|
154
|
+
const marketId = BigInt(a.marketId);
|
|
155
|
+
const size = BigInt(a.size);
|
|
156
|
+
const kind = kindOf(a.side, a.outcome);
|
|
157
|
+
const { timestamp } = await now(ctx);
|
|
158
|
+
const [m, outcome, fee] = await Promise.all([
|
|
159
|
+
read(ctx, "getMarket", [marketId]),
|
|
160
|
+
read(ctx, "outcome", [marketId]),
|
|
161
|
+
feeBps(ctx, marketId),
|
|
162
|
+
]);
|
|
163
|
+
const status = marketStatus(timestamp, m.openTime, m.closeTime, outcome);
|
|
164
|
+
const escrow = escrowOf(kind, a.price, size, ctx.cent, fee.bps);
|
|
165
|
+
const s = scenarios(kind, a.price, size, ctx.cent, fee.bps);
|
|
166
|
+
const notional = size * BigInt(a.price) * ctx.cent;
|
|
167
|
+
const problems = [];
|
|
168
|
+
if (status !== "trading")
|
|
169
|
+
problems.push(`market is ${status}, not trading (open ${iso(m.openTime)}, close ${iso(m.closeTime)})`);
|
|
170
|
+
if (size > ctx.maxOrderSize)
|
|
171
|
+
problems.push(`size ${size} is over this server's limit of ${ctx.maxOrderSize} shares`);
|
|
172
|
+
if (escrow.collateral > ctx.maxSpend) {
|
|
173
|
+
problems.push(`escrow $${usd(escrow.collateral, ctx.cent)} is over this server's limit of $${usd(ctx.maxSpend, ctx.cent)} per call`);
|
|
174
|
+
}
|
|
175
|
+
let wallet;
|
|
176
|
+
if (ctx.address) {
|
|
177
|
+
const [bal, allowance, up, down] = await Promise.all([
|
|
178
|
+
readToken(ctx, "balanceOf", [ctx.address]),
|
|
179
|
+
readToken(ctx, "allowance", [ctx.address, ctx.everyblock]),
|
|
180
|
+
read(ctx, "upBalance", [marketId, ctx.address]),
|
|
181
|
+
read(ctx, "downBalance", [marketId, ctx.address]),
|
|
182
|
+
]);
|
|
183
|
+
if (bal < escrow.collateral)
|
|
184
|
+
problems.push(`collateral balance $${usd(bal, ctx.cent)} is below the escrow`);
|
|
185
|
+
if (up < escrow.up)
|
|
186
|
+
problems.push(`you hold ${up} UP of this market; selling ${size} needs them (split or buy first)`);
|
|
187
|
+
if (down < escrow.down)
|
|
188
|
+
problems.push(`you hold ${down} DOWN of this market; selling ${size} needs them (split or buy first)`);
|
|
189
|
+
wallet = {
|
|
190
|
+
account: ctx.address,
|
|
191
|
+
collateralUsd: usd(bal, ctx.cent),
|
|
192
|
+
upShares: up,
|
|
193
|
+
downShares: down,
|
|
194
|
+
approvalNeeded: allowance < escrow.collateral,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const money = (v) => (v === undefined ? undefined : { usd: usd(v, ctx.cent), units: v });
|
|
198
|
+
return {
|
|
199
|
+
marketId,
|
|
200
|
+
kind,
|
|
201
|
+
size,
|
|
202
|
+
status,
|
|
203
|
+
problems,
|
|
204
|
+
escrowCollateral: escrow.collateral,
|
|
205
|
+
view: {
|
|
206
|
+
market: { marketId, feed: feedName(m.feedId), status, close: iso(m.closeTime), secondsToClose: status === "trading" ? m.closeTime - timestamp : 0n },
|
|
207
|
+
order: {
|
|
208
|
+
side: a.side,
|
|
209
|
+
outcome: a.outcome,
|
|
210
|
+
kind: KINDS[kind],
|
|
211
|
+
limitCents: a.price,
|
|
212
|
+
limitUsd: centsToUsd(a.price),
|
|
213
|
+
size,
|
|
214
|
+
book: isBid(kind) ? "bid" : "ask",
|
|
215
|
+
upBookTick: tickOf(kind, a.price),
|
|
216
|
+
},
|
|
217
|
+
escrow: { collateral: money(escrow.collateral), upShares: escrow.up, downShares: escrow.down },
|
|
218
|
+
ifFullyFilledAtLimit: {
|
|
219
|
+
maxCost: money(s.maxCost),
|
|
220
|
+
payoutIfWins: money(s.payoutIfWins),
|
|
221
|
+
minProceeds: money(s.minProceeds),
|
|
222
|
+
maxProfit: money(s.maxProfit),
|
|
223
|
+
maxLoss: money(s.maxLoss),
|
|
224
|
+
},
|
|
225
|
+
fee: {
|
|
226
|
+
bps: fee.bps,
|
|
227
|
+
source: fee.source,
|
|
228
|
+
estimateAtLimit: money(feeOn(notional, fee.bps)),
|
|
229
|
+
},
|
|
230
|
+
limits: { maxOrderSize: ctx.maxOrderSize, maxSpendUsd: usd(ctx.maxSpend, ctx.cent) },
|
|
231
|
+
wallet,
|
|
232
|
+
ok: problems.length === 0,
|
|
233
|
+
problems,
|
|
234
|
+
note: "The order joins the current block's batch and clears at one price for everyone once the block ends; you never pay more than your limit. Claim it with its nonce afterwards.",
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
// ------------------------------------------------------------------------------------------------
|
|
239
|
+
// Server
|
|
240
|
+
// ------------------------------------------------------------------------------------------------
|
|
241
|
+
const id = z.number().int().positive();
|
|
242
|
+
const orderShape = {
|
|
243
|
+
marketId: id.describe("Market id (from list_markets)."),
|
|
244
|
+
side: z.enum(["buy", "sell"]).describe("buy or sell."),
|
|
245
|
+
outcome: z.enum(["up", "down"]).describe("The outcome traded: up (price rises) or down."),
|
|
246
|
+
price: z
|
|
247
|
+
.number()
|
|
248
|
+
.int()
|
|
249
|
+
.min(1)
|
|
250
|
+
.max(99)
|
|
251
|
+
.describe("Limit in cents of that outcome, 1-99: the most a buyer pays, the least a seller takes, per $1 share."),
|
|
252
|
+
size: z.number().int().positive().describe("Number of shares; each pays $1.00 if its outcome wins."),
|
|
253
|
+
};
|
|
254
|
+
export function createServer(ctx) {
|
|
255
|
+
const server = new McpServer({ name: "everyblock", version: "0.1.0" }, { instructions: INSTRUCTIONS });
|
|
256
|
+
const readOnly = { readOnlyHint: true, openWorldHint: true };
|
|
257
|
+
const writes = { readOnlyHint: false, destructiveHint: false, openWorldHint: true };
|
|
258
|
+
server.registerTool("list_markets", {
|
|
259
|
+
title: "List markets",
|
|
260
|
+
description: "The current and next market of every listed feed: times, status (upcoming/trading/closed/resolved/void), strike, outcome, last clearing price and fee. Start here to find a marketId to trade. A slot shown with marketId null has not been opened yet (the keeper opens it; anyone may).",
|
|
261
|
+
inputSchema: { feed: z.string().optional().describe("Only this feed: a symbol like BTC or a Pyth feed id.") },
|
|
262
|
+
annotations: readOnly,
|
|
263
|
+
}, guarded(async ({ feed }) => {
|
|
264
|
+
const { timestamp, number } = await now(ctx);
|
|
265
|
+
const slots = await currentAndNext(ctx, resolveFeeds(ctx, feed), timestamp);
|
|
266
|
+
const markets = await Promise.all(slots.map((s) => s.marketId === 0n
|
|
267
|
+
? {
|
|
268
|
+
marketId: null,
|
|
269
|
+
feed: { id: s.feedId, name: feedName(s.feedId) },
|
|
270
|
+
openTime: s.openTime,
|
|
271
|
+
closeTime: s.openTime + s.duration,
|
|
272
|
+
open: iso(s.openTime),
|
|
273
|
+
close: iso(s.openTime + s.duration),
|
|
274
|
+
status: "not opened",
|
|
275
|
+
}
|
|
276
|
+
: marketView(ctx, s.marketId, timestamp, number)));
|
|
277
|
+
return ok({ chainId: ctx.chainId, block: number, time: iso(timestamp), markets });
|
|
278
|
+
}));
|
|
279
|
+
server.registerTool("get_market", {
|
|
280
|
+
title: "Get market",
|
|
281
|
+
description: "One market in detail: times, status, strike and closing price, outcome, fee, last clearing price, and the batch still waiting to clear (with its indicative price), plus your UP/DOWN balance if this server has a wallet.",
|
|
282
|
+
inputSchema: { marketId: id.describe("Market id.") },
|
|
283
|
+
annotations: readOnly,
|
|
284
|
+
}, guarded(async ({ marketId }) => {
|
|
285
|
+
const mid = BigInt(marketId);
|
|
286
|
+
const { timestamp, number } = await now(ctx);
|
|
287
|
+
const view = await marketView(ctx, mid, timestamp, number);
|
|
288
|
+
const pending = view.pendingBatch ? await batchView(ctx, mid, view.pendingBatch) : null;
|
|
289
|
+
let yours;
|
|
290
|
+
if (ctx.address) {
|
|
291
|
+
const [up, down] = await Promise.all([
|
|
292
|
+
read(ctx, "upBalance", [mid, ctx.address]),
|
|
293
|
+
read(ctx, "downBalance", [mid, ctx.address]),
|
|
294
|
+
]);
|
|
295
|
+
yours = { account: ctx.address, upShares: up, downShares: down };
|
|
296
|
+
}
|
|
297
|
+
return ok({ ...view, pendingBatchBook: pending, yours });
|
|
298
|
+
}));
|
|
299
|
+
server.registerTool("get_batch", {
|
|
300
|
+
title: "Get batch",
|
|
301
|
+
description: "The order book of one batch (a market's orders from one block, batchId = block number): size resting at each tick on each side, and the clearing result (price, volume, fills at the clearing tick). A batch not cleared yet shows the indicative clearing price.",
|
|
302
|
+
inputSchema: { marketId: id.describe("Market id."), batchId: id.describe("Block number of the batch (from place_order or get_positions).") },
|
|
303
|
+
annotations: readOnly,
|
|
304
|
+
}, guarded(async ({ marketId, batchId }) => ok(await batchView(ctx, BigInt(marketId), BigInt(batchId)))));
|
|
305
|
+
server.registerTool("get_positions", {
|
|
306
|
+
title: "Get positions",
|
|
307
|
+
description: "An account's open (unclaimed) orders with what claim would pay now, its UP/DOWN balances in recent and listed markets with their redeemable value, and its collateral balance. Defaults to this server's wallet.",
|
|
308
|
+
inputSchema: {
|
|
309
|
+
account: z.string().optional().describe("Address to inspect; defaults to this server's wallet."),
|
|
310
|
+
marketIds: z.array(id).max(20).optional().describe("Extra market ids to include balances for."),
|
|
311
|
+
},
|
|
312
|
+
annotations: readOnly,
|
|
313
|
+
}, guarded(async ({ account, marketIds }) => {
|
|
314
|
+
const who = (account ?? ctx.address);
|
|
315
|
+
if (!who || !isAddress(who))
|
|
316
|
+
return fail("pass an account address (this server has no wallet)");
|
|
317
|
+
const { timestamp, number } = await now(ctx);
|
|
318
|
+
const n = (await read(ctx, "orderNonce", [who]));
|
|
319
|
+
const first = n > MAX_ORDERS_SCANNED ? n - MAX_ORDERS_SCANNED : 0n;
|
|
320
|
+
const orders = [];
|
|
321
|
+
const markets = new Set((marketIds ?? []).map(BigInt));
|
|
322
|
+
for (let nonce = first; nonce < n; nonce++) {
|
|
323
|
+
// viem decodes uint32/uint40 as numbers and uint64 as bigints.
|
|
324
|
+
let o;
|
|
325
|
+
try {
|
|
326
|
+
o = (await read(ctx, "getOrder", [who, nonce]));
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
continue; // claimed
|
|
330
|
+
}
|
|
331
|
+
const mid = BigInt(o.marketId);
|
|
332
|
+
const batchId = BigInt(o.batchId);
|
|
333
|
+
markets.add(mid);
|
|
334
|
+
const kind = o.kind;
|
|
335
|
+
const cleared = (await read(ctx, "isCleared", [mid, batchId]));
|
|
336
|
+
let claim = null;
|
|
337
|
+
if (cleared) {
|
|
338
|
+
const [filled, upOut, downOut, cashOut] = (await read(ctx, "previewClaim", [who, nonce]));
|
|
339
|
+
const [, c] = (await read(ctx, "orderFill", [who, nonce]));
|
|
340
|
+
claim = {
|
|
341
|
+
filled,
|
|
342
|
+
clearingPriceCents: c === 0 ? null : ownPriceAt(kind, c),
|
|
343
|
+
upOut,
|
|
344
|
+
downOut,
|
|
345
|
+
cashOutUsd: usd(cashOut, ctx.cent),
|
|
346
|
+
cashOut,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
orders.push({
|
|
350
|
+
nonce,
|
|
351
|
+
marketId: mid,
|
|
352
|
+
batchId,
|
|
353
|
+
...describeKind(kind),
|
|
354
|
+
kind: KINDS[kind],
|
|
355
|
+
limitCents: priceOf(kind, o.tick),
|
|
356
|
+
size: o.size,
|
|
357
|
+
status: cleared ? "cleared: claim it" : batchId === number ? "in the current block's batch" : "waiting to clear",
|
|
358
|
+
claim,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
for (const s of await currentAndNext(ctx, ctx.feeds, timestamp))
|
|
362
|
+
if (s.marketId !== 0n)
|
|
363
|
+
markets.add(s.marketId);
|
|
364
|
+
const balances = [];
|
|
365
|
+
for (const mid of [...markets].sort((a, b) => (a < b ? -1 : 1))) {
|
|
366
|
+
const [up, down] = (await Promise.all([read(ctx, "upBalance", [mid, who]), read(ctx, "downBalance", [mid, who])]));
|
|
367
|
+
if (up === 0n && down === 0n)
|
|
368
|
+
continue;
|
|
369
|
+
const outcome = (await read(ctx, "outcome", [mid]));
|
|
370
|
+
const share = 100n * ctx.cent;
|
|
371
|
+
const value = outcome === 1 ? up * share : outcome === 2 ? down * share : outcome === 3 ? ((up + down) * share) / 2n : null;
|
|
372
|
+
balances.push({
|
|
373
|
+
marketId: mid,
|
|
374
|
+
upShares: up,
|
|
375
|
+
downShares: down,
|
|
376
|
+
outcome: OUTCOMES[outcome],
|
|
377
|
+
redeemableUsd: value === null ? null : usd(value, ctx.cent),
|
|
378
|
+
action: value === null ? "wait for resolution (or merge UP+DOWN pairs back to collateral)" : "redeem",
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
const [collateral, native] = await Promise.all([
|
|
382
|
+
readToken(ctx, "balanceOf", [who]),
|
|
383
|
+
ctx.pub.getBalance({ address: who }),
|
|
384
|
+
]);
|
|
385
|
+
return ok({
|
|
386
|
+
account: who,
|
|
387
|
+
block: number,
|
|
388
|
+
collateral: { usd: usd(collateral, ctx.cent), units: collateral },
|
|
389
|
+
nativeBalanceWei: native,
|
|
390
|
+
openOrders: orders,
|
|
391
|
+
balances,
|
|
392
|
+
note: n > MAX_ORDERS_SCANNED ? `only the last ${MAX_ORDERS_SCANNED} order nonces were scanned` : undefined,
|
|
393
|
+
});
|
|
394
|
+
}));
|
|
395
|
+
server.registerTool("preview_order", {
|
|
396
|
+
title: "Preview order",
|
|
397
|
+
description: "Quotes an order without sending anything: which book side and tick it maps to, the escrow it locks, the worst/best case if fully filled at the limit, the fee, and whether it passes the market's state, your balances and this server's limits. Use before place_order.",
|
|
398
|
+
inputSchema: orderShape,
|
|
399
|
+
annotations: readOnly,
|
|
400
|
+
}, guarded(async (a) => ok((await quote(ctx, a)).view)));
|
|
401
|
+
server.registerTool("place_order", {
|
|
402
|
+
title: "Place order",
|
|
403
|
+
description: "Sends a limit order into the current block's batch (one transaction; plus a collateral approval first if needed, capped at this server's per-call spend limit). Returns the tx hash, the order's nonce and batchId (block). The order lives one block: afterwards call claim with its nonce to collect fills and refunds. Refused if over the server's size or spend limits, on a chain not allowed for writes, or if the market is not trading.",
|
|
404
|
+
inputSchema: orderShape,
|
|
405
|
+
annotations: writes,
|
|
406
|
+
}, guarded(async (a) => {
|
|
407
|
+
const blocked = writeBlocker(ctx);
|
|
408
|
+
if (blocked)
|
|
409
|
+
return fail(blocked);
|
|
410
|
+
return serialize(ctx, async () => {
|
|
411
|
+
const q = await quote(ctx, a);
|
|
412
|
+
if (q.problems.length)
|
|
413
|
+
return fail(`order refused: ${q.problems.join("; ")}`);
|
|
414
|
+
let approval;
|
|
415
|
+
const escrow = q.escrowCollateral;
|
|
416
|
+
if (escrow > 0n)
|
|
417
|
+
approval = await ensureAllowance(ctx, escrow);
|
|
418
|
+
const sent = await send(ctx, "everyblock", "placeOrder", [q.marketId, q.kind, a.price, q.size]);
|
|
419
|
+
const ev = eventArgs(sent, "OrderPlaced");
|
|
420
|
+
return ok({
|
|
421
|
+
placed: true,
|
|
422
|
+
...tx(sent),
|
|
423
|
+
approval,
|
|
424
|
+
order: { ...q.view.order, nonce: ev?.nonce, batchId: ev?.batchId, upBookTick: ev?.tick },
|
|
425
|
+
escrow: q.view.escrow,
|
|
426
|
+
next: `Batch ${ev?.batchId} clears once its block has passed. Then call claim with nonces [${ev?.nonce}] (get_batch shows the clearing price).`,
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
}));
|
|
430
|
+
server.registerTool("claim", {
|
|
431
|
+
title: "Claim orders",
|
|
432
|
+
description: "Settles your orders whose batch has cleared (the block after they were placed or later): credits the UP/DOWN shares bought, pays the cash from sales, refunds anything unfilled plus price improvement. One transaction per nonce; returns each tx hash and what it paid. Order nonces come from place_order or get_positions.",
|
|
433
|
+
inputSchema: { nonces: z.array(z.number().int().min(0)).min(1).max(20).describe("Order nonces to claim.") },
|
|
434
|
+
annotations: writes,
|
|
435
|
+
}, guarded(async ({ nonces }) => {
|
|
436
|
+
const blocked = writeBlocker(ctx);
|
|
437
|
+
if (blocked)
|
|
438
|
+
return fail(blocked);
|
|
439
|
+
return serialize(ctx, async () => {
|
|
440
|
+
const results = [];
|
|
441
|
+
for (const nonce of nonces) {
|
|
442
|
+
try {
|
|
443
|
+
const sent = await send(ctx, "everyblock", "claim", [BigInt(nonce)]);
|
|
444
|
+
const c = eventArgs(sent, "Claimed");
|
|
445
|
+
results.push({
|
|
446
|
+
nonce,
|
|
447
|
+
claimed: true,
|
|
448
|
+
...tx(sent),
|
|
449
|
+
marketId: c?.marketId,
|
|
450
|
+
batchId: c?.batchId,
|
|
451
|
+
filled: c?.filled,
|
|
452
|
+
upShares: c?.upOut,
|
|
453
|
+
downShares: c?.downOut,
|
|
454
|
+
cashUsd: c ? usd(c.cashOut, ctx.cent) : undefined,
|
|
455
|
+
cashUnits: c?.cashOut,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
catch (e) {
|
|
459
|
+
results.push({ nonce, claimed: false, error: reason(e) });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return ok({ results });
|
|
463
|
+
});
|
|
464
|
+
}));
|
|
465
|
+
server.registerTool("redeem", {
|
|
466
|
+
title: "Redeem",
|
|
467
|
+
description: "After a market resolves, burns all your UP and DOWN shares of it and pays $1.00 per winning share ($0.50 per share of either side if void). Claim open orders in that market first so their shares are included.",
|
|
468
|
+
inputSchema: { marketId: id.describe("Market id.") },
|
|
469
|
+
annotations: writes,
|
|
470
|
+
}, guarded(async ({ marketId }) => {
|
|
471
|
+
const blocked = writeBlocker(ctx);
|
|
472
|
+
if (blocked)
|
|
473
|
+
return fail(blocked);
|
|
474
|
+
return serialize(ctx, async () => {
|
|
475
|
+
const sent = await send(ctx, "everyblock", "redeem", [BigInt(marketId)]);
|
|
476
|
+
const r = eventArgs(sent, "Redeemed");
|
|
477
|
+
return ok({
|
|
478
|
+
redeemed: true,
|
|
479
|
+
...tx(sent),
|
|
480
|
+
marketId,
|
|
481
|
+
upShares: r?.up,
|
|
482
|
+
downShares: r?.down,
|
|
483
|
+
payoutUsd: r ? usd(r.payout, ctx.cent) : undefined,
|
|
484
|
+
payoutUnits: r?.payout,
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
}));
|
|
488
|
+
server.registerTool("split", {
|
|
489
|
+
title: "Split collateral into UP + DOWN",
|
|
490
|
+
description: "Locks $1.00 of collateral per set and credits 1 UP + 1 DOWN share of the market per set, e.g. to sell one side. Allowed until the market closes; capped by this server's spend limit.",
|
|
491
|
+
inputSchema: { marketId: id.describe("Market id."), sets: z.number().int().positive().describe("Number of sets.") },
|
|
492
|
+
annotations: writes,
|
|
493
|
+
}, guarded(async ({ marketId, sets }) => {
|
|
494
|
+
const blocked = writeBlocker(ctx);
|
|
495
|
+
if (blocked)
|
|
496
|
+
return fail(blocked);
|
|
497
|
+
const cost = BigInt(sets) * 100n * ctx.cent;
|
|
498
|
+
if (cost > ctx.maxSpend) {
|
|
499
|
+
return fail(`split of $${usd(cost, ctx.cent)} is over this server's limit of $${usd(ctx.maxSpend, ctx.cent)} per call`);
|
|
500
|
+
}
|
|
501
|
+
return serialize(ctx, async () => {
|
|
502
|
+
const approval = await ensureAllowance(ctx, cost);
|
|
503
|
+
const sent = await send(ctx, "everyblock", "split", [BigInt(marketId), BigInt(sets)]);
|
|
504
|
+
return ok({ split: true, ...tx(sent), approval, marketId, sets, costUsd: usd(cost, ctx.cent), upShares: sets, downShares: sets });
|
|
505
|
+
});
|
|
506
|
+
}));
|
|
507
|
+
server.registerTool("merge", {
|
|
508
|
+
title: "Merge UP + DOWN into collateral",
|
|
509
|
+
description: "Burns 1 UP + 1 DOWN share per set and returns $1.00 of collateral per set. Works any time, even before resolution.",
|
|
510
|
+
inputSchema: { marketId: id.describe("Market id."), sets: z.number().int().positive().describe("Number of sets.") },
|
|
511
|
+
annotations: writes,
|
|
512
|
+
}, guarded(async ({ marketId, sets }) => {
|
|
513
|
+
const blocked = writeBlocker(ctx);
|
|
514
|
+
if (blocked)
|
|
515
|
+
return fail(blocked);
|
|
516
|
+
return serialize(ctx, async () => {
|
|
517
|
+
const sent = await send(ctx, "everyblock", "merge", [BigInt(marketId), BigInt(sets)]);
|
|
518
|
+
return ok({ merged: true, ...tx(sent), marketId, sets, returnedUsd: usd(BigInt(sets) * 100n * ctx.cent, ctx.cent) });
|
|
519
|
+
});
|
|
520
|
+
}));
|
|
521
|
+
server.registerTool("faucet", {
|
|
522
|
+
title: "Testnet faucet",
|
|
523
|
+
description: "Testnet only: mints free, valueless TestUSD collateral (10,000 per call) to this server's wallet. Refused on mainnet and when the collateral is not TestUSD. The wallet still needs a little native MON for gas.",
|
|
524
|
+
inputSchema: {},
|
|
525
|
+
annotations: { ...writes, idempotentHint: false },
|
|
526
|
+
}, guarded(async () => {
|
|
527
|
+
const blocked = writeBlocker(ctx);
|
|
528
|
+
if (blocked)
|
|
529
|
+
return fail(blocked);
|
|
530
|
+
if (ctx.chainId === MONAD_MAINNET)
|
|
531
|
+
return fail("the faucet is testnet only");
|
|
532
|
+
try {
|
|
533
|
+
await readToken(ctx, "FAUCET_AMOUNT");
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
return fail(`collateral ${ctx.collateral} is not TestUSD; there is no faucet`);
|
|
537
|
+
}
|
|
538
|
+
return serialize(ctx, async () => {
|
|
539
|
+
const sent = await send(ctx, "collateral", "faucet", []);
|
|
540
|
+
const bal = (await readToken(ctx, "balanceOf", [ctx.address]));
|
|
541
|
+
return ok({ minted: true, ...tx(sent), collateralUsd: usd(bal, ctx.cent), units: bal });
|
|
542
|
+
});
|
|
543
|
+
}));
|
|
544
|
+
server.registerTool("open_market", {
|
|
545
|
+
title: "Open a market",
|
|
546
|
+
description: "Opens the current (or next) market for a feed, so there is something to trade when no keeper runs. Anyone may open one; it costs gas only. If it is already open, returns its id without sending anything.",
|
|
547
|
+
inputSchema: {
|
|
548
|
+
feed: z.string().optional().describe('Feed name or id, e.g. "BTC" (default BTC).'),
|
|
549
|
+
when: z.enum(["current", "next"]).optional().describe("The slot trading now (default) or the next one."),
|
|
550
|
+
},
|
|
551
|
+
annotations: { ...writes, idempotentHint: true },
|
|
552
|
+
}, guarded(async ({ feed, when }) => {
|
|
553
|
+
const blocked = writeBlocker(ctx);
|
|
554
|
+
if (blocked)
|
|
555
|
+
return fail(blocked);
|
|
556
|
+
const [feedId] = resolveFeeds(ctx, feed ?? "BTC");
|
|
557
|
+
const d = (await read(ctx, "feedDuration", [feedId]));
|
|
558
|
+
if (d === 0n)
|
|
559
|
+
return fail(`${feedName(feedId)} is not listed`);
|
|
560
|
+
const t = (await now(ctx)).timestamp;
|
|
561
|
+
const openTime = (t / d) * d + (when === "next" ? d : 0n);
|
|
562
|
+
const existing = (await read(ctx, "marketOf", [feedId, openTime]));
|
|
563
|
+
if (existing !== 0n)
|
|
564
|
+
return ok({ opened: false, alreadyOpen: true, marketId: existing, feed: feedName(feedId), open: iso(openTime), close: iso(openTime + d) });
|
|
565
|
+
return serialize(ctx, async () => {
|
|
566
|
+
const sent = await send(ctx, "everyblock", "openMarket", [feedId, openTime]);
|
|
567
|
+
const created = eventArgs(sent, "MarketCreated");
|
|
568
|
+
return ok({ opened: true, ...tx(sent), marketId: created?.marketId, feed: feedName(feedId), open: iso(openTime), close: iso(openTime + d) });
|
|
569
|
+
});
|
|
570
|
+
}));
|
|
571
|
+
server.registerTool("settle", {
|
|
572
|
+
title: "Prove the price to beat or the result",
|
|
573
|
+
description: "Submits the signed Pyth prices that set a market's strike (after its open) or resolve it (about 70 s after its close), when no keeper has. Anyone may do this; it costs gas plus Pyth's tiny update fee. The prices come from the Everyblock app's proof endpoint, so no Pyth key is needed. Proves the strike first if it is missing.",
|
|
574
|
+
inputSchema: {
|
|
575
|
+
marketId: id.describe("Market id."),
|
|
576
|
+
what: z.enum(["strike", "result"]).optional().describe("strike: only the price to beat. result (default): the result, and the strike first if missing."),
|
|
577
|
+
},
|
|
578
|
+
annotations: writes,
|
|
579
|
+
}, guarded(async ({ marketId, what }) => {
|
|
580
|
+
const blocked = writeBlocker(ctx);
|
|
581
|
+
if (blocked)
|
|
582
|
+
return fail(blocked);
|
|
583
|
+
if (!ctx.proofUrl)
|
|
584
|
+
return fail("no proof endpoint configured (set EVERYBLOCK_PROOF_URL to the app's /api/proof)");
|
|
585
|
+
const m = (await read(ctx, "getMarket", [BigInt(marketId)]));
|
|
586
|
+
if (m.outcome !== 0)
|
|
587
|
+
return ok({ settled: false, alreadyResolved: true, marketId });
|
|
588
|
+
const steps = what === "strike" ? (m.strikeSet ? [] : ["strike"]) : m.strikeSet ? ["close"] : ["strike", "close"];
|
|
589
|
+
if (steps.length === 0)
|
|
590
|
+
return ok({ settled: false, strikeAlreadySet: true, marketId });
|
|
591
|
+
const pyth = (await read(ctx, "pyth"));
|
|
592
|
+
return serialize(ctx, async () => {
|
|
593
|
+
const done = [];
|
|
594
|
+
for (const w of steps) {
|
|
595
|
+
const res = await fetch(`${ctx.proofUrl}?market=${marketId}&window=${w}`, { signal: AbortSignal.timeout(20_000) });
|
|
596
|
+
const body = (await res.json().catch(() => ({})));
|
|
597
|
+
if (!res.ok || !body.updates) {
|
|
598
|
+
const hint = res.status === 425 ? " (too early: the window's last price isn't out yet)" : "";
|
|
599
|
+
throw new Error(`proof endpoint: ${body.error ?? `HTTP ${res.status}`}${hint}`);
|
|
600
|
+
}
|
|
601
|
+
const fee = (await ctx.pub.readContract({ address: pyth, abi: PYTH_FEE_ABI, functionName: "getUpdateFee", args: [body.updates] }));
|
|
602
|
+
const sent = await send(ctx, "everyblock", w === "strike" ? "setStrike" : "resolve", [BigInt(marketId), body.updates], fee);
|
|
603
|
+
done.push({ step: w === "strike" ? "strike" : "result", ...tx(sent) });
|
|
604
|
+
}
|
|
605
|
+
const after = (await read(ctx, "getMarket", [BigInt(marketId)]));
|
|
606
|
+
return ok({
|
|
607
|
+
settled: true,
|
|
608
|
+
marketId,
|
|
609
|
+
steps: done,
|
|
610
|
+
strike: meanPrice(after.strikeSum, after.expo, after.strikeSet),
|
|
611
|
+
closingPrice: after.outcome !== 0 ? meanPrice(after.closeSum, after.expo, true) : null,
|
|
612
|
+
outcome: OUTCOMES[after.outcome] ?? after.outcome,
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
}));
|
|
616
|
+
return server;
|
|
617
|
+
}
|
|
618
|
+
const PYTH_FEE_ABI = [
|
|
619
|
+
{
|
|
620
|
+
type: "function",
|
|
621
|
+
name: "getUpdateFee",
|
|
622
|
+
stateMutability: "view",
|
|
623
|
+
inputs: [{ name: "updateData", type: "bytes[]" }],
|
|
624
|
+
outputs: [{ name: "feeAmount", type: "uint256" }],
|
|
625
|
+
},
|
|
626
|
+
];
|
|
627
|
+
/**
|
|
628
|
+
* Tops the Everyblock allowance up to the per-call spend limit when it is below what a write needs, so the
|
|
629
|
+
* standing approval never exceeds MCP_MAX_SPEND_USD.
|
|
630
|
+
*/
|
|
631
|
+
async function ensureAllowance(ctx, needed) {
|
|
632
|
+
const allowance = (await readToken(ctx, "allowance", [ctx.address, ctx.everyblock]));
|
|
633
|
+
if (allowance >= needed)
|
|
634
|
+
return undefined;
|
|
635
|
+
const amount = needed > ctx.maxSpend ? needed : ctx.maxSpend;
|
|
636
|
+
const sent = await send(ctx, "collateral", "approve", [ctx.everyblock, amount]);
|
|
637
|
+
return { ...tx(sent), approvedUsd: usd(amount, ctx.cent) };
|
|
638
|
+
}
|