@xgamesfun/solana 0.28.7 → 0.28.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/js/economy/challenge.mjs +198 -0
- package/js/economy/check.mjs +5 -0
- package/js/economy/explain.mjs +3 -0
- package/js/economy/review.mjs +8 -1
- package/js/economy/simulate.mjs +1 -1
- package/js/economy/walk.mjs +313 -0
- package/js/mcp/server.mjs +6 -0
- package/package.json +1 -1
- package/skills/xsolana-economy/SKILL.md +23 -21
- package/types/economy/challenge.d.mts +4 -0
- package/types/economy/simulate.d.mts +1 -0
- package/types/economy/walk.d.mts +4 -0
- package/types/mcp/server.d.mts +1 -1
- package/types/mcp/tools.d.mts +1 -1
package/README.md
CHANGED
|
@@ -77,7 +77,7 @@ codex plugin add xsolana@xgamesfun
|
|
|
77
77
|
|
|
78
78
|
**claude.ai, ChatGPT or Claude Desktop:** add `https://xgames.fun/mcp` as a custom connector. Nothing to install.
|
|
79
79
|
|
|
80
|
-
**Cursor, Cline, opencode or any MCP client:** run `npx -y --package=@xgamesfun/solana@0.28.
|
|
80
|
+
**Cursor, Cline, opencode or any MCP client:** run `npx -y --package=@xgamesfun/solana@0.28.8 xsolana mcp`.
|
|
81
81
|
|
|
82
82
|
**Any agent that reads skills** (Copilot, Gemini CLI, Windsurf…): `npx skills add XGamesFun/solana-kit`.
|
|
83
83
|
|
|
@@ -88,26 +88,26 @@ are in [docs/harnesses.md](docs/harnesses.md).
|
|
|
88
88
|
|
|
89
89
|
```bash
|
|
90
90
|
# find a worked example close to your idea
|
|
91
|
-
npx -y @xgamesfun/solana@0.28.
|
|
91
|
+
npx -y @xgamesfun/solana@0.28.8 examples shop with loot boxes
|
|
92
92
|
|
|
93
93
|
# using a coin you already launched? read it from the chain into its "tokens" entry
|
|
94
|
-
npx -y @xgamesfun/solana@0.28.
|
|
94
|
+
npx -y @xgamesfun/solana@0.28.8 token <its mint address>
|
|
95
95
|
|
|
96
96
|
# does it work? where does the money go? what does it cost?
|
|
97
|
-
npx -y @xgamesfun/solana@0.28.
|
|
98
|
-
npx -y @xgamesfun/solana@0.28.
|
|
99
|
-
npx -y @xgamesfun/solana@0.28.
|
|
97
|
+
npx -y @xgamesfun/solana@0.28.8 check xsolana.json
|
|
98
|
+
npx -y @xgamesfun/solana@0.28.8 simulate xsolana.json
|
|
99
|
+
npx -y @xgamesfun/solana@0.28.8 cost xsolana.json
|
|
100
100
|
|
|
101
101
|
# the whole economy in plain words, and what a cheater could get
|
|
102
|
-
npx -y @xgamesfun/solana@0.28.
|
|
103
|
-
npx -y @xgamesfun/solana@0.28.
|
|
102
|
+
npx -y @xgamesfun/solana@0.28.8 explain xsolana.json
|
|
103
|
+
npx -y @xgamesfun/solana@0.28.8 review xsolana.json
|
|
104
104
|
|
|
105
105
|
# go live: a key file for the project (it prints the address to send SOL to, never the key)…
|
|
106
|
-
npx -y @xgamesfun/solana@0.28.
|
|
106
|
+
npx -y @xgamesfun/solana@0.28.8 keygen ~/.config/xsolana/my-game.json
|
|
107
107
|
# …then everything going live would send, and a code; nothing is sent
|
|
108
|
-
npx -y @xgamesfun/solana@0.28.
|
|
108
|
+
npx -y @xgamesfun/solana@0.28.8 approve xsolana.json --keypair ~/.config/xsolana/my-game.json
|
|
109
109
|
# …and with that code, exactly what it listed is sent
|
|
110
|
-
npx -y @xgamesfun/solana@0.28.
|
|
110
|
+
npx -y @xgamesfun/solana@0.28.8 approve xsolana.json --keypair ~/.config/xsolana/my-game.json --yes <code>
|
|
111
111
|
```
|
|
112
112
|
|
|
113
113
|
Each command names an exact version, so you always run the release you meant to. `xsolana help` lists them all.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Traps the program takes as written, found by a game that went live with them (Bubble Day, 2026-11-11): a ticket
|
|
2
|
+
// token bought with another token, so a cancelled round refunds the ticket token and not what was paid for it; a way
|
|
3
|
+
// back from that token left open at all times; a free grant of a token that is also sold and converts back; a least
|
|
4
|
+
// sale the scenario's players never reach; a ticket that needs a craft first, two wallet prompts unless both go in one
|
|
5
|
+
// transaction; and a token still earned after the last draw it was good for. Each is a warning: the person decides.
|
|
6
|
+
import { prizeLine, prizeToken, toSeconds, toTime } from './model.mjs';
|
|
7
|
+
import { DEFAULT_PER_DAY } from './simulate.mjs';
|
|
8
|
+
|
|
9
|
+
const DAY = 86_400;
|
|
10
|
+
const num = x => Number(x ?? 0);
|
|
11
|
+
const at = seconds =>
|
|
12
|
+
new Date(seconds * 1000)
|
|
13
|
+
.toISOString()
|
|
14
|
+
.replace('T', ' ')
|
|
15
|
+
.replace(/:\d\d\.\d+Z$/, ' UTC');
|
|
16
|
+
const named = part => `“${part?.name ?? part?.key}”`;
|
|
17
|
+
const words = list => (list.length < 2 ? list.join('') : `${list.slice(0, -1).join(', ')} and ${list.at(-1)}`);
|
|
18
|
+
|
|
19
|
+
/** The token a token line pays in or gives, by key; null for an item, an NFT or a roll. */
|
|
20
|
+
function lineToken(line, m) {
|
|
21
|
+
const prize = prizeLine(line);
|
|
22
|
+
if (prize?.gives !== 'tokens') return null;
|
|
23
|
+
return prizeToken(prize, m)?.key ?? prize.currency ?? m.currencyKey;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What a recipe takes in tokens: [{ token, amount }]. */
|
|
27
|
+
const paysIn = (r, m) =>
|
|
28
|
+
(r.in ?? []).filter(l => l.takes === 'pay').map(l => ({ token: l.currency ?? m.currencyKey, amount: num(l.amount) }));
|
|
29
|
+
|
|
30
|
+
/** What a recipe gives in tokens: [{ token, amount }]. */
|
|
31
|
+
const givesOut = (r, m) =>
|
|
32
|
+
(r.out ?? []).map(l => ({ token: lineToken(l, m), amount: num(l.amount) })).filter(x => x.token);
|
|
33
|
+
|
|
34
|
+
/** The token a lottery's tickets are paid in, or null for a free ticket or one paid with an NFT. */
|
|
35
|
+
const ticketOf = (l, m) =>
|
|
36
|
+
l.ticketNft || !(num(l.price) > 0) ? null : (l.ticketCurrency ?? l.currency ?? m.currencyKey);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Warns of the traps above. `warn(code, where, message, fix)` as check() calls it; `now` in seconds.
|
|
40
|
+
*/
|
|
41
|
+
export function challenge(m, warn, now) {
|
|
42
|
+
const sym = key => m.tokens.get(key)?.symbol ?? key;
|
|
43
|
+
const on = r => r.active !== false;
|
|
44
|
+
/** The recipes that sell token `t` for another token: [{ recipe, pays: [{ token, amount }], makes }]. */
|
|
45
|
+
const sellers = t =>
|
|
46
|
+
m.recipes
|
|
47
|
+
.map(r => ({
|
|
48
|
+
recipe: r,
|
|
49
|
+
pays: paysIn(r, m).filter(p => p.token !== t),
|
|
50
|
+
makes: givesOut(r, m).find(g => g.token === t)?.amount ?? 0,
|
|
51
|
+
}))
|
|
52
|
+
.filter(s => s.makes > 0 && s.pays.length);
|
|
53
|
+
/** The recipes that turn token `t` back into another token: [{ recipe, takes, gives: [{ token, amount }] }]. */
|
|
54
|
+
const backs = t =>
|
|
55
|
+
m.recipes
|
|
56
|
+
.map(r => ({
|
|
57
|
+
recipe: r,
|
|
58
|
+
takes: paysIn(r, m).find(p => p.token === t)?.amount ?? 0,
|
|
59
|
+
gives: givesOut(r, m).filter(g => g.token !== t),
|
|
60
|
+
}))
|
|
61
|
+
.filter(b => b.takes > 0 && b.gives.length);
|
|
62
|
+
const priced = list => words(list.map(p => `${p.amount} ${sym(p.token)}`));
|
|
63
|
+
const tickets = new Map();
|
|
64
|
+
for (const l of m.lotteries) {
|
|
65
|
+
const t = ticketOf(l, m);
|
|
66
|
+
if (t) tickets.set(t, [...(tickets.get(t) ?? []), l]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---- W-REFUND-NOT-PAID: a cancelled round refunds what reached it, in its ticket token.
|
|
70
|
+
for (const l of m.lotteries) {
|
|
71
|
+
const t = ticketOf(l, m);
|
|
72
|
+
// Tickets in the game's currency or SOL refund what the player paid.
|
|
73
|
+
if (!t || t === m.currencyKey || m.tokens.get(t)?.source === 'sol' || !(num(l.minSold) > 0)) continue;
|
|
74
|
+
const sold = sellers(t);
|
|
75
|
+
if (!sold.length) continue;
|
|
76
|
+
const s = sold[0];
|
|
77
|
+
const ways = sold.map(x => `${named(x.recipe)} (${priced(x.pays)} for ${x.makes} ${sym(t)})`);
|
|
78
|
+
warn(
|
|
79
|
+
'W-REFUND-NOT-PAID',
|
|
80
|
+
`economy.lotteries.${l.key}.minSold`,
|
|
81
|
+
`${named(l)}’s tickets are paid in ${sym(t)}, which players buy with another token: ${words(ways)}. A round short of its ${l.minSold} tickets is cancelled, and its refund pays back ${sym(t)}, the ticket token, not the ${priced(s.pays)} a player paid for it: that stays where ${named(s.recipe)} sent it. A player who wanted their ${sym(s.pays[0].token)} back has no way to get it unless the project opens one.`,
|
|
82
|
+
`Price the tickets in ${sym(s.pays[0].token)} itself ("currency": "${s.pays[0].token}", "price": ${s.pays[0].amount / s.makes}, "rest": "burn" if they should burn), so a refund pays back what was paid; or keep a recipe that turns ${sym(t)} back into ${sym(s.pays[0].token)} switched off ("active": false) and switch it on after a cancelled round.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- W-SWAP-BACK-OPEN: a way back from a ticket token, open at all times.
|
|
87
|
+
for (const [t, lots] of tickets) {
|
|
88
|
+
const made = sellers(t);
|
|
89
|
+
if (!made.length) continue;
|
|
90
|
+
const paidWith = new Set(made.flatMap(s => s.pays.map(p => p.token)));
|
|
91
|
+
for (const b of backs(t)) {
|
|
92
|
+
const r = b.recipe;
|
|
93
|
+
if (!on(r) || r.closes !== undefined || r.opens !== undefined) continue;
|
|
94
|
+
if (!b.gives.some(g => paidWith.has(g.token))) continue;
|
|
95
|
+
warn(
|
|
96
|
+
'W-SWAP-BACK-OPEN',
|
|
97
|
+
`economy.recipes.${r.key}`,
|
|
98
|
+
`${named(r)} turns ${sym(t)}, the ticket of ${words(lots.map(named))}, back into ${priced(b.gives)} for ${b.takes}, and it is open with no "opens" or "closes": anyone holding ${sym(t)} can undo their purchase at any time, before a draw or after losing interest, and every ${sym(t)} given free becomes ${priced(b.gives)}. A way back is for a cancelled round.`,
|
|
99
|
+
`Make it "active": false and switch it on only after a cancelled round (an edit by the project), or give it "opens" and "closes" around the time it is for.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- W-FREE-IS-PAID: a free grant of a token that is also sold, and turns back into another token.
|
|
105
|
+
for (const g of m.grants) {
|
|
106
|
+
const prize = prizeLine(g.prize ?? g.gives);
|
|
107
|
+
const t = lineToken(prize, m);
|
|
108
|
+
// The project's own currency, or SOL, given as a reward is what a grant is for.
|
|
109
|
+
if (!t || t === m.currencyKey || m.tokens.get(t)?.source === 'sol') continue;
|
|
110
|
+
const sold = sellers(t);
|
|
111
|
+
const back = backs(t);
|
|
112
|
+
if (!sold.length || !back.length) continue;
|
|
113
|
+
const b = back.find(x => on(x.recipe)) ?? back[0];
|
|
114
|
+
const per = num(prize.amount) / b.takes;
|
|
115
|
+
const worth = b.gives.map(x => ({ token: x.token, amount: +(x.amount * per).toFixed(9) }));
|
|
116
|
+
warn(
|
|
117
|
+
'W-FREE-IS-PAID',
|
|
118
|
+
`economy.grants.${g.key}`,
|
|
119
|
+
`${named(g)} gives ${num(prize.amount)} ${sym(t)} for nothing, the token ${named(sold[0].recipe)} sells for ${priced(sold[0].pays)}, and ${named(b.recipe)} turns it back into ${priced(b.gives)}${on(b.recipe) ? '' : ' once it is switched on'}: each claim is worth ${priced(worth)}, and the grant’s limits are all that hold that back.`,
|
|
120
|
+
`Give the free one a token of its own that turns into nothing (a free-entry token a lottery also takes, or an NFT ticket), keep every way back switched off, or set the grant's "limits" to what you would pay out.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- W-MINSOLD-VS-SCENARIO: a least sale above what the scenario's players buy in a round.
|
|
125
|
+
for (const l of m.lotteries) {
|
|
126
|
+
if (!(num(l.minSold) > 0)) continue;
|
|
127
|
+
const every = toSeconds(l.every);
|
|
128
|
+
const untilDraw = l.firstDraw ? toTime(l.firstDraw, now) - now : 0;
|
|
129
|
+
const roundDays = every ? every / DAY : untilDraw > 0 ? untilDraw / DAY : 0;
|
|
130
|
+
if (!(roundDays > 0)) continue;
|
|
131
|
+
const said = m.scenario.perDay?.[`lottery:${l.key}`];
|
|
132
|
+
const perDay = num(said ?? DEFAULT_PER_DAY);
|
|
133
|
+
let sold = perDay * roundDays;
|
|
134
|
+
const players = num(m.scenario.players);
|
|
135
|
+
const each = num(l.perWallet);
|
|
136
|
+
const capped = players > 0 && each > 0 && players * each < sold;
|
|
137
|
+
if (capped) sold = players * each;
|
|
138
|
+
sold = Math.floor(sold);
|
|
139
|
+
if (sold >= num(l.minSold)) continue;
|
|
140
|
+
const round = every ? 'a round' : `its one round, until its draw at ${at(toTime(l.firstDraw, now))}`;
|
|
141
|
+
warn(
|
|
142
|
+
'W-MINSOLD-VS-SCENARIO',
|
|
143
|
+
`economy.lotteries.${l.key}.minSold`,
|
|
144
|
+
`${named(l)} is cancelled unless ${l.minSold} tickets sell in ${round}, and the scenario sells about ${sold}${capped ? ` (${players} players, at most ${each} each)` : said === undefined ? ` (it names no tickets for it: ${DEFAULT_PER_DAY} a day assumed)` : ` (${perDay} a day)`}. A cancelled round refunds every ticket and draws nothing. Its least sale can change only before the round's first ticket: the round keeps the terms it opened with.`,
|
|
145
|
+
`Lower "minSold" to what you expect to sell (or leave it out), or set the scenario ("scenario.perDay", "scenario.players") to what you really expect and check again.`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---- W-TWO-PROMPTS: a ticket the player crafts first, then buys: one wallet prompt each, unless one transaction.
|
|
150
|
+
for (const [t, lots] of tickets) {
|
|
151
|
+
const made = sellers(t).filter(s => on(s.recipe) && !(s.recipe.out ?? []).some(o => o.gives === 'roll'));
|
|
152
|
+
if (!made.length || t === m.currencyKey) continue;
|
|
153
|
+
const s = made[0];
|
|
154
|
+
for (const l of lots)
|
|
155
|
+
warn(
|
|
156
|
+
'W-TWO-PROMPTS',
|
|
157
|
+
`economy.lotteries.${l.key}`,
|
|
158
|
+
`A player buys ${named(l)}’s tickets with ${sym(t)}, which they get from ${named(s.recipe)}: a craft, then a purchase, two wallet prompts a buy (more, for each ticket crafted one at a time).`,
|
|
159
|
+
`Send both in one transaction: the play client's buyTickets(“${l.key}”, count, { listing, make: { recipe: “${s.recipe.key}”, times: ${num(l.price) / s.makes === 1 ? 'count' : `count × ${+(num(l.price) / s.makes).toFixed(9)}`}, listing } }) crafts what the tickets cost and buys them with one approval (kit 0.28.7 and later).`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- W-AFTER-THE-END: a token still given after the last draw it was good for.
|
|
164
|
+
for (const [t, lots] of tickets) {
|
|
165
|
+
if (t === m.currencyKey || m.tokens.get(t)?.source === 'sol' || lots.some(l => toSeconds(l.every))) continue;
|
|
166
|
+
if (lots.some(l => !l.firstDraw)) continue;
|
|
167
|
+
const used =
|
|
168
|
+
m.recipes.some(r => on(r) && paysIn(r, m).some(p => p.token === t)) ||
|
|
169
|
+
m.allBoxes.some(b => b.currency === t) ||
|
|
170
|
+
m.auctions.some(a => a.currency === t) ||
|
|
171
|
+
m.contests.some(c => c.currency === t) ||
|
|
172
|
+
m.payments.some(p => p.currency === t) ||
|
|
173
|
+
m.farms.some(f => f.stakes === t);
|
|
174
|
+
if (used) continue;
|
|
175
|
+
const last = Math.max(...lots.map(l => toTime(l.firstDraw, now)));
|
|
176
|
+
const lastLot = lots.find(l => toTime(l.firstDraw, now) === last);
|
|
177
|
+
const after = `${sym(t)} has no use after the last draw (${named(lastLot)}, ${at(last)})`;
|
|
178
|
+
for (const g of m.grants) {
|
|
179
|
+
if (g.active === false || lineToken(prizeLine(g.prize ?? g.gives), m) !== t) continue;
|
|
180
|
+
warn(
|
|
181
|
+
'W-AFTER-THE-END',
|
|
182
|
+
`economy.grants.${g.key}`,
|
|
183
|
+
`${named(g)} keeps giving ${sym(t)} for as long as it is switched on, and ${after}: players go on claiming a token that does nothing.`,
|
|
184
|
+
`Switch it off after the draw ("active": false, an edit by the project), make the lottery repeat ("every"), or give ${sym(t)} a use after it.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
for (const r of m.recipes) {
|
|
188
|
+
if (!on(r) || !givesOut(r, m).some(x => x.token === t)) continue;
|
|
189
|
+
if (r.closes !== undefined && toTime(r.closes, now) <= last) continue;
|
|
190
|
+
warn(
|
|
191
|
+
'W-AFTER-THE-END',
|
|
192
|
+
`economy.recipes.${r.key}`,
|
|
193
|
+
`${named(r)} keeps making ${sym(t)}${r.closes === undefined ? ' with no "closes"' : ` until ${at(toTime(r.closes, now))}`}, and ${after}: a player can still pay for a token that does nothing.`,
|
|
194
|
+
`Give it "closes" at the draw (${at(last)}) or before, or switch it off after the draw.`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
package/js/economy/check.mjs
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
} from './model.mjs';
|
|
63
63
|
// plan.mjs imports check() in turn: each module only calls the other's functions, never at load time.
|
|
64
64
|
import { boxArgs, boxOrderBytes, placeholderContext, recipeArgs, setupSteps, stepAloneBytes } from './plan.mjs';
|
|
65
|
+
import { challenge } from './challenge.mjs';
|
|
65
66
|
import { findSecrets } from './secrets.mjs';
|
|
66
67
|
import { simulate } from './simulate.mjs';
|
|
67
68
|
import { BUDGET, MAX_TRANSACTION_BYTES, transactionLocks, transactionSize, WALLET_ROOM } from './txsize.mjs';
|
|
@@ -5823,6 +5824,10 @@ export function check(economy, { now = Math.floor(Date.now() / 1000), project =
|
|
|
5823
5824
|
}
|
|
5824
5825
|
}
|
|
5825
5826
|
|
|
5827
|
+
// ---- traps the program takes as written: a ticket token bought with another token, a way back left open, a free
|
|
5828
|
+
// grant worth a paid one, a least sale the scenario never reaches, two wallet prompts a buy, a token after its end
|
|
5829
|
+
if (!refusals.length) challenge(m, warn, now);
|
|
5830
|
+
|
|
5826
5831
|
// ---- pools that run dry at the scenario's rates (W-POOL-DRY above names a pool nothing fills): one that is filled
|
|
5827
5832
|
// but pays out faster drains, and then skips or fails what it owes.
|
|
5828
5833
|
if (!refusals.length) {
|
package/js/economy/explain.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The economy in plain words, from the checker, the simulation, the cost and the play-time steps: what players do
|
|
2
2
|
// and what it costs them, how the pools fill and drain, what runs by itself, what the authority must sign, what
|
|
3
3
|
// going live costs, and what the owner must know. Deterministic. Pure.
|
|
4
|
+
import { walkThrough } from './walk.mjs';
|
|
4
5
|
import { check, describe } from './check.mjs';
|
|
5
6
|
import { cost } from './cost.mjs';
|
|
6
7
|
import {
|
|
@@ -616,6 +617,8 @@ export function explainEconomy(economy, { now = Math.floor(Date.now() / 1000) }
|
|
|
616
617
|
'',
|
|
617
618
|
`${realMoney() ? 'This economy is on mainnet: what it takes and what it pays is real money, and loot has not been independently audited.' : 'This economy is on devnet: nothing here is real money.'} Going live is signed on your own machine by the project’s key file, which is what “your wallet” means above. The command first prints everything it would send and sends nothing; it sends only when run again with the code it printed, which your agent is told to do only after your yes. No seed phrase or wallet key is ever asked for.`,
|
|
618
619
|
);
|
|
620
|
+
const walk = walkThrough(m, { now });
|
|
621
|
+
if (walk.length) lines.push('', ...walk);
|
|
619
622
|
return {
|
|
620
623
|
ok: true,
|
|
621
624
|
text: lines.join('\n').trim(),
|
package/js/economy/review.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
toSeconds,
|
|
25
25
|
} from './model.mjs';
|
|
26
26
|
import { TRUST_LEVELS, TRUST_WORDS } from './trust.mjs';
|
|
27
|
+
import { walkThrough } from './walk.mjs';
|
|
27
28
|
|
|
28
29
|
/** Severities, most serious first. */
|
|
29
30
|
export const SEVERITIES = Object.freeze(['high', 'medium', 'low', 'info']);
|
|
@@ -710,6 +711,12 @@ export function review(economy, { now = Math.floor(Date.now() / 1000) } = {}) {
|
|
|
710
711
|
['W-LOTTERY-WEIGHT', 'R-LOTTERY-WEIGHT', 'info'],
|
|
711
712
|
['W-ZERO', 'R-ZERO', 'low'],
|
|
712
713
|
['W-ANSWER', 'R-ANSWER', 'low'],
|
|
714
|
+
['W-REFUND-NOT-PAID', 'R-REFUND-NOT-PAID', 'medium'],
|
|
715
|
+
['W-SWAP-BACK-OPEN', 'R-SWAP-BACK-OPEN', 'medium'],
|
|
716
|
+
['W-FREE-IS-PAID', 'R-FREE-IS-PAID', 'medium'],
|
|
717
|
+
['W-MINSOLD-VS-SCENARIO', 'R-MINSOLD-VS-SCENARIO', 'medium'],
|
|
718
|
+
['W-AFTER-THE-END', 'R-AFTER-THE-END', 'low'],
|
|
719
|
+
['W-TWO-PROMPTS', 'R-TWO-PROMPTS', 'low'],
|
|
713
720
|
])
|
|
714
721
|
for (const w of warned(code)) find(id, severity, routeOf(w.where), w.message, w.fix);
|
|
715
722
|
|
|
@@ -718,7 +725,7 @@ export function review(economy, { now = Math.floor(Date.now() / 1000) } = {}) {
|
|
|
718
725
|
ok: !findings.some(f => f.severity === 'high'),
|
|
719
726
|
routes,
|
|
720
727
|
findings,
|
|
721
|
-
tradeoffs: tradeoffs(routes, findings),
|
|
728
|
+
tradeoffs: [tradeoffs(routes, findings), walkThrough(m, { now }).join('\n')].filter(Boolean).join('\n\n'),
|
|
722
729
|
};
|
|
723
730
|
}
|
|
724
731
|
|
package/js/economy/simulate.mjs
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
} from './model.mjs';
|
|
53
53
|
|
|
54
54
|
const DAY = 86_400;
|
|
55
|
-
const DEFAULT_PER_DAY = 100;
|
|
55
|
+
export const DEFAULT_PER_DAY = 100;
|
|
56
56
|
/** How many wallets the simulation's crowd is, next to one: what a person with many wallets could take from a grant. */
|
|
57
57
|
const MANY_WALLETS = 100;
|
|
58
58
|
/** Bids a day on an auction the scenario doesn't name (`auction:<key>`); a grant it doesn't name is claimed by nobody. */
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// The walk-through at the end of `xsolana explain` and the review: each route of the spec, state by state (before it
|
|
2
|
+
// opens, while it runs, cancelled or failed, won, after it ends, a change of mind), as a player, the owner and a
|
|
3
|
+
// cheater see it, and what can change only before its first sale or claim. Words the program's rules back
|
|
4
|
+
// (PROGRAM.md), filled with the spec's own names, prices and dates, so an agent has the trade-offs in front of it at
|
|
5
|
+
// the moment it decides.
|
|
6
|
+
import { boxRolls, ORACLE_FEE, prizeLine, prizeToken, prizeWords, toSeconds, toTime } from './model.mjs';
|
|
7
|
+
|
|
8
|
+
const num = x => Number(x ?? 0);
|
|
9
|
+
const at = seconds =>
|
|
10
|
+
new Date(seconds * 1000)
|
|
11
|
+
.toISOString()
|
|
12
|
+
.replace('T', ' ')
|
|
13
|
+
.replace(/:\d\d\.\d+Z$/, ' UTC');
|
|
14
|
+
const named = part => `“${part?.name ?? part?.key}”`;
|
|
15
|
+
const words = list => (list.length < 2 ? list.join('') : `${list.slice(0, -1).join(', ')} and ${list.at(-1)}`);
|
|
16
|
+
const span = seconds =>
|
|
17
|
+
seconds % 86_400 === 0
|
|
18
|
+
? `${seconds / 86_400} d`
|
|
19
|
+
: seconds % 3600 === 0
|
|
20
|
+
? `${seconds / 3600} h`
|
|
21
|
+
: seconds % 60 === 0
|
|
22
|
+
? `${seconds / 60} min`
|
|
23
|
+
: `${seconds} s`;
|
|
24
|
+
|
|
25
|
+
/** What a draw or a roll pays the oracle, in SOL. */
|
|
26
|
+
const oracleSol = Number(ORACLE_FEE) / 1e9;
|
|
27
|
+
|
|
28
|
+
/** The walk-through, as lines: a title, then for each route its name and one line a state. [] for a spec with no route. */
|
|
29
|
+
export function walkThrough(m, { now }) {
|
|
30
|
+
const sym = key => m.tokens.get(key)?.symbol ?? key;
|
|
31
|
+
const on = x => x.active !== false;
|
|
32
|
+
const lineToken = l => {
|
|
33
|
+
const p = prizeLine(l);
|
|
34
|
+
return p?.gives === 'tokens' ? (prizeToken(p, m)?.key ?? p.currency ?? m.currencyKey) : null;
|
|
35
|
+
};
|
|
36
|
+
const inWords = r =>
|
|
37
|
+
words(
|
|
38
|
+
(r.in ?? []).map(l =>
|
|
39
|
+
l.takes === 'pay'
|
|
40
|
+
? `${num(l.amount)} ${sym(l.currency ?? m.currencyKey)}`
|
|
41
|
+
: l.item
|
|
42
|
+
? `${l.count ?? 1} ${named(m.item.get(l.item) ?? { key: l.item })} (${l.takes === 'burn' ? 'burned' : l.takes === 'use' || l.takes === 'renew' ? 'used, kept' : l.takes === 'staked' ? 'staked, kept' : l.takes})`
|
|
43
|
+
: l.takes === 'show'
|
|
44
|
+
? 'an NFT of a collection, shown and kept'
|
|
45
|
+
: l.takes === 'trade'
|
|
46
|
+
? 'an NFT of a collection, traded in'
|
|
47
|
+
: l.takes,
|
|
48
|
+
),
|
|
49
|
+
) || 'nothing';
|
|
50
|
+
const outWords = r => words((r.out ?? []).map(o => prizeWords(o, m))) || 'nothing';
|
|
51
|
+
/** Recipes that make token `t` from something else, and recipes that turn it back into another token. */
|
|
52
|
+
const makers = t => m.recipes.filter(r => (r.out ?? []).some(o => lineToken(o) === t) && (r.in ?? []).length);
|
|
53
|
+
const backs = t =>
|
|
54
|
+
m.recipes.filter(
|
|
55
|
+
r =>
|
|
56
|
+
(r.in ?? []).some(l => l.takes === 'pay' && (l.currency ?? m.currencyKey) === t) &&
|
|
57
|
+
(r.out ?? []).some(o => lineToken(o) && lineToken(o) !== t),
|
|
58
|
+
);
|
|
59
|
+
const backWords = (t, how) => {
|
|
60
|
+
const b = backs(t);
|
|
61
|
+
if (!b.length) return '';
|
|
62
|
+
const open = b.filter(on);
|
|
63
|
+
return open.length
|
|
64
|
+
? ` ${words(open.map(named))} ${how} (open now).`
|
|
65
|
+
: ` ${words(b.map(named))} ${how} once the project switches it on.`;
|
|
66
|
+
};
|
|
67
|
+
const windowWords = (x, what, none) =>
|
|
68
|
+
!on(x)
|
|
69
|
+
? `It is switched off ("active": false): ${none} until the project switches it on.`
|
|
70
|
+
: x.opens !== undefined
|
|
71
|
+
? `${none[0].toUpperCase()}${none.slice(1)} before ${at(toTime(x.opens, now))}.`
|
|
72
|
+
: `It ${what} from the moment it is live.`;
|
|
73
|
+
const perWalletWords = x =>
|
|
74
|
+
x.perWallet ? ` (at most ${x.perWallet} a wallet: a wallet is free, so it counts wallets, not people)` : '';
|
|
75
|
+
const routes = [];
|
|
76
|
+
const route = (title, rows) =>
|
|
77
|
+
routes.push(title, ...rows.filter(([, text]) => text).map(([s, text]) => ` - ${s}: ${text}`));
|
|
78
|
+
|
|
79
|
+
for (const l of m.lotteries) {
|
|
80
|
+
const t = l.ticketNft ? null : (l.ticketCurrency ?? l.currency ?? m.currencyKey);
|
|
81
|
+
const price = num(l.price);
|
|
82
|
+
const every = toSeconds(l.every);
|
|
83
|
+
const draw = l.firstDraw ? at(toTime(l.firstDraw, now)) : null;
|
|
84
|
+
const made = t ? makers(t) : [];
|
|
85
|
+
const closesBefore = num(toSeconds(l.closesBefore));
|
|
86
|
+
const ticket = l.ticketNft
|
|
87
|
+
? 'an NFT that passes its rule'
|
|
88
|
+
: price > 0
|
|
89
|
+
? `${price} ${sym(t)}`
|
|
90
|
+
: 'nothing but the player fee';
|
|
91
|
+
route(
|
|
92
|
+
`${named(l)}: a lottery, ${every ? `drawn every ${span(every)}` : `drawn once${draw ? `, at ${draw}` : ''}`}`,
|
|
93
|
+
[
|
|
94
|
+
[
|
|
95
|
+
'Before it opens',
|
|
96
|
+
`It sells from the moment it is live; its first ticket opens the round.${l.gate ? ' Only wallets its gate admits buy.' : ''}`,
|
|
97
|
+
],
|
|
98
|
+
[
|
|
99
|
+
'While it runs',
|
|
100
|
+
`Player: pays ${ticket} a ticket${made.length ? ` (${sym(t)} comes from ${words(made.map(named))}: a craft before the purchase)` : ''}${perWalletWords(l)}, and holds an entry in the round, not a token: it can't be sold or taken back. Sales close ${closesBefore ? `${span(closesBefore)} before` : 'at'} the draw. Owner: nothing to send; an edit reaches the next round, never the one running. Cheater: many wallets buy many tickets; nobody sees or picks the result, drawn from the oracle's answer after sales close.`,
|
|
101
|
+
],
|
|
102
|
+
[
|
|
103
|
+
'Cancelled or failed',
|
|
104
|
+
`${num(l.minSold) > 0 ? `A round short of ${l.minSold} tickets is cancelled when its draw is asked, with no draw. ` : ''}A draw the oracle hasn't answered 7 days after it was asked is cancelled at the next ask. Player: every ticket is refunded${t && price > 0 ? ` in ${sym(t)}` : ''}${l.ticketNft ? ' (the NFT back)' : ''}, the player fee not; anyone may pull a refund to its buyer.${made.length ? ` What was paid for the ${sym(t)} does not come back with it:${backWordsOr(backWords(t, 'turns it back'), ` nothing turns ${sym(t)} back.`)}` : ''} Owner: nothing to send; a round nobody asks to draw waits, with no deadline. Cheater: nothing to gain: a refund goes to the entry's own buyer.`,
|
|
105
|
+
],
|
|
106
|
+
[
|
|
107
|
+
'Won',
|
|
108
|
+
`Once it is due anyone asks for the draw, paying the oracle ${oracleSol} SOL, and anyone pulls each winner's part to the winner: ${words((l.places ?? []).map((p, i) => `place ${i + 1} ${p.prize ? prizeWords(p.prize, m) : `${num(p.percent)}% of the pot`}`)) || 'the pot'}. An item is given while it has stock${(l.places ?? []).some(p => prizeLine(p.prize)?.gives === 'item' && m.item.get(prizeLine(p.prize).item)?.kind !== 'collectible') ? '; a normal item is an off-chain prize, yours to deliver from its receipt' : ''}.`,
|
|
109
|
+
],
|
|
110
|
+
[
|
|
111
|
+
'After it ends',
|
|
112
|
+
every
|
|
113
|
+
? 'The next round sells at once, on the lottery’s terms as they are then.'
|
|
114
|
+
: `It sells no more tickets.${t && price > 0 && t !== m.currencyKey ? ` ${sym(t)} left in wallets does only what something else takes it for.` : ''} The owner may close it once its round is paid out, for its rent back.`,
|
|
115
|
+
],
|
|
116
|
+
[
|
|
117
|
+
'A change of mind',
|
|
118
|
+
`No way out: a ticket stays in the round until its draw or a cancel.${t && made.length ? backWords(t, `turns ${sym(t)} not yet spent on tickets back`) : ''}`,
|
|
119
|
+
],
|
|
120
|
+
[
|
|
121
|
+
'Only before the first ticket',
|
|
122
|
+
`The round copies the lottery's terms (price, places, least sale, draw date) at its first ticket: an edit after it reaches only the next round${every ? '' : ', and a lottery that draws once has none'}.`,
|
|
123
|
+
],
|
|
124
|
+
],
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const b of m.allBoxes) {
|
|
129
|
+
const item = b.shopOf ? m.item.get(b.shopOf) : null;
|
|
130
|
+
const source = item ?? b;
|
|
131
|
+
const rolls = boxRolls(b, m);
|
|
132
|
+
const cur = sym(b.currency ?? m.currencyKey);
|
|
133
|
+
const price = num(b.price);
|
|
134
|
+
const what = item ? `${named(item)} from the shop` : named(b);
|
|
135
|
+
route(`${what}: ${item ? 'an item for sale' : rolls ? 'a box, rolled by the oracle' : 'a box'}`, [
|
|
136
|
+
['Before it opens', windowWords(source, 'sells', 'nothing is sold')],
|
|
137
|
+
[
|
|
138
|
+
'While it runs',
|
|
139
|
+
`Player: pays ${price > 0 ? `${price} ${cur}` : 'nothing but the player fee'}${perWalletWords(source)}; ${rolls ? `the oracle rolls what they get (paid ${oracleSol} SOL), which lands with its answer` : 'gets what it gives at once'}. Owner: nothing to send${rolls ? '; an edit of its entries or odds refunds every paid roll still waiting, at once; a price change reaches the next purchase' : ''}. Cheater: ${rolls ? "can't see or pick a roll" : 'nothing to take: it sells at its price'}${item?.supply || (!item && rolls) ? '; bots can buy limited stock first' : ''}.`,
|
|
140
|
+
],
|
|
141
|
+
[
|
|
142
|
+
'Cancelled or failed',
|
|
143
|
+
rolls
|
|
144
|
+
? "A roll the oracle hasn't answered is asked again once it is stale, by its player or your keys; nothing times it out. A prize out of stock is never set aside for a roll."
|
|
145
|
+
: 'A purchase that can’t give what it sells fails whole: nothing is taken.',
|
|
146
|
+
],
|
|
147
|
+
[
|
|
148
|
+
'After it ends',
|
|
149
|
+
source.closes !== undefined
|
|
150
|
+
? `Nothing sells after ${at(toTime(source.closes, now))}.`
|
|
151
|
+
: 'It sells until its stock runs out or the project switches it off.',
|
|
152
|
+
],
|
|
153
|
+
['A change of mind', 'No refund once paid.'],
|
|
154
|
+
[
|
|
155
|
+
'Only before the first sale',
|
|
156
|
+
item?.kind === 'collectible' ? 'The item’s collection can’t change once a unit is out.' : '',
|
|
157
|
+
],
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const r of m.recipes) {
|
|
162
|
+
const takes = (r.in ?? []).filter(l => l.takes === 'pay').map(l => l.currency ?? m.currencyKey);
|
|
163
|
+
const back = takes.length
|
|
164
|
+
? m.recipes.filter(
|
|
165
|
+
x =>
|
|
166
|
+
x !== r &&
|
|
167
|
+
(x.out ?? []).some(o => takes.includes(lineToken(o))) &&
|
|
168
|
+
(x.in ?? []).some(
|
|
169
|
+
l => l.takes === 'pay' && (r.out ?? []).some(o => lineToken(o) === (l.currency ?? m.currencyKey)),
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
: [];
|
|
173
|
+
route(`${named(r)}: a recipe`, [
|
|
174
|
+
['Before it opens', windowWords(r, 'crafts', 'nothing is crafted')],
|
|
175
|
+
[
|
|
176
|
+
'While it runs',
|
|
177
|
+
`Player: gives ${inWords(r)}, gets ${outWords(r)}, in one transaction, all or nothing${perWalletWords(r)}. Owner: nothing to send; an edit reaches the next craft. Cheater: crafts as often as they hold what it takes.`,
|
|
178
|
+
],
|
|
179
|
+
['Cancelled or failed', 'A craft that can’t give every line fails whole: nothing is taken.'],
|
|
180
|
+
[
|
|
181
|
+
'After it ends',
|
|
182
|
+
r.closes !== undefined
|
|
183
|
+
? `Nothing crafts after ${at(toTime(r.closes, now))}.`
|
|
184
|
+
: 'It crafts until the project switches it off.',
|
|
185
|
+
],
|
|
186
|
+
[
|
|
187
|
+
'A change of mind',
|
|
188
|
+
back.length
|
|
189
|
+
? `${words(back.map(named))} turns it back${back.some(on) ? '' : ' once the project switches it on'}.`
|
|
190
|
+
: 'No undo: what it took is spent.',
|
|
191
|
+
],
|
|
192
|
+
]);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const g of m.grants) {
|
|
196
|
+
const prize = prizeWords(g.prize ?? g.gives, m);
|
|
197
|
+
const how = g.vouchers?.length
|
|
198
|
+
? 'a voucher from the project’s results server'
|
|
199
|
+
: g.list
|
|
200
|
+
? 'a place on its list'
|
|
201
|
+
: 'nothing but its limits';
|
|
202
|
+
const limits = Object.entries(g.limits ?? {})
|
|
203
|
+
.map(([k, v]) => `${k} ${typeof v === 'object' ? JSON.stringify(v) : v}`)
|
|
204
|
+
.join(', ');
|
|
205
|
+
route(`${named(g)}: a grant`, [
|
|
206
|
+
[
|
|
207
|
+
'Before it opens',
|
|
208
|
+
on(g)
|
|
209
|
+
? 'Claims work from the moment it is live.'
|
|
210
|
+
: 'It is switched off ("active": false): no claim until the project switches it on.',
|
|
211
|
+
],
|
|
212
|
+
[
|
|
213
|
+
'While it runs',
|
|
214
|
+
`Player: claims ${prize}, proving it with ${how}; only their own wallet signs the claim. Owner: ${g.vouchers?.length ? 'the results server must be up to sign vouchers (no server, no claims), and ' : ''}its funds pay each claim's rent: keep them topped up. Cheater: ${g.vouchers?.length ? `gets whatever the server signs (${g.trust ?? 'what it checks'}): a signed result is worth what the server checks; ` : g.list ? '' : 'every wallet may claim; '}its limits are the ceiling (${limits || 'none'}).`,
|
|
215
|
+
],
|
|
216
|
+
[
|
|
217
|
+
'Cancelled or failed',
|
|
218
|
+
'A claim whose prize can’t be given (a pool run dry, an item out of stock) is refused and keeps its round: it can be claimed later.',
|
|
219
|
+
],
|
|
220
|
+
['After it ends', 'It gives until its limits are reached or the project switches it off: it has no end date.'],
|
|
221
|
+
]);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const a of m.auctions) {
|
|
225
|
+
route(`${named(a)}: an auction`, [
|
|
226
|
+
[
|
|
227
|
+
'Before it opens',
|
|
228
|
+
a.start !== undefined ? `No bid before ${at(toTime(a.start, now))}.` : 'Bids from the moment it opens.',
|
|
229
|
+
],
|
|
230
|
+
[
|
|
231
|
+
'While it runs',
|
|
232
|
+
`Player: bids at least ${num(a.reserve) || 1} ${sym(a.currency ?? m.currencyKey)}; a higher bid sends theirs back in the same instruction. Owner: nothing to send; its terms are fixed when it opens. Cheater: anyone, the seller included, can bid to push the price${a.extendBy ? `; a bid in the last ${a.extendBy} moves the end` : ''}.`,
|
|
233
|
+
],
|
|
234
|
+
[
|
|
235
|
+
'Cancelled or failed',
|
|
236
|
+
'With no bid it ends unsold and the lot goes home. The owner or authority may call off a sale whose lot has not moved: the bid back, the lot home.',
|
|
237
|
+
],
|
|
238
|
+
['Won', 'Anyone ends it after its end: the lot to the high bidder, the bid to the seller and the shares.'],
|
|
239
|
+
['A change of mind', 'A bid can’t be taken back: only a higher bid returns it before the end.'],
|
|
240
|
+
['Only before the first bid', 'Every term is fixed when it opens.'],
|
|
241
|
+
]);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for (const c of m.contests) {
|
|
245
|
+
route(`${named(c)}: a contest`, [
|
|
246
|
+
[
|
|
247
|
+
'While it runs',
|
|
248
|
+
`Player: enters one side${c.stake ? `, a stake of ${num(c.stake)}` : ''}${c.entriesClose ? ` (entries close: ${c.entriesClose})` : ''}; a wallet backs one side, never two. Owner: ${c.decidedBy === 'authority' || !c.deciders ? 'the result is yours to decide once entries close' : 'its deciders sign the result once entries close'}. Cheater: the result is worth what its decider checks; nobody names a side before entries close.`,
|
|
249
|
+
],
|
|
250
|
+
[
|
|
251
|
+
'Cancelled or failed',
|
|
252
|
+
`With no result by its result deadline${c.minPot ? `, or less than ${num(c.minPot)} in its sides when entries close` : ''}, or a call-off, every entry is refunded.`,
|
|
253
|
+
],
|
|
254
|
+
['Won', 'The winners claim their share of the pot, by what each put in.'],
|
|
255
|
+
['A change of mind', 'An entry stays until the result or a refund.'],
|
|
256
|
+
['Only before the first entry', 'Every term is fixed when it opens and never edited.'],
|
|
257
|
+
]);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
for (const f of m.farms) {
|
|
261
|
+
route(`${named(f)}: a farm`, [
|
|
262
|
+
[
|
|
263
|
+
'While it runs',
|
|
264
|
+
`Player: stakes ${f.stakes === 'collectibles' ? 'NFTs' : sym(f.stakes ?? m.currencyKey)} and earns while staked, by tier. Owner: ${f.rate ? 'its rate pays from its rewards' : 'its stakers share what arrives in its rewards vault: nothing arrives, nothing is paid'}. Cheater: ${f.custody === 'watch' ? 'a watched NFT sold keeps earning until someone refreshes its seat' : 'many wallets stake as one: rewards follow the stake, not the person'}.`,
|
|
265
|
+
],
|
|
266
|
+
[
|
|
267
|
+
'After it ends',
|
|
268
|
+
f.runs
|
|
269
|
+
? `It takes no stake after its end (${f.runs}); seats keep what they earned.`
|
|
270
|
+
: 'It has no end: it runs until it is closed.',
|
|
271
|
+
],
|
|
272
|
+
['A change of mind', 'A stake leaves once its tier’s lock has run (earlier only where a tier sets an exit fee).'],
|
|
273
|
+
['Only before it opens', 'A farm is never edited once open: a change is a new farm.'],
|
|
274
|
+
]);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
for (const s of m.schedules)
|
|
278
|
+
route(`${named(s)}: a schedule`, [
|
|
279
|
+
[
|
|
280
|
+
'While it runs',
|
|
281
|
+
s.sealed
|
|
282
|
+
? 'Its recipient sends each run when it is due; nobody can stop it.'
|
|
283
|
+
: 'Owner: you, or a key of the project’s, sends each run when it is due: nothing runs it otherwise, and a missed run lapses. Player: nothing to do. Cheater: nothing: only the project’s keys send it.',
|
|
284
|
+
],
|
|
285
|
+
]);
|
|
286
|
+
|
|
287
|
+
for (const p of m.payments)
|
|
288
|
+
route(`${named(p)}: a payment`, [
|
|
289
|
+
[
|
|
290
|
+
'While it runs',
|
|
291
|
+
`Player: pays ${p.any ? 'what they choose' : `${num(p.amount)} ${sym(p.currency ?? m.currencyKey)}`}. Owner: what it pays for is yours to deliver from its receipt.`,
|
|
292
|
+
],
|
|
293
|
+
['A change of mind', 'No refund: the program keeps no record to undo.'],
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
for (const h of m.holds)
|
|
297
|
+
route(`${named(h)}: a hold`, [
|
|
298
|
+
[
|
|
299
|
+
'While it runs',
|
|
300
|
+
'Player: hands an NFT to the project until the time they chose. Owner: until then the authority may release it, to any wallet. Cheater: whoever holds the authority’s key could release it elsewhere before its time: a hold trusts the project.',
|
|
301
|
+
],
|
|
302
|
+
['After it ends', 'Anyone sends it home once its time is up.'],
|
|
303
|
+
]);
|
|
304
|
+
|
|
305
|
+
if (!routes.length) return [];
|
|
306
|
+
return [
|
|
307
|
+
'Each route, state by state (as a player, the owner and a cheater see it)',
|
|
308
|
+
'The owner’s pause stops every purchase, ticket, entry, stake and bid coming in; nothing owed waits on it: claims, refunds, unstakes, draws and payouts go on.',
|
|
309
|
+
...routes,
|
|
310
|
+
];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const backWordsOr = (text, otherwise) => text || otherwise;
|
package/js/mcp/server.mjs
CHANGED
|
@@ -15,9 +15,14 @@ import { withCluster } from './with-cluster.mjs';
|
|
|
15
15
|
/** Where loot runs, for every agent that connects. */
|
|
16
16
|
const WHERE = `loot ${VERSION} runs on devnet (program ${CLUSTERS.devnet.program}) and on mainnet (program ${CLUSTERS.mainnet.program}). An economy is on devnet, where nothing is real money, unless its file says "network": "mainnet", where everything is. Build and try an economy on devnet first. loot has not been independently audited.`;
|
|
17
17
|
|
|
18
|
+
/** How to meet a request: the simplest version first, its trade said, before building what was asked. */
|
|
19
|
+
const CHALLENGE =
|
|
20
|
+
'Challenge before you build. For every request, name the simplest version that delivers most of it. Say what it gives up and what the asked-for version costs: wallet prompts, moving parts, failure states, who must act later. Recommend one. Build the asked-for version only after the person has seen that trade and still wants it. Challenge economy, flow and irreversible choices; don’t challenge taste. Composing parts is still how you find the versions; xsolana_explain ends with each route’s states as a player, the owner and a cheater see them.';
|
|
21
|
+
|
|
18
22
|
export const INSTRUCTIONS = [
|
|
19
23
|
'loot is one shared Solana program for game and community economies (shops, loot boxes, lotteries, auctions, staking farms, crafting, contests, schedules, perks for holders of any NFT, capped rewards). An economy is a JSON spec, not a new smart contract: never write Anchor or Rust for what loot covers.',
|
|
20
24
|
'Start with xsolana_guide, then xsolana_examples with the person’s own words; write the spec; xsolana_check after every change; xsolana_simulate and xsolana_explain before telling them what it does; xsolana_review before going live (fix its high findings, say honestly what each voucher server checks, and tell the person what a cheater gets); xsolana_save; xsolana_publish when they say to put it live. Going live is signed by a key file on their own machine (xsolana keygen makes one; never ask for, read or print a key) with xsolana approve --keypair, which prints everything it would send and sends nothing until it is run again with the code it printed: do that only after they have read the printout and said yes. That key owns the project for good: a change to an economy that is already live is signed by the key that owns it, never by a new one. A browser wallet’s signing page (approve --browser) only if they ask for one, and they run it themselves.',
|
|
25
|
+
CHALLENGE,
|
|
21
26
|
WHERE,
|
|
22
27
|
].join(' ');
|
|
23
28
|
|
|
@@ -27,6 +32,7 @@ export const INSTRUCTIONS = [
|
|
|
27
32
|
*/
|
|
28
33
|
export const REMOTE_INSTRUCTIONS = [
|
|
29
34
|
INSTRUCTIONS.split(' Start with')[0],
|
|
35
|
+
CHALLENGE,
|
|
30
36
|
'Start with xsolana_guide, then xsolana_examples with the person’s own words; write the spec and pass it whole as "spec" to every tool (this hosted server reads no files); xsolana_check after every change; xsolana_simulate and xsolana_explain before telling them what it does; xsolana_review before going live (fix its high findings, say honestly what each voucher server checks, and tell the person what a cheater gets).',
|
|
31
37
|
`To go live, the spec goes in a file named xsolana.json on the person’s own computer and is signed there by a key file. In that folder: npx -y @xgamesfun/solana@${PACKAGE_VERSION} keygen ~/.config/xsolana/<name>.json makes the key file (it prints the address, never the key; the person sends that address devnet SOL), then npx -y @xgamesfun/solana@${PACKAGE_VERSION} approve xsolana.json --keypair <that file> prints everything that would be sent and sends nothing. When the person has read that and said yes, the same command with --yes <the code it printed> sends it. Run them yourself if you have a terminal there; otherwise hand them to the person. Never ask for, read or print a key. Say once what the key file is: the project’s owner for good (whoever can read it controls the project; they back it up themselves; nothing restores a lost one) and, unless the spec names a treasury, where what the economy earns lands. A change to an economy that is already live is signed by the key that owns it: never make a new key for it. A browser wallet’s signing page (the same approve command with --browser in place of --keypair), which the person runs themselves, only if they ask for one or a browser wallet owns the project. (xsolana_save and xsolana_publish, which tool texts may name, run only in the local server.)`,
|
|
32
38
|
WHERE,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xgamesfun/solana",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.8",
|
|
4
4
|
"mcpName": "io.github.XGamesFun/solana-kit",
|
|
5
5
|
"description": "Loot boxes, gacha, shops, lotteries, auctions, NFT staking, crafting and rewards on Solana without writing a smart contract: one shared program, its JS client, a CLI, and an MCP server and skill so Claude Code, Codex or any AI agent can design, check, simulate and launch an economy.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -11,9 +11,9 @@ signed by a key file on the owner's own machine, once they have read what will b
|
|
|
11
11
|
**devnet**, where nothing is real money, and on **mainnet**, where everything is: an economy is on devnet unless its
|
|
12
12
|
file says `"network": "mainnet"`.
|
|
13
13
|
|
|
14
|
-
The tools are the `xsolana_*` MCP tools. Without MCP, the same from a terminal: `npx -y @xgamesfun/solana@0.28.
|
|
15
|
-
`npx -y @xgamesfun/solana@0.28.
|
|
16
|
-
or `npx -y @xgamesfun/solana@0.28.
|
|
14
|
+
The tools are the `xsolana_*` MCP tools. Without MCP, the same from a terminal: `npx -y @xgamesfun/solana@0.28.8 <guide|examples|check|simulate|cost|explain|review|publish|status> [xsolana.json]`,
|
|
15
|
+
`npx -y @xgamesfun/solana@0.28.8 token <mint>` for a coin made elsewhere,
|
|
16
|
+
or `npx -y @xgamesfun/solana@0.28.8 tool <name> '<json>'` for any tool (always with the exact version).
|
|
17
17
|
|
|
18
18
|
## Rules (always)
|
|
19
19
|
|
|
@@ -56,22 +56,24 @@ or `npx -y @xgamesfun/solana@0.28.7 tool <name> '<json>'` for any tool (always w
|
|
|
56
56
|
7. **Take addresses from the tools, never from memory**: the program, its accounts, the IDL. Every answer names them.
|
|
57
57
|
8. **Public data only in a spec**: names, prices, percents, public addresses. Leave out `id`, `index`, `shopBox`,
|
|
58
58
|
and `live`: they are written when it goes live.
|
|
59
|
-
9. **
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
59
|
+
9. **Challenge before you build.** For every request, name the simplest version that delivers most of it. Say what
|
|
60
|
+
it gives up and what the asked-for version costs: wallet prompts, moving parts, failure states, who must act
|
|
61
|
+
later. Recommend one. Build the asked-for version only after the person has seen that trade and still wants it.
|
|
62
|
+
Challenge economy, flow and irreversible choices; don't challenge taste. `xsolana_explain` ends with each route's
|
|
63
|
+
states (before it opens, while it runs, cancelled or failed, won, after it ends, a change of mind) as a player, the
|
|
64
|
+
owner and a cheater see them, and what can change only before a first sale or claim; the checker's warnings name
|
|
65
|
+
the traps a game already fell into. Read both before you answer.
|
|
66
|
+
Composing parts is still how you find the versions. Most ideas are several parts working together: break the idea
|
|
67
|
+
into its pieces (what players pay, what they get, what is random, what the chain can't see, what is held and for
|
|
68
|
+
how long, who is paid) and find the part for each (`references/sentence-to-spec.md`). What the chain can't see (a
|
|
69
|
+
match, a score, something done offline) is vouched for by a signer: a results server's voucher, a decider's key, a
|
|
70
|
+
list. The program checks the signer's signature and limits, never what happened, so a signed result is worth what
|
|
71
|
+
that server checks (it decides the result, replays it, or only bounds what the page reports: rule 12). When the
|
|
72
|
+
tools' words and the checker disagree (a field the schema takes that a sentence somewhere calls impossible), the
|
|
73
|
+
checker and `xsolana_guide`'s parts decide: try it in a spec and run `xsolana_check`. A piece is a limit only when
|
|
74
|
+
no part or combination does it: say in one line what is missing and why, and offer the nearest versions
|
|
75
|
+
(`xsolana_examples` with the words of that piece returns them), best first, each with what it gives up and its "in
|
|
76
|
+
all" cost.
|
|
75
77
|
|
|
76
78
|
10. **Design for many wallets and bots.** Wallets are free and bots play well: bound every reward with totals
|
|
77
79
|
(`perDay`, `total`), gate open rewards on holding something or count people by vouchers (`perPerson`, with the
|
|
@@ -141,7 +143,7 @@ or `npx -y @xgamesfun/solana@0.28.7 tool <name> '<json>'` for any tool (always w
|
|
|
141
143
|
9. **When they say "put it live"**: `xsolana_publish` lists every step and its cost and gives the commands, which you
|
|
142
144
|
run in the project folder exactly as given (they name the exact version; never drop it):
|
|
143
145
|
- **A new economy gets a key file**, unless the project has one:
|
|
144
|
-
`npx -y @xgamesfun/solana@0.28.
|
|
146
|
+
`npx -y @xgamesfun/solana@0.28.8 keygen ~/.config/xsolana/<name>.json`. Give the person the address it prints. A
|
|
145
147
|
Solana key file they already have and name themselves (`~/.config/solana/id.json`) works the same; never go
|
|
146
148
|
looking for one.
|
|
147
149
|
- **Say what the key file is** before anything is sent: the project's owner, and, unless the spec says otherwise,
|
|
@@ -156,7 +158,7 @@ or `npx -y @xgamesfun/solana@0.28.7 tool <name> '<json>'` for any tool (always w
|
|
|
156
158
|
devnet SOL for an economy on devnet, real SOL for one on mainnet), any seed in a coin that already exists, and every NFT a stash, a raffle or an auction names. A
|
|
157
159
|
collection they already have needs the key's address as its update authority. If they would rather not move
|
|
158
160
|
those, offer the browser wallet.
|
|
159
|
-
- **Show it**: `npx -y @xgamesfun/solana@0.28.
|
|
161
|
+
- **Show it**: `npx -y @xgamesfun/solana@0.28.8 approve xsolana.json --keypair <key file>` prints every step with
|
|
160
162
|
what it pays and trusts, the warnings, the notice and a code, and sends nothing. Give the person that printout as
|
|
161
163
|
it is, not a summary of it, and ask.
|
|
162
164
|
- **On their yes**, the same command with `--yes <code>`. What lands is written back into `xsolana.json`. If it
|
package/types/mcp/server.d.mts
CHANGED
|
@@ -299,10 +299,10 @@ export declare function runTool(name: any, input?: {}, ctx?: {}): Promise<{
|
|
|
299
299
|
})[];
|
|
300
300
|
text: string;
|
|
301
301
|
} | {
|
|
302
|
+
token?: undefined;
|
|
302
303
|
mint?: undefined;
|
|
303
304
|
ok: boolean;
|
|
304
305
|
text: string;
|
|
305
|
-
token?: undefined;
|
|
306
306
|
} | {
|
|
307
307
|
mint?: undefined;
|
|
308
308
|
ok: boolean;
|
package/types/mcp/tools.d.mts
CHANGED
|
@@ -745,10 +745,10 @@ export declare const tools: ({
|
|
|
745
745
|
additionalProperties: boolean;
|
|
746
746
|
};
|
|
747
747
|
handler(input: any, ctx: any): Promise<{
|
|
748
|
-
token?: undefined;
|
|
749
748
|
mint?: undefined;
|
|
750
749
|
ok: boolean;
|
|
751
750
|
text: string;
|
|
751
|
+
token?: undefined;
|
|
752
752
|
} | {
|
|
753
753
|
mint?: undefined;
|
|
754
754
|
ok: boolean;
|