@xgamesfun/solana 0.25.0 → 0.25.2
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/DISCLAIMER.md +1 -1
- package/NOTICE +4 -4
- package/README.md +81 -31
- package/bin/xsolana.mjs +19 -5
- package/examples/stakers-draw.json +1 -0
- package/js/client/index.js +1 -0
- package/js/client/kit.js +36 -0
- package/js/client/random.js +1 -1
- package/js/economy/check.mjs +11 -0
- package/js/economy/cost.mjs +1 -1
- package/js/economy/explain.mjs +10 -3
- package/js/economy/guide.mjs +1 -1
- package/js/economy/live.mjs +2 -1
- package/js/economy/review.mjs +760 -0
- package/js/economy/schema.json +8 -4
- package/js/economy/trust.mjs +15 -0
- package/js/index.js +1 -0
- package/js/mcp/http.mjs +136 -0
- package/js/mcp/server.mjs +34 -10
- package/js/mcp/tools.mjs +50 -2
- package/js/play/index.js +116 -2
- package/js/play/sponsor.js +302 -0
- package/js/results/index.js +2 -2
- package/package.json +35 -8
- package/release.json +1 -1
- package/skills/xsolana-economy/SKILL.md +32 -13
- package/skills/xsolana-economy/references/after-launch.md +2 -1
- package/skills/xsolana-economy/references/apps.md +28 -0
- package/skills/xsolana-economy/references/sentence-to-spec.md +1 -0
- package/skills/xsolana-economy/references/telling-the-person.md +6 -0
- package/skills/xsolana-economy/references/trust-and-cheating.md +126 -0
- package/types/client/index.d.ts +1 -0
- package/types/client/kit.d.ts +49 -0
- package/types/economy/cost.d.mts +1 -1
- package/types/economy/explain.d.mts +3 -0
- package/types/economy/plan.d.mts +14 -14
- package/types/economy/review.d.mts +45 -0
- package/types/economy/trust.d.mts +8 -0
- package/types/index.d.ts +1 -0
- package/types/mcp/http.d.mts +30 -0
- package/types/mcp/server.d.mts +321 -0
- package/types/mcp/tools.d.mts +71 -1
- package/types/play/index.d.ts +3 -1
- package/types/play/sponsor.d.ts +149 -0
- package/types/results/index.d.ts +1 -1
- package/examples/holder-den.json +0 -38
- package/examples/holders-drop.json +0 -105
- package/examples/match-predictions.json +0 -24
- package/examples/pack-league.json +0 -108
- package/examples/stride-sneakers.json +0 -164
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
// The review: an economy read the way a cheater reads it, not a validity check (that is check.mjs). For every route
|
|
2
|
+
// that pays or gives something of value it says who can trigger it, what the program enforces, what it trusts off the
|
|
3
|
+
// chain, the declared trust level of a signed result, the most one cheater gets, and what the prize goes on to earn.
|
|
4
|
+
// Then findings, each with a stable id, a severity, its route, what it means and the fix, and the trade-off between a
|
|
5
|
+
// server that decides, replays or bounds a result, in words an agent can show the person. Pure.
|
|
6
|
+
//
|
|
7
|
+
// The program enforces its own rules: a voucher names its wallet, grant and incarnation; a round pays a wallet once;
|
|
8
|
+
// limits, gates, seats and custody are the program's. What is left to get wrong is what a voucher signer decides
|
|
9
|
+
// (what it checks, which person it names, the limits it is given) and the economy's own design (emission without an
|
|
10
|
+
// end, prizes worth farming). The findings are about those.
|
|
11
|
+
import { check } from './check.mjs';
|
|
12
|
+
import { gateWords, ruleWords } from './explain.mjs';
|
|
13
|
+
import {
|
|
14
|
+
boxRolls,
|
|
15
|
+
destination,
|
|
16
|
+
listOf,
|
|
17
|
+
model,
|
|
18
|
+
outcomeParts,
|
|
19
|
+
poolFillers,
|
|
20
|
+
prizeLine,
|
|
21
|
+
prizePool,
|
|
22
|
+
prizeToken,
|
|
23
|
+
prizeWords,
|
|
24
|
+
toSeconds,
|
|
25
|
+
} from './model.mjs';
|
|
26
|
+
import { TRUST_LEVELS, TRUST_WORDS } from './trust.mjs';
|
|
27
|
+
|
|
28
|
+
/** Severities, most serious first. */
|
|
29
|
+
export const SEVERITIES = Object.freeze(['high', 'medium', 'low', 'info']);
|
|
30
|
+
export { TRUST_LEVELS, TRUST_WORDS };
|
|
31
|
+
|
|
32
|
+
const label = part => `“${part?.name ?? part?.key}”`;
|
|
33
|
+
const fmt = x =>
|
|
34
|
+
Number.isFinite(Number(x)) ? Number(x).toLocaleString('en-US', { maximumFractionDigits: 6 }) : String(x);
|
|
35
|
+
const short = a => `${String(a).slice(0, 4)}…${String(a).slice(-4)}`;
|
|
36
|
+
const days = d => toSeconds(d) / 86_400;
|
|
37
|
+
|
|
38
|
+
/** Which money a pool filler is: what players pay (not the owner's seed or a timer's mint). */
|
|
39
|
+
const PLAYER_MONEY = /^(payment|box|item|lottery|contest|recipe|auction):/;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The review of an economy: { ok, routes, findings, tradeoffs }. `ok` is false when there is a high finding, or when
|
|
43
|
+
* the spec does not check (then `refusals` says why, and the rest is empty).
|
|
44
|
+
*/
|
|
45
|
+
export function review(economy, { now = Math.floor(Date.now() / 1000) } = {}) {
|
|
46
|
+
const verdict = check(economy, { now });
|
|
47
|
+
if (!verdict.ok) return { ok: false, refusals: verdict.refusals, routes: [], findings: [], tradeoffs: '' };
|
|
48
|
+
const m = model(economy, { now });
|
|
49
|
+
const warnings = verdict.warnings;
|
|
50
|
+
const warned = (code, where) => warnings.filter(w => w.code === code && (!where || w.where.startsWith(where)));
|
|
51
|
+
const sym = key => m.tokens.get(key)?.symbol ?? key;
|
|
52
|
+
const routes = [];
|
|
53
|
+
const findings = [];
|
|
54
|
+
const find = (id, severity, route, message, fix) =>
|
|
55
|
+
findings.push({ id, severity, route, message, ...(fix ? { fix } : {}) });
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------- what a prize goes on to earn or buy
|
|
58
|
+
|
|
59
|
+
/** Whether farm `f` takes collectible `key`: its gate's rules, as far as the spec says (an attribute test may not pass). */
|
|
60
|
+
const farmTakesItem = (f, key) => {
|
|
61
|
+
const it = m.item.get(key);
|
|
62
|
+
if (f.stakes !== 'collectibles' || !it || it.kind === 'normal') return false;
|
|
63
|
+
return listOf(f.gate).every(r => {
|
|
64
|
+
if (r.items) return r.items.includes(key);
|
|
65
|
+
if (r.item) return r.item === key;
|
|
66
|
+
if (r.collection) return it.collection === r.collection;
|
|
67
|
+
return true;
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
/** The routes a prize line feeds: farms that stake it, recipes that take it, what its token buys. ["farm:reef", …] */
|
|
71
|
+
const feedsOf = (p, seen = new Set()) => {
|
|
72
|
+
const line = prizeLine(p);
|
|
73
|
+
if (!line) return [];
|
|
74
|
+
const out = new Set();
|
|
75
|
+
if (line.gives === 'item') {
|
|
76
|
+
for (const f of m.farms) if (farmTakesItem(f, line.item)) out.add(`farm:${f.key}`);
|
|
77
|
+
for (const r of m.recipes) if ((r.in ?? []).some(i => i.item === line.item)) out.add(`recipe:${r.key}`);
|
|
78
|
+
} else if (line.gives === 'tokens') {
|
|
79
|
+
const t = prizeToken(line, m)?.key;
|
|
80
|
+
if (t) for (const id of tokenFeeds(t)) out.add(id);
|
|
81
|
+
} else if (line.gives === 'roll' && line.box && !seen.has(line.box)) {
|
|
82
|
+
seen.add(line.box);
|
|
83
|
+
for (const o of (m.box.get(line.box)?.outcomes ?? []).flatMap(outcomeParts)) {
|
|
84
|
+
const as =
|
|
85
|
+
o.gives === 'mint'
|
|
86
|
+
? { gives: 'tokens', currency: o.currency, amount: o.amount, from: 'mint' }
|
|
87
|
+
: o.gives === 'pay'
|
|
88
|
+
? { gives: 'tokens', amount: o.amount, from: `pool:${o.pool}` }
|
|
89
|
+
: o;
|
|
90
|
+
for (const id of feedsOf(as, seen)) out.add(id);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return [...out];
|
|
94
|
+
};
|
|
95
|
+
/** What a token is staked in or buys. */
|
|
96
|
+
const tokenFeeds = t => [
|
|
97
|
+
...m.farms.filter(f => f.stakes === t).map(f => `farm:${f.key}`),
|
|
98
|
+
...m.allBoxes
|
|
99
|
+
.filter(b => b.active !== false && (b.currency ?? m.currencyKey) === t && Number(b.price ?? 0) > 0)
|
|
100
|
+
.map(b => (b.shopOf ? `item:${b.shopOf}` : `box:${b.key}`)),
|
|
101
|
+
...m.lotteries.filter(l => (l.ticketCurrency ?? l.currency ?? m.currencyKey) === t).map(l => `lottery:${l.key}`),
|
|
102
|
+
...m.recipes
|
|
103
|
+
.filter(r => (r.in ?? []).some(i => i.takes === 'pay' && (i.currency ?? m.currencyKey) === t))
|
|
104
|
+
.map(r => `recipe:${r.key}`),
|
|
105
|
+
];
|
|
106
|
+
const farmOf = id => (id.startsWith('farm:') ? m.farm.get(id.slice(5)) : null);
|
|
107
|
+
const mints = f => Boolean(f?.rate?.minted) && Number(f.rate.perDay ?? 0) > 0;
|
|
108
|
+
/** A farm that pays for good: no end, no term on every tier, no upkeep, no halving. */
|
|
109
|
+
const endless = f => !f.runs && !(f.tiers ?? []).every(t => t.earnsFor) && !f.upkeepAttribute && !f.rate?.halveEvery;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What one staked unit (a collectible `item`, or one whole token) earns at its best tier and boosts: { perDay, life }
|
|
113
|
+
* (`life`: days it can earn, or null for good; `perDay` null for a farm that shares what lands).
|
|
114
|
+
*/
|
|
115
|
+
const earns = (f, item) => {
|
|
116
|
+
if (!f.rate) return { perDay: null, life: f.runs ? days(f.runs) : null };
|
|
117
|
+
const mult = Math.max(1, ...(f.tiers ?? []).map(t => Number(t.multiplier ?? 1)));
|
|
118
|
+
let boost = 0;
|
|
119
|
+
for (const b of f.boosts ?? []) {
|
|
120
|
+
if (item && b.when?.item && b.when.item !== item) continue;
|
|
121
|
+
if (item && b.when?.items && !b.when.items.includes(item)) continue;
|
|
122
|
+
boost +=
|
|
123
|
+
b.add?.addPercent ??
|
|
124
|
+
(b.add?.perPointPercent !== undefined ? b.add.perPointPercent * (b.add.upTo ?? Infinity) : 0);
|
|
125
|
+
}
|
|
126
|
+
if (f.maxBoostPercent !== undefined) boost = Math.min(boost, Number(f.maxBoostPercent));
|
|
127
|
+
const perDay = Number.isFinite(boost) ? Number(f.rate.perDay) * mult * (1 + boost / 100) : null;
|
|
128
|
+
const lives = [
|
|
129
|
+
f.runs ? days(f.runs) : null,
|
|
130
|
+
f.rate.halveEvery ? days(f.rate.halveEvery) * 2 : null,
|
|
131
|
+
(f.tiers ?? []).every(t => t.earnsFor) && (f.tiers ?? []).length
|
|
132
|
+
? Math.max(...f.tiers.map(t => days(t.earnsFor)))
|
|
133
|
+
: null,
|
|
134
|
+
].filter(x => x !== null);
|
|
135
|
+
return { perDay, life: lives.length ? Math.min(...lives) : null };
|
|
136
|
+
};
|
|
137
|
+
const earnsWords = (f, item) => {
|
|
138
|
+
const e = earns(f, item);
|
|
139
|
+
const what = f.stakes === 'collectibles' ? `each ${item ? (m.item.get(item)?.name ?? item) : 'NFT'}` : 'each token';
|
|
140
|
+
const t = sym(f.rewardsToken);
|
|
141
|
+
if (e.perDay === null)
|
|
142
|
+
return f.rate
|
|
143
|
+
? `${what} earns more in ${label(f)} the more its boosts read`
|
|
144
|
+
: `${what} takes a share of what lands in ${label(f)}`;
|
|
145
|
+
const shared = f.rate.shared ? ' (the whole farm’s, shared by weight: all of it while alone)' : '';
|
|
146
|
+
return e.life === null
|
|
147
|
+
? `${what} earns up to ${fmt(e.perDay)} ${t} a day in ${label(f)}${shared} (${fmt(e.perDay * 30)} in 30 days, ${fmt(e.perDay * 365)} a year), for as long as it stays staked, with no end`
|
|
148
|
+
: `${what} earns up to ${fmt(e.perDay)} ${t} a day in ${label(f)}${shared}, at most about ${fmt(e.perDay * e.life)} over ${fmt(e.life)} days`;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------- grants
|
|
152
|
+
|
|
153
|
+
const tradeable = p =>
|
|
154
|
+
p?.gives === 'tokens' ||
|
|
155
|
+
p?.gives === 'nft' ||
|
|
156
|
+
p?.gives === 'roll' ||
|
|
157
|
+
(p?.gives === 'item' && !m.item.get(p.item)?.soulbound && m.item.get(p.item)?.kind !== 'normal');
|
|
158
|
+
/** The pools a prize pays from, a roll's included. */
|
|
159
|
+
const poolsOf = (p, seen = new Set()) => {
|
|
160
|
+
const line = prizeLine(p);
|
|
161
|
+
if (!line) return [];
|
|
162
|
+
if (prizePool(line)) return [prizePool(line)];
|
|
163
|
+
if (line.gives !== 'roll' || !line.box || seen.has(line.box)) return [];
|
|
164
|
+
seen.add(line.box);
|
|
165
|
+
return (m.box.get(line.box)?.outcomes ?? [])
|
|
166
|
+
.flatMap(outcomeParts)
|
|
167
|
+
.flatMap(o => (o.gives === 'pay' ? [o.pool] : o.gives === 'roll' ? poolsOf(o, seen) : []));
|
|
168
|
+
};
|
|
169
|
+
const fillers = poolFillers(m);
|
|
170
|
+
|
|
171
|
+
for (const g of m.grants) {
|
|
172
|
+
const id = `grant:${g.key}`;
|
|
173
|
+
const prize = prizeLine(g.prize ?? g.gives);
|
|
174
|
+
const l = g.limits ?? {};
|
|
175
|
+
const vouchers = g.vouchers ?? [];
|
|
176
|
+
const keys = g.keys ?? [];
|
|
177
|
+
const list = g.list ?? [];
|
|
178
|
+
const open = !vouchers.length && !keys.length && !list.length;
|
|
179
|
+
const feeds = feedsOf(prize);
|
|
180
|
+
const earning = feeds.map(farmOf).filter(f => f && (Number(f.rate?.perDay ?? 0) > 0 || !f.rate));
|
|
181
|
+
const minting = earning.filter(mints);
|
|
182
|
+
const worth = tradeable(prize) || earning.length > 0;
|
|
183
|
+
const gives = prizeWords(prize, m);
|
|
184
|
+
const level = g.trust ?? null;
|
|
185
|
+
// An undeclared server is read as the weakest: nothing says it checks more.
|
|
186
|
+
const assumed = level ?? (vouchers.length ? 'bounds' : null);
|
|
187
|
+
|
|
188
|
+
const enforced = [
|
|
189
|
+
l.perWallet && `${l.perWallet} per wallet`,
|
|
190
|
+
l.perWalletEach?.count && `${l.perWalletEach.count} per wallet every ${l.perWalletEach.every ?? '1d'}`,
|
|
191
|
+
l.perPerson && `${l.perPerson} per person (counted only for a voucher that names one)`,
|
|
192
|
+
l.perKey && `${l.perKey} per key`,
|
|
193
|
+
l.perDay && `${l.perDay} a day, all wallets together`,
|
|
194
|
+
l.total && `${l.total} in all`,
|
|
195
|
+
l.opens && `not before ${l.opens}`,
|
|
196
|
+
l.until && `not after ${l.until}`,
|
|
197
|
+
g.gate && gateWords(g.gate, 'claim it', m).replace(/^Only/, 'only'),
|
|
198
|
+
!open &&
|
|
199
|
+
'each round pays a wallet once; a voucher names its wallet, grant and incarnation, so it can’t be moved or replayed',
|
|
200
|
+
'only the receiving wallet signs the claim',
|
|
201
|
+
].filter(Boolean);
|
|
202
|
+
const trusts = [
|
|
203
|
+
...vouchers.map(v => `the voucher key ${short(v)} (whatever it signs is paid, within the limits)`),
|
|
204
|
+
...(keys.length ? [`${keys.length} code keys (whoever has a code claims with it)`] : []),
|
|
205
|
+
...(list.length ? [`the list of ${list.length} wallets in the spec`] : []),
|
|
206
|
+
];
|
|
207
|
+
const trigger = open
|
|
208
|
+
? `any wallet${
|
|
209
|
+
g.gate
|
|
210
|
+
? ` that passes its gate (${gateWords(g.gate, 'claim it', m)
|
|
211
|
+
.replace(/^Only /, '')
|
|
212
|
+
.replace(/ can claim it$/, '')})`
|
|
213
|
+
: ''
|
|
214
|
+
}`
|
|
215
|
+
: [
|
|
216
|
+
vouchers.length && 'a wallet the voucher key signs for',
|
|
217
|
+
keys.length && 'a wallet holding a code',
|
|
218
|
+
list.length && 'a wallet on the list',
|
|
219
|
+
]
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
.join(', or ');
|
|
222
|
+
|
|
223
|
+
// The most one cheater gets.
|
|
224
|
+
const perWallet = l.perWallet
|
|
225
|
+
? `${l.perWallet} per wallet`
|
|
226
|
+
: l.perWalletEach?.count
|
|
227
|
+
? `${l.perWalletEach.count} per wallet every ${l.perWalletEach.every ?? '1d'}`
|
|
228
|
+
: 'no limit per wallet';
|
|
229
|
+
const cap = l.perDay
|
|
230
|
+
? `up to ${l.perDay} a day (everyone’s daily total)${l.total ? ` and ${l.total} in all` : ', with no total: without end'}`
|
|
231
|
+
: l.total
|
|
232
|
+
? `up to ${l.total} in all (everyone’s total)`
|
|
233
|
+
: 'with no daily or overall cap: until the prize or its funds run out';
|
|
234
|
+
const many = list.length
|
|
235
|
+
? `only the ${list.length} listed wallets: ${perWallet}, each round once`
|
|
236
|
+
: keys.length && !vouchers.length
|
|
237
|
+
? l.perKey
|
|
238
|
+
? `${l.perKey} per code, ${fmt(keys.length * l.perKey)} with every code; ${cap}`
|
|
239
|
+
: `a shared code pays every wallet that uses it; ${cap}`
|
|
240
|
+
: l.perPerson
|
|
241
|
+
? `${l.perPerson} per person when the server names a real one (an X account); a voucher with no person counts nobody, and then free wallets claim ${perWallet} each, ${cap}`
|
|
242
|
+
: `${perWallet}, and wallets are free: ${cap}`;
|
|
243
|
+
const how = {
|
|
244
|
+
decides: 'Playing for real (a bot that plays well): ',
|
|
245
|
+
replays: 'A bot that plays well, or one that finds the best run offline when it knows the randomness up front: ',
|
|
246
|
+
bounds: 'Without playing (a script at human speed): ',
|
|
247
|
+
};
|
|
248
|
+
const ceiling = `${open ? 'Without playing: ' : (how[assumed] ?? '')}${gives}, ${many}.${minting.length || earning.length ? ` Then ${earning.map(f => earnsWords(f, prize?.gives === 'item' ? prize.item : null)).join('; ')}.` : ''}`;
|
|
249
|
+
routes.push({ kind: 'grant', key: g.key, name: g.name, trigger, enforced, trusts, trust: level, ceiling, feeds });
|
|
250
|
+
|
|
251
|
+
// A voucher grant that says nothing of what its server checks.
|
|
252
|
+
if (vouchers.length && !level)
|
|
253
|
+
find(
|
|
254
|
+
'R-TRUST-UNSAID',
|
|
255
|
+
'medium',
|
|
256
|
+
id,
|
|
257
|
+
`${label(g)} pays whatever its voucher key signs, and the spec doesn’t say what that server checks before it signs. The program checks the signature, the wallet and the limits, never what happened in the game, so this review reads it as "bounds" (the page reports, the server only checks pace and caps): a script that never plays gets ${gives} as often as the limits allow.`,
|
|
258
|
+
'Set "trust" to what the server really does: "decides" (it runs the game or match; the page sends only inputs), "replays" (it re-runs the page’s inputs from randomness it keeps) or "bounds" (it takes the page’s word within a pace and caps). Then tell the person which, and what a cheater gets.',
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
// A cheatable claim whose prize keeps earning.
|
|
262
|
+
if ((assumed === 'bounds' || assumed === 'replays') && minting.length) {
|
|
263
|
+
const forGood = minting.some(endless);
|
|
264
|
+
const serious = forGood || !l.total;
|
|
265
|
+
const severity = assumed === 'bounds' ? (serious ? 'high' : 'medium') : serious ? 'medium' : 'low';
|
|
266
|
+
find(
|
|
267
|
+
'R-CLAIM-COMPOUNDS',
|
|
268
|
+
severity,
|
|
269
|
+
id,
|
|
270
|
+
`${label(g)} is ${level ? `"${level}"` : 'read as "bounds"'}: ${TRUST_WORDS[assumed]}. What it gives keeps earning: ${minting.map(f => earnsWords(f, prize?.gives === 'item' ? prize.item : null)).join('; ')}. So a cheater’s claim mints tokens ${forGood ? 'for good' : 'for as long as the farm runs'}, ${l.total ? `from up to ${l.total} claims in all` : 'and the grant has no "total"'}${l.perPerson ? '' : ', one claim for every free wallet'}.`,
|
|
271
|
+
`Pick one, and tell the person the trade-off: have the server decide or replay the result (${assumed === 'bounds' ? 'the strongest fix' : 'decide it, for the strongest'}); give ${minting.map(label).join(', ')} an end ("runs") or a term on every tier ("earnsFor"); ${[!l.total && '"total"', !l.perPerson && '"perPerson"'].filter(Boolean).length ? `set the grant’s ${[!l.total && '"total"', !l.perPerson && '"perPerson"'].filter(Boolean).join(' and ')}; ` : ''}or pay a rate from the farm’s vault (no "minted"), so it can only pay what it holds.`,
|
|
272
|
+
);
|
|
273
|
+
} else if ((assumed === 'bounds' || assumed === 'replays') && earning.length)
|
|
274
|
+
find(
|
|
275
|
+
'R-CLAIM-COMPOUNDS',
|
|
276
|
+
assumed === 'bounds' ? 'medium' : 'low',
|
|
277
|
+
id,
|
|
278
|
+
`${label(g)} is ${level ? `"${level}"` : 'read as "bounds"'}, and what it gives earns in ${earning.map(label).join(', ')}: what a cheater claims takes rewards honest stakers would have had.`,
|
|
279
|
+
'Have the server decide or replay the result, or keep "total" and "perPerson" small.',
|
|
280
|
+
);
|
|
281
|
+
// A cheatable claim with no total, worth farming.
|
|
282
|
+
else if ((assumed === 'bounds' || assumed === 'replays') && tradeable(prize) && !l.total)
|
|
283
|
+
find(
|
|
284
|
+
'R-CLAIM-UNCAPPED',
|
|
285
|
+
assumed === 'bounds' ? 'medium' : 'low',
|
|
286
|
+
id,
|
|
287
|
+
`${label(g)} is ${level ? `"${level}"` : 'read as "bounds"'} and has no "total": a cheater claims ${gives} ${l.perDay ? `every day, up to ${l.perDay} a day,` : ''} for as long as it runs.`,
|
|
288
|
+
'Set "total" (the most it ever pays), keep "perDay" small, or have the server decide or replay the result.',
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
// Entry fees and prize pools paid on a result the server only bounds or replays.
|
|
292
|
+
const paidBy = [
|
|
293
|
+
...new Set(
|
|
294
|
+
poolsOf(prize).flatMap(pool => fillers.filter(f => f.pool === pool && PLAYER_MONEY.test(f.by)).map(f => f.by)),
|
|
295
|
+
),
|
|
296
|
+
];
|
|
297
|
+
if ((assumed === 'bounds' || assumed === 'replays') && paidBy.length)
|
|
298
|
+
find(
|
|
299
|
+
'R-STAKES-PAID',
|
|
300
|
+
assumed === 'bounds' ? 'high' : 'medium',
|
|
301
|
+
id,
|
|
302
|
+
`${label(g)} pays ${gives} out of what players paid in (${paidBy.join(', ')}), on a result the server ${assumed === 'bounds' ? 'only bounds' : 'replays'}${level ? '' : ' (read as "bounds": its "trust" is not said)'}: a script takes other players’ money.`,
|
|
303
|
+
'Keep entry fees and prize pools for results the server decides (a match it runs) and for chance (a lottery, a box: the oracle). Pay a bounded or replayed result only from what the project mints or seeds, small and capped a day.',
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Each free wallet claims again: a voucher grant with no person counted.
|
|
307
|
+
if (vouchers.length && !l.perPerson && worth && Number(l.total ?? 0) !== 1)
|
|
308
|
+
find(
|
|
309
|
+
'R-GRANT-PERSON',
|
|
310
|
+
g.gate ? 'low' : 'medium',
|
|
311
|
+
id,
|
|
312
|
+
`${label(g)} counts no person ("perPerson"): its limits are per wallet, and wallets are free, so one person with many wallets claims ${gives} once for each, up to ${l.perDay ? `${l.perDay} a day` : l.total ? `${l.total} in all` : 'its funds or prize'}${g.gate ? ', each wallet holding what its gate asks' : ''}.`,
|
|
313
|
+
'Have the server sign only for players signed in with an outside account (X), naming that person in each voucher (a person hash, never a wallet), and set "perPerson". For a wallet-only player, keep the prize small and capped a day.',
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
// Open grants: anyone claims.
|
|
317
|
+
if (open && !list.length) {
|
|
318
|
+
const extra =
|
|
319
|
+
!l.perWallet && !l.perWalletEach?.count ? ' It limits no wallet: one wallet claims again and again.' : '';
|
|
320
|
+
if (worth) {
|
|
321
|
+
const severity = !g.gate ? 'high' : g.gate.wallets || g.gate.list ? 'info' : 'medium';
|
|
322
|
+
find(
|
|
323
|
+
'R-GRANT-OPEN',
|
|
324
|
+
severity,
|
|
325
|
+
id,
|
|
326
|
+
`${label(g)} is open: no voucher, code or list, so ${g.gate ? (g.gate.wallets || g.gate.list ? 'every wallet its gate lets in' : 'any wallet that passes its gate (each collectible shown counts once, so passing one around claims nothing more)') : 'any wallet'} claims ${gives} without doing anything, ${many}.${extra}${earning.length ? ` And ${earning.map(f => earnsWords(f, prize?.gives === 'item' ? prize.item : null)).join('; ')}.` : ''}`,
|
|
327
|
+
g.gate
|
|
328
|
+
? 'Gate on something a sybil must pay for (an item the game sells, a locked stake), and keep "perDay" and "total" small.'
|
|
329
|
+
: 'Pay it on vouchers from a server that checks what was done (and counts people with "perPerson"), or gate it on holding something the game sells; keep "perDay" and "total" small either way.',
|
|
330
|
+
);
|
|
331
|
+
} else
|
|
332
|
+
find(
|
|
333
|
+
'R-GRANT-OPEN',
|
|
334
|
+
'low',
|
|
335
|
+
id,
|
|
336
|
+
`${label(g)} is open: any wallet claims ${gives}, ${many}. It can’t be sold, so farming it gains little.${extra}`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// A leaked key or a shared code: the checker's notes, read as exploits.
|
|
341
|
+
const totals = [...warned('W-GRANT-TOTAL', `economy.grants.${g.key}`)].concat(
|
|
342
|
+
warned('W-GRANT-LIMITS', `economy.grants.${g.key}.limits`).filter(w => w.where.endsWith('.limits')),
|
|
343
|
+
);
|
|
344
|
+
if (totals.length) find('R-GRANT-TOTAL', 'medium', id, totals.map(w => w.message).join(' '), totals[0].fix);
|
|
345
|
+
for (const w of warned('W-GRANT-LIMITS', `economy.grants.${g.key}.limits.perKey`))
|
|
346
|
+
find('R-CODES', 'medium', id, w.message, w.fix);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------- the shop, boxes, payments, recipes
|
|
350
|
+
|
|
351
|
+
for (const b of m.allBoxes) {
|
|
352
|
+
if (b.active === false) continue;
|
|
353
|
+
const it = b.shopOf ? m.item.get(b.shopOf) : null;
|
|
354
|
+
const kind = it ? 'item' : 'box';
|
|
355
|
+
const key = it ? it.key : b.key;
|
|
356
|
+
const id = `${kind}:${key}`;
|
|
357
|
+
const price = Number(b.price ?? 0);
|
|
358
|
+
const t = sym(b.currency ?? m.currencyKey);
|
|
359
|
+
const rolls = boxRolls(b, m);
|
|
360
|
+
const outcomes = (b.outcomes ?? []).flatMap(outcomeParts);
|
|
361
|
+
const feeds = [
|
|
362
|
+
...new Set(
|
|
363
|
+
outcomes.flatMap(o =>
|
|
364
|
+
feedsOf(
|
|
365
|
+
o.gives === 'mint'
|
|
366
|
+
? { gives: 'tokens', currency: o.currency, amount: o.amount, from: 'mint' }
|
|
367
|
+
: o.gives === 'pay'
|
|
368
|
+
? { gives: 'tokens', amount: o.amount, from: `pool:${o.pool}` }
|
|
369
|
+
: o,
|
|
370
|
+
),
|
|
371
|
+
),
|
|
372
|
+
),
|
|
373
|
+
];
|
|
374
|
+
const enforced = [
|
|
375
|
+
price > 0 ? `the price, ${fmt(price)} ${t}` : 'no price: free',
|
|
376
|
+
it?.supply && `${it.supply} exist`,
|
|
377
|
+
b.perWallet && `${b.perWallet} per wallet`,
|
|
378
|
+
b.perWalletEach?.count && `${b.perWalletEach.count} per wallet every ${b.perWalletEach.every ?? '1d'}`,
|
|
379
|
+
b.opens && `from ${b.opens}`,
|
|
380
|
+
b.closes && `until ${b.closes}`,
|
|
381
|
+
b.gate && gateWords(b.gate, kind === 'item' ? 'buy it' : 'open it', m).replace(/^Only/, 'only'),
|
|
382
|
+
rolls && 'the roll is on its written odds, by the oracle',
|
|
383
|
+
].filter(Boolean);
|
|
384
|
+
const free = price === 0;
|
|
385
|
+
const ceiling = free
|
|
386
|
+
? `${b.perWallet ? `${b.perWallet} per wallet, and wallets are free: ` : ''}${it?.supply ? `all ${it.supply}` : 'as many as they like'}, for only the fees.`
|
|
387
|
+
: `What they pay for, at the price: ${rolls ? 'nobody, the project included, can steer the roll' : 'nothing free'}.${b.whenOut === 'drawLeft' && rolls ? ' With "whenOut": "drawLeft", someone watching the stock can empty a common outcome, then open for the rare at better odds.' : ''}`;
|
|
388
|
+
routes.push({
|
|
389
|
+
kind,
|
|
390
|
+
key,
|
|
391
|
+
name: it?.name ?? b.name,
|
|
392
|
+
trigger: `any wallet${free ? ', for only the fees' : ` that pays ${fmt(price)} ${t}`}`,
|
|
393
|
+
enforced,
|
|
394
|
+
trusts: rolls ? ['MagicBlock’s oracle (the roll)'] : [],
|
|
395
|
+
trust: null,
|
|
396
|
+
ceiling,
|
|
397
|
+
feeds,
|
|
398
|
+
});
|
|
399
|
+
if (b.whenOut === 'drawLeft' && rolls && !free)
|
|
400
|
+
find(
|
|
401
|
+
'R-STOCK-STEER',
|
|
402
|
+
'low',
|
|
403
|
+
id,
|
|
404
|
+
`${label(b)} keeps selling when an outcome runs out and draws among what is left: anyone watching the stock can empty a common outcome, then open for the rare at better odds.`,
|
|
405
|
+
'Keep the default ("stopSales") for a box that sells a rare prize.',
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
for (const w of warned('W-FREE')) {
|
|
409
|
+
const [, list, key] = /^economy\.(items|boxes)\.([^.[]+)/.exec(w.where) ?? [];
|
|
410
|
+
const route = list === 'items' ? `item:${key}` : list === 'boxes' ? `box:${key}` : 'economy';
|
|
411
|
+
const r = routes.find(x => `${x.kind}:${x.key}` === route);
|
|
412
|
+
const earning = (r?.feeds ?? []).map(farmOf).filter(f => f && mints(f));
|
|
413
|
+
find(
|
|
414
|
+
'R-FREE',
|
|
415
|
+
earning.length ? 'high' : 'medium',
|
|
416
|
+
route,
|
|
417
|
+
`${w.message}${earning.length ? ` And ${earning.map(f => earnsWords(f, list === 'items' ? key : null)).join('; ')}.` : ''}`,
|
|
418
|
+
w.fix,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
for (const p of m.payments)
|
|
422
|
+
routes.push({
|
|
423
|
+
kind: 'payment',
|
|
424
|
+
key: p.key,
|
|
425
|
+
name: p.name,
|
|
426
|
+
trigger: 'any wallet that pays',
|
|
427
|
+
enforced: [`${p.any ? 'at least ' : ''}${fmt(p.amount)} ${sym(p.currency ?? m.currencyKey)}`],
|
|
428
|
+
trusts: ['your server, which reads the receipt and gives off the chain what was paid for'],
|
|
429
|
+
trust: null,
|
|
430
|
+
ceiling: 'What your server gives for a receipt: the program only takes the money. Count each receipt once.',
|
|
431
|
+
feeds: [],
|
|
432
|
+
});
|
|
433
|
+
for (const r of m.recipes) {
|
|
434
|
+
if (r.active === false) continue;
|
|
435
|
+
routes.push({
|
|
436
|
+
kind: 'recipe',
|
|
437
|
+
key: r.key,
|
|
438
|
+
name: r.name,
|
|
439
|
+
trigger: 'any wallet holding what it takes',
|
|
440
|
+
enforced: [
|
|
441
|
+
'every line it takes, all or nothing',
|
|
442
|
+
r.perWallet && `${r.perWallet} per wallet`,
|
|
443
|
+
r.perWalletEach?.count && `${r.perWalletEach.count} per wallet every ${r.perWalletEach.every ?? '1d'}`,
|
|
444
|
+
r.opens && `from ${r.opens}`,
|
|
445
|
+
r.closes && `until ${r.closes}`,
|
|
446
|
+
].filter(Boolean),
|
|
447
|
+
trusts: [],
|
|
448
|
+
trust: null,
|
|
449
|
+
ceiling: 'As many crafts as what it takes allows: as cheap to farm as its inputs are.',
|
|
450
|
+
feeds: [...new Set((r.out ?? []).flatMap(o => feedsOf(o)))],
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ---------------------------------------------------------------- farms
|
|
455
|
+
|
|
456
|
+
for (const f of m.farms) {
|
|
457
|
+
const id = `farm:${f.key}`;
|
|
458
|
+
const takes =
|
|
459
|
+
f.stakes === 'collectibles'
|
|
460
|
+
? f.gate
|
|
461
|
+
? listOf(f.gate).map(ruleWords).join(' and ')
|
|
462
|
+
: 'one of the game’s collectibles'
|
|
463
|
+
: `${sym(f.stakes)} tokens`;
|
|
464
|
+
const items = f.stakes === 'collectibles' ? m.items.filter(it => farmTakesItem(f, it.key)) : [];
|
|
465
|
+
const enforced = [
|
|
466
|
+
f.stakes === 'collectibles'
|
|
467
|
+
? 'only NFTs its gate takes: the project’s collection and the item number the program writes at mint (nobody can forge or change it)'
|
|
468
|
+
: `only ${sym(f.stakes)}`,
|
|
469
|
+
'one seat per NFT, ever; nobody else harvests or unstakes a seat',
|
|
470
|
+
f.rate
|
|
471
|
+
? `${f.rate.shared ? 'the whole farm’s' : 'a fixed'} ${fmt(f.rate.perDay)} ${sym(f.rewardsToken)} a day${f.rate.shared ? '' : ' per unit at x1'}, ${f.rate.minted ? 'minted' : 'from its vault while it lasts'}`
|
|
472
|
+
: 'a share of what lands in its vault',
|
|
473
|
+
`locks ${(f.tiers ?? [{ lock: 0 }]).map(t => `${t.lock ?? 0} (x${t.multiplier ?? 1})`).join(', ')}`,
|
|
474
|
+
f.maxBoostPercent !== undefined && `boosts at most +${f.maxBoostPercent}%`,
|
|
475
|
+
f.runs ? `ends ${f.runs} after it goes live` : 'no end',
|
|
476
|
+
`${f.custody ?? 'escrow'} custody`,
|
|
477
|
+
].filter(Boolean);
|
|
478
|
+
const best = items.length ? items.map(it => earnsWords(f, it.key)).join('; ') : earnsWords(f, null);
|
|
479
|
+
routes.push({
|
|
480
|
+
kind: 'farm',
|
|
481
|
+
key: f.key,
|
|
482
|
+
name: f.name,
|
|
483
|
+
trigger: `any wallet that stakes ${takes}`,
|
|
484
|
+
enforced,
|
|
485
|
+
trusts: [],
|
|
486
|
+
trust: null,
|
|
487
|
+
ceiling: `${best}. Whoever holds the most of what it takes earns the most: how cheaply that is had is what to look at.`,
|
|
488
|
+
feeds: [...new Set([...tokenFeeds(f.rewardsToken), ...(f.tiers ?? []).flatMap(t => feedsOf(t.prize))])],
|
|
489
|
+
});
|
|
490
|
+
if (mints(f) && endless(f)) {
|
|
491
|
+
const self = warned('W-FARM-MINT', `economy.farms.${f.key}`);
|
|
492
|
+
find(
|
|
493
|
+
'R-FARM-ENDLESS',
|
|
494
|
+
'medium',
|
|
495
|
+
id,
|
|
496
|
+
`${label(f)} mints ${sym(f.rewardsToken)} with no end: ${best}. Nothing bounds the supply${self.length ? `, and what it pays ${f.stakes === f.rewardsToken ? 'can be staked in it for more' : 'buys more of what earns it'}: whoever stakes most compounds it` : ''}. Every source of what it takes is worth that stream to a cheater.`,
|
|
497
|
+
'Give it an end ("runs"), a term on every tier ("earnsFor"), an upkeep, or a halving rate ("halveEvery"), or pay its rate from its vault (no "minted") so it pays only what it holds.',
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
if (f.custody === 'watch')
|
|
501
|
+
find(
|
|
502
|
+
'R-FARM-WATCH',
|
|
503
|
+
'info',
|
|
504
|
+
id,
|
|
505
|
+
`${label(f)} watches NFTs left in their wallets: one NFT has one seat, ever. Sold while staked, its old staker earns nothing more and its buyer takes the seat over, so passing an NFT between wallets earns nothing twice.`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
for (const w of warned('W-FARM-SWITCH')) {
|
|
509
|
+
const key = /^economy\.farms\.([^.]+)/.exec(w.where)?.[1];
|
|
510
|
+
find('R-FARM-SWITCH', 'low', key ? `farm:${key}` : 'economy', w.message, w.fix);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ---------------------------------------------------------------- chance, contests, auctions, timers, the authority
|
|
514
|
+
|
|
515
|
+
for (const l of m.lotteries) {
|
|
516
|
+
const t = sym(l.ticketCurrency ?? l.currency ?? m.currencyKey);
|
|
517
|
+
routes.push({
|
|
518
|
+
kind: 'lottery',
|
|
519
|
+
key: l.key,
|
|
520
|
+
name: l.name,
|
|
521
|
+
trigger: `any wallet that buys a ticket${l.ticketNft ? ' (with an NFT)' : ` (${fmt(l.price ?? 0)} ${t})`}`,
|
|
522
|
+
enforced: [
|
|
523
|
+
l.perWallet && `${l.perWallet} tickets per wallet a round`,
|
|
524
|
+
l.gate && gateWords(l.gate, 'buy a ticket', m).replace(/^Only/, 'only'),
|
|
525
|
+
'the draw is by the oracle; its places are paid by the program’s rules',
|
|
526
|
+
].filter(Boolean),
|
|
527
|
+
trusts: ['MagicBlock’s oracle (the draw)'],
|
|
528
|
+
trust: null,
|
|
529
|
+
ceiling: `A place by chance: each ticket is one chance, and "perWallet" limits a wallet, not a person. Tickets are not weighted by time, so one buyer may take most of a round’s tickets just before the draw.`,
|
|
530
|
+
feeds: [...new Set((l.places ?? []).flatMap(p => feedsOf(p.prize)))],
|
|
531
|
+
});
|
|
532
|
+
find(
|
|
533
|
+
'R-LOTTERY-LATE',
|
|
534
|
+
'info',
|
|
535
|
+
`lottery:${l.key}`,
|
|
536
|
+
`Anyone may buy most of ${label(l)}’s tickets right before the draw, risking only the price and fees; the draw itself is the oracle’s, which nobody steers.`,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
for (const c of m.contests) {
|
|
540
|
+
const deciders = c.deciders ?? [];
|
|
541
|
+
routes.push({
|
|
542
|
+
kind: 'contest',
|
|
543
|
+
key: c.key,
|
|
544
|
+
name: c.name,
|
|
545
|
+
trigger: 'the authority opens it; any wallet enters',
|
|
546
|
+
enforced: [
|
|
547
|
+
c.stake && `every entry ${fmt(c.stake)} ${sym(c.currency ?? m.currencyKey)}`,
|
|
548
|
+
c.gate && gateWords(c.gate, 'enter', m).replace(/^Only/, 'only'),
|
|
549
|
+
c.minPot && `at least ${fmt(c.minPot)} in the pot, or everyone is paid back`,
|
|
550
|
+
'no result before entries close; a result overdue pays everyone back',
|
|
551
|
+
].filter(Boolean),
|
|
552
|
+
trusts: deciders.length
|
|
553
|
+
? deciders.map(d => `the decider key ${short(d)} (it names the side that won)`)
|
|
554
|
+
: ['the authority, which names the side that won'],
|
|
555
|
+
trust: null,
|
|
556
|
+
ceiling:
|
|
557
|
+
'The losing sides’ stakes, for whichever side the signer names: the pot is only as honest as that result.',
|
|
558
|
+
feeds: feedsOf(c.prize),
|
|
559
|
+
});
|
|
560
|
+
find(
|
|
561
|
+
'R-CONTEST-RESULT',
|
|
562
|
+
'info',
|
|
563
|
+
`contest:${c.key}`,
|
|
564
|
+
`Who takes ${label(c)}’s pot is what ${deciders.length ? 'its decider key signs' : 'the authority names'}: players’ stakes ride on that result. Keep contests for results the server decides (a match it runs) or that anyone can check outside the game.`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
for (const a of m.auctions) {
|
|
568
|
+
routes.push({
|
|
569
|
+
kind: 'auction',
|
|
570
|
+
key: a.key,
|
|
571
|
+
name: a.name,
|
|
572
|
+
trigger: 'any wallet that bids',
|
|
573
|
+
enforced: [
|
|
574
|
+
a.reserve !== undefined && `a reserve of ${fmt(a.reserve)}`,
|
|
575
|
+
a.minRaise !== undefined && `each bid ${a.minRaise} over the last`,
|
|
576
|
+
`runs ${a.runs}`,
|
|
577
|
+
'an outbid is paid back at once',
|
|
578
|
+
].filter(Boolean),
|
|
579
|
+
trusts: [],
|
|
580
|
+
trust: null,
|
|
581
|
+
ceiling: 'The lot, for the highest bid: nothing free. Anyone, the seller included, can bid the price up.',
|
|
582
|
+
feeds: a.lot?.item ? feedsOf({ gives: 'item', item: a.lot.item }) : [],
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
for (const w of warned('W-AUCTION-SHILL')) {
|
|
586
|
+
const key = /^economy\.auctions\.([^.]+)/.exec(w.where)?.[1];
|
|
587
|
+
find('R-AUCTION-SHILL', 'info', key ? `auction:${key}` : 'economy', w.message, w.fix);
|
|
588
|
+
}
|
|
589
|
+
for (const s of m.schedules)
|
|
590
|
+
routes.push({
|
|
591
|
+
kind: 'schedule',
|
|
592
|
+
key: s.key,
|
|
593
|
+
name: s.name ?? s.key,
|
|
594
|
+
trigger: s.sealed ? 'whoever it pays, when it is due' : 'the project’s keys (or whoever it pays), when it is due',
|
|
595
|
+
enforced: [
|
|
596
|
+
`${s.does} ${s.amount !== undefined ? fmt(s.amount) : `${s.percent}%`} every ${s.every}`,
|
|
597
|
+
s.maxRuns && `${s.maxRuns} runs at most`,
|
|
598
|
+
s.halveEvery && `halving every ${s.halveEvery} runs`,
|
|
599
|
+
s.sealed && 'sealed: nobody, the owner included, changes or stops it',
|
|
600
|
+
].filter(Boolean),
|
|
601
|
+
trusts: s.sealed ? [] : ['the authority, which runs it'],
|
|
602
|
+
trust: null,
|
|
603
|
+
ceiling: `Only what it pays to where it pays it${s.maxRuns ? '' : ', for good'}: nobody else can take it.`,
|
|
604
|
+
feeds: [destination(s.to ?? '')].filter(d => d.kind === 'farm').map(d => `farm:${d.key}`),
|
|
605
|
+
});
|
|
606
|
+
for (const h of m.holds)
|
|
607
|
+
routes.push({
|
|
608
|
+
kind: 'hold',
|
|
609
|
+
key: h.key,
|
|
610
|
+
name: h.name,
|
|
611
|
+
trigger: 'a player hands in a collectible',
|
|
612
|
+
enforced: ['until its time is up only the authority moves it; after it, anyone sends it home'],
|
|
613
|
+
trusts: ['the authority, which says where it goes'],
|
|
614
|
+
trust: null,
|
|
615
|
+
ceiling: 'Whatever the authority awards: the program trusts it.',
|
|
616
|
+
feeds: [],
|
|
617
|
+
});
|
|
618
|
+
for (const d of m.decisions) {
|
|
619
|
+
routes.push({
|
|
620
|
+
kind: 'decision',
|
|
621
|
+
key: d.key,
|
|
622
|
+
name: d.key,
|
|
623
|
+
trigger: `the authority${d.signedBy === 'server' ? ' (a server holding its key)' : ' (approved in its wallet)'}, when ${d.when}`,
|
|
624
|
+
enforced: ['nothing beyond the authority’s signature: its caps are the server’s to keep'],
|
|
625
|
+
trusts: [d.signedBy === 'server' ? 'the server holding the authority key' : 'the authority’s own judgement'],
|
|
626
|
+
trust: null,
|
|
627
|
+
ceiling:
|
|
628
|
+
d.does === 'payout'
|
|
629
|
+
? `What the pool “${d.pool}” holds, on the authority’s word.`
|
|
630
|
+
: 'Whatever the authority signs: the program trusts it.',
|
|
631
|
+
feeds: d.item ? feedsOf({ gives: 'item', item: d.item }) : [],
|
|
632
|
+
});
|
|
633
|
+
if (d.signedBy === 'server' && ['payout', 'issue', 'make', 'award'].includes(d.does))
|
|
634
|
+
find(
|
|
635
|
+
m.authority === 'owner' ? 'R-AUTHORITY-ON-SERVER' : 'R-DECISION-SERVER',
|
|
636
|
+
m.authority === 'owner' ? 'high' : 'medium',
|
|
637
|
+
`decision:${d.key}`,
|
|
638
|
+
m.authority === 'owner'
|
|
639
|
+
? `${label(d)} is signed by a server, but the authority is the owner’s own wallet: its key would sit on a machine that signs by itself, able to change and drain everything.`
|
|
640
|
+
: `${label(d)} is signed by a server holding the authority key: the program enforces no caps on it, so whoever takes that server can ${d.does === 'payout' ? `empty the pool “${d.pool}”` : 'mint and make as it likes'}.`,
|
|
641
|
+
m.authority === 'owner'
|
|
642
|
+
? 'Give the authority its own address ("keys.authority"), made for that server.'
|
|
643
|
+
: 'Keep the server’s checks and caps strict, its key out of anything players load, and pools it pays from small.',
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ---------------------------------------------------------------- keys, the authority and the treasury
|
|
648
|
+
|
|
649
|
+
const voucherKeys = new Map();
|
|
650
|
+
for (const g of m.grants)
|
|
651
|
+
for (const v of g.vouchers ?? []) voucherKeys.set(v, [...(voucherKeys.get(v) ?? []), label(g)]);
|
|
652
|
+
for (const [v, grants] of voucherKeys)
|
|
653
|
+
find(
|
|
654
|
+
'R-VOUCHER-KEY',
|
|
655
|
+
'info',
|
|
656
|
+
'economy',
|
|
657
|
+
`The voucher key ${v} signs for ${grants.join(', ')}. The program refuses any other key and any voucher moved to another wallet, grant or incarnation, and pays each round once; what it can’t check is what the key’s server decides, and which person it names. Whoever holds that key pays anyone, up to these grants’ limits.`,
|
|
658
|
+
'Keep its secret on the server only (never in a page, a repository or a spec); to rotate it, put the new public key in "vouchers" and publish. Their "perDay" and "total" are what a stolen key could take.',
|
|
659
|
+
);
|
|
660
|
+
const owner = 'the owner’s wallet';
|
|
661
|
+
const authority = m.authority === 'owner' ? owner : `the key ${m.authority}`;
|
|
662
|
+
find(
|
|
663
|
+
'R-AUTHORITY',
|
|
664
|
+
m.authority === 'owner' ? 'info' : 'low',
|
|
665
|
+
'economy',
|
|
666
|
+
`${owner[0].toUpperCase()}${owner.slice(1)} alone changes every price, rate, grant, limit and voucher key (publishing again), and closes parts. ${m.authority === 'owner' ? 'It is also the authority, which' : `The authority, ${authority},`} mints the project’s tokens, moves what pools hold, names contest results and releases holds, with no caps. ${m.treasuryWallet ? `What sales leave goes to the wallet ${m.treasuryWallet}.` : 'What sales leave goes to the owner’s wallet (the treasury).'} Money held for a record (a farm’s stakes and rewards, a pot, an order) leaves only by its record’s rules: neither key pays it out.`,
|
|
667
|
+
'Keep these keys in a hardware wallet or a multisig; never on a server that players can reach, unless it is an authority made for that server.',
|
|
668
|
+
);
|
|
669
|
+
for (const w of warned('W-MINT-POWERS')) {
|
|
670
|
+
const key = /^economy\.tokens\.([^.]+)/.exec(w.where)?.[1];
|
|
671
|
+
find('R-MINT-POWERS', 'low', key ? `token:${key}` : 'economy', w.message, w.fix);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
findings.sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity));
|
|
675
|
+
return {
|
|
676
|
+
ok: !findings.some(f => f.severity === 'high'),
|
|
677
|
+
routes,
|
|
678
|
+
findings,
|
|
679
|
+
tradeoffs: tradeoffs(routes, findings),
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** What moving one level up takes, for a route at `level`. */
|
|
684
|
+
const UP = {
|
|
685
|
+
bounds:
|
|
686
|
+
'Moving up to "replays": make the game deterministic (a fixed time step, seeded randomness the server keeps or reveals as the run goes) and have the server re-run each run’s inputs: a forged result then fails. It costs server CPU per run (replay short runs, or a long one in pieces as it is played).',
|
|
687
|
+
replays:
|
|
688
|
+
'Moving up to "decides": run the game or the match on the server (a turn game, a room whose server runs the match) so the page sends only inputs. It costs the most to build, and every move waits on the server.',
|
|
689
|
+
decides: 'Already the strongest: a bot that plays well still earns, so keep its limits and "perPerson".',
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
/** The trade-off in short markdown, for the agent to show the person: the levels, this economy's routes, the costs. */
|
|
693
|
+
function tradeoffs(routes, findings) {
|
|
694
|
+
const lines = [
|
|
695
|
+
'**How far a result the server signs can be trusted**',
|
|
696
|
+
'',
|
|
697
|
+
'| Level | What the server does | What it costs to build | What a cheater gets |',
|
|
698
|
+
'| --- | --- | --- | --- |',
|
|
699
|
+
'| decides | runs the game or match itself; the page sends only inputs | the most: the rules run on the server, and every move waits on it | what a bot that plays well earns; nothing forged |',
|
|
700
|
+
'| replays | re-runs the page’s inputs from randomness it keeps, reads the result | a deterministic game (fixed time step, seeded randomness) and CPU for each replay | what a bot that plays well earns; with the seed up front, the best run found offline |',
|
|
701
|
+
'| bounds | starts each run, takes checkpoints in order, refuses what is too fast, caps a day | the least: a clock and counters | every reward a script claims at human speed, up to the limits |',
|
|
702
|
+
'',
|
|
703
|
+
'**In this economy**',
|
|
704
|
+
];
|
|
705
|
+
const signed = routes.filter(r => r.kind === 'grant' && r.trusts.some(t => t.startsWith('the voucher key')));
|
|
706
|
+
for (const r of signed) {
|
|
707
|
+
const top = findings.find(f => f.route === `grant:${r.key}` && f.severity !== 'info');
|
|
708
|
+
lines.push(
|
|
709
|
+
`- “${r.name}”: **${r.trust ?? 'not said (read as bounds)'}**. ${r.ceiling}${top ? ` (${top.severity}: ${top.id})` : ''}`,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
const open = routes.filter(r => r.kind === 'grant' && !r.trusts.length);
|
|
713
|
+
for (const r of open) lines.push(`- “${r.name}”: open, no server: ${r.ceiling}`);
|
|
714
|
+
const chance = routes.filter(r => r.trusts.some(t => t.startsWith('MagicBlock')));
|
|
715
|
+
if (chance.length)
|
|
716
|
+
lines.push(
|
|
717
|
+
`- Chance (the oracle): ${chance.map(r => `“${r.name}”`).join(', ')}. Nobody, the project included, steers a roll or a draw.`,
|
|
718
|
+
);
|
|
719
|
+
const contests = routes.filter(r => r.kind === 'contest');
|
|
720
|
+
if (contests.length)
|
|
721
|
+
lines.push(
|
|
722
|
+
`- Results that move players’ stakes: ${contests.map(r => `“${r.name}”`).join(', ')}, as honest as whoever names the winner.`,
|
|
723
|
+
);
|
|
724
|
+
if (!signed.length && !open.length && !chance.length && !contests.length)
|
|
725
|
+
lines.push('- No route pays on a result from off the chain.');
|
|
726
|
+
const levels = [...new Set(signed.map(r => r.trust ?? 'bounds'))];
|
|
727
|
+
if (levels.length) lines.push('', '**What the next level up costs**', ...levels.map(l => `- From ${l}: ${UP[l]}`));
|
|
728
|
+
lines.push(
|
|
729
|
+
'',
|
|
730
|
+
'Pay a bounded or replayed result only what you would accept losing to a patient script (small, capped a day), and keep entry fees and prize pools for results the server decides and for chance. A wallet is not a person: count people only through vouchers that name an X account. Tell players which level each reward uses.',
|
|
731
|
+
);
|
|
732
|
+
return lines.join('\n');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** A review in words, for the terminal and the tool's text. */
|
|
736
|
+
export function describeReview(r, name = 'This economy') {
|
|
737
|
+
if (r.refusals?.length)
|
|
738
|
+
return `${name} does not check (${r.refusals.length} refusal${r.refusals.length === 1 ? '' : 's'}): run xsolana_check and fix them first.`;
|
|
739
|
+
const count = s => r.findings.filter(f => f.severity === s).length;
|
|
740
|
+
const lines = [
|
|
741
|
+
`Review of ${name}: ${r.routes.length} route${r.routes.length === 1 ? '' : 's'} that pay or give value; findings: ${SEVERITIES.map(s => `${count(s)} ${s}`).join(', ')}.`,
|
|
742
|
+
'',
|
|
743
|
+
'Routes',
|
|
744
|
+
];
|
|
745
|
+
for (const x of r.routes) {
|
|
746
|
+
lines.push(`- ${x.kind} “${x.name}” (${x.kind}:${x.key})${x.trust ? `, trust: ${x.trust}` : ''}`);
|
|
747
|
+
lines.push(` Who: ${x.trigger}`);
|
|
748
|
+
if (x.enforced.length) lines.push(` The program keeps: ${x.enforced.join('; ')}`);
|
|
749
|
+
lines.push(` Trusts off the chain: ${x.trusts.length ? x.trusts.join('; ') : 'nothing'}`);
|
|
750
|
+
lines.push(` Most one cheater gets: ${x.ceiling}`);
|
|
751
|
+
if (x.feeds.length) lines.push(` Feeds: ${x.feeds.join(', ')}`);
|
|
752
|
+
}
|
|
753
|
+
lines.push('', 'Findings');
|
|
754
|
+
for (const f of r.findings) {
|
|
755
|
+
lines.push(`- [${f.severity}] ${f.id} (${f.route}): ${f.message}`);
|
|
756
|
+
if (f.fix) lines.push(` Fix: ${f.fix}`);
|
|
757
|
+
}
|
|
758
|
+
lines.push('', 'Trade-offs to show the person', '', r.tradeoffs);
|
|
759
|
+
return lines.join('\n');
|
|
760
|
+
}
|