@aibtc/mcp-server 1.69.0 → 1.70.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/dist/config/at-stake.d.ts +120 -0
- package/dist/config/at-stake.js +183 -0
- package/dist/services/at-stake.service.d.ts +144 -0
- package/dist/services/at-stake.service.js +272 -0
- package/dist/services/spend-limiter.js +42 -18
- package/dist/services/state-lock.d.ts +73 -0
- package/dist/services/state-lock.js +229 -0
- package/dist/tools/at-stake-legion.tools.d.ts +31 -0
- package/dist/tools/at-stake-legion.tools.js +776 -0
- package/dist/tools/at-stake.tools.d.ts +29 -0
- package/dist/tools/at-stake.tools.js +684 -0
- package/dist/tools/competition.tools.d.ts +12 -11
- package/dist/tools/competition.tools.js +3 -115
- package/dist/tools/index.js +6 -3
- package/package.json +1 -1
- package/skill/SKILL.md +37 -1
- package/skill/references/at-stake.md +214 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* At Stake market tools — the El Salvador PoX-5 prediction market.
|
|
3
|
+
*
|
|
4
|
+
* Trading shape, in the order a caller meets it:
|
|
5
|
+
* mint — 1 sat of sBTC buys 1 IDLE + 1 BONDED share. Always 50/50, never a
|
|
6
|
+
* directional bet on its own.
|
|
7
|
+
* bid — escrow sBTC against a resting offer to buy one side under par.
|
|
8
|
+
* merge — hand back a matched pair for its sat, any time before resolve.
|
|
9
|
+
* redeem— after resolve, the winning side pays 1 sat per share.
|
|
10
|
+
*
|
|
11
|
+
* THERE IS NO BUY-YES. Minting leaves a caller flat. The directional trade
|
|
12
|
+
* these tools expose is a resting bid on the side they believe, which fills when
|
|
13
|
+
* a keeper matches it against an opposite-side bid. A caller who mints and stops has spent sBTC to
|
|
14
|
+
* take no view at all, so `atstake_mint_complete_set` says so in its result.
|
|
15
|
+
*
|
|
16
|
+
* EVERYTHING HERE MOVES REAL VALUE on Stacks mainnet. `mint` and `place_bid`
|
|
17
|
+
* spend sBTC with no withdrawal path other than merge, cancel or redeem, and
|
|
18
|
+
* both meter against the wallet's `sats` spending rail. That metering happens
|
|
19
|
+
* inside `callContract`, which reads the exact post-condition these calls sign
|
|
20
|
+
* in DENY mode — it must NOT be repeated here, or every mint would bill twice.
|
|
21
|
+
*
|
|
22
|
+
* `resolve-bonded` is deliberately absent. It carries a Bitcoin SPV proof —
|
|
23
|
+
* raw lockup transaction, 80-byte header, merkle path, funding transaction —
|
|
24
|
+
* and building one belongs in a prover, not in an MCP argument list. The
|
|
25
|
+
* permissionless NO settlement, `resolve-idle`, takes no arguments and is here.
|
|
26
|
+
*/
|
|
27
|
+
import { z } from "zod";
|
|
28
|
+
import { Pc, PostConditionMode, principalCV, uintCV, } from "@stacks/transactions";
|
|
29
|
+
import { getExplorerTxUrl } from "../config/networks.js";
|
|
30
|
+
import { createJsonResponse, createErrorResponse } from "../utils/index.js";
|
|
31
|
+
import { callContract } from "../transactions/builder.js";
|
|
32
|
+
import { AT_STAKE_NETWORK, CLOSE_HEIGHT, MARKET_ADDRESS, MARKET_CONTRACT, MARKET_NAME, MARKET_SITE_URL, MIN_FILL_BPS, SBTC_ASSET_NAME, SBTC_CONTRACT, SIDES, STATUS_OPEN, resolveSide, } from "../config/at-stake.js";
|
|
33
|
+
import { explainAbort, getAtStakeAccount, getBid, getBurnHeight, getMarket, getPosition, isTradeable, readMarket, } from "../services/at-stake.service.js";
|
|
34
|
+
/** sBTC post-conditions all name the same asset; spell it once. */
|
|
35
|
+
const SBTC_FT = SBTC_CONTRACT;
|
|
36
|
+
const SIDE_ARG = z
|
|
37
|
+
.enum(["yes", "no", "bonded", "idle"])
|
|
38
|
+
.describe('Which side: "yes"/"bonded" (pays if the coins entered a PoX-5 bond) or ' +
|
|
39
|
+
'"no"/"idle" (pays if they did not).');
|
|
40
|
+
function explorerUrl(txid) {
|
|
41
|
+
return getExplorerTxUrl(txid, AT_STAKE_NETWORK);
|
|
42
|
+
}
|
|
43
|
+
function atStakeError(error) {
|
|
44
|
+
return createErrorResponse(explainAbort(error));
|
|
45
|
+
}
|
|
46
|
+
export function registerAtStakeTools(server) {
|
|
47
|
+
// ==========================================================================
|
|
48
|
+
// atstake_market_status
|
|
49
|
+
// ==========================================================================
|
|
50
|
+
server.registerTool("atstake_market_status", {
|
|
51
|
+
description: "The El Salvador PoX-5 prediction market as it stands right now: the claim, " +
|
|
52
|
+
"whether it still trades, how much sBTC is escrowed, and how far the close " +
|
|
53
|
+
"height is.\n\n" +
|
|
54
|
+
"The market asks whether any of twenty frozen El Salvador reserve scripts spent " +
|
|
55
|
+
"an output into a Stacks PoX-5 protocol bond before burn height 994,699. It has " +
|
|
56
|
+
"no admin key and no oracle: it settles YES only on a Bitcoin SPV proof, and NO " +
|
|
57
|
+
"by anyone calling resolve-idle after the close height.\n\n" +
|
|
58
|
+
"Read-only, no wallet needed.",
|
|
59
|
+
inputSchema: {},
|
|
60
|
+
}, async () => {
|
|
61
|
+
try {
|
|
62
|
+
const [market, burnHeight] = await Promise.all([
|
|
63
|
+
getMarket(),
|
|
64
|
+
getBurnHeight(),
|
|
65
|
+
]);
|
|
66
|
+
const tradeable = isTradeable(market, burnHeight);
|
|
67
|
+
const blocksLeft = market.closeHeight - burnHeight;
|
|
68
|
+
return createJsonResponse({
|
|
69
|
+
contract: MARKET_CONTRACT,
|
|
70
|
+
network: AT_STAKE_NETWORK,
|
|
71
|
+
site: MARKET_SITE_URL,
|
|
72
|
+
claim: market.title,
|
|
73
|
+
status: market.statusLabel,
|
|
74
|
+
tradeable,
|
|
75
|
+
burnHeight,
|
|
76
|
+
closeHeight: market.closeHeight,
|
|
77
|
+
blocksToClose: blocksLeft > 0 ? blocksLeft : 0,
|
|
78
|
+
approxDaysToClose: blocksLeft > 0 ? Math.round((blocksLeft * 10) / 144) / 10 : 0,
|
|
79
|
+
escrow: {
|
|
80
|
+
vaultSats: market.vault,
|
|
81
|
+
idleShares: market.idleCirc,
|
|
82
|
+
bondedShares: market.bondedCirc,
|
|
83
|
+
invariantHolds: market.vault === market.idleCirc &&
|
|
84
|
+
market.vault === market.bondedCirc,
|
|
85
|
+
},
|
|
86
|
+
settlement: market.status === STATUS_OPEN
|
|
87
|
+
? blocksLeft > 0
|
|
88
|
+
? "Open. YES needs a Bitcoin SPV proof via resolve-bonded; NO becomes " +
|
|
89
|
+
"callable by anyone once burn height passes the close height."
|
|
90
|
+
: "Past the close height and still unresolved — atstake_resolve_idle " +
|
|
91
|
+
"can be called by anyone to settle it NO."
|
|
92
|
+
: `Resolved ${market.statusLabel}. Winning shares redeem at 1 sat each.`,
|
|
93
|
+
note: "Vault, idle and bonded circulation are equal by construction: every sat " +
|
|
94
|
+
"in escrow backs exactly one share of each side.",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
return atStakeError(error);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
// ==========================================================================
|
|
102
|
+
// atstake_position
|
|
103
|
+
// ==========================================================================
|
|
104
|
+
server.registerTool("atstake_position", {
|
|
105
|
+
description: "Share balances for an address, and what they are worth under each outcome.\n\n" +
|
|
106
|
+
"Equal idle and bonded balances mean a flat book: the holder minted and has not " +
|
|
107
|
+
"yet taken a side, and the pair merges back to sats at par. The directional " +
|
|
108
|
+
"position is the DIFFERENCE between the two.\n\n" +
|
|
109
|
+
"Read-only. Defaults to the unlocked wallet, which requires one; pass an address " +
|
|
110
|
+
"to read anyone without unlocking.",
|
|
111
|
+
inputSchema: {
|
|
112
|
+
address: z
|
|
113
|
+
.string()
|
|
114
|
+
.optional()
|
|
115
|
+
.describe("Stacks address to read. Defaults to the unlocked wallet."),
|
|
116
|
+
},
|
|
117
|
+
}, async ({ address }) => {
|
|
118
|
+
try {
|
|
119
|
+
const who = address ?? (await getAtStakeAccount()).address;
|
|
120
|
+
const [position, market] = await Promise.all([
|
|
121
|
+
getPosition(who),
|
|
122
|
+
getMarket(),
|
|
123
|
+
]);
|
|
124
|
+
const matched = Math.min(position.idle, position.bonded);
|
|
125
|
+
return createJsonResponse({
|
|
126
|
+
address: who,
|
|
127
|
+
contract: MARKET_CONTRACT,
|
|
128
|
+
network: AT_STAKE_NETWORK,
|
|
129
|
+
shares: {
|
|
130
|
+
bonded_yes: position.bonded,
|
|
131
|
+
idle_no: position.idle,
|
|
132
|
+
},
|
|
133
|
+
mergeableSats: matched,
|
|
134
|
+
netExposure: position.bonded === position.idle
|
|
135
|
+
? "flat"
|
|
136
|
+
: position.bonded > position.idle
|
|
137
|
+
? `long yes by ${position.bonded - position.idle} shares`
|
|
138
|
+
: `long no by ${position.idle - position.bonded} shares`,
|
|
139
|
+
payout: {
|
|
140
|
+
ifBonded_yes: position.bonded,
|
|
141
|
+
ifIdle_no: position.idle,
|
|
142
|
+
},
|
|
143
|
+
marketStatus: market.statusLabel,
|
|
144
|
+
redeemable: market.status === STATUS_OPEN
|
|
145
|
+
? 0
|
|
146
|
+
: market.status === SIDES.yes.winStatus
|
|
147
|
+
? position.bonded
|
|
148
|
+
: position.idle,
|
|
149
|
+
legionWeight: {
|
|
150
|
+
yes: position.bonded,
|
|
151
|
+
no: position.idle,
|
|
152
|
+
note: "Legion voting weight IS this balance, read live. Selling a side drops " +
|
|
153
|
+
"the vote that side carries.",
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
return atStakeError(error);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
// ==========================================================================
|
|
162
|
+
// atstake_subject
|
|
163
|
+
// ==========================================================================
|
|
164
|
+
server.registerTool("atstake_subject", {
|
|
165
|
+
description: "What the market is actually about: the twenty El Salvador reserve addresses, " +
|
|
166
|
+
"their output scripts, and the PoX-5 reward cycle indices that count.\n\n" +
|
|
167
|
+
"These are the addresses a YES proof must show spending into a lockup. Anyone " +
|
|
168
|
+
"arguing either side in a legion should be reading these on Bitcoin directly " +
|
|
169
|
+
"rather than taking the market's word for the balances.\n\n" +
|
|
170
|
+
"Read-only, no wallet needed.",
|
|
171
|
+
inputSchema: {},
|
|
172
|
+
}, async () => {
|
|
173
|
+
try {
|
|
174
|
+
const [addresses, scripts, indices] = await Promise.all([
|
|
175
|
+
readMarket("get-reserve-addresses"),
|
|
176
|
+
readMarket("get-subject-scripts"),
|
|
177
|
+
readMarket("get-bond-indices"),
|
|
178
|
+
]);
|
|
179
|
+
const list = (cv) => Array.isArray(cv?.value)
|
|
180
|
+
? cv.value
|
|
181
|
+
: [];
|
|
182
|
+
const plain = (cv) => list(cv).map((item) => String(item.value));
|
|
183
|
+
return createJsonResponse({
|
|
184
|
+
contract: MARKET_CONTRACT,
|
|
185
|
+
reserveAddresses: plain(addresses),
|
|
186
|
+
subjectScripts: plain(scripts),
|
|
187
|
+
bondIndices: plain(indices).map(Number),
|
|
188
|
+
closeHeight: CLOSE_HEIGHT,
|
|
189
|
+
howYesIsProven: "resolve-bonded must show a Bitcoin transaction that spends an output " +
|
|
190
|
+
"paying one of these scripts into a PoX-5 lockup, at one of these reward " +
|
|
191
|
+
"cycle indices, mined before the close height. The proof is a raw " +
|
|
192
|
+
"transaction, its block header, and a merkle path.",
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
return atStakeError(error);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
// ==========================================================================
|
|
200
|
+
// atstake_mint_complete_set
|
|
201
|
+
// ==========================================================================
|
|
202
|
+
server.registerTool("atstake_mint_complete_set", {
|
|
203
|
+
description: "Spend sBTC to mint a matched pair: each sat buys 1 BONDED (yes) share AND " +
|
|
204
|
+
"1 IDLE (no) share.\n\n" +
|
|
205
|
+
"THIS DOES NOT TAKE A SIDE. It leaves the caller flat and fully hedged — the " +
|
|
206
|
+
"pair is worth exactly what it cost, whatever happens. To take a view at a " +
|
|
207
|
+
"price, use atstake_place_bid on the side you believe instead.\n\n" +
|
|
208
|
+
"It is also how you buy legion voting weight, because weight is the share " +
|
|
209
|
+
"balance. Minting 1,000 sats clears the minimum on BOTH legions at once.\n\n" +
|
|
210
|
+
"Reversible before the market resolves: atstake_merge_complete_set hands any " +
|
|
211
|
+
"matched pair back for its sat. Spends real sBTC and meters against the " +
|
|
212
|
+
"wallet's sats rail. Requires an unlocked wallet.",
|
|
213
|
+
inputSchema: {
|
|
214
|
+
sats: z
|
|
215
|
+
.number()
|
|
216
|
+
.int()
|
|
217
|
+
.positive()
|
|
218
|
+
.describe("sBTC sats to escrow. Mints this many shares of EACH side. The legion " +
|
|
219
|
+
"minimum is 1,000."),
|
|
220
|
+
},
|
|
221
|
+
}, async ({ sats }) => {
|
|
222
|
+
try {
|
|
223
|
+
const account = await getAtStakeAccount();
|
|
224
|
+
const [market, burnHeight] = await Promise.all([
|
|
225
|
+
getMarket(),
|
|
226
|
+
getBurnHeight(),
|
|
227
|
+
]);
|
|
228
|
+
if (!isTradeable(market, burnHeight)) {
|
|
229
|
+
return createErrorResponse(new Error(market.status !== STATUS_OPEN
|
|
230
|
+
? `The market has resolved ${market.statusLabel} and no longer mints ` +
|
|
231
|
+
`(u102). Winning shares redeem with atstake_redeem.`
|
|
232
|
+
: `The close height ${market.closeHeight} has passed at burn height ` +
|
|
233
|
+
`${burnHeight}, so the market no longer trades (u103). It can be ` +
|
|
234
|
+
`settled NO with atstake_resolve_idle.`));
|
|
235
|
+
}
|
|
236
|
+
const result = await callContract(account, {
|
|
237
|
+
contractAddress: MARKET_ADDRESS,
|
|
238
|
+
contractName: MARKET_NAME,
|
|
239
|
+
functionName: "mint-complete-set",
|
|
240
|
+
functionArgs: [uintCV(sats)],
|
|
241
|
+
postConditionMode: PostConditionMode.Deny,
|
|
242
|
+
// Exactly `sats` leaves the caller, and nothing comes back: shares are
|
|
243
|
+
// map entries, not an asset a post-condition can name.
|
|
244
|
+
postConditions: [
|
|
245
|
+
Pc.principal(account.address)
|
|
246
|
+
.willSendEq(sats)
|
|
247
|
+
.ft(SBTC_FT, SBTC_ASSET_NAME),
|
|
248
|
+
],
|
|
249
|
+
});
|
|
250
|
+
return createJsonResponse({
|
|
251
|
+
success: true,
|
|
252
|
+
txid: result.txid,
|
|
253
|
+
explorerUrl: explorerUrl(result.txid),
|
|
254
|
+
spentSats: sats,
|
|
255
|
+
minted: { bonded_yes: sats, idle_no: sats },
|
|
256
|
+
netExposure: "flat — this pair is hedged",
|
|
257
|
+
legionWeightGained: {
|
|
258
|
+
yes: sats,
|
|
259
|
+
no: sats,
|
|
260
|
+
clearsMinimum: sats >= 1000,
|
|
261
|
+
},
|
|
262
|
+
nextStep: "To take a view, atstake_place_bid. To undo, " +
|
|
263
|
+
"atstake_merge_complete_set. To govern, atstake_legion_propose.",
|
|
264
|
+
network: AT_STAKE_NETWORK,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
return atStakeError(error);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
// ==========================================================================
|
|
272
|
+
// atstake_merge_complete_set
|
|
273
|
+
// ==========================================================================
|
|
274
|
+
server.registerTool("atstake_merge_complete_set", {
|
|
275
|
+
description: "Hand back a matched pair of shares and take the sBTC out: burns `sats` BONDED " +
|
|
276
|
+
"and `sats` IDLE, returns `sats` sBTC.\n\n" +
|
|
277
|
+
"This is the exit at par, and the reason the two sides' prices sum to one. It " +
|
|
278
|
+
"needs both sides in equal measure, so it can only unwind the hedged part of a " +
|
|
279
|
+
"position, never the directional part.\n\n" +
|
|
280
|
+
"Only works while the market is open — after resolve, use atstake_redeem. " +
|
|
281
|
+
"Requires an unlocked wallet.",
|
|
282
|
+
inputSchema: {
|
|
283
|
+
sats: z
|
|
284
|
+
.number()
|
|
285
|
+
.int()
|
|
286
|
+
.positive()
|
|
287
|
+
.describe("Matched pairs to merge back into sBTC."),
|
|
288
|
+
},
|
|
289
|
+
}, async ({ sats }) => {
|
|
290
|
+
try {
|
|
291
|
+
const account = await getAtStakeAccount();
|
|
292
|
+
const [position, market] = await Promise.all([
|
|
293
|
+
getPosition(account.address),
|
|
294
|
+
getMarket(),
|
|
295
|
+
]);
|
|
296
|
+
if (market.status !== STATUS_OPEN) {
|
|
297
|
+
return createErrorResponse(new Error(`The market has resolved ${market.statusLabel}, so pairs no longer merge ` +
|
|
298
|
+
`(u102). Use atstake_redeem to cash the winning side.`));
|
|
299
|
+
}
|
|
300
|
+
const matched = Math.min(position.idle, position.bonded);
|
|
301
|
+
if (matched < sats) {
|
|
302
|
+
return createErrorResponse(new Error(`You hold ${position.bonded} bonded and ${position.idle} idle, so only ` +
|
|
303
|
+
`${matched} pairs can merge — ${sats} were asked for (u107).`));
|
|
304
|
+
}
|
|
305
|
+
const result = await callContract(account, {
|
|
306
|
+
contractAddress: MARKET_ADDRESS,
|
|
307
|
+
contractName: MARKET_NAME,
|
|
308
|
+
functionName: "merge-complete-set",
|
|
309
|
+
functionArgs: [uintCV(sats)],
|
|
310
|
+
postConditionMode: PostConditionMode.Deny,
|
|
311
|
+
postConditions: [
|
|
312
|
+
Pc.principal(MARKET_CONTRACT)
|
|
313
|
+
.willSendEq(sats)
|
|
314
|
+
.ft(SBTC_FT, SBTC_ASSET_NAME),
|
|
315
|
+
],
|
|
316
|
+
});
|
|
317
|
+
return createJsonResponse({
|
|
318
|
+
success: true,
|
|
319
|
+
txid: result.txid,
|
|
320
|
+
explorerUrl: explorerUrl(result.txid),
|
|
321
|
+
mergedPairs: sats,
|
|
322
|
+
receivedSats: sats,
|
|
323
|
+
remaining: {
|
|
324
|
+
bonded_yes: position.bonded - sats,
|
|
325
|
+
idle_no: position.idle - sats,
|
|
326
|
+
},
|
|
327
|
+
note: "Legion weight falls with the balance. Merging below 1,000 on a side gives " +
|
|
328
|
+
"up the right to propose and vote there.",
|
|
329
|
+
network: AT_STAKE_NETWORK,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
return atStakeError(error);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
// ==========================================================================
|
|
337
|
+
// atstake_place_bid
|
|
338
|
+
// ==========================================================================
|
|
339
|
+
server.registerTool("atstake_place_bid", {
|
|
340
|
+
description: "Escrow sBTC in a resting bid to buy one side below par.\n\n" +
|
|
341
|
+
"A bid is the directional trade this market has: offer `total_sats` for " +
|
|
342
|
+
"`amount` shares of one side. Because a share pays at most 1 sat, a bid at or " +
|
|
343
|
+
"above par is rejected outright — the price you set is total_sats/amount, and it " +
|
|
344
|
+
"must be under 1.\n\n" +
|
|
345
|
+
"The bid fills when a counterparty matches it, and the two sides of a match " +
|
|
346
|
+
"must together cover one sat per share. The escrow is locked meanwhile and " +
|
|
347
|
+
"comes back in full via atstake_cancel_bid if it never fills.\n\n" +
|
|
348
|
+
"One resting bid per side at a time. Spends real sBTC and meters against the " +
|
|
349
|
+
"wallet's sats rail. Requires an unlocked wallet.",
|
|
350
|
+
inputSchema: {
|
|
351
|
+
side: SIDE_ARG,
|
|
352
|
+
amount: z
|
|
353
|
+
.number()
|
|
354
|
+
.int()
|
|
355
|
+
.positive()
|
|
356
|
+
.describe("Shares wanted."),
|
|
357
|
+
total_sats: z
|
|
358
|
+
.number()
|
|
359
|
+
.int()
|
|
360
|
+
.positive()
|
|
361
|
+
.describe("sBTC escrowed for the whole bid. Must be less than `amount` — the implied " +
|
|
362
|
+
"price per share is total_sats/amount."),
|
|
363
|
+
},
|
|
364
|
+
}, async ({ side, amount, total_sats }) => {
|
|
365
|
+
try {
|
|
366
|
+
const chosen = resolveSide(side);
|
|
367
|
+
const account = await getAtStakeAccount();
|
|
368
|
+
if (total_sats > amount) {
|
|
369
|
+
return createErrorResponse(new Error(`A share pays at most 1 sat, so bidding ${total_sats} sats for ${amount} ` +
|
|
370
|
+
`shares is above par and always a mistake (u133).`));
|
|
371
|
+
}
|
|
372
|
+
const [market, burnHeight, existing] = await Promise.all([
|
|
373
|
+
getMarket(),
|
|
374
|
+
getBurnHeight(),
|
|
375
|
+
getBid(account.address, chosen),
|
|
376
|
+
]);
|
|
377
|
+
if (!isTradeable(market, burnHeight)) {
|
|
378
|
+
return createErrorResponse(new Error(`The market no longer trades (status ${market.statusLabel}, burn height ` +
|
|
379
|
+
`${burnHeight}, close height ${market.closeHeight}), so bids are closed.`));
|
|
380
|
+
}
|
|
381
|
+
if (existing) {
|
|
382
|
+
return createErrorResponse(new Error(`You already have a resting ${chosen.argues} bid: ${existing.amount} shares ` +
|
|
383
|
+
`for ${existing.escrow} sats (u129). Cancel it before placing another.`));
|
|
384
|
+
}
|
|
385
|
+
const result = await callContract(account, {
|
|
386
|
+
contractAddress: MARKET_ADDRESS,
|
|
387
|
+
contractName: MARKET_NAME,
|
|
388
|
+
functionName: "place-bid",
|
|
389
|
+
functionArgs: [
|
|
390
|
+
uintCV(chosen.side),
|
|
391
|
+
uintCV(amount),
|
|
392
|
+
uintCV(total_sats),
|
|
393
|
+
],
|
|
394
|
+
postConditionMode: PostConditionMode.Deny,
|
|
395
|
+
postConditions: [
|
|
396
|
+
Pc.principal(account.address)
|
|
397
|
+
.willSendEq(total_sats)
|
|
398
|
+
.ft(SBTC_FT, SBTC_ASSET_NAME),
|
|
399
|
+
],
|
|
400
|
+
});
|
|
401
|
+
return createJsonResponse({
|
|
402
|
+
success: true,
|
|
403
|
+
txid: result.txid,
|
|
404
|
+
explorerUrl: explorerUrl(result.txid),
|
|
405
|
+
side: chosen.argues,
|
|
406
|
+
shareLabel: chosen.label,
|
|
407
|
+
amount,
|
|
408
|
+
escrowedSats: total_sats,
|
|
409
|
+
pricePerShare: Number((total_sats / amount).toFixed(6)),
|
|
410
|
+
minimumFill: Math.max(1, Math.floor((amount * MIN_FILL_BPS) / 10000)),
|
|
411
|
+
note: "The escrow is locked until the bid fills or is cancelled. A partial fill " +
|
|
412
|
+
"costs escrow pro rata.",
|
|
413
|
+
network: AT_STAKE_NETWORK,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
return atStakeError(error);
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
// ==========================================================================
|
|
421
|
+
// atstake_get_bid
|
|
422
|
+
// ==========================================================================
|
|
423
|
+
server.registerTool("atstake_get_bid", {
|
|
424
|
+
description: "Read a resting bid: shares wanted, sBTC escrowed, and the price that implies.\n\n" +
|
|
425
|
+
"Read-only. Defaults to the unlocked wallet, which requires one; pass an address " +
|
|
426
|
+
"to read anyone without unlocking.",
|
|
427
|
+
inputSchema: {
|
|
428
|
+
side: SIDE_ARG,
|
|
429
|
+
address: z
|
|
430
|
+
.string()
|
|
431
|
+
.optional()
|
|
432
|
+
.describe("Bidder to read. Defaults to the unlocked wallet."),
|
|
433
|
+
},
|
|
434
|
+
}, async ({ side, address }) => {
|
|
435
|
+
try {
|
|
436
|
+
const chosen = resolveSide(side);
|
|
437
|
+
const who = address ?? (await getAtStakeAccount()).address;
|
|
438
|
+
const bid = await getBid(who, chosen);
|
|
439
|
+
if (!bid) {
|
|
440
|
+
return createJsonResponse({
|
|
441
|
+
address: who,
|
|
442
|
+
side: chosen.argues,
|
|
443
|
+
bid: null,
|
|
444
|
+
note: `No resting ${chosen.argues} bid for this address.`,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
return createJsonResponse({
|
|
448
|
+
address: who,
|
|
449
|
+
side: chosen.argues,
|
|
450
|
+
shareLabel: chosen.label,
|
|
451
|
+
amount: bid.amount,
|
|
452
|
+
escrowedSats: bid.escrow,
|
|
453
|
+
pricePerShare: Number((bid.escrow / bid.amount).toFixed(6)),
|
|
454
|
+
minimumFill: Math.max(1, Math.floor((bid.amount * MIN_FILL_BPS) / 10000)),
|
|
455
|
+
network: AT_STAKE_NETWORK,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
return atStakeError(error);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
// ==========================================================================
|
|
463
|
+
// atstake_cancel_bid
|
|
464
|
+
// ==========================================================================
|
|
465
|
+
server.registerTool("atstake_cancel_bid", {
|
|
466
|
+
description: "Withdraw a resting bid and get the unfilled escrow back.\n\n" +
|
|
467
|
+
"Requires an unlocked wallet.",
|
|
468
|
+
inputSchema: { side: SIDE_ARG },
|
|
469
|
+
}, async ({ side }) => {
|
|
470
|
+
try {
|
|
471
|
+
const chosen = resolveSide(side);
|
|
472
|
+
const account = await getAtStakeAccount();
|
|
473
|
+
const bid = await getBid(account.address, chosen);
|
|
474
|
+
if (!bid) {
|
|
475
|
+
return createErrorResponse(new Error(`You have no resting ${chosen.argues} bid to cancel (u130).`));
|
|
476
|
+
}
|
|
477
|
+
const result = await callContract(account, {
|
|
478
|
+
contractAddress: MARKET_ADDRESS,
|
|
479
|
+
contractName: MARKET_NAME,
|
|
480
|
+
functionName: "cancel-bid",
|
|
481
|
+
functionArgs: [uintCV(chosen.side)],
|
|
482
|
+
postConditionMode: PostConditionMode.Deny,
|
|
483
|
+
// A bid that filled between this read and the block that mines the
|
|
484
|
+
// cancel refunds less than the escrow read here, and one that filled
|
|
485
|
+
// completely refunds nothing. A cap covers every case.
|
|
486
|
+
postConditions: [
|
|
487
|
+
Pc.principal(MARKET_CONTRACT)
|
|
488
|
+
.willSendLte(bid.escrow)
|
|
489
|
+
.ft(SBTC_FT, SBTC_ASSET_NAME),
|
|
490
|
+
],
|
|
491
|
+
});
|
|
492
|
+
return createJsonResponse({
|
|
493
|
+
success: true,
|
|
494
|
+
txid: result.txid,
|
|
495
|
+
explorerUrl: explorerUrl(result.txid),
|
|
496
|
+
side: chosen.argues,
|
|
497
|
+
refundUpToSats: bid.escrow,
|
|
498
|
+
note: "The refund is whatever escrow is still unfilled at the block that mines " +
|
|
499
|
+
"this, which may be less than the amount read a moment ago.",
|
|
500
|
+
network: AT_STAKE_NETWORK,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
return atStakeError(error);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
// ==========================================================================
|
|
508
|
+
// atstake_transfer_shares
|
|
509
|
+
// ==========================================================================
|
|
510
|
+
server.registerTool("atstake_transfer_shares", {
|
|
511
|
+
description: "Send shares of one side to another principal.\n\n" +
|
|
512
|
+
"Transfers are free: the recipient pays nothing, so sending shares away gives " +
|
|
513
|
+
"their value away. Do not use this to take a side. It also moves " +
|
|
514
|
+
"legion voting weight, since weight is the balance — sending 1,000 bonded " +
|
|
515
|
+
"shares hands the recipient a vote in the yes legion and costs you yours.\n\n" +
|
|
516
|
+
"Shares are map entries, not a SIP-010 token, so no fungible-token " +
|
|
517
|
+
"post-condition can guard this and none is signed. Only works while the market " +
|
|
518
|
+
"trades. Requires an unlocked wallet.",
|
|
519
|
+
inputSchema: {
|
|
520
|
+
side: SIDE_ARG,
|
|
521
|
+
amount: z.number().int().positive().describe("Shares to send."),
|
|
522
|
+
to: z.string().describe("Recipient Stacks principal."),
|
|
523
|
+
},
|
|
524
|
+
}, async ({ side, amount, to }) => {
|
|
525
|
+
try {
|
|
526
|
+
const chosen = resolveSide(side);
|
|
527
|
+
const account = await getAtStakeAccount();
|
|
528
|
+
if (to === account.address) {
|
|
529
|
+
return createErrorResponse(new Error("You cannot transfer shares to yourself (u110)."));
|
|
530
|
+
}
|
|
531
|
+
const [position, market, burnHeight] = await Promise.all([
|
|
532
|
+
getPosition(account.address),
|
|
533
|
+
getMarket(),
|
|
534
|
+
getBurnHeight(),
|
|
535
|
+
]);
|
|
536
|
+
if (!isTradeable(market, burnHeight)) {
|
|
537
|
+
return createErrorResponse(new Error(`The market no longer trades (status ${market.statusLabel}, burn height ` +
|
|
538
|
+
`${burnHeight}, close height ${market.closeHeight}), so shares are frozen.`));
|
|
539
|
+
}
|
|
540
|
+
const held = position[chosen.positionKey];
|
|
541
|
+
if (held < amount) {
|
|
542
|
+
return createErrorResponse(new Error(`You hold ${held} ${chosen.label} shares, fewer than the ${amount} asked ` +
|
|
543
|
+
`for (u107).`));
|
|
544
|
+
}
|
|
545
|
+
const result = await callContract(account, {
|
|
546
|
+
contractAddress: MARKET_ADDRESS,
|
|
547
|
+
contractName: MARKET_NAME,
|
|
548
|
+
functionName: "transfer-shares",
|
|
549
|
+
functionArgs: [uintCV(chosen.side), uintCV(amount), principalCV(to)],
|
|
550
|
+
postConditionMode: PostConditionMode.Deny,
|
|
551
|
+
postConditions: [],
|
|
552
|
+
});
|
|
553
|
+
return createJsonResponse({
|
|
554
|
+
success: true,
|
|
555
|
+
txid: result.txid,
|
|
556
|
+
explorerUrl: explorerUrl(result.txid),
|
|
557
|
+
side: chosen.argues,
|
|
558
|
+
shareLabel: chosen.label,
|
|
559
|
+
amount,
|
|
560
|
+
to,
|
|
561
|
+
remainingOnThatSide: held - amount,
|
|
562
|
+
legionWeightAfter: held - amount,
|
|
563
|
+
note: held - amount < 1000
|
|
564
|
+
? "This drops you below the 1,000-share legion minimum on that side: no " +
|
|
565
|
+
"proposing and no voting there until you hold more."
|
|
566
|
+
: "You keep the legion minimum on that side.",
|
|
567
|
+
network: AT_STAKE_NETWORK,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
return atStakeError(error);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
// ==========================================================================
|
|
575
|
+
// atstake_redeem
|
|
576
|
+
// ==========================================================================
|
|
577
|
+
server.registerTool("atstake_redeem", {
|
|
578
|
+
description: "Cash a resolved position: the winning side pays 1 sat per share, the losing " +
|
|
579
|
+
"side pays nothing, and BOTH are burned.\n\n" +
|
|
580
|
+
"Only callable after the market resolves. Redeeming burns the whole position in " +
|
|
581
|
+
"one call, so there is nothing left to redeem a second time.\n\n" +
|
|
582
|
+
"Requires an unlocked wallet.",
|
|
583
|
+
inputSchema: {},
|
|
584
|
+
}, async () => {
|
|
585
|
+
try {
|
|
586
|
+
const account = await getAtStakeAccount();
|
|
587
|
+
const [position, market] = await Promise.all([
|
|
588
|
+
getPosition(account.address),
|
|
589
|
+
getMarket(),
|
|
590
|
+
]);
|
|
591
|
+
if (market.status === STATUS_OPEN) {
|
|
592
|
+
return createErrorResponse(new Error(`The market has not resolved yet, so there is nothing to redeem (u108). ` +
|
|
593
|
+
`While it is open, a matched pair exits at par with ` +
|
|
594
|
+
`atstake_merge_complete_set.`));
|
|
595
|
+
}
|
|
596
|
+
const won = market.status === SIDES.yes.winStatus ? "yes" : "no";
|
|
597
|
+
const payout = won === "yes" ? position.bonded : position.idle;
|
|
598
|
+
if (payout === 0) {
|
|
599
|
+
return createErrorResponse(new Error(`The market resolved ${won.toUpperCase()} and you hold no shares on that ` +
|
|
600
|
+
`side, so redeem would pay nothing (u107). Your losing shares are worth ` +
|
|
601
|
+
`zero and need no action.`));
|
|
602
|
+
}
|
|
603
|
+
const result = await callContract(account, {
|
|
604
|
+
contractAddress: MARKET_ADDRESS,
|
|
605
|
+
contractName: MARKET_NAME,
|
|
606
|
+
functionName: "redeem",
|
|
607
|
+
functionArgs: [],
|
|
608
|
+
postConditionMode: PostConditionMode.Deny,
|
|
609
|
+
// Status is terminal and trading is frozen once resolved, so the
|
|
610
|
+
// payout read here is the payout the chain will compute.
|
|
611
|
+
postConditions: [
|
|
612
|
+
Pc.principal(MARKET_CONTRACT)
|
|
613
|
+
.willSendEq(payout)
|
|
614
|
+
.ft(SBTC_FT, SBTC_ASSET_NAME),
|
|
615
|
+
],
|
|
616
|
+
});
|
|
617
|
+
return createJsonResponse({
|
|
618
|
+
success: true,
|
|
619
|
+
txid: result.txid,
|
|
620
|
+
explorerUrl: explorerUrl(result.txid),
|
|
621
|
+
outcome: won,
|
|
622
|
+
payoutSats: payout,
|
|
623
|
+
burned: { bonded_yes: position.bonded, idle_no: position.idle },
|
|
624
|
+
network: AT_STAKE_NETWORK,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
catch (error) {
|
|
628
|
+
return atStakeError(error);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
// ==========================================================================
|
|
632
|
+
// atstake_resolve_idle
|
|
633
|
+
// ==========================================================================
|
|
634
|
+
server.registerTool("atstake_resolve_idle", {
|
|
635
|
+
description: "Settle the market NO, permissionlessly, once the close height has passed.\n\n" +
|
|
636
|
+
"This is half of the market's settlement: the coins did not enter a bond before " +
|
|
637
|
+
"the deadline, so the idle side wins. Anyone may call it and someone must — " +
|
|
638
|
+
"nobody is paid until the status moves off open, and the legions cannot redeem " +
|
|
639
|
+
"their vaults either.\n\n" +
|
|
640
|
+
"It pays the caller nothing and costs them gas. Moves no assets. Requires an " +
|
|
641
|
+
"unlocked wallet.",
|
|
642
|
+
inputSchema: {},
|
|
643
|
+
}, async () => {
|
|
644
|
+
try {
|
|
645
|
+
const account = await getAtStakeAccount();
|
|
646
|
+
const [market, burnHeight] = await Promise.all([
|
|
647
|
+
getMarket(),
|
|
648
|
+
getBurnHeight(),
|
|
649
|
+
]);
|
|
650
|
+
if (market.status !== STATUS_OPEN) {
|
|
651
|
+
return createErrorResponse(new Error(`The market has already resolved ${market.statusLabel} (u102).`));
|
|
652
|
+
}
|
|
653
|
+
if (burnHeight <= market.closeHeight) {
|
|
654
|
+
return createErrorResponse(new Error(`The trading window is still open: burn height ${burnHeight} has not ` +
|
|
655
|
+
`passed the close height ${market.closeHeight}, ` +
|
|
656
|
+
`${market.closeHeight - burnHeight + 1} blocks to go (u104).`));
|
|
657
|
+
}
|
|
658
|
+
const result = await callContract(account, {
|
|
659
|
+
contractAddress: MARKET_ADDRESS,
|
|
660
|
+
contractName: MARKET_NAME,
|
|
661
|
+
functionName: "resolve-idle",
|
|
662
|
+
functionArgs: [],
|
|
663
|
+
postConditionMode: PostConditionMode.Deny,
|
|
664
|
+
postConditions: [],
|
|
665
|
+
});
|
|
666
|
+
return createJsonResponse({
|
|
667
|
+
success: true,
|
|
668
|
+
txid: result.txid,
|
|
669
|
+
explorerUrl: explorerUrl(result.txid),
|
|
670
|
+
settledAs: "idle (no)",
|
|
671
|
+
calledBy: account.address,
|
|
672
|
+
burnHeight,
|
|
673
|
+
closeHeight: market.closeHeight,
|
|
674
|
+
nextStep: "Idle holders can now call atstake_redeem for 1 sat per share, and the no " +
|
|
675
|
+
"legion can call atstake_legion_redeem_vault.",
|
|
676
|
+
network: AT_STAKE_NETWORK,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
return atStakeError(error);
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
//# sourceMappingURL=at-stake.tools.js.map
|