@xgamesfun/solana 0.20.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/examples/coin-flip-arena.json +2 -7
- package/examples/elements-set.json +2 -9
- package/examples/expedition-guild.json +1 -8
- package/examples/guild-missions.json +3 -10
- package/examples/halving-seasons.json +4 -9
- package/examples/hero-trials.json +1 -4
- package/examples/merge-2048.json +5 -5
- package/examples/paper-hands-farm.json +4 -16
- package/examples/partner-vault.json +1 -4
- package/examples/stakers-draw.json +1 -8
- package/examples/star-fleet.json +1 -8
- package/examples/starforce.json +6 -6
- package/js/approve/details.mjs +8 -7
- package/js/approve/server.mjs +20 -5
- package/js/chain/borsh.js +9 -0
- package/js/chain/core.js +14 -2
- package/js/chain/lookup.js +8 -2
- package/js/chain/rpc.js +4 -2
- package/js/client/accounts.js +448 -116
- package/js/client/grant.js +125 -66
- package/js/client/idl/loot.js +1 -1
- package/js/client/idl/loot.json +2360 -2218
- package/js/client/index.js +1 -0
- package/js/client/ix.js +1204 -473
- package/js/client/pda.js +20 -16
- package/js/client/policy.js +44 -23
- package/js/client/random.js +70 -0
- package/js/client/sizes.js +255 -12
- package/js/economy/check.mjs +455 -210
- package/js/economy/cost.mjs +45 -30
- package/js/economy/examples.mjs +2 -2
- package/js/economy/explain.mjs +24 -20
- package/js/economy/guide.mjs +31 -30
- package/js/economy/live.mjs +30 -7
- package/js/economy/model.mjs +171 -56
- package/js/economy/plan.mjs +380 -112
- package/js/economy/schema.json +291 -143
- package/js/economy/simulate.mjs +154 -87
- package/js/mcp/tools.mjs +14 -8
- package/js/play/index.js +1121 -375
- package/js/play/keys.js +167 -0
- package/package.json +1 -1
- package/release.json +5 -5
- package/skills/xsolana-economy/SKILL.md +15 -11
- package/skills/xsolana-economy/references/after-launch.md +37 -27
- package/skills/xsolana-economy/references/apps.md +55 -19
- package/skills/xsolana-economy/references/sentence-to-spec.md +30 -22
- package/skills/xsolana-economy/references/telling-the-person.md +52 -11
- package/types/chain/core.d.ts +3 -1
- package/types/chain/lookup.d.ts +5 -2
- package/types/chain/rpc.d.ts +3 -1
- package/types/client/accounts.d.ts +195 -55
- package/types/client/grant.d.ts +72 -27
- package/types/client/idl/loot.d.ts +590 -559
- package/types/client/index.d.ts +1 -0
- package/types/client/ix.d.ts +3862 -234
- package/types/client/pda.d.ts +19 -15
- package/types/client/policy.d.ts +39 -14
- package/types/client/random.d.ts +26 -0
- package/types/client/sizes.d.ts +93 -5
- package/types/economy/check.d.mts +5 -10
- package/types/economy/cost.d.mts +2 -1
- package/types/economy/live.d.mts +16 -1
- package/types/economy/model.d.mts +105 -37
- package/types/economy/plan.d.mts +193 -192
- package/types/economy/simulate.d.mts +21 -13
- package/types/mcp/tools.d.mts +21 -13
- package/types/play/index.d.ts +450 -39
- package/types/play/keys.d.ts +51 -0
package/js/economy/check.mjs
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
// lottery's pot, a pool nothing fills, a recipe too big to send, a prize its pool cannot pay.
|
|
7
7
|
import IDL from '../client/idl/loot.js';
|
|
8
8
|
import { gateList } from '../client/grant.js';
|
|
9
|
-
import {
|
|
9
|
+
import { MAX_BID_NFTS, MAX_SHARES } from '../client/ix.js';
|
|
10
10
|
import { vault } from '../client/pda.js';
|
|
11
|
+
import { boxBytes, buyFits, drawsFit, MAX_ORDER_BYTES, tableBudget } from '../client/sizes.js';
|
|
11
12
|
import { TOKEN_2022_PROGRAM, TOKEN_PROGRAM } from '../chain/pda.js';
|
|
12
13
|
import { addressBytes, encodeBase58 } from '../chain/base58.js';
|
|
13
14
|
import { isOnCurve } from '../chain/curve.js';
|
|
@@ -26,10 +27,12 @@ import {
|
|
|
26
27
|
model,
|
|
27
28
|
placeOf,
|
|
28
29
|
poolFillers,
|
|
29
|
-
|
|
30
|
+
farmFillers,
|
|
30
31
|
outcomeParts,
|
|
31
32
|
boxBuys,
|
|
32
33
|
boxRolls,
|
|
34
|
+
drawsOf,
|
|
35
|
+
lifeLines,
|
|
33
36
|
prizeLine,
|
|
34
37
|
prizePool,
|
|
35
38
|
prizeToken,
|
|
@@ -49,38 +52,54 @@ import {
|
|
|
49
52
|
toUnits,
|
|
50
53
|
utf8Bytes,
|
|
51
54
|
wholeCount,
|
|
55
|
+
weights,
|
|
52
56
|
} from './model.mjs';
|
|
53
57
|
// plan.mjs imports check() in turn: each module only calls the other's functions, never at load time.
|
|
54
|
-
import { placeholderContext, setupSteps, stepBytes } from './plan.mjs';
|
|
58
|
+
import { boxArgs, boxOrderBytes, placeholderContext, recipeArgs, setupSteps, stepBytes } from './plan.mjs';
|
|
55
59
|
import { findSecrets } from './secrets.mjs';
|
|
56
60
|
import { simulate } from './simulate.mjs';
|
|
57
61
|
import { BUDGET, MAX_TRANSACTION_BYTES, transactionLocks, transactionSize, WALLET_ROOM } from './txsize.mjs';
|
|
58
62
|
import { validate } from './validate.mjs';
|
|
59
63
|
|
|
64
|
+
/** The most a u64 holds. */
|
|
65
|
+
const U64_MAX = 2n ** 64n - 1n;
|
|
66
|
+
|
|
60
67
|
/** A transaction's estimated bytes, or with `detail` { bytes, locks }. */
|
|
61
68
|
const measured = (input, detail) => (detail ? { bytes: transactionSize(input), locks: transactionLocks(input) } : transactionSize(input));
|
|
62
69
|
|
|
63
70
|
const label = (part) => `“${part?.name ?? part?.key}”`;
|
|
64
|
-
const isCollectible = (it) => (it?.kind ?? 'collectible') === 'collectible';
|
|
65
71
|
|
|
66
|
-
|
|
72
|
+
/** Every place a spec gives a share of a pool as a prize, as a path ("economy.boxes[0].outcomes[1]"): the program has none. */
|
|
73
|
+
function sharePrizes(value, path = 'economy', found = []) {
|
|
74
|
+
if (Array.isArray(value)) value.forEach((v, i) => sharePrizes(v, `${path}[${i}]`, found));
|
|
75
|
+
else if (value && typeof value === 'object') {
|
|
76
|
+
if (value.gives === 'share') found.push(path);
|
|
77
|
+
for (const [k, v] of Object.entries(value)) if (k !== 'live') sharePrizes(v, `${path}.${k}`, found);
|
|
78
|
+
}
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
67
81
|
|
|
68
82
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
83
|
+
* Two prizes of one box outcome that want the same thing from one source (prize.rs `wants_same`): the words for them,
|
|
84
|
+
* or null. An outcome gives all it names or isn't given, so it names a pool once with the whole amount, a stash's
|
|
85
|
+
* random NFT beside nothing else of that stash, and a named NFT once. An item may repeat: two of it is how it gives two.
|
|
72
86
|
*/
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
87
|
+
function samePrizes(x, y) {
|
|
88
|
+
if (x.gives === 'pay' && y.gives === 'pay' && x.pool === y.pool) return `pays from the pool “${x.pool}” twice`;
|
|
89
|
+
if (x.gives === 'stash' && y.gives === 'stash' && x.stash === y.stash) return `gives two random NFTs of the stash “${x.stash}”`;
|
|
90
|
+
if ((x.gives === 'stash' && y.gives === 'nft' && x.stash === y.stash) || (x.gives === 'nft' && y.gives === 'stash' && x.stash === y.stash)) return `gives a random NFT of the stash “${x.stash}” beside a named one of it`;
|
|
91
|
+
if (x.gives === 'nft' && y.gives === 'nft' && x.stash === y.stash && x.nft === y.nft) return `names the NFT ${x.nft} twice`;
|
|
92
|
+
return null;
|
|
79
93
|
}
|
|
94
|
+
/** The bytes of a loot instruction's tag: 1, or 8 for the oracle's callbacks. */
|
|
95
|
+
const tagBytes = (name) => IDL.instructions.find((i) => i.name === name).discriminator.length;
|
|
96
|
+
const isCollectible = (it) => (it?.kind ?? 'collectible') === 'collectible';
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------- a player's craft, in bytes
|
|
80
99
|
|
|
81
100
|
/**
|
|
82
101
|
* The transaction a player sends to craft `recipe`, estimated (txsize.mjs): the craft's own accounts, then each
|
|
83
|
-
* line's (craft.rs; a roll's
|
|
102
|
+
* line's (craft.rs; a roll's box and order), a compute limit (a big recipe needs more
|
|
84
103
|
* than the default) and, for each token it gives, the player's token account created if missing.
|
|
85
104
|
*/
|
|
86
105
|
export function craftSize(recipe, m, { detail = false } = {}) {
|
|
@@ -117,17 +136,17 @@ export function craftSize(recipe, m, { detail = false } = {}) {
|
|
|
117
136
|
if (line.gives === 'item' && line.compressed) rest.push(`pda:item/${line.item}`, 'pda:tree-config', 'account:tree', `${collectionOf(line.item)}`, 'program:bubblegum', 'program:compression', 'program:noop', 'program:core', 'account:bubblegum-signer');
|
|
118
137
|
else if (line.gives === 'item') rest.push(`pda:item/${line.item}`, ...(m.item.get(line.item)?.kind === 'normal' ? [] : [`${collectionOf(line.item)}`, `pda:asset/${line.item}/${i}`, 'program:core']));
|
|
119
138
|
else if (line.gives === 'nft') rest.push(`pda:stash/${line.stash}`, ...Array.from({ length: PIECE_SLOTS }, (_, k) => (k === 0 ? `asset:nft/${i}` : k < 3 ? `nft/${i}/${k}` : 'program:loot')));
|
|
120
|
-
// A roll (given, or on a kept collectible): the box
|
|
121
|
-
else if (line.gives === 'roll') rest.push(`pda:box/${line.box}`, `pda:order/${i}
|
|
139
|
+
// A roll (given, or on a kept collectible): the box and the order (its roll is asked for after, `ask_roll`).
|
|
140
|
+
else if (line.gives === 'roll') rest.push(`pda:box/${line.box}`, `pda:order/${i}`);
|
|
122
141
|
else {
|
|
123
|
-
const currency = line.
|
|
124
|
-
const pool = line.
|
|
142
|
+
const currency = line.from?.startsWith('pool:') ? m.pool.get(line.from.slice(5))?.currency : line.currency ?? m.currencyKey;
|
|
143
|
+
const pool = line.from?.startsWith('pool:') ? line.from.slice(5) : null;
|
|
125
144
|
rest.push(mintOf(currency), ...(pool ? [`pda:pool/${pool}`] : []), `ata:player/${currency}`);
|
|
126
145
|
gives.add(currency);
|
|
127
146
|
}
|
|
128
147
|
});
|
|
129
148
|
for (const currency of gives) extra.push({ program: 'associated-token', accounts: ['wallet:player', `ata:player/${currency}`, 'wallet:player', mintOf(currency), 'program:system', 'program:token'], data: 1 });
|
|
130
|
-
return measured({ signers, instructions: [...extra, { program: 'loot', accounts: [...fixed, ...rest], data:
|
|
149
|
+
return measured({ signers, instructions: [...extra, { program: 'loot', accounts: [...fixed, ...rest], data: tagBytes('craft') + 4 + 8 + 8 }] }, detail);
|
|
131
150
|
}
|
|
132
151
|
|
|
133
152
|
// ---------------------------------------------------------------- a player's purchase of a box, in bytes
|
|
@@ -149,8 +168,6 @@ export function openBoxSize(b, m, { detail = false } = {}) {
|
|
|
149
168
|
const vault = (key) => `vault:${key}`;
|
|
150
169
|
const none = 'program:loot';
|
|
151
170
|
const outcomes = b.outcomes ?? [];
|
|
152
|
-
// A stash's NFT is picked at random: a box of one stash outcome is rolled too.
|
|
153
|
-
const rolls = boxRolls(b);
|
|
154
171
|
const limited = Boolean(b.perWallet || b.perWalletEach?.count);
|
|
155
172
|
const chosenItem = (b.split ?? []).some((s) => /^chosen:./.test(String(s.to)));
|
|
156
173
|
// The NFT it rolls on fills the `piece` slots: a Core collectible of the game's, else at its largest a pNFT's
|
|
@@ -166,16 +183,17 @@ export function openBoxSize(b, m, { detail = false } = {}) {
|
|
|
166
183
|
'wallet:player', 'pda:project', 'pda:platform', 'pda:order', mint(currency), `ata:player/${currency}`, vault(currency),
|
|
167
184
|
'program:token', 'program:token-2022', 'program:system', 'program:associated-token',
|
|
168
185
|
...piece, ...Array.from({ length: PIECE_SLOTS - piece.length }, () => none),
|
|
169
|
-
|
|
186
|
+
// Every open asks the oracle.
|
|
187
|
+
'vrf:queue', 'pda:identity', 'program:vrf', 'sysvar:slot-hashes',
|
|
170
188
|
chosenItem ? 'asset:chosen' : none,
|
|
171
|
-
limited ?
|
|
189
|
+
limited ? 'pda:counts/player' : none,
|
|
172
190
|
];
|
|
173
191
|
const rest = [];
|
|
174
192
|
const prizeMints = [];
|
|
175
193
|
for (const o of outcomes.flatMap(outcomeParts)) {
|
|
176
194
|
if (o.gives === 'item') rest.push(`pda:item/${o.item}`);
|
|
177
195
|
if (o.gives === 'stash' || o.gives === 'nft') rest.push(`pda:stash/${o.stash}`);
|
|
178
|
-
if (o.gives === 'pay' || o.gives === '
|
|
196
|
+
if (o.gives === 'pay' || o.gives === 'mint') {
|
|
179
197
|
if (o.gives !== 'mint') rest.push(`pda:pool/${o.pool}`);
|
|
180
198
|
const c = o.gives === 'mint' ? o.currency ?? m.currencyKey : m.pool.get(o.pool)?.currency;
|
|
181
199
|
if (c && !prizeMints.includes(c)) prizeMints.push(c);
|
|
@@ -191,7 +209,7 @@ export function openBoxSize(b, m, { detail = false } = {}) {
|
|
|
191
209
|
? [{ program: 'system', accounts: ['wallet:player', ata], data: 12 }, { program: 'token', accounts: [ata], data: 1 }]
|
|
192
210
|
: [];
|
|
193
211
|
const unwrap = wrap.length ? [{ program: 'token', accounts: [ata, 'wallet:player', 'wallet:player'], data: 1 }] : [];
|
|
194
|
-
const data =
|
|
212
|
+
const data = tagBytes('open_box') + 8 + 8 + (chosenItem || (b.split ?? []).some((s) => s.to === 'chosen') ? 33 : 1) + 4 + 4;
|
|
195
213
|
return measured({ signers: ['wallet:player'], instructions: [...wallet, create, ...wrap, { program: 'loot', accounts: [...own, ...rest], data }, ...unwrap] }, detail);
|
|
196
214
|
}
|
|
197
215
|
|
|
@@ -242,7 +260,7 @@ export function settleAccounts(b, o, m, { proof = LONGEST_PROOF } = {}) {
|
|
|
242
260
|
let picks = 0;
|
|
243
261
|
let corePicks = 0;
|
|
244
262
|
for (const [i, x] of outcomeParts(o).entries()) {
|
|
245
|
-
if (x.gives === 'pay' || x.gives === '
|
|
263
|
+
if (x.gives === 'pay' || x.gives === 'mint') {
|
|
246
264
|
const c = x.gives === 'mint' ? x.currency ?? m.currencyKey : m.pool.get(x.pool)?.currency;
|
|
247
265
|
add(`mint:${c}`, `vault:${c}`, `ata:player/${c}`);
|
|
248
266
|
} else if (x.gives === 'item') {
|
|
@@ -303,7 +321,7 @@ export function settleUnits(b, o, m) {
|
|
|
303
321
|
let units = 60_000;
|
|
304
322
|
if (b.rollsOn) units += b.rollsOn.collection ? 100_000 : 60_000;
|
|
305
323
|
for (const x of outcomeParts(o)) {
|
|
306
|
-
if (x.gives === 'pay' || x.gives === '
|
|
324
|
+
if (x.gives === 'pay' || x.gives === 'mint') units += 30_000 * hooked(x.gives === 'mint' ? x.currency ?? m.currencyKey : m.pool.get(x.pool)?.currency);
|
|
307
325
|
else if (x.gives === 'item') units += isCollectible(m.item.get(x.item)) ? 40_000 : 0;
|
|
308
326
|
else if (x.gives === 'roll') units += 20_000;
|
|
309
327
|
else if (x.gives === 'stash' || x.gives === 'nft') {
|
|
@@ -326,16 +344,17 @@ export function settleSize(b, o, m) {
|
|
|
326
344
|
const { keys, count, memo, compressedSubject } = settleAccounts(b, o, m);
|
|
327
345
|
const parts = outcomeParts(o);
|
|
328
346
|
const moves = parts.filter((x) => x.gives === 'stash' || x.gives === 'nft').length + (b.rollsOn ? 1 : 0);
|
|
329
|
-
// The settle's account list, with each slot it leaves empty (`settleIx`): its own
|
|
347
|
+
// The settle's account list, with each slot it leaves empty (`settleIx`): its own 38, the proof of a compressed NFT
|
|
330
348
|
// it rolls on, each prize's (tokens 3, a collectible 4, a normal item 1, a roll 2, an NFT the 23 of a piece and a
|
|
331
349
|
// compressed one's proof), each share's (a holder's 3, a wallet's 2, a pool's or an account's 1), a hook's and a memo.
|
|
332
350
|
const compressedPick = (x) => (m.stash.get(x.stash) ? stashNfts(m.stash.get(x.stash)) : []).filter((n) => x.gives !== 'nft' || n.asset === x.nft).some((n) => n.kind === 'compressed');
|
|
333
351
|
const won = parts.reduce((n, x) => n + ({ pay: 3, share: 3, mint: 3, item: isCollectible(m.item.get(x.item)) ? 4 : 1, roll: 2, stash: PIECE_SLOTS, nft: PIECE_SLOTS }[x.gives] ?? 0) + ((x.gives === 'stash' || x.gives === 'nft') && compressedPick(x) ? LONGEST_PROOF : 0), 0);
|
|
334
352
|
const split = sharesOf(b.split).reduce((n, x) => n + ({ holder: 3, chosen: 3, wallet: 2, pool: 1, account: 1, lottery: 1 }[x.kind] ?? 0), 0);
|
|
335
353
|
const hooks = keys.filter((k) => k.startsWith('hook:') || k === 'program:hook').length;
|
|
336
|
-
const entries =
|
|
354
|
+
const entries = 38 + (compressedSubject ? LONGEST_PROOF : 0) + won + split + hooks + (memo ? 1 : 0);
|
|
337
355
|
const named = keys.filter((k) => k !== 'program:compute-budget' && k !== 'program:loot');
|
|
338
|
-
|
|
356
|
+
// Its data: the tag, `upto` and `prize_accounts`.
|
|
357
|
+
const settle = { program: 'loot', accounts: [...named, ...Array.from({ length: Math.max(0, entries - named.length) }, () => 'program:loot')], data: tagBytes('settle') + 4 + 2 };
|
|
339
358
|
const budget = [{ program: 'compute-budget', accounts: [], data: 5 }, ...(moves >= 3 ? [{ program: 'compute-budget', accounts: [], data: 5 }] : [])];
|
|
340
359
|
// A table holds every account but the signer and the programs the transaction calls.
|
|
341
360
|
const held = keys.filter((k) => !['wallet:signer', 'program:compute-budget', 'program:loot'].includes(k)).length;
|
|
@@ -350,10 +369,10 @@ const showsNft = (gate) => Boolean(gate && !gate.wallets && !gate.list && !gate.
|
|
|
350
369
|
/**
|
|
351
370
|
* The transaction a player sends to buy box `b` (buy.rs), estimated as `openBoxSize` is: its compute limit and price,
|
|
352
371
|
* the player's token account of the currency created if missing, then `buy`: its own accounts, what the gate takes
|
|
353
|
-
* (a list's proof, or the NFT shown), the accounts its shares name, and each prize's (prize.rs; a roll's
|
|
354
|
-
*
|
|
372
|
+
* (a list's proof, or the NFT shown), the accounts its shares name, and each prize's (prize.rs; a roll's box and
|
|
373
|
+
* order), for each draw of a sale (a fixed amount of tokens once). `count`: the sales in one purchase.
|
|
355
374
|
*/
|
|
356
|
-
export function buySize(b, m, { detail = false } = {}) {
|
|
375
|
+
export function buySize(b, m, { detail = false, count = 1 } = {}) {
|
|
357
376
|
const collectionOf = (key) => `collection:${m.item.get(key)?.collection ?? 'project'}`;
|
|
358
377
|
const currency = b.currency ?? m.currencyKey;
|
|
359
378
|
const none = 'program:loot';
|
|
@@ -361,7 +380,7 @@ export function buySize(b, m, { detail = false } = {}) {
|
|
|
361
380
|
const chosenItem = (b.split ?? []).some((s) => /^chosen:./.test(String(s.to)));
|
|
362
381
|
const own = [
|
|
363
382
|
'wallet:player', 'pda:project', `pda:box/${b.key}`, `mint:${currency}`, `ata:player/${currency}`, 'wallet:treasury', `ata:treasury/${currency}`,
|
|
364
|
-
'program:token', 'program:token-2022', 'program:associated-token', chosenItem ? 'asset:chosen' : none, limited ?
|
|
383
|
+
'program:token', 'program:token-2022', 'program:associated-token', chosenItem ? 'asset:chosen' : none, limited ? 'pda:counts/player' : none,
|
|
365
384
|
'pda:platform', 'pda:fees', 'program:system',
|
|
366
385
|
];
|
|
367
386
|
const rest = [...listProof(b.gate)];
|
|
@@ -372,10 +391,11 @@ export function buySize(b, m, { detail = false } = {}) {
|
|
|
372
391
|
if (s.kind === 'wallet' || s.kind === 'treasury' || s.kind === 'chosen') rest.push(`wallet:${s.key}`, `ata:${s.key}/${currency}`);
|
|
373
392
|
if (s.kind === 'holder') rest.push(`asset:${s.key}`, `wallet:holder/${s.key}`, `ata:holder/${s.key}`);
|
|
374
393
|
}
|
|
375
|
-
|
|
394
|
+
const draws = drawsOf(b) * count;
|
|
395
|
+
outcomeParts(b.outcomes?.[0]).flatMap((o) => Array(o.gives === 'mint' || o.gives === 'pay' ? 1 : draws).fill(o)).forEach((o, i) => {
|
|
376
396
|
if (o.gives === 'item') rest.push(`pda:item/${o.item}`, ...(m.item.get(o.item)?.kind === 'normal' ? [] : [collectionOf(o.item), `pda:asset/${o.item}/${i}`, 'program:mpl-core']));
|
|
377
397
|
else if (o.gives === 'nft') rest.push(`pda:stash/${o.stash}`, ...Array.from({ length: PIECE_SLOTS }, (_, k) => (k === 0 ? `asset:nft/${i}` : k < 3 ? `nft/${i}/${k}` : none)));
|
|
378
|
-
else if (o.gives === 'roll') rest.push(`pda:box/${o.box}`, `pda:order/${i}
|
|
398
|
+
else if (o.gives === 'roll') rest.push(`pda:box/${o.box}`, `pda:order/${i}`);
|
|
379
399
|
else {
|
|
380
400
|
const c = o.gives === 'mint' ? o.currency ?? m.currencyKey : m.pool.get(o.pool)?.currency;
|
|
381
401
|
rest.push(`mint:${c}`, ...(o.gives === 'mint' ? [] : [`pda:pool/${o.pool}`]), `ata:player/${c}`);
|
|
@@ -384,7 +404,7 @@ export function buySize(b, m, { detail = false } = {}) {
|
|
|
384
404
|
const ata = `ata:player/${currency}`;
|
|
385
405
|
const wallet = [{ program: 'compute-budget', accounts: [], data: 5 }, { program: 'compute-budget', accounts: [], data: 9 }];
|
|
386
406
|
const create = { program: 'associated-token', accounts: ['wallet:player', ata, 'wallet:player', `mint:${currency}`, 'program:system', 'program:token'], data: 1 };
|
|
387
|
-
const data =
|
|
407
|
+
const data = tagBytes('buy') + 8 + (chosenItem || (b.split ?? []).some((s) => s.to === 'chosen') ? 33 : 1) + 4 + 8 + 4;
|
|
388
408
|
return measured({ signers: ['wallet:player'], instructions: [...wallet, create, { program: 'loot', accounts: [...own, ...rest], data }] }, detail);
|
|
389
409
|
}
|
|
390
410
|
|
|
@@ -399,6 +419,8 @@ const MAX_CONTEST_SECONDS = Number(IDL.constants.find((c) => c.name === 'MAX_CON
|
|
|
399
419
|
/** A sealed schedule's last run comes within this of going live (schedule.rs). */
|
|
400
420
|
const MAX_SCHEDULE_SECONDS = Number(IDL.constants.find((c) => c.name === 'MAX_SCHEDULE_SECONDS')?.value ?? 315_360_000);
|
|
401
421
|
/** The most runs a sealed schedule makes (schedule.rs). */
|
|
422
|
+
/** The most places a run of a lottery's places given one by one holds (lottery.rs). */
|
|
423
|
+
const MAX_RUN_PLACES = Number(IDL.constants.find((c) => c.name === 'MAX_RUN_PLACES')?.value ?? 4_096);
|
|
402
424
|
const MAX_SEALED_RUNS = Number(IDL.constants.find((c) => c.name === 'MAX_SEALED_RUNS')?.value ?? 10_000);
|
|
403
425
|
|
|
404
426
|
/**
|
|
@@ -420,8 +442,20 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
420
442
|
refuse('E-NETWORK', 'economy.network', `The economy names the network “${economy?.network ?? network}”. loot runs on devnet only: mainnet waits for an audit.`, 'Set the network to "devnet".');
|
|
421
443
|
const oldVersion = economy?.version === 1;
|
|
422
444
|
if (oldVersion) refuse('E-VERSION', 'economy.version', 'Only the current version 2 economy format is supported.', 'Write the economy using the current schema.');
|
|
445
|
+
// A roll no longer burns the payment for one outcome (loot 0.23): said in words, not again by the schema.
|
|
446
|
+
const burnsGone = new Set();
|
|
447
|
+
(Array.isArray(economy?.boxes) ? economy.boxes : []).forEach((b, i) => (Array.isArray(b?.outcomes) ? b.outcomes : []).forEach((o, j) => {
|
|
448
|
+
const at = `economy.boxes[${i}].outcomes[${j}]`;
|
|
449
|
+
if (o?.gives !== 'burn' && o?.burns === undefined) return;
|
|
450
|
+
burnsGone.add(o?.gives === 'burn' ? `${at}.gives` : `${at}.burns`);
|
|
451
|
+
refuse('E-ODDS', at, `An outcome of “${b?.name ?? b?.key}” burns the payment: a box's outcome no longer does (BadEntries). A box burns its price, whatever its roll gives, with a "burn" share in its split.`, o?.gives === 'burn' ? 'Make this outcome "nothing", and give the box a "burn" share in its "split".' : 'Leave "burns" out, and give the box a "burn" share in its "split".');
|
|
452
|
+
}));
|
|
453
|
+
// A share of a pool as a prize is gone (loot 0.24): said in words, not again by the schema.
|
|
454
|
+
const shares = sharePrizes(economy);
|
|
455
|
+
for (const at of shares) refuse('E-SHARE-PRIZE', at, `${at} gives a share of a pool as a prize, and the program has no such prize on any route: a prize is what its record says, a fixed amount, all of it or none (BadEntries).`, 'Give a fixed amount: in a box { "gives": "pay", "pool": "<key>", "amount": n }, anywhere else { "gives": "tokens", "from": "pool:<key>", "amount": n }. For a jackpot that grows, use a lottery: its pot is what its tickets fill, and its places take percents of it.');
|
|
456
|
+
const aboutShare = (path) => shares.some((at) => path === at || path.startsWith(`${at}.`) || at.startsWith(`${path}.`) || at.startsWith(`${path}[`));
|
|
423
457
|
// An old version is said once, by E-VERSION, not again by the schema.
|
|
424
|
-
for (const e of validate(economy)) if (!(oldVersion && e.path === 'economy.version')) refuse('E-SCHEMA', e.path, `${e.path} ${e.message}.`, 'Fix that field (xsolana_guide lists what each part takes).');
|
|
458
|
+
for (const e of validate(economy)) if (!(oldVersion && e.path === 'economy.version') && !burnsGone.has(e.path) && !aboutShare(e.path)) refuse('E-SCHEMA', e.path, `${e.path} ${e.message}.`, 'Fix that field (xsolana_guide lists what each part takes).');
|
|
425
459
|
if (refusals.length) return result();
|
|
426
460
|
|
|
427
461
|
const m = model(economy, { now });
|
|
@@ -465,6 +499,10 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
465
499
|
if (now + s > Number.MAX_SAFE_INTEGER) refuse('E-TIME', where, `${duration} from go-live is further off than the program can count.`, 'Use a shorter time.');
|
|
466
500
|
return s;
|
|
467
501
|
};
|
|
502
|
+
/** Refuses a list over one of the program's caps (`LIMITS`), which it would refuse when the owner signs. */
|
|
503
|
+
const tooMany = (count, most, code, where, what, error, fix) => {
|
|
504
|
+
if (count > most) refuse(code, where, `${what}; the program takes at most ${most} (${error}).`, fix);
|
|
505
|
+
};
|
|
468
506
|
const percent = (value, where) => {
|
|
469
507
|
if (value === undefined) return 0;
|
|
470
508
|
const bps = toBps(value);
|
|
@@ -500,11 +538,6 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
500
538
|
ids.add(part[field]);
|
|
501
539
|
}
|
|
502
540
|
}
|
|
503
|
-
// The empty pool of a farm that pays nothing keeps the number written on the farm (`rewardsIndex`): no pool may share it.
|
|
504
|
-
for (const p of m.pools.filter((x) => x.farm && Number.isInteger(x.index))) {
|
|
505
|
-
const other = m.pools.find((x) => x !== p && x.index === p.index);
|
|
506
|
-
if (other) refuse('E-KEY-DUP', `economy.farms.${p.farm}.rewardsIndex`, `The farm “${p.farm}”’s empty rewards pool and the pool “${other.key}” both have the program number ${p.index}: the second would overwrite the first.`, other.farm ? 'Remove the number from the newer one; it gets a free number when it goes live.' : `Remove the "index" from “${other.key}” if it is new; it gets a free number when it goes live.`);
|
|
507
|
-
}
|
|
508
541
|
const boxIds = new Map();
|
|
509
542
|
for (const b of m.allBoxes) {
|
|
510
543
|
if (boxIds.has(b.id)) refuse('E-KEY-DUP', b.shopOf ? `economy.items.${b.shopOf}.shopBox` : `economy.boxes.${b.key}.id`, `The box number ${b.id} is taken twice (a box and a shop listing share the numbers).`, 'Remove one of the numbers; it gets a free one when it goes live.');
|
|
@@ -589,11 +622,14 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
589
622
|
* with a compressed prize in the spec; one made before never can.
|
|
590
623
|
*/
|
|
591
624
|
const compressedCheck = (it, at, what, code) => {
|
|
592
|
-
|
|
625
|
+
// The item records whether its collection carries the plugin (`Item::compressed`): without it, a box's open is
|
|
626
|
+
// refused (SoldOut), a sale, a claim or a craft too (PrizeNotCovered), and a finishing route gives nothing.
|
|
627
|
+
const never = code === 'E-GRANT' || code === 'E-RECIPE' ? 'every claim or craft would be refused (PrizeNotCovered)' : code === 'E-ODDS' ? 'opening the box would be refused (SoldOut), and buying it (PrizeNotCovered)' : 'its winner would get nothing';
|
|
593
628
|
if (!isCollectible(it)) return refuse(code, `${at}.compressed`, `${what} gives ${label(it)} compressed, but it is a normal item: only a collectible is an NFT.`, 'Make it a collectible, or leave "compressed" out.');
|
|
594
629
|
if (it.soulbound) return refuse(code, `${at}.compressed`, `${what} gives ${label(it)} compressed, but it is soulbound: a compressed NFT can’t be kept from moving, so the program never gives one (${never}).`, 'Give it as a Core collectible (leave "compressed" out), or make the item tradable.');
|
|
595
|
-
|
|
596
|
-
|
|
630
|
+
// Its serial at its longest, and each drawn trait's value at its longest (32 bytes) wherever its `{attribute}` is.
|
|
631
|
+
const longest = (it.drawn ?? []).reduce((uri, d) => uri.replaceAll(`{${d.attribute}}`, 'x'.repeat(32)), (it.uri ?? '').replaceAll('{serial}', String(Number(it.supply ?? 0) || 4_294_967_295)));
|
|
632
|
+
if (utf8Bytes(longest) > LIMITS.compressedUriBytes) refuse(code, `${at}.compressed`, `${label(it)}’s link, its serial written in, is longer than Bubblegum’s ${LIMITS.compressedUriBytes} bytes: ${never}.`, 'Use a shorter link.');
|
|
597
633
|
if (it.collection) warn('W-COMPRESSED', `${at}.compressed`, `${label(it)} is minted into ${it.collection}: a compressed one is given only if that collection was made with Bubblegum’s plugin (BubblegumV2), which a collection can’t add later; otherwise ${never}.`, 'Use the setup’s collection, or one made with the plugin.');
|
|
598
634
|
// A new setup still landing made its collection with the plugin, and opens the tree after the project.
|
|
599
635
|
else if (m.live.project && !m.live.tree && !(m.live.partial && !m.live.published)) refuse(code, `${at}.compressed`, `${what} gives ${label(it)} compressed, but the project’s collection went live without Bubblegum’s plugin, which a collection can’t add later: ${never}.`, 'Give it as a Core collectible (leave "compressed" out), or mint it into another collection made with the plugin ("collection" on the item).');
|
|
@@ -605,10 +641,10 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
605
641
|
const prizeCheck = (given, at, what, code) => {
|
|
606
642
|
const line = prizeLine(given);
|
|
607
643
|
if (!line) {
|
|
608
|
-
refuse(code, at, `${what} gives a prize this spec can’t read.`, 'Write a prize line: { "gives": "item", "item": key }, { "gives": "tokens", "amount": n, "from": "mint" or "pool:<key>" }, { "gives": "
|
|
644
|
+
refuse(code, at, `${what} gives a prize this spec can’t read.`, 'Write a prize line: { "gives": "item", "item": key }, { "gives": "tokens", "amount": n, "from": "mint" or "pool:<key>" }, { "gives": "nft", "stash": key, "nft": address } or { "gives": "roll", "box": key }.');
|
|
609
645
|
return null;
|
|
610
646
|
}
|
|
611
|
-
if (line.split && (code !== 'E-CONTEST' ||
|
|
647
|
+
if (line.split && (code !== 'E-CONTEST' || line.gives !== 'tokens'))
|
|
612
648
|
refuse(code, `${at}.split`, `${what} splits its prize by stake: only a contest's prize of tokens can be split (BadContest).`, 'Leave "split" out.');
|
|
613
649
|
if (line.gives === 'item') {
|
|
614
650
|
const it = line.item ? item(line.item, `${at}.item`) : refuse(code, at, `${what} gives an item but does not say which.`, 'Add "item".');
|
|
@@ -625,10 +661,6 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
625
661
|
if (minted && t && !t.gameMints) refuse('E-MINT-NOT-OURS', `${at}.currency`, `${what} mints ${t.symbol}, which the project cannot mint.`, 'Pay it from a pool ("from": "pool:<key>"), or use a token with source "project".');
|
|
626
662
|
if (p && line.currency && line.currency !== p.currency) refuse('E-POOL-MINT', `${at}.from`, `${what} gives ${m.tokens.get(line.currency)?.symbol ?? line.currency} from “${p.key}”, which holds ${m.tokens.get(p.currency)?.symbol}.`, `Leave "currency" out: a pool’s prize is in what it holds.`);
|
|
627
663
|
if (t && units(line.amount, t, `${at}.amount`, `What ${what} gives`) === 0n) refuse(code, `${at}.amount`, `${what} gives an amount of 0.`, 'Give it an amount above 0.');
|
|
628
|
-
} else if (line.gives === 'share') {
|
|
629
|
-
if (line.pool) pool(line.pool, `${at}.pool`);
|
|
630
|
-
else refuse(code, at, `${what} gives a share but does not say of which pool.`, 'Add "pool".');
|
|
631
|
-
if (!(percent(line.percent, `${at}.percent`) > 0)) refuse(code, `${at}.percent`, `${what} gives a share of 0%.`, 'Give it a percent above 0.');
|
|
632
664
|
} else if (line.gives === 'nft' && line.held) {
|
|
633
665
|
// An NFT a lottery's place holds in the lottery's own stash: a lottery's place only (prize.rs Prize::Held).
|
|
634
666
|
if (code !== 'E-LOTTERY') refuse(code, `${at}.held`, `${what} gives a held NFT: only a lottery's place holds one, in its own stash (BadPrize).`, 'Put it in a stash and give it with "stash", or raffle it in a lottery.');
|
|
@@ -642,7 +674,9 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
642
674
|
else if (st && !stashNfts(st).some((n) => n.asset === line.nft))
|
|
643
675
|
refuse(code, `${at}.nft`, `${what} gives the NFT ${line.nft}, which ${label(st)} does not list: a prize gives only an NFT a stash of the game holds, so it would never be given.`, `List it in ${label(st)}'s "nfts", which puts it in when the economy goes live.`);
|
|
644
676
|
} else if (line.gives === 'roll') prizeBox(line.box, `${at}.box`, what, code);
|
|
645
|
-
else
|
|
677
|
+
else if (line.gives === 'raffled') {
|
|
678
|
+
if (code !== 'E-LOTTERY') refuse(code, `${at}.gives`, `${what} gives an NFT drawn from a lottery's own stash: only a lottery's place does (BadPrize).`, 'Give an NFT of a stash through a box\'s roll ("gives": "stash"), or raffle them in a lottery.');
|
|
679
|
+
} else refuse(code, `${at}.gives`, `${what} gives “${line.gives}”, which no prize is.`, 'Use "item", "tokens", "nft", "roll" or, in a lottery\'s place, "raffled".');
|
|
646
680
|
return line;
|
|
647
681
|
};
|
|
648
682
|
/**
|
|
@@ -682,16 +716,23 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
682
716
|
if (!r?.collection || !r.attribute || m.collections.includes(r.collection)) return;
|
|
683
717
|
warn('W-RULE-OUTSIDE', where, `${what} test “${r.attribute}” of NFTs of ${r.collection}, outside the project’s collections: there it reads a Core NFT’s own attributes, never what the project wrote in its Sheet, so the test fails for an NFT that doesn’t carry “${r.attribute}” itself (every Token Metadata or compressed NFT).`, 'Test only the collection, or use Core NFTs that carry the attribute themselves.');
|
|
684
718
|
};
|
|
719
|
+
/** Whether the words on a Token Metadata print were said (once a spec). */
|
|
720
|
+
let printsSaid = false;
|
|
685
721
|
/** A rule on what an NFT says (rules.rs): an item of the game's must be a collectible; or a collection is named. */
|
|
686
722
|
const rule = (r, where, what) => {
|
|
687
723
|
if (!r) return;
|
|
724
|
+
// A print of a Token Metadata master carries its master's collection, but its master's holder makes it without the
|
|
725
|
+
// collection's authority: the program reads it as in no collection (metadata.rs).
|
|
726
|
+
if (r.collection && isAddress(r.collection) && !m.collections.includes(r.collection) && !printsSaid) {
|
|
727
|
+
printsSaid = true;
|
|
728
|
+
warn('W-RULE-PRINTS', `${where}.collection`, `${what} names the collection ${r.collection}: if it is a Token Metadata collection, a print of one of its NFTs (an edition of a master) doesn’t count, since whoever holds the master makes prints without the collection’s authority; the program reads a print as in no collection. This holds for every rule on a collection in the spec.`, 'Nothing to change: tell holders of prints that only the original NFTs count.');
|
|
729
|
+
}
|
|
688
730
|
// A gate may list wallets instead (rules.rs Gate::Wallets): a duel's two players, a tournament's entrants.
|
|
689
731
|
// Or a list of any size (rules.rs Gate::List): its root on chain, each player's proof passed as accounts.
|
|
690
732
|
if (r.list !== undefined) {
|
|
691
733
|
r.list.forEach((w, i) => isAddress(w) || refuse('E-REF', `${where}.list[${i}]`, `${what} lists “${w}”, which is not an address.`, 'Write the wallet’s public address.'));
|
|
692
734
|
if (!refusals.some((x) => x.where.startsWith(`${where}.list`))) {
|
|
693
735
|
const { depth } = gateList(r.list);
|
|
694
|
-
if (depth > 20) refuse('E-RULE', `${where}.list`, `${what} lists ${r.list.length} wallets: a proof is one account a level, and the program reads at most 20 (BadRule).`, 'Keep the list under 1,048,576 wallets, or gate by an NFT they hold.');
|
|
695
736
|
if (depth > 12) warn('W-LIST', `${where}.list`, `${what}’s list is ${depth} levels deep: each player passes ${depth} proof nodes as accounts, ${depth * 32} bytes of their transaction.`, 'A shorter list, or gate by an NFT they hold.');
|
|
696
737
|
warn('W-LIST', `${where}.list`, `${what} lets in ${r.list.length} listed wallet${r.list.length === 1 ? '' : 's'}: the program keeps only the list’s root, so the app (or xsolana_play) passes each player’s proof, and the list itself must stay where the app can read it (this spec keeps it).`);
|
|
697
738
|
}
|
|
@@ -717,8 +758,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
717
758
|
return;
|
|
718
759
|
}
|
|
719
760
|
if (r.wallets !== undefined) {
|
|
720
|
-
if (r.wallets.length
|
|
721
|
-
else if (!r.wallets.length) warn('W-GATE', `${where}.wallets`, `${what} lists no wallets: nobody passes it.`, 'List the wallets it lets in, or leave the gate out.');
|
|
761
|
+
if (!r.wallets.length) warn('W-GATE', `${where}.wallets`, `${what} lists no wallets: nobody passes it.`, 'List the wallets it lets in, or leave the gate out.');
|
|
722
762
|
r.wallets.forEach((w, i) => isAddress(w) || refuse('E-REF', `${where}.wallets[${i}]`, `${what} lists “${w}”, which is not an address.`, 'Write the wallet’s public address.'));
|
|
723
763
|
return;
|
|
724
764
|
}
|
|
@@ -727,7 +767,6 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
727
767
|
if (!r.item && !r.collection && !r.attribute) refuse('E-RULE', where, `${what} asks for nothing: no item, no collection and no attribute.`, 'Name the item or the collection players must hold, an attribute, or both.');
|
|
728
768
|
if (r.item && r.collection) refuse('E-RULE', where, `${what} names both an item and a collection: a rule is about one.`, 'Keep "item" for an item the game mints, or "collection" for any NFT of a collection.');
|
|
729
769
|
if (r.collection && !isAddress(r.collection)) refuse('E-REF', `${where}.collection`, `${what} names the collection “${r.collection}”, which is not an address.`, 'Write the Core collection’s address, or the Token Metadata collection mint.');
|
|
730
|
-
if (utf8Bytes(r.attribute ?? '') > LIMITS.keyBytes) refuse('E-RULE', `${where}.attribute`, `${what} reads an attribute name longer than ${LIMITS.keyBytes} bytes.`, 'Shorten it.');
|
|
731
770
|
if (r.atLeast !== undefined && r.equals !== undefined) refuse('E-RULE', where, `${what} asks for an attribute that is both at least a number and equal to a text.`, 'Keep one of "atLeast" and "equals".');
|
|
732
771
|
if (r.item) {
|
|
733
772
|
const it = item(r.item, `${where}.item`);
|
|
@@ -760,7 +799,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
760
799
|
for (const p of m.pools) if (!m.tokens.has(p.currency)) refuse('E-REF', `economy.pools.${p.key}.currency`, `The pool “${p.key}” holds “${p.currency}”, which is not in the tokens.`, 'Add the token, or name one that is there.');
|
|
761
800
|
|
|
762
801
|
/**
|
|
763
|
-
* A split carried out in `tok`: percents, at most
|
|
802
|
+
* A split carried out in `tok`: percents, at most 8 shares (any number in a payment's) and 100% at most (state.rs check_later_shares), pools of the same
|
|
764
803
|
* token, and no share that burns in wrapped SOL (the program refuses it). What the shares leave goes to the
|
|
765
804
|
* treasury wallet's account of `tok`, whatever token it is. `chosen`: the buyer may name one share ("chosen",
|
|
766
805
|
* "chosen:<item>"): a box's or a lottery's split; 'wallet': only "chosen", a wallet (a payment's split).
|
|
@@ -769,9 +808,11 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
769
808
|
const shares = sharesOf(list);
|
|
770
809
|
shares.forEach((s, i) => percent(s.percent, `${where}[${i}].percent`));
|
|
771
810
|
const total = shares.reduce((n, s) => n + (s.bps ?? 0), 0);
|
|
772
|
-
if (shares.length > LIMITS.shares) refuse('E-SPLIT', where, `${what} has ${shares.length} shares; the program takes at most ${LIMITS.shares}.`, 'Merge shares that go to the same place, or drop one.');
|
|
773
811
|
if (total > 10_000) refuse('E-SPLIT', where, `${what}’s shares add up to ${total / 100}%, more than the whole payment.`, 'Make them add up to 100% or less; the treasury gets what they leave.');
|
|
774
812
|
if (shares.filter((s) => s.kind === 'chosen').length > 1) refuse('E-CHOSEN', where, `${what} lets the buyer choose more than one share.`, 'Let them choose one.');
|
|
813
|
+
// A split kept to be paid later (a box's, a lottery's, a contest's, an auction's) holds at most 8 shares; a
|
|
814
|
+
// payment's is paid at once and has no such cap.
|
|
815
|
+
if (chosen !== 'wallet' && shares.length > MAX_SHARES) refuse('E-SPLIT', where, `${what}’s split has ${shares.length} shares; the program keeps at most ${MAX_SHARES} (BadShares).`, `Merge some, to ${MAX_SHARES} or fewer.`);
|
|
775
816
|
for (const [i, s] of shares.entries()) {
|
|
776
817
|
if (s.kind === 'pool') {
|
|
777
818
|
const p = pool(s.key, `${where}[${i}].to`);
|
|
@@ -784,6 +825,12 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
784
825
|
if (!l) refuse('E-REF', `${where}[${i}].to`, `There is no lottery “${s.key}”.`, 'Name one of "lotteries" by its key.');
|
|
785
826
|
else if (tok && potKey !== tok.key) refuse('E-POOL-MINT', `${where}[${i}].to`, `${what} is paid in ${tok.symbol}, but the pot of ${label(l)} is in ${m.tokens.get(potKey)?.symbol ?? potKey}.`, `Send it to a lottery whose pot is in ${tok.symbol}.`);
|
|
786
827
|
}
|
|
828
|
+
// A farm's rewards vault of this spec, in the token the share is paid in.
|
|
829
|
+
if (s.kind === 'farm') {
|
|
830
|
+
const f = m.farm.get(s.key);
|
|
831
|
+
if (!f) refuse('E-REF', `${where}[${i}].to`, `There is no farm “${s.key}”.`, 'Name one of "farms" by its key.');
|
|
832
|
+
else if (tok && f.rewardsToken !== tok.key) refuse('E-POOL-MINT', `${where}[${i}].to`, `${what} is paid in ${tok.symbol}, but ${label(f)}’s rewards are ${m.tokens.get(f.rewardsToken)?.symbol ?? f.rewardsToken}.`, `Send it to a farm whose rewards are ${tok.symbol}.`);
|
|
833
|
+
}
|
|
787
834
|
if (['account', 'holder', 'wallet'].includes(s.kind) && !isAddress(s.key)) refuse('E-REF', `${where}[${i}].to`, `${what} sends a share to “${s.to}”, which is not an address.`, `Write "${s.kind}:<address>".`);
|
|
788
835
|
// A token account named as it is can't be opened for anyone: while none is there, its share goes to the treasury.
|
|
789
836
|
if (s.kind === 'account' && isAddress(s.key) && gameVault(s.key, tok)) refuse('E-SPLIT', `${where}[${i}].to`, `${what} sends a share to the game’s own vault for ${tok.symbol}, where open orders, prizes set aside and bids wait: the program refuses it (it would mix into what those records hold).`, 'Send it to a pool ("pool:<key>") or the treasury instead.');
|
|
@@ -827,10 +874,10 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
827
874
|
};
|
|
828
875
|
|
|
829
876
|
// ---- the collections and items
|
|
830
|
-
if (m.collection && utf8Bytes(m.collection.name) > LIMITS.nameBytes) refuse('E-COLLECTION', 'economy.collection.name', `The collection’s name is longer than ${LIMITS.nameBytes} bytes.`, 'Shorten it.');
|
|
831
877
|
if (m.collection && toBps(m.collection.royaltyPercent) === null) refuse('E-PERCENT', 'economy.collection.royaltyPercent', 'The royalty is finer than hundredths of a percent.', 'Round it to two decimals.');
|
|
832
|
-
// A project takes
|
|
833
|
-
|
|
878
|
+
// A project takes the one collection it makes and the owner's others: as many as `create_project`'s transaction carries
|
|
879
|
+
// (about 25; no cap of the program's own), which the setup's size check below refuses past (E-TX-SIZE).
|
|
880
|
+
tooMany(m.collections.length + (m.collection ? 1 : 0), LIMITS.collections, 'E-REF', 'economy.collections', `The project takes ${m.collections.length + (m.collection ? 1 : 0)} collections`, 'BadCollection', 'Take fewer: a gate or a boost can name a collection the project doesn’t take.');
|
|
834
881
|
m.collections.forEach((c, i) => isAddress(c) || refuse('E-REF', `economy.collections[${i}]`, `“${c}” is not an address.`, 'Write the Core collection’s address.'));
|
|
835
882
|
if (m.collections.length) warn('W-COLLECTIONS', 'economy.collections', `The project takes ${m.collections.length} collection${m.collections.length === 1 ? '' : 's'} you already have: going live passes ${m.collections.length === 1 ? 'it' : 'them'} to the program, which refuses one whose update authority is not your wallet (BadCollection). An item minted into one needs the project as an update delegate of it, which you add to the collection yourself.`);
|
|
836
883
|
const given = new Set();
|
|
@@ -838,10 +885,50 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
838
885
|
for (const p of prizes(m)) if (p.prize.gives === 'item') given.add(p.prize.item);
|
|
839
886
|
for (const a of m.auctions) if (a.lot?.item) given.add(a.lot.item);
|
|
840
887
|
for (const d of m.decisions) if (d.does === 'make') given.add(d.item);
|
|
888
|
+
// A fresh item is given only by a box's roll: a route the player pays or asks for refuses it (PrizeNotCovered); one that
|
|
889
|
+
// only finishes (a lottery place, a contest, a mission's end) gives nothing in its place; an auction refuses the lot.
|
|
890
|
+
const fix = 'Give a roll of a box that gives it (`{ "gives": "roll", "box": key }`) instead.';
|
|
891
|
+
for (const p of prizes(m)) {
|
|
892
|
+
const it = p.prize.gives === 'item' ? m.item.get(p.prize.item) : null;
|
|
893
|
+
if (!(it?.drawn ?? []).length) continue;
|
|
894
|
+
if (p.strict) refuse('E-ITEM', p.where, `${label(it)} draws its traits per unit, which only a box’s roll does: a ${p.route} gives alike units (PrizeNotCovered).`, fix);
|
|
895
|
+
else warn('W-ITEM', p.where, `${label(it)} draws its traits per unit, which only a box’s roll does: as a ${p.route}’s prize it is never given (its winner gets nothing for it).`, fix);
|
|
896
|
+
}
|
|
897
|
+
for (const a of m.auctions) {
|
|
898
|
+
const it = a.lot?.item ? m.item.get(a.lot.item) : null;
|
|
899
|
+
if ((it?.drawn ?? []).length) refuse('E-AUCTION', `economy.auctions.${a.key}.lot.item`, `${label(it)} draws its traits per unit, which only a box’s roll does: an auction can’t sell a new one (LotRefused).`, 'Auction a roll of a box that gives it (`"lot": { "roll": key }`) instead.');
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// A table: rows that weigh something, a label of 32 bytes at most in a table of labels, none in one of numbers.
|
|
903
|
+
for (const t of m.tables) {
|
|
904
|
+
const where = `economy.tables.${t.key}`;
|
|
905
|
+
if (!(t.rows ?? []).length) refuse('E-TABLE', `${where}.rows`, `The table “${t.key}” has no rows.`, 'Give it at least one row.');
|
|
906
|
+
(t.rows ?? []).forEach((r, i) => {
|
|
907
|
+
const bytes = new TextEncoder().encode(String(r.label ?? '')).length;
|
|
908
|
+
if (t.kind === 'numbers' && r.label !== undefined) refuse('E-TABLE', `${where}.rows[${i}].label`, `The table “${t.key}” is of numbers: a row gives its own number, and takes no label (BadTable).`, 'Remove the label, or make the table "labels".');
|
|
909
|
+
else if (t.kind !== 'numbers' && (bytes === 0 || bytes > 32)) refuse('E-TABLE', `${where}.rows[${i}].label`, `Row ${i} of the table “${t.key}” has a label of ${bytes} bytes: a label is 1 to 32 bytes (BadTable).`, 'Shorten it.');
|
|
910
|
+
});
|
|
911
|
+
if (!m.items.some((it) => (it.drawn ?? []).some((d) => d.table === t.key))) warn('W-TABLE', where, `No item draws a trait from the table “${t.key}”.`, 'Name it in an item’s "drawn", or leave it out.');
|
|
912
|
+
}
|
|
913
|
+
|
|
841
914
|
for (const it of m.items) {
|
|
842
915
|
const where = `economy.items.${it.key}`;
|
|
843
|
-
|
|
844
|
-
|
|
916
|
+
// A fresh collectible: each trait its own attribute, drawn from a table the spec has or a range from its least to its
|
|
917
|
+
// most; given only by a box's roll (a shop sells alike units: PrizeNotCovered).
|
|
918
|
+
const drawn = it.drawn ?? [];
|
|
919
|
+
if (drawn.length && !isCollectible(it)) refuse('E-ITEM', `${where}.drawn`, `${label(it)} draws traits, but it is a normal item: only a collectible carries them.`, 'Make it a collectible, or leave "drawn" out.');
|
|
920
|
+
// Each trait is written as its unit is minted, which one transaction must hold (catalog.rs).
|
|
921
|
+
tooMany(drawn.length, LIMITS.itemTraits, 'E-ITEM', `${where}.drawn`, `${label(it)} draws ${drawn.length} traits`, 'BadItem', 'Draw fewer: put what goes together in one table’s labels.');
|
|
922
|
+
drawn.forEach((d, i) => {
|
|
923
|
+
const at = `${where}.drawn[${i}]`;
|
|
924
|
+
if (utf8Bytes(d.attribute ?? '') > LIMITS.traitKeyBytes) refuse('E-ITEM', `${at}.attribute`, `${label(it)}’s trait “${String(d.attribute).slice(0, 24)}…” has a name of ${utf8Bytes(d.attribute)} bytes; the program takes at most ${LIMITS.traitKeyBytes} (BadItem).`, 'Use a shorter name.');
|
|
925
|
+
if (RESERVED_ATTRIBUTES.includes(d.attribute)) refuse('E-ITEM', `${at}.attribute`, `${label(it)} draws “${d.attribute}”, an attribute the program writes itself (BadItem).`, 'Name another attribute.');
|
|
926
|
+
if (drawn.slice(0, i).some((e) => e.attribute === d.attribute)) refuse('E-ITEM', `${at}.attribute`, `${label(it)} draws “${d.attribute}” twice (BadItem).`, 'Draw each attribute once.');
|
|
927
|
+
if (d.table !== undefined && (d.min !== undefined || d.max !== undefined)) refuse('E-ITEM', at, `${label(it)}’s trait “${d.attribute}” names a table and a range.`, 'Keep one.');
|
|
928
|
+
else if (d.table !== undefined && !m.table.get(d.table)) refuse('E-REF', `${at}.table`, `${label(it)}’s trait “${d.attribute}” draws from “${d.table}”, which is not one of the spec’s tables.`, 'Add the table to "tables", or name one it has.');
|
|
929
|
+
else if (d.table === undefined && !(d.min !== undefined && d.max !== undefined && d.min <= d.max)) refuse('E-ITEM', at, `${label(it)}’s trait “${d.attribute}” needs a table, or a range from its "min" to its "max", the least first.`, 'Name a table, or give a "min" and a "max".');
|
|
930
|
+
});
|
|
931
|
+
if (drawn.length && (Number(it.price ?? 0) > 0 || it.free)) refuse('E-ITEM', `${where}.price`, `${label(it)} draws its traits per unit, by a roll: the shop sells only alike units (PrizeNotCovered).`, 'Sell it from a box (one outcome is enough: the box still rolls), and leave "price" out.');
|
|
845
932
|
// A collectible is minted into a collection of the project's (catalog.rs BadCollection).
|
|
846
933
|
if (isCollectible(it) && it.collection && !m.collections.includes(it.collection))
|
|
847
934
|
refuse('E-COLLECTION', `${where}.collection`, `${label(it)} is minted into ${it.collection}, which is not one of the project’s "collections" (BadCollection).`, 'Add it to "collections", or leave "collection" out to mint into the one the setup makes.');
|
|
@@ -874,18 +961,11 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
874
961
|
const fills = fillers.filter((f) => f.pool === p.key);
|
|
875
962
|
if (takes.length && !fills.length)
|
|
876
963
|
warn('W-POOL-DRY', where, `The pool “${p.key}” is paid out of (${takes.map((t) => t.by).join(', ')}) but nothing in this spec fills it: until someone sends it tokens, those payouts give nothing (a claim of a fixed amount is refused).`, `Seed it ("seed"), send it a share of a sale (a split to "pool:${p.key}"), or fill it with a schedule.`);
|
|
877
|
-
|
|
878
|
-
if (!takes.length && !fills.length && !names) warn('W-POOL', where, `The pool “${p.key}” is never used.`, 'Remove it, or use it.');
|
|
964
|
+
if (!takes.length && !fills.length) warn('W-POOL', where, `The pool “${p.key}” is never used.`, 'Remove it, or use it.');
|
|
879
965
|
if (fills.length && !takes.length)
|
|
880
966
|
warn('W-POOL', where, `Money goes into “${p.key}” (${fills.map((f) => f.by).join(', ')}) and nothing in the economy takes it out: only the authority can, with a payout.`, 'Add what pays from it (a box prize, a lottery, a farm, a recipe, a grant), or a decision that pays out of it.');
|
|
881
|
-
const farm = takes.find((t) => t.by.startsWith('farm:') && t.how.startsWith('releases everything'));
|
|
882
|
-
if (farm) {
|
|
883
|
-
const others = takes.filter((t) => t !== farm);
|
|
884
|
-
if (others.length)
|
|
885
|
-
warn('W-POOL-SHARED', where, `The farm ${label(m.farm.get(farm.by.slice(5)))} releases everything that arrives in “${p.key}” to its stakers, and ${others.map((t) => t.by).join(', ')} also take from it: the farm releases all the pool holds to its seats, so what the others need is paid out to stakers, and whichever takes first leaves the others less.`, 'Give the farm a rewards pool of its own, and feed it with a share or a schedule.');
|
|
886
|
-
}
|
|
887
967
|
const lottery = takes.find((t) => t.by.startsWith('lottery:'));
|
|
888
|
-
if (lottery
|
|
968
|
+
if (lottery) {
|
|
889
969
|
const others = takes.filter((t) => t !== lottery);
|
|
890
970
|
if (others.length) warn('W-POOL-SHARED', where, `The pot “${p.key}” of ${lottery.by} is also taken from by ${others.map((t) => t.by).join(', ')}: until its winners are paid it is a pool like any other, and the winners share what is left.`, 'Give the lottery a pot of its own.');
|
|
891
971
|
}
|
|
@@ -917,7 +997,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
917
997
|
if (listed.has(n.asset)) refuse('E-STASH', at, `${label(s)} lists ${n.asset}, which ${listed.get(n.asset)} lists too: an NFT can be handed to the game once.`, 'List each NFT in one stash, once.');
|
|
918
998
|
listed.set(n.asset, label(s));
|
|
919
999
|
if (n.kind === 'compressed')
|
|
920
|
-
refuse('E-STASH', at, `${label(s)} lists a compressed NFT: putting one in needs a proof of its leaf fetched when it is signed, which going live does not do.`, 'Leave it out here; once the economy is live, put it in with `loot.proveLeafIx` then `loot.stashIx` (both in one transaction, signed by the owner or the authority who holds it). A compressed prize NFT can’t move in the same instruction as another move on its tree (a mission whose staked NFTs share that tree, a lottery
|
|
1000
|
+
refuse('E-STASH', at, `${label(s)} lists a compressed NFT: putting one in needs a proof of its leaf fetched when it is signed, which going live does not do.`, 'Leave it out here; once the economy is live, put it in with `loot.proveLeafIx` then `loot.stashIx` (both in one transaction, signed by the owner or the authority who holds it). A compressed prize NFT can’t move in the same instruction as another move on its tree (a mission whose staked NFTs share that tree, a lottery entry winning two places of one tree): the second proof is stale and the unstake or the payment fails until it is sent again. Keep prize NFTs on a tree of their own, or give them through a roll.');
|
|
921
1001
|
if (n.kind === 'core' && !n.collection && !m.live.project)
|
|
922
1002
|
refuse('E-STASH', at, `${label(s)} lists an NFT of the game’s own collection, which is only made when the economy goes live: there is none to put in yet.`, 'Give the collection it is in ("collection"), or put it in once the economy is live and your wallet holds it.');
|
|
923
1003
|
}
|
|
@@ -932,7 +1012,11 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
932
1012
|
const fits = Math.floor(1_200_000 / (52 * (most + 1)) / 100) * 100;
|
|
933
1013
|
warn('W-STASH-NAMED', where, `${[...namers.map(([b]) => label(b)), ...named.map((p) => p.by)].join(', ')} name${namers.length + named.length === 1 ? 's' : ''} NFTs of ${label(s)}, which ${[...new Set(intoStash.get(s.key))].join(', ')} put${intoStash.get(s.key).length === 1 ? 's' : ''} NFTs into, so it can grow without end. A named NFT is found by reading the stash’s list, about 52 compute units for each NFT ahead of it${most ? `: a roll of ${label(namers.find(([, n]) => n === most)[0])}, which names ${most}, stops fitting a transaction at about ${fits.toLocaleString('en-US')} NFTs in the stash (its order then waits for its refund)` : ''}${named.length ? `${most ? ', and' : ':'} a prize naming one stops at about 25,000` : ''}. Keep a stash that prizes name under about 1,000 NFTs.`, 'Send what players put in to a stash of its own, and name prizes from a stash only the owner fills; a random pick of a stash costs the same at any size.');
|
|
934
1014
|
}
|
|
935
|
-
if (!(s.nfts ?? []).length && !intoStash.has(s.key))
|
|
1015
|
+
if (!(s.nfts ?? []).length && !intoStash.has(s.key)) {
|
|
1016
|
+
// A box that stops selling when an outcome runs out (the default) isn't sold at all while it is empty.
|
|
1017
|
+
const stopped = users.filter((b) => b.active !== false && b.whenOut !== 'drawLeft' && !boxBuys(b, m));
|
|
1018
|
+
warn('W-STASH', `${where}.nfts`, `${label(s)} starts empty: until the owner or the authority puts NFTs in (\`loot.stashIx\`), ${stopped.length ? `${stopped.map(label).join(', ')} ${stopped.length === 1 ? 'isn’t' : 'aren’t'} sold at all (it stops selling while an outcome is out, "whenOut": "stopSales"), and ` : ''}a draw that lands on it is not made and gives back that draw’s worth, no other outcome taking its place, and a box of nothing else sells nothing.`);
|
|
1019
|
+
}
|
|
936
1020
|
if (!users.length && !named.length) warn('W-STASH', where, `No box and no prize gives an NFT of ${label(s)}: what it holds stays with the game.`, 'Add an outcome { "gives": "stash", "stash": key } to a box, or a prize { "gives": "nft", "stash": key, "nft": address }.');
|
|
937
1021
|
// Who takes from it: buyers and every route that rolls those boxes (one NFT a roll), and the prizes naming one.
|
|
938
1022
|
const takers = [...new Set([...users.flatMap((b) => [...(b.active !== false ? [b.shopOf ? `item:${b.shopOf}` : `box:${b.key}`] : []), ...rolls.filter((r) => r.box === b.key).map((r) => r.by)]), ...named.map((p) => p.by)])];
|
|
@@ -959,6 +1043,130 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
959
1043
|
const seedNeeds = new Map();
|
|
960
1044
|
for (const b of m.boxes) {
|
|
961
1045
|
const where = `economy.boxes.${b.key}`;
|
|
1046
|
+
// A pack: each slot draws at least once, among outcomes the box has, each named once. Several draws need a box that
|
|
1047
|
+
// rolls, or one outcome of items, with no outcome that burns the payment and no NFT to roll on (BadEntries).
|
|
1048
|
+
const slots = b.slots ?? [];
|
|
1049
|
+
const outs = b.outcomes ?? [];
|
|
1050
|
+
tooMany(outs.length, LIMITS.boxEntries, 'E-ODDS', `${where}.outcomes`, `${label(b)} has ${outs.length} outcomes`, 'BadEntries', 'Use fewer outcomes, or roll on a table of rows ("drawn").');
|
|
1051
|
+
tooMany(slots.length, LIMITS.boxSlots, 'E-ODDS', `${where}.slots`, `${label(b)} has ${slots.length} slots`, 'BadEntries', 'Put alike draws into one slot with "count".');
|
|
1052
|
+
tooMany((b.odds ?? []).length, LIMITS.boxOdds, 'E-ODDS', `${where}.odds`, `${label(b)} has ${(b.odds ?? []).length} odds that read an NFT`, 'BadEntries', 'Keep the ones that matter.');
|
|
1053
|
+
const fated = outs.filter((o) => (o.shifts ?? []).length || (o.then ?? b.then ?? 'back') !== 'back').length;
|
|
1054
|
+
tooMany(fated, LIMITS.boxFates, 'E-ROLL', `${where}.outcomes`, `${fated} outcomes of ${label(b)} act on the NFT it rolls on`, 'BadEntries', 'Have fewer outcomes act on it.');
|
|
1055
|
+
for (const [i, o] of outs.entries()) {
|
|
1056
|
+
const given = outcomeParts(o);
|
|
1057
|
+
tooMany(given.length, LIMITS.entryPrizes, 'E-ODDS', `${where}.outcomes[${i}].prizes`, `An outcome of ${label(b)} gives ${given.length} prizes`, 'BadEntries', 'Give fewer, or make it two outcomes.');
|
|
1058
|
+
// An outcome gives all it names or isn't given (order.rs): what it names is refused at the write when it can clash.
|
|
1059
|
+
for (const [j, x] of given.entries()) {
|
|
1060
|
+
const clash = given.slice(0, j).map((y) => samePrizes(y, x)).find(Boolean);
|
|
1061
|
+
if (clash) refuse('E-ODDS', `${where}.outcomes[${i}].prizes[${j}]`, `An outcome of ${label(b)} ${clash}: it gives all it names or isn't given, so the program refuses the box (BadEntries).`, 'Name each pool once, with the whole amount, and a stash\'s random NFT beside nothing else of that stash.');
|
|
1062
|
+
}
|
|
1063
|
+
// Two of an item take two units: an entry of more than its supply can never be given.
|
|
1064
|
+
for (const key of new Set(given.filter((x) => x.gives === 'item').map((x) => x.item))) {
|
|
1065
|
+
const count = given.filter((x) => x.gives === 'item' && x.item === key).length;
|
|
1066
|
+
const supply = Number(m.item.get(key)?.supply ?? 0);
|
|
1067
|
+
if (supply > 0 && count > supply) warn('W-OUT-OF-STOCK', `${where}.outcomes[${i}]`, `An outcome of ${label(b)} gives ${count} of ${label(m.item.get(key))}, which has a supply of ${supply}: it needs them all left, so it can never be given${b.whenOut !== 'drawLeft' && b.active !== false && boxRolls(b, m) ? ', and a box that stops selling while an outcome is out ("whenOut": "stopSales") is never sold' : ', and a draw that lands on it gives back that draw’s worth'}.`, 'Give fewer, or raise its supply.');
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
// A fresh outcome's tables come with the answer while they fit what the box's own accounts leave, in outcome order
|
|
1071
|
+
// (`tableBudget`). One past that is out of stock on every roll, so its odds would be false.
|
|
1072
|
+
{
|
|
1073
|
+
const box = (() => {
|
|
1074
|
+
try {
|
|
1075
|
+
return boxArgs(b, m);
|
|
1076
|
+
} catch {
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
})();
|
|
1080
|
+
if (box && boxRolls(b, m)) {
|
|
1081
|
+
let left = Math.max(0, tableBudget(box.entries));
|
|
1082
|
+
const cut = [];
|
|
1083
|
+
outs.forEach((out, i) => {
|
|
1084
|
+
for (const part of outcomeParts(out)) {
|
|
1085
|
+
if (part.gives !== 'item') continue;
|
|
1086
|
+
const tables = (m.item.get(part.item)?.drawn ?? []).filter((d) => d.table).length;
|
|
1087
|
+
if (tables > left) cut.push(i);
|
|
1088
|
+
else left -= tables;
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
if (cut.length) refuse('E-ODDS', `${where}.outcomes`, `${label(b)} draws from more tables than one answer of the oracle’s can carry, so outcome${cut.length === 1 ? '' : 's'} ${[...new Set(cut)].join(', ')} could never be won.`, 'Fewer outcomes with drawn traits, fewer table traits on each, or a range (`min`, `max`) in place of a table.');
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
slots.forEach((s, i) => (s.from ?? []).forEach((o, j) => {
|
|
1095
|
+
if (o >= outs.length) refuse('E-ODDS', `${where}.slots[${i}].from[${j}]`, `Slot ${i} of ${label(b)} names outcome ${o}, but the box has ${outs.length}.`, 'Name outcomes by their place in "outcomes", from 0.');
|
|
1096
|
+
else if ((s.from ?? []).slice(0, j).includes(o)) refuse('E-ODDS', `${where}.slots[${i}].from[${j}]`, `Slot ${i} of ${label(b)} names outcome ${o} twice.`, 'Name each once.');
|
|
1097
|
+
}));
|
|
1098
|
+
// A compressed item an outcome gives is minted at the settle, as any prize's (`compressedCheck`).
|
|
1099
|
+
outs.forEach((o, i) => outcomeParts(o).forEach((part) => part.gives === 'item' && part.compressed && m.item.get(part.item) && compressedCheck(m.item.get(part.item), `${where}.outcomes[${i}]`, label(b), 'E-ODDS')));
|
|
1100
|
+
const cards = slots.reduce((n, s) => n + s.count, 0);
|
|
1101
|
+
// Slots that name outcomes draw different things, so each says what it is worth (BadEntries): what a draw that
|
|
1102
|
+
// can't be made gives back.
|
|
1103
|
+
const shared = slots.reduce((n, s) => n + Math.round((s.share ?? 0) * 100), 0);
|
|
1104
|
+
const named = outs.length > 1 && slots.some((s) => (s.from ?? []).length);
|
|
1105
|
+
const zero = named && slots.some((s) => !s.share);
|
|
1106
|
+
if (zero || shared !== (named ? 10_000 : 0))
|
|
1107
|
+
refuse('E-ODDS', `${where}.slots`, named ? `The slots of ${label(b)} name outcomes, so each says what it is worth as a share of an open's price, more than 0 each and 100 together; these come to ${shared / 100}.` : `The slots of ${label(b)} all draw among every outcome, so every draw is worth the same: they take no share.`, named ? 'Give each slot a "share": a draw that can\'t be made gives its slot\'s worth back, so make a rare slot worth what the rare is.' : 'Leave "share" out.');
|
|
1108
|
+
// Every outcome is one some slot draws (BadEntries): one no slot draws would count as stock no roll can give.
|
|
1109
|
+
if (slots.length && !slots.some((s) => !(s.from ?? []).length)) {
|
|
1110
|
+
const undrawn = outs.map((_, i) => i).filter((i) => !slots.some((s) => (s.from ?? []).includes(i)));
|
|
1111
|
+
if (undrawn.length)
|
|
1112
|
+
refuse('E-ODDS', `${where}.slots`, `No slot of ${label(b)} draws its outcome${undrawn.length === 1 ? '' : 's'} ${undrawn.join(', ')}: an outcome no slot draws is never given, so the program refuses the box (BadEntries).`, 'Name each outcome in some slot’s "from", leave "from" out of a slot (it draws among all), or drop the outcome.');
|
|
1113
|
+
}
|
|
1114
|
+
// Every draw of one open fits one answer of the oracle's (PackTooLarge): a pack past it could only draw its first
|
|
1115
|
+
// cards, so the program refuses the box. A box that doesn't roll sells by `buy`, the whole purchase in one
|
|
1116
|
+
// transaction, or through the oracle when one `buy` can't carry it (`buyFits`): then the answer's heap counts, and it
|
|
1117
|
+
// can't give a roll, which only `buy` gives (catalog.rs). The heap counts the box's own bytes too (`boxBytes`).
|
|
1118
|
+
if (cards > 1 && outs.length) {
|
|
1119
|
+
const args = (() => {
|
|
1120
|
+
try {
|
|
1121
|
+
return boxArgs(b, m);
|
|
1122
|
+
} catch {
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
})();
|
|
1126
|
+
const entries = args?.entries;
|
|
1127
|
+
if (entries) {
|
|
1128
|
+
const byBuy = boxRolls(b, m) || Boolean(b.rollsOn) || buyFits(args, cards);
|
|
1129
|
+
const extras = { odds: args.odds.length, shares: args.shares.length, fates: args.fates.length, bytes: boxBytes(args), oracle: !byBuy };
|
|
1130
|
+
if (!byBuy && entries.some((e) => e.prizes.some((p) => p.Roll != null)))
|
|
1131
|
+
refuse('E-ODDS', `${where}.slots`, `${label(b)} gives a roll, which only a sale in one step gives, but ${cards} of what it gives don’t fit one transaction (its accounts, its instructions or its compute), so the program refuses the box (PackTooLarge).`, 'Fewer cards an open, fewer prizes in its outcome, or fewer shares in its split.');
|
|
1132
|
+
else if (!drawsFit(entries, slots.map((s) => ({ count: s.count, only: s.from ?? [] })), 1, extras)) {
|
|
1133
|
+
let most = cards - 1;
|
|
1134
|
+
while (most > 1 && !drawsFit(entries, most, 1, extras)) most -= 1;
|
|
1135
|
+
refuse('E-ODDS', `${where}.slots`, `${label(b)} draws ${cards} cards an open among ${outs.length} outcome${outs.length === 1 ? '' : 's'}: more than one answer of the oracle’s can draw (about ${most} here), so the program refuses the box (PackTooLarge).`, 'Fewer cards an open (sell two packs instead of one), fewer outcomes, fewer prizes in an outcome, or a smaller box (fewer odds, shares, slots or outcomes that act on the NFT).');
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
if (cards > 1 && b.rollsOn)
|
|
1140
|
+
refuse('E-ODDS', `${where}.slots`, `${label(b)} draws ${cards} times an open, but a pack has no NFT it rolls on (BadEntries).`, 'Leave "slots" out, or drop "rollsOn".');
|
|
1141
|
+
// An open's order holds every prize it can set aside: at most 10,240 bytes (about 243 one-prize draws).
|
|
1142
|
+
const orderSize = (() => {
|
|
1143
|
+
try {
|
|
1144
|
+
return boxOrderBytes(b, m);
|
|
1145
|
+
} catch {
|
|
1146
|
+
return 0;
|
|
1147
|
+
}
|
|
1148
|
+
})();
|
|
1149
|
+
if (orderSize > MAX_ORDER_BYTES) refuse('E-ODDS', `${where}.slots`, `${label(b)}’s order would be ${orderSize.toLocaleString('en-US')} bytes, more than the ${MAX_ORDER_BYTES.toLocaleString('en-US')} an order can be (BadEntries).`, 'Fewer draws an open, or fewer prizes in its largest outcome.');
|
|
1150
|
+
// A roll moves its prizes aside with no accounts of a transfer hook's, and can't move tokens whose mint allows
|
|
1151
|
+
// confidential transfers or none at all: such a prize is out of stock in every roll (order.rs `mints_in_stock`): a draw
|
|
1152
|
+
// that lands on it is not made and gives back its worth, and a box with nothing else in stock is refused when it is
|
|
1153
|
+
// opened or asked for (SoldOut).
|
|
1154
|
+
const rolledAtAll = !boxBuys(b, m) || rolls.some((r) => r.box === b.key);
|
|
1155
|
+
if (rolledAtAll)
|
|
1156
|
+
for (const [i, o] of outs.entries()) {
|
|
1157
|
+
const tokenOf = (x) => (x.gives === 'pay' ? m.pool.get(x.pool)?.currency : x.gives === 'mint' ? x.currency ?? m.currencyKey : undefined);
|
|
1158
|
+
const bad = outcomeParts(o).map((x) => m.tokens.get(tokenOf(x))).find((t) => t?.powers?.transferHook || t?.powers?.confidentialTransfers);
|
|
1159
|
+
if (!bad) continue;
|
|
1160
|
+
const why = bad.powers.transferHook ? 'whose mint has a transfer hook (a roll carries none of the accounts a hook asks for)' : 'whose mint allows confidential transfers';
|
|
1161
|
+
warn('W-OUT-OF-STOCK', `${where}.outcomes[${i}]`, `${label(b)} pays a prize in ${bad.symbol ?? bad.key}, ${why}: a roll can’t move it, so this outcome is out of stock in every roll of the box: its odds stay as written, a draw that lands on it makes no draw and gives back that draw’s worth, and no other outcome takes its place${outs.length === 1 ? '; with nothing else in it, every opening or roll of the box is refused (SoldOut)' : ''}.`, `Pay its prizes in a token without ${bad.powers.transferHook ? 'a transfer hook' : 'confidential transfers'}${boxRolls(b, m) || b.rollsOn ? '' : ', or sell the box in one step (one outcome, nothing random, no NFT rolled on: the purchase moves its prize with the hook’s accounts)'}.`);
|
|
1162
|
+
}
|
|
1163
|
+
// A roll is given only by a box that sells in one step (`buy`): one oracle answer can't serve a second draw. In a
|
|
1164
|
+
// box that rolls it is refused; in an order (an open, a prize's or a recipe's roll of the box) it is out of stock.
|
|
1165
|
+
const givesRoll = outs.some((o) => outcomeParts(o).some((x) => x.gives === 'roll'));
|
|
1166
|
+
if (givesRoll && boxRolls(b, m))
|
|
1167
|
+
refuse('E-ODDS', `${where}.outcomes`, `${label(b)} rolls (more than one outcome, or an NFT of a stash at random), and gives a roll of a box: a box that rolls can’t give one (BadEntries).`, 'Give the roll from a box of one outcome that sells in one step, or give the box’s prizes directly.');
|
|
1168
|
+
else if (givesRoll && rolledAtAll)
|
|
1169
|
+
warn('W-OUT-OF-STOCK', `${where}.outcomes`, `${label(b)} gives a roll of a box, which only its sale in one step gives: ${b.rollsOn ? 'it rolls on an NFT, so it is opened' : `${[...new Set(rolls.filter((r) => r.box === b.key).map(roller))].join(', ')} roll${rolls.filter((r) => r.box === b.key).length === 1 ? 's' : ''} it`}, and in an order the roll is out of stock: that order gives nothing${outs.length === 1 && outcomeParts(outs[0]).length === 1 ? ' (with nothing else in it, the roll is refused, SoldOut)' : ''}.`, 'Give the roll from a box nothing rolls, or give the box’s prizes directly.');
|
|
962
1170
|
const tok = token(b.currency, `${where}.currency`);
|
|
963
1171
|
const price = units(b.price, tok, `${where}.price`, `${label(b)}’s price`);
|
|
964
1172
|
prices(b, tok, where);
|
|
@@ -979,7 +1187,6 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
979
1187
|
timed(x, `${at}.shifts[${j}]`, `${label(b)} moves “${x.attribute}”`, 'E-ROLL');
|
|
980
1188
|
fourByteSeconds(x.by, `${at}.shifts[${j}].by`, { signed: true });
|
|
981
1189
|
fourByteSeconds(x.upTo, `${at}.shifts[${j}].upTo`);
|
|
982
|
-
if (toBy(x.by) > 0 && x.upTo !== undefined && !(toSeconds(x.upTo) >= 1)) warn('W-ROLL', `${at}.shifts[${j}]`, `${label(b)} raises “${x.attribute}” up to ${x.upTo}: it never raises it.`, 'Set "upTo" to 1 or more, or leave it out.');
|
|
983
1190
|
}
|
|
984
1191
|
thenCheck(o.then, `${at}.then`, label(b));
|
|
985
1192
|
}
|
|
@@ -1008,7 +1215,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1008
1215
|
// may turn on memos) is warned of, and one bigger than a legacy transaction goes as v0 with a lookup table its
|
|
1009
1216
|
// sender makes. Two shapes outgrow the 32 KiB heap on a compressed subject (three Core NFTs of stashes; two and four
|
|
1010
1217
|
// holders' or wallets' shares): with the 256 KiB heap frame xsolana's senders ask for they land (48 and 58 accounts).
|
|
1011
|
-
if (!boxBuys(b) || rolls.some((r) => r.box === b.key)) {
|
|
1218
|
+
if (!boxBuys(b, m) || rolls.some((r) => r.box === b.key)) {
|
|
1012
1219
|
const sizes = (outcomes.length ? outcomes : [{}]).map((o, i) => ({ i, ...settleAccounts(b, o, m), least: settleAccounts(b, o, m, { proof: 0 }).count, units: settleUnits(b, o, m), ...settleSize(b, o, m) }));
|
|
1013
1220
|
const at = (x) => `${where}.outcomes${outcomes.length ? `[${x.i}]` : ''}`;
|
|
1014
1221
|
// As v0 (any settle past a legacy transaction), the transaction locks its lookup table and the table's program
|
|
@@ -1031,7 +1238,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1031
1238
|
warn('W-BOX-HEAP', `${where}.rollsOn`, `${label(b)} rolls on an NFT of a collection that may be compressed, whose proof every NFT a settle moves is handed: with ${sizes.some((x) => x.corePicks >= 3) ? 'three NFTs of stashes' : 'two NFTs of stashes and four holders’ or wallets’ shares'} it doesn’t fit the 32 KiB heap a transaction has by default. ${sends}. One sent without the frame (another app’s) fails, and as a rolled order is never refunded, its player then settles by putting every NFT won back in its stash.`, 'Give an outcome fewer NFTs of stashes or fewer holders’ or wallets’ shares, or roll on the game’s own collectibles.');
|
|
1032
1239
|
const big = sizes.reduce((a, x) => (x.legacy > a.legacy ? x : a), sizes[0]);
|
|
1033
1240
|
if (!certain && !heavy && total <= ACCOUNT_LOCKS && big.legacy > MAX_TRANSACTION_BYTES)
|
|
1034
|
-
warn('W-SETTLE-V0', at(big), `${label(b)}’s settle of its outcome ${big.i} takes about ${big.legacy} bytes as a legacy transaction, more than the ${MAX_TRANSACTION_BYTES} one holds (${big.locks} accounts, ${big.locks + TABLE_LOCKS} as v0 with its table). xsolana’s senders send it as a v0 transaction that loads its accounts from an address lookup table
|
|
1241
|
+
warn('W-SETTLE-V0', at(big), `${label(b)}’s settle of its outcome ${big.i} takes about ${big.legacy} bytes as a legacy transaction, more than the ${MAX_TRANSACTION_BYTES} one holds (${big.locks} accounts, ${big.locks + TABLE_LOCKS} as v0 with its table). xsolana’s senders send it as a v0 transaction that loads its accounts from an address lookup table: js/play from the project’s standing table, which going live makes (one approval; a table of the player’s own, about ${(Number(big.tableRent) / 1e9).toFixed(4)} SOL of rent back about two minutes later, only for accounts it lacks), the keeper’s sweep from its own payer. A sender of legacy transactions only (another app’s) can’t settle it, and a rolled order is never refunded.`, 'To keep it a legacy transaction, give the outcome fewer prizes or fewer holders’ or wallets’ shares.');
|
|
1035
1242
|
}
|
|
1036
1243
|
if (b.rollsOn && custodial)
|
|
1037
1244
|
warn('W-ROLL-CUSTODY', `${where}.rollsOn`, `${label(b)} can ${outcomes.some((o) => thenOfOutcome(o) === 'burn') ? 'burn' : outcomes.some((o) => thenOfOutcome(o)?.stash) ? 'keep' : 'give away'} the NFT it rolls on: the project holds the NFT from the moment it is bought until the roll is settled, so nobody can move it away from a bad roll. A player who brings their own NFT must agree to that.`);
|
|
@@ -1047,27 +1254,10 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1047
1254
|
const prizedBy = rolls.filter((r) => r.box === b.key && !['mission', 'recipe'].includes(r.route)).map(roller);
|
|
1048
1255
|
if (prizedBy.length && b.active !== false && !b.shopOf)
|
|
1049
1256
|
warn('W-ROLL-PRIZE', `${where}.active`, `${label(b)} is a prize of ${[...new Set(prizedBy)].join(', ')}, and is for sale too: anyone can open it for its price${price === 0n ? ' (free)' : ''}, and each opening takes from what the prize gives (a stash’s NFTs, a pool’s prizes).`, 'Set "active": false for a box only prizes roll.');
|
|
1050
|
-
// A prize moves in the roll, which carries none of the accounts a transfer hook asks for (order.rs): the program
|
|
1051
|
-
// refuses a hooked prize mint at every open of a box that rolls, and fails every waiting roll of a box (a recipe's,
|
|
1052
|
-
// a mission's, a prize's, another box's outcome's, of any number of outcomes) at its ask, after the player gave what
|
|
1053
|
-
// it took.
|
|
1054
|
-
const rolledHere = [...new Set(rolls.filter((r) => r.box === b.key).map(roller))];
|
|
1055
|
-
if (boxRolls(b) || rolledHere.length)
|
|
1056
|
-
for (const [i, o] of outcomes.entries()) {
|
|
1057
|
-
const hookedKey = outcomeParts(o)
|
|
1058
|
-
.map((x) => (x.gives === 'pay' || x.gives === 'share' ? m.pool.get(x.pool)?.currency : x.gives === 'mint' ? x.currency ?? m.currencyKey : undefined))
|
|
1059
|
-
.find((k) => k && m.tokens.get(k)?.powers?.transferHook);
|
|
1060
|
-
if (!hookedKey) continue;
|
|
1061
|
-
const fails = [
|
|
1062
|
-
boxRolls(b) && 'every opening of it fails',
|
|
1063
|
-
rolledHere.length && `every roll of it ${rolledHere.join(', ')} make${rolledHere.length === 1 ? 's' : ''} fails when it is asked for, keeping what the player gave for it (a recipe’s inputs, a mission’s return, a grant’s round, a box’s price), or is refused at once on a route that can’t give it (a purchase in one step)`,
|
|
1064
|
-
].filter(Boolean);
|
|
1065
|
-
refuse('E-BOX', `${where}.outcomes[${i}]`, `${label(b)} pays a prize in “${hookedKey}”, a token whose mint has a transfer hook, and its prizes move in a roll, which carries none of the accounts a hook asks for: the program refuses them (PrizeHasHook), so ${fails.join(', and ')}.`, `Pay its prizes in a token without a transfer hook${rolledHere.length ? '' : ', or give it one outcome (a box of one outcome moves its prize in the purchase, with the hook’s accounts)'}.`);
|
|
1066
|
-
}
|
|
1067
1257
|
if (prizedBy.length && b.rollsOn)
|
|
1068
1258
|
warn('W-ROLL-PRIZE', `${where}.rollsOn`, `${label(b)} rolls on an NFT, but ${[...new Set(prizedBy)].join(', ')} give${prizedBy.length === 1 ? 's' : ''} a roll of it on nothing: its outcomes are given, and their shifts and where they send an NFT are not.`, 'Give prizes a box with no "rollsOn".');
|
|
1069
1259
|
if (price === 0n && !rolledBy.length && b.active !== false)
|
|
1070
|
-
warn('W-FREE', `${where}.price`, `${label(b)} is free: anyone, a bot included, can open it again and again for only the fees${outcomes.flatMap(outcomeParts).some((o) => o.gives === 'pay'
|
|
1260
|
+
warn('W-FREE', `${where}.price`, `${label(b)} is free: anyone, a bot included, can open it again and again for only the fees${outcomes.flatMap(outcomeParts).some((o) => o.gives === 'pay') ? ', and drain the pools its prizes come from' : ''}.`, 'Give it a price, a gate, or hand out openings through a grant.');
|
|
1071
1261
|
const withChance = outcomes.filter((o) => o.chance !== undefined).length;
|
|
1072
1262
|
const withWeight = outcomes.filter((o) => o.weight !== undefined).length;
|
|
1073
1263
|
if (withChance && withWeight) refuse('E-ODDS', `${where}.outcomes`, `${label(b)} mixes chances and weights, so its odds cannot be read.`, 'Give every outcome a chance (percents adding up to 100), or every one a weight.');
|
|
@@ -1086,9 +1276,6 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1086
1276
|
for (const [i, outcome] of outcomes.entries()) {
|
|
1087
1277
|
const at = `${where}.outcomes[${i}]`;
|
|
1088
1278
|
if (outcome.gives === 'bundle' && !(outcome.prizes ?? []).length) refuse('E-ODDS', `${at}.prizes`, `A bundle of ${label(b)} gives nothing.`, 'List up to three prizes in "prizes".');
|
|
1089
|
-
if (outcomeParts(outcome).length > 3) refuse('E-ODDS', `${at}.prizes`, `An outcome of ${label(b)} gives ${outcomeParts(outcome).length} prizes; an entry gives at most 3 (BadEntries).`, 'Give fewer, or move some to another outcome.');
|
|
1090
|
-
if (outcome.burns && outcome.gives !== 'bundle') refuse('E-ODDS', `${at}.burns`, `"burns" is a bundle's; this outcome is “${outcome.gives}”.`, 'Use "gives": "burn" for an outcome that only burns, or make it a bundle.');
|
|
1091
|
-
if (outcomeBurns(outcome) && outcome.gives !== 'burn' && tok?.wsol) refuse('E-WSOL-BURN', at, `${label(b)} is paid in SOL, and wrapped SOL cannot be burned.`, 'Leave "burns" out.');
|
|
1092
1279
|
for (const [k, o] of outcomeParts(outcome).entries()) {
|
|
1093
1280
|
const at = outcome.gives === 'bundle' ? `${where}.outcomes[${i}].prizes[${k}]` : `${where}.outcomes[${i}]`;
|
|
1094
1281
|
// A prize line's own kinds: tokens minted, a named NFT, a roll of a box (prize.rs).
|
|
@@ -1097,42 +1284,42 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1097
1284
|
if (!o.item) refuse('E-ODDS', at, `An outcome of ${label(b)} gives an item but does not say which.`, 'Add "item".');
|
|
1098
1285
|
else item(o.item, `${at}.item`);
|
|
1099
1286
|
}
|
|
1100
|
-
if (o.gives === 'pay'
|
|
1287
|
+
if (o.gives === 'pay') {
|
|
1101
1288
|
chance = true;
|
|
1102
1289
|
const p = o.pool ? pool(o.pool, `${at}.pool`) : refuse('E-ODDS', at, `An outcome of ${label(b)} pays from a pool but does not say which.`, 'Add "pool".');
|
|
1103
|
-
if (
|
|
1290
|
+
if (p) {
|
|
1104
1291
|
const amount = units(o.amount, m.tokens.get(p.currency), `${at}.amount`, `${label(b)}’s prize`);
|
|
1105
1292
|
if (amount === 0n) refuse('E-ODDS', `${at}.amount`, `An outcome of ${label(b)} pays nothing (BadEntries).`, 'Give it an amount above 0, or make it "nothing".');
|
|
1106
1293
|
// In base units, exact (null when it is not an amount, refused above).
|
|
1107
1294
|
prizes.push({ pool: p, amount });
|
|
1108
1295
|
}
|
|
1109
|
-
if (o.gives === 'share' && !(percent(o.percent, `${at}.percent`) > 0)) refuse('E-ODDS', `${at}.percent`, `An outcome of ${label(b)} pays a share of 0% (BadEntries).`, 'Give it a percent above 0.');
|
|
1110
1296
|
}
|
|
1111
1297
|
if (o.gives === 'stash') {
|
|
1112
1298
|
if (!o.stash) refuse('E-ODDS', at, `An outcome of ${label(b)} gives a stash’s NFT but does not say which stash.`, 'Add "stash".');
|
|
1113
1299
|
else if (!m.stash.get(o.stash)) refuse('E-REF', `${at}.stash`, `There is no stash “${o.stash}”.`, 'Add it to "stashes", or name one that is there.');
|
|
1114
1300
|
}
|
|
1115
1301
|
}
|
|
1116
|
-
if (outcome.gives === 'burn' && tok?.wsol) refuse('E-WSOL-BURN', at, `${label(b)} is paid in SOL, and wrapped SOL cannot be burned.`, 'Make that outcome "nothing", or send its share somewhere.');
|
|
1117
1302
|
}
|
|
1118
|
-
if (outcomes.length > LIMITS.entries) refuse('E-ODDS', `${where}.outcomes`, `${label(b)} has ${outcomes.length} outcomes; a box holds at most ${LIMITS.entries}.`, 'Merge outcomes, or split it into two boxes.');
|
|
1119
1303
|
// The oracle's answer to a roll carries the order, the box, its ticket, an account for each outcome that wins
|
|
1120
1304
|
// something, and with prizes the token programs, the game and a mint and a vault for each pool (catalog.rs).
|
|
1121
|
-
if (boxRolls(b)) {
|
|
1122
|
-
const named = parts.filter((o) => ['item', 'pay', '
|
|
1123
|
-
const sources = new Set(parts.filter((o) => ['pay', '
|
|
1305
|
+
if (boxRolls(b, m)) {
|
|
1306
|
+
const named = parts.filter((o) => ['item', 'pay', 'stash', 'nft'].includes(o.gives)).length;
|
|
1307
|
+
const sources = new Set(parts.filter((o) => ['pay', 'mint'].includes(o.gives)).map((o) => (o.gives === 'mint' ? `mint:${o.currency ?? m.currencyKey}` : o.pool))).size;
|
|
1124
1308
|
const callback = 3 + named + (sources ? 3 + 2 * sources : 0);
|
|
1125
1309
|
if (callback > LIMITS.callbackAccounts) refuse('E-ODDS', `${where}.outcomes`, `${label(b)}’s roll would hand the oracle ${callback} accounts; its answer carries at most ${LIMITS.callbackAccounts} (TooManyAccounts).`, 'Pay prizes from fewer pools, or have fewer outcomes.');
|
|
1126
1310
|
}
|
|
1127
1311
|
// The player's purchase must be sendable, with the compute budget their wallet adds: js/play sends one
|
|
1128
1312
|
// too big for a legacy transaction as v0 with a lookup table, which locks the table and its program too.
|
|
1129
1313
|
if (b.active !== false && !refusals.some((x) => x.where.startsWith(where))) {
|
|
1130
|
-
|
|
1131
|
-
const
|
|
1314
|
+
// A fixed pack too big to buy in one legacy transaction is opened instead (js/play): the oracle answers it.
|
|
1315
|
+
const bought = boxBuys(b, m) ? buySize(b, m, { detail: true }) : null;
|
|
1316
|
+
const buys = Boolean(bought) && (drawsOf(b) === 1 || (bought.bytes <= MAX_TRANSACTION_BYTES && bought.locks <= ACCOUNT_LOCKS));
|
|
1317
|
+
const { bytes, locks } = buys ? bought : openBoxSize(b, m, { detail: true });
|
|
1318
|
+
const verb = buys ? 'buy' : 'open';
|
|
1132
1319
|
if (bytes > MAX_TRANSACTION_BYTES && locks + TABLE_LOCKS > ACCOUNT_LOCKS)
|
|
1133
|
-
refuse('E-TX-SIZE', where, `${
|
|
1320
|
+
refuse('E-TX-SIZE', where, `${buys ? 'Buying' : 'Opening'} ${label(b)} would take about ${bytes} bytes and ${locks} accounts, ${locks + TABLE_LOCKS} as a v0 transaction with a lookup table: more than the ${ACCOUNT_LOCKS} a transaction can lock, so no player could ${verb} it.`, 'Give it fewer outcomes that name an item or a pool, pay its prizes (and those of a box it rolls) from fewer pools, or drop its per-wallet limit.');
|
|
1134
1321
|
else if (bytes > MAX_TRANSACTION_BYTES)
|
|
1135
|
-
warn('W-TX-V0', where, `${
|
|
1322
|
+
warn('W-TX-V0', where, `${buys ? 'Buying' : 'Opening'} ${label(b)} takes about ${bytes} bytes with the compute budget a wallet adds, more than the ${MAX_TRANSACTION_BYTES} a legacy transaction holds: js/play sends it as a v0 transaction that loads its accounts from the project’s standing lookup table, which going live makes (one approval; going live is refused without it, E-NO-TABLE). A wallet or app that sends only legacy transactions can’t ${verb} it.`, 'To keep it a legacy transaction, give it fewer outcomes that name an item or a pool, or pay its prizes from fewer pools.');
|
|
1136
1323
|
}
|
|
1137
1324
|
for (const [i, o] of (b.odds ?? []).entries()) {
|
|
1138
1325
|
const at = `${where}.odds[${i}]`;
|
|
@@ -1141,8 +1328,8 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1141
1328
|
effect(o.add, `${at}.add`, `${label(b)}’s odds`);
|
|
1142
1329
|
}
|
|
1143
1330
|
if ((b.odds ?? []).length && outcomes.length < 2) warn('W-ODDS', `${where}.odds`, `${label(b)} has one outcome, so odds change nothing.`);
|
|
1144
|
-
// A
|
|
1145
|
-
//
|
|
1331
|
+
// A fixed prize is in stock only while its pool holds all of it (order.rs): a prize won leaves the pool for the
|
|
1332
|
+
// game's vault at the roll.
|
|
1146
1333
|
for (const z of prizes) {
|
|
1147
1334
|
if (z.amount === null) continue;
|
|
1148
1335
|
const need = seedNeeds.get(z.pool.key) ?? { pool: z.pool, amount: 0n, box: b, boxes: new Set() };
|
|
@@ -1152,9 +1339,25 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1152
1339
|
}
|
|
1153
1340
|
if (b.stockOdds && !parts.some((o) => o.gives === 'item' && m.item.get(o.item)?.supply > 0))
|
|
1154
1341
|
warn('W-ODDS', `${where}.stockOdds`, `${label(b)} has odds that shrink with stock, but none of its items has a limited supply, so nothing changes.`, 'Give its items a supply, or turn stockOdds off.');
|
|
1342
|
+
// What it does once an outcome runs out (order.rs `WhenOut`): only an open of a box that rolls reads it.
|
|
1343
|
+
if (b.whenOut !== undefined && boxBuys(b, m) && drawsOf(b) === 1)
|
|
1344
|
+
warn('W-WHEN-OUT', `${where}.whenOut`, `${label(b)} sells in one step, all of it or nothing, so "whenOut" changes nothing.`, 'Leave "whenOut" out.');
|
|
1345
|
+
if (b.whenOut === 'drawLeft' && boxRolls(b, m)) {
|
|
1346
|
+
// The rarest outcome that gives something, against the likeliest outcome that can run out.
|
|
1347
|
+
const w = weights(b);
|
|
1348
|
+
const total = w.reduce((a, x) => a + x, 0) || 1;
|
|
1349
|
+
const runsOut = (o) => outcomeParts(o).some((x) => x.gives === 'pay' || x.gives === 'stash' || x.gives === 'nft' || (x.gives === 'item' && Number(m.item.get(x.item)?.supply ?? 0) > 0));
|
|
1350
|
+
const giving = outcomes.map((o, i) => i).filter((i) => outcomeParts(outcomes[i]).length);
|
|
1351
|
+
const rare = giving.reduce((r, i) => (r === -1 || w[i] < w[r] ? i : r), -1);
|
|
1352
|
+
const common = outcomes.map((o, i) => i).filter((i) => i !== rare && runsOut(outcomes[i]) && w[i] > w[rare]).reduce((c, i) => (c === -1 || w[i] > w[c] ? i : c), -1);
|
|
1353
|
+
if (rare >= 0 && common >= 0) {
|
|
1354
|
+
const pct = (x) => Math.round(x * 10_000) / 100;
|
|
1355
|
+
warn('W-WHEN-OUT', `${where}.whenOut`, `${label(b)} draws among what is left once an outcome runs out ("whenOut": "drawLeft"): once outcome ${common} runs out, outcome ${rare}’s chance goes from ${pct(w[rare] / total)}% to ${pct(w[rare] / (total - w[common]))}%. Anyone who reads the stock can buy until outcome ${common} is gone and then open for outcome ${rare} at better odds.`, 'Keep "stopSales" (the default) when a rare outcome matters: the box stops selling instead, and every roll stays on the odds written.');
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1155
1358
|
}
|
|
1156
|
-
// A
|
|
1157
|
-
//
|
|
1359
|
+
// A fixed prize a pool can't cover is out of stock (order.rs): a draw that lands on it is not made and gives back its
|
|
1360
|
+
// worth, a box with nothing else in stock is refused (SoldOut), and a sale in one step is refused (PrizeNotCovered).
|
|
1158
1361
|
for (const { pool: p, amount: most, box, boxes } of seedNeeds.values()) {
|
|
1159
1362
|
const tok = m.tokens.get(p.currency);
|
|
1160
1363
|
const sym = tok?.symbol ?? p.currency;
|
|
@@ -1163,20 +1366,23 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1163
1366
|
if (seed === null || seed >= most) continue;
|
|
1164
1367
|
const amount = fromUnits(most, tok?.decimals ?? 0);
|
|
1165
1368
|
const others = [...boxes].filter((x) => x !== box).map(label);
|
|
1166
|
-
|
|
1369
|
+
// A box that stops selling when an outcome is out (the default) needs the prize for this purchase's draws and every
|
|
1370
|
+
// draw still waiting for the oracle before it sells at all.
|
|
1371
|
+
const stopped = [...boxes].filter((x) => x.active !== false && x.whenOut !== 'drawLeft' && boxRolls(x, m));
|
|
1372
|
+
const stops = stopped.length ? ` ${stopped.map(label).join(', ')} stop${stopped.length === 1 ? 's' : ''} selling while an outcome is out ("whenOut": "stopSales"): ${stopped.length === 1 ? 'it isn’t' : 'they aren’t'} sold at all while the pool holds less than the prize for that purchase’s draws and every draw still waiting for the oracle.` : '';
|
|
1373
|
+
warn('W-PRIZE-SEED', `economy.pools.${p.key}.seed`, `“${p.key}” starts with ${fromUnits(seed, tok?.decimals ?? 0)} ${sym}, but a win in ${label(box)} pays ${amount} ${sym}. A fixed prize its pool can’t cover is out of stock: the odds stay as written, so a roll that lands on it before the pool holds ${amount} ${sym} makes no draw and gives back that draw’s worth, no other outcome taking its place (a box with nothing else in stock is refused, SoldOut), and so again whenever a run of wins takes the pool below that${others.length ? ` (${others.join(', ')} pay${others.length === 1 ? 's' : ''} fixed prizes from it too, each out of stock the same way below its own amount)` : ''}.${stops}`, `Seed “${p.key}” with at least ${amount} ${sym} for each purchase that may wait for its roll at once; more rides out a run of wins (the simulation suggests how much). For a prize that grows with sales, use a lottery’s pot instead.`);
|
|
1167
1374
|
}
|
|
1168
1375
|
|
|
1169
|
-
// ---- a live box's roll rewritten: an
|
|
1170
|
-
//
|
|
1171
|
-
//
|
|
1172
|
-
// and refunds 0.
|
|
1376
|
+
// ---- a live box's roll rewritten: an edit of the entries, odds, stock odds, `whenOut`, the NFT it rolls on or the fates fails an
|
|
1377
|
+
// order that holds a payment, which is refunded; any other roll waiting (a prize, a mission's chest, a recipe's) rolls on
|
|
1378
|
+
// the box as edited, so an edit never loses what was spent.
|
|
1173
1379
|
const published = new Map((economy.live?.published?.boxes ?? []).map((b) => [b.key, b]));
|
|
1174
|
-
const rollOf = (b) => JSON.stringify([b.outcomes ?? [], b.odds ?? [], Boolean(b.stockOdds), b.rollsOn ?? null, b.then ?? null]);
|
|
1380
|
+
const rollOf = (b) => JSON.stringify([b.outcomes ?? [], b.odds ?? [], Boolean(b.stockOdds), b.whenOut ?? 'stopSales', b.rollsOn ?? null, b.then ?? null]);
|
|
1175
1381
|
for (const b of economy.boxes ?? []) {
|
|
1176
1382
|
const was = published.get(b.key);
|
|
1177
1383
|
if (!was || rollOf(was) === rollOf(b)) continue;
|
|
1178
1384
|
const routes = [...new Set(rolls.filter((x) => x.box === b.key).map(roller))];
|
|
1179
|
-
warn('W-LIVE-ROLLS', `economy.boxes.${b.key}`, `${label(m.box.get(b.key) ?? b)} is live, and this changes its roll (its outcomes, odds, stock odds, the NFT it rolls on or what becomes of it).
|
|
1385
|
+
warn('W-LIVE-ROLLS', `economy.boxes.${b.key}`, `${label(m.box.get(b.key) ?? b)} is live, and this changes its roll (its outcomes, odds, stock odds, what it does when an outcome runs out, the NFT it rolls on or what becomes of it). A purchase still waiting for its roll fails when the change lands, and gets its payment back${routes.length ? `; a roll won on another route (${routes.join(', ')}) holds no payment and rolls on the box as it is then` : '; a roll won on another route holds no payment and rolls on the box as it is then'}.`, 'Publish the change only once no roll of it waits (each settled or refunded), or open a new box with the new roll and point the routes at it.');
|
|
1180
1386
|
}
|
|
1181
1387
|
|
|
1182
1388
|
// ---- payments
|
|
@@ -1194,9 +1400,18 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1194
1400
|
for (const r of m.recipes) {
|
|
1195
1401
|
const where = `economy.recipes.${r.key}`;
|
|
1196
1402
|
const ins = r.in ?? [];
|
|
1403
|
+
// Each kind of line has its cap, a collectible's life adding its own needs and raises.
|
|
1404
|
+
const lines = (() => {
|
|
1405
|
+
try {
|
|
1406
|
+
return recipeArgs(r, m);
|
|
1407
|
+
} catch {
|
|
1408
|
+
return { inputs: ins, outputs: r.out ?? [], needs: r.needs ?? [], keeps: r.keeps ?? [], raises: r.raises ?? [] };
|
|
1409
|
+
}
|
|
1410
|
+
})();
|
|
1411
|
+
for (const [kind, words] of [['inputs', 'lines in'], ['outputs', 'lines out'], ['needs', 'needs'], ['keeps', 'numbers it carries over'], ['raises', 'raises']])
|
|
1412
|
+
tooMany(lines[kind].length, LIMITS.recipeLines, 'E-RECIPE', where, `${label(r)} has ${lines[kind].length} ${words}`, 'BadRecipe', 'Split it into two recipes.');
|
|
1197
1413
|
const outs = r.out ?? [];
|
|
1198
1414
|
if (!ins.length && !outs.length) warn('W-RECIPE', where, `${label(r)} takes nothing and gives nothing: crafting it only charges the player fee.`, 'Add what it takes and what it gives.');
|
|
1199
|
-
if (ins.length > LIMITS.lines || outs.length > LIMITS.lines) refuse('E-RECIPE', where, `${label(r)} has more than ${LIMITS.lines} lines on a side.`, 'Merge lines, or split it into two recipes.');
|
|
1200
1415
|
perWalletWindow(r, where);
|
|
1201
1416
|
let taken = 0;
|
|
1202
1417
|
const kept = [];
|
|
@@ -1234,7 +1449,8 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1234
1449
|
// The line names its farm (craft.rs, `Input::Staked { item, farm }`): the craft takes the piece only from its seat
|
|
1235
1450
|
// there, a farm of collectibles that holds or freezes it (a watched one could be sold while staked).
|
|
1236
1451
|
const f = line.farm === undefined ? null : m.farm.get(line.farm);
|
|
1237
|
-
|
|
1452
|
+
// The recipe's own raises on it, and its life's (uses, a cooldown: `lifeLines`).
|
|
1453
|
+
const raised = [...(r.raises ?? []).filter((x) => x.place === taken).map((x) => x.key), ...lifeLines(r, m).raises.filter((x) => x.place === taken).map((x) => x.shift.key)];
|
|
1238
1454
|
const candidates = m.farms.filter((x) => x.stakes === 'collectibles' && (x.custody ?? 'escrow') !== 'watch' && (!x.item || x.item === it.key));
|
|
1239
1455
|
if (line.farm === undefined)
|
|
1240
1456
|
refuse('E-RECIPE', `${at}.farm`, `${label(r)} takes ${label(it)} only while it is staked, but does not say in which farm: the program takes the piece only from its seat in the farm the line names.`, candidates.length ? `Add "farm": ${candidates.map((x) => `"${x.key}"`).join(' or ')}.` : `Add a farm of collectibles that takes ${label(it)} with "custody": "escrow" or "freeze", and name it in "farm".`);
|
|
@@ -1272,15 +1488,15 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1272
1488
|
if (line.on !== undefined && !b.rollsOn) refuse('E-RECIPE', `${at}.on`, `${label(r)} rolls ${label(b)} on collectible ${line.on}, but the box rolls on none.`, 'Give the box "rollsOn", or leave "on" out.');
|
|
1273
1489
|
if (line.on !== undefined && !kept[line.on]) refuse('E-RECIPE', `${at}.on`, `${label(r)} rolls on collectible ${line.on}, which it ${line.on < taken ? 'burns' : 'does not take'}.`, 'Point it at a collectible the recipe uses, renews or shows.');
|
|
1274
1490
|
const custodial = (b.outcomes ?? []).some((o) => o.then && o.then !== 'back');
|
|
1275
|
-
|
|
1276
|
-
|
|
1491
|
+
// A roll that moves numbers holds the NFT until it settles, as one that can burn or give it away takes it (craft.rs).
|
|
1492
|
+
const moves = (b.outcomes ?? []).some((o) => (o.shifts ?? []).length);
|
|
1493
|
+
if ((custodial || moves) && places[line.on] === 'show')
|
|
1494
|
+
warn('W-ROLL-CUSTODY', `${at}.on`, `${label(b)} ${custodial ? 'can burn or give away' : 'moves numbers on'} the NFT ${label(r)} shows: the craft hands it to the game until it settles, which only a Core NFT can be (a Token Metadata or compressed one is refused, NeedsCustody).`, 'Roll on one of the game’s items ("use").');
|
|
1277
1495
|
}
|
|
1278
1496
|
for (const [i, raise] of (r.raises ?? []).entries()) {
|
|
1279
1497
|
timed(raise, `${where}.raises[${i}]`, `${label(r)} raises “${raise.key}”`, 'E-RECIPE');
|
|
1280
1498
|
fourByteSeconds(raise.by, `${where}.raises[${i}].by`, { signed: true });
|
|
1281
1499
|
fourByteSeconds(raise.upTo, `${where}.raises[${i}].upTo`);
|
|
1282
|
-
const asWritten = !raise.fromNow && m.farms.some((f) => f.lockAttribute === raise.key || f.upkeepAttribute === raise.key);
|
|
1283
|
-
if (toBy(raise.by) > 0 && !asWritten && !(toSeconds(raise.upTo ?? 0) >= 1)) warn('W-RECIPE', `${where}.raises[${i}].upTo`, `${label(r)} raises “${raise.key}” with no "upTo": it is maxed out for every NFT and never raises it.`, 'Say the most it reaches.');
|
|
1284
1500
|
if (!kept[raise.place]) refuse('E-RECIPE', `${where}.raises[${i}]`, `${label(r)} raises “${raise.key}” on collectible ${raise.place}, which it ${raise.place < taken ? 'burns' : 'does not take'}: only one it keeps can be raised.`, 'Point it at a collectible the recipe uses, renews or shows.');
|
|
1285
1501
|
if (RESERVED_ATTRIBUTES.includes(raise.key)) refuse('E-RECIPE', `${where}.raises[${i}].key`, `“${raise.key}” is a number the program writes itself.`, 'Raise a key of the game’s own, such as "level" or "power".');
|
|
1286
1502
|
}
|
|
@@ -1296,7 +1512,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1296
1512
|
if (bytes > MAX_TRANSACTION_BYTES && locks + TABLE_LOCKS > ACCOUNT_LOCKS)
|
|
1297
1513
|
refuse('E-TX-SIZE', where, `Crafting ${label(r)} would take about ${bytes} bytes and ${locks} accounts, ${locks + TABLE_LOCKS} as a v0 transaction with a lookup table: more than the ${ACCOUNT_LOCKS} a transaction can lock, so no player could craft it.`, 'Take or give fewer collectibles, or split it into two recipes.');
|
|
1298
1514
|
else if (bytes > MAX_TRANSACTION_BYTES)
|
|
1299
|
-
warn('W-TX-V0', where, `Crafting ${label(r)} takes about ${bytes} bytes, more than the ${MAX_TRANSACTION_BYTES} a legacy transaction holds: js/play sends it as a v0 transaction
|
|
1515
|
+
warn('W-TX-V0', where, `Crafting ${label(r)} takes about ${bytes} bytes, more than the ${MAX_TRANSACTION_BYTES} a legacy transaction holds: js/play sends it as a v0 transaction that loads its accounts from the project’s standing lookup table, which going live makes (one approval; going live is refused without it, E-NO-TABLE). A wallet or app that sends only legacy transactions can’t craft it.`, 'To keep it a legacy transaction, take or give fewer collectibles, or split it into two recipes.');
|
|
1300
1516
|
else if (bytes > BUDGET)
|
|
1301
1517
|
warn('W-TX-SIZE', where, `Crafting ${label(r)} takes about ${bytes} of ${MAX_TRANSACTION_BYTES} bytes: it fits only if the player’s wallet adds nothing of its own.`, 'Take or give fewer collectibles if players see it fail.');
|
|
1302
1518
|
}
|
|
@@ -1333,8 +1549,13 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1333
1549
|
const byShare = s.percent !== undefined;
|
|
1334
1550
|
if (s.does !== 'issue' && byShare && s.amount !== undefined) refuse('E-SCHEDULE', where, `${label(s)} names both a percent and an amount.`, 'Keep one.');
|
|
1335
1551
|
if (s.does === 'issue') {
|
|
1336
|
-
|
|
1337
|
-
const
|
|
1552
|
+
// Into a pool, or a farm's rewards vault ("to": "farm:<key>").
|
|
1553
|
+
const intoFarm = destination(s.to ?? '').kind === 'farm';
|
|
1554
|
+
const farm = intoFarm ? m.farm.get(destination(s.to).key) : null;
|
|
1555
|
+
if (intoFarm && !farm) refuse('E-REF', `${where}.to`, `There is no farm “${destination(s.to).key}”.`, 'Name one of "farms" by its key.');
|
|
1556
|
+
const p = intoFarm ? null : s.pool ? pool(s.pool, `${where}.pool`) : refuse('E-SCHEDULE', where, `${label(s)} mints but does not say into which pool or farm.`, 'Add "pool", or "to": "farm:<key>".');
|
|
1557
|
+
const tok = token(s.currency ?? p?.currency ?? farm?.rewardsToken, `${where}.currency`);
|
|
1558
|
+
if (farm && tok && farm.rewardsToken !== tok.key) refuse('E-POOL-MINT', `${where}.to`, `${label(s)} mints ${tok.symbol} into ${label(farm)}, whose rewards are ${m.tokens.get(farm.rewardsToken)?.symbol ?? farm.rewardsToken}.`, 'Mint the farm’s rewards token.');
|
|
1338
1559
|
if (tok && !tok.gameMints) refuse('E-MINT-NOT-OURS', where, `${label(s)} mints ${tok.symbol}, but the project cannot mint it: only a token the project makes itself can be minted, so every run of it would fail.`, 'Fill the pool with a share of sales or a move from another pool, or use a token with source "project".');
|
|
1339
1560
|
if (p && tok && p.currency !== tok.key) refuse('E-POOL-MINT', `${where}.pool`, `${label(s)} mints ${tok.symbol} into “${p.key}”, which holds ${m.tokens.get(p.currency)?.symbol}.`, `Mint into a pool of ${tok.symbol}.`);
|
|
1340
1561
|
if (units(s.amount, tok, `${where}.amount`, `What ${label(s)} mints`) === 0n) refuse('E-SCHEDULE', `${where}.amount`, `${label(s)} mints 0.`, 'Give it an amount above 0.');
|
|
@@ -1351,7 +1572,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1351
1572
|
const d = destination(s.to ?? '');
|
|
1352
1573
|
// A wallet that is a program's address (off the curve) nobody can sign for: a run would pay where nobody can
|
|
1353
1574
|
// spend it, or pass it over (BadMove).
|
|
1354
|
-
if (d.kind === 'wallet' && isAddress(d.key) && !isOnCurve(addressBytes(d.key))) refuse('E-SCHEDULE', `${where}.to`, `${label(s)} is sealed and pays ${d.key}, the address of a program's account, not a wallet someone holds the key of: nobody could spend what it pays (BadMove).`, 'Pay the wallet of a person (its public key)
|
|
1575
|
+
if (d.kind === 'wallet' && isAddress(d.key) && !isOnCurve(addressBytes(d.key))) refuse('E-SCHEDULE', `${where}.to`, `${label(s)} is sealed and pays ${d.key}, the address of a program's account, not a wallet someone holds the key of: nobody could spend what it pays (BadMove).`, 'Pay the wallet of a person (its public key).');
|
|
1355
1576
|
// Its last run within 10 years of going live.
|
|
1356
1577
|
if (s.maxRuns > 0) {
|
|
1357
1578
|
let first = now + seconds(s.every, `${where}.every`);
|
|
@@ -1363,20 +1584,9 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1363
1584
|
if (first + seconds(s.every, `${where}.every`) * (s.maxRuns - 1) - now > MAX_SCHEDULE_SECONDS) refuse('E-SCHEDULE', `${where}.maxRuns`, `${label(s)} is sealed and its last run would come more than 10 years after go-live (BadSchedule).`, 'Fewer runs, a shorter "every", or an earlier "start".');
|
|
1364
1585
|
}
|
|
1365
1586
|
if (s.maxRuns > MAX_SEALED_RUNS) refuse('E-SCHEDULE', `${where}.maxRuns`, `${label(s)} is sealed with ${s.maxRuns} runs; a sealed schedule runs at most ${MAX_SEALED_RUNS} times (BadSchedule).`, 'Fewer runs, each larger.');
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
if (!['wallet', '
|
|
1369
|
-
// What it pays into is this spec's, and holds what it pays: a pool of its token, a lottery whose pot is in it.
|
|
1370
|
-
if (d.kind === 'pool') {
|
|
1371
|
-
const p = pool(d.key, `${where}.to`);
|
|
1372
|
-
if (p && tok && p.currency !== tok.key) refuse('E-POOL-MINT', `${where}.to`, `${label(s)} is sealed in ${tok.symbol}, but pays the pool “${p.key}”, which holds ${m.tokens.get(p.currency)?.symbol ?? p.currency}.`, `Pay a pool of ${tok.symbol}, or seal it in ${m.tokens.get(p.currency)?.symbol ?? p.currency}.`);
|
|
1373
|
-
}
|
|
1374
|
-
if (d.kind === 'lottery') {
|
|
1375
|
-
const l = m.lottery.get(d.key);
|
|
1376
|
-
const potKey = l?.currency ?? m.currencyKey;
|
|
1377
|
-
if (!l) refuse('E-REF', `${where}.to`, `There is no lottery “${d.key}”.`, 'Name one of "lotteries" by its key.');
|
|
1378
|
-
else if (tok && potKey !== tok.key) refuse('E-POOL-MINT', `${where}.to`, `${label(s)} is sealed in ${tok.symbol}, but pays the pot of ${label(l)}, which is in ${m.tokens.get(potKey)?.symbol ?? potKey}.`, `Pay a lottery whose pot is in ${tok.symbol}, or seal it in ${m.tokens.get(potKey)?.symbol ?? potKey}.`);
|
|
1379
|
-
}
|
|
1587
|
+
// A wallet, the treasury or a burn: a stream into a pool or an account would be only as safe as where it lands (the
|
|
1588
|
+
// authority moves a pool; an account's holder may close it).
|
|
1589
|
+
if (!['wallet', 'treasury', 'burn'].includes(d.kind) || !s.to) refuse('E-SCHEDULE', `${where}.to`, `${label(s)} is sealed and pays “${s.to ?? 'nowhere'}”: a sealed schedule pays a wallet, the treasury or a burn, never a pool, a pot, a farm or an account (BadMove).`, 'Write "to": "wallet:<address>"; for a stream into a pool, a pot or a farm, leave "sealed" out.');
|
|
1380
1590
|
if (tok?.wsol && d.kind === 'burn') refuse('E-WSOL-BURN', `${where}.to`, `${label(s)} burns SOL, and wrapped SOL cannot be burned.`, 'Pay it somewhere instead.');
|
|
1381
1591
|
startsFrozen(tok, `${where}.currency`, `${label(s)} (sealed)`, 'E-SCHEDULE', 'BadSchedule');
|
|
1382
1592
|
if (tok?.powers?.permanentDelegate || tok?.powers?.nonTransferable || tok?.powers?.confidentialTransfers)
|
|
@@ -1386,7 +1596,6 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1386
1596
|
if (s.fund !== undefined) units(s.fund, tok, `${where}.fund`, `${label(s)}’s fund`);
|
|
1387
1597
|
if (s.fundFrom === 'mint' && tok && !tok.gameMints) refuse('E-MINT-NOT-OURS', `${where}.fundFrom`, `${label(s)} is funded by minting ${tok.symbol}, which the project cannot mint.`, 'Fund it from the owner\'s wallet or a pool.');
|
|
1388
1598
|
if (s.fundFrom?.startsWith('pool:')) pool(s.fundFrom.slice(5), `${where}.fundFrom`);
|
|
1389
|
-
if (d.kind === 'lottery' || d.kind === 'pool') warn('W-SCHEDULE', `${where}.to`, `${label(s)} is sealed, but pays ${d.kind === 'pool' ? 'a pool, which the authority moves' : 'a lottery’s pot, which the owner may close between rounds with nothing sold'}: a stream is as safe as where it lands.`);
|
|
1390
1599
|
warn('W-SCHEDULE', where, `${label(s)} is sealed: once written it never closes, and nobody can edit or stop it. Check its terms before it goes live.`);
|
|
1391
1600
|
if (!Number(s.fund ?? 0)) warn('W-SCHEDULE', `${where}.fund`, `${label(s)} starts empty: its runs pay nothing until someone sends tokens to its vault.`, 'Give it "fund".');
|
|
1392
1601
|
if (d.kind === 'treasury') warn('W-SCHEDULE', `${where}.to`, `${label(s)} pays the treasury: the owner can change which wallet that is, so it is the owner's promise to themselves.`);
|
|
@@ -1407,7 +1616,11 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1407
1616
|
if (s.does === 'pay') {
|
|
1408
1617
|
const d = destination(s.to ?? '');
|
|
1409
1618
|
if (d.kind === 'lottery') m.lottery.has(d.key) || refuse('E-REF', `${where}.to`, `There is no lottery “${d.key}”.`, 'Name one of "lotteries" by its key.');
|
|
1410
|
-
else if (
|
|
1619
|
+
else if (d.kind === 'farm') {
|
|
1620
|
+
const f = m.farm.get(d.key);
|
|
1621
|
+
if (!f) refuse('E-REF', `${where}.to`, `There is no farm “${d.key}”.`, 'Name one of "farms" by its key.');
|
|
1622
|
+
else if (srcToken && f.rewardsToken !== srcToken.key) refuse('E-POOL-MINT', `${where}.to`, `${label(s)} pays ${srcToken.symbol} into ${label(f)}, whose rewards are ${m.tokens.get(f.rewardsToken)?.symbol ?? f.rewardsToken}.`, 'Pay it from a pool of the farm’s rewards token.');
|
|
1623
|
+
} else if (!['account', 'holder', 'wallet'].includes(d.kind) || !isAddress(d.key)) refuse('E-SCHEDULE', `${where}.to`, `${label(s)} pays “${s.to ?? 'nowhere'}”: a pay schedule pays a wallet, a token account, a collectible’s holder, a lottery’s pot or a farm’s rewards.`, 'Write "to": "wallet:<address>", "account:<token account address>", "holder:<asset address>", "lottery:<key>" or "farm:<key>".');
|
|
1411
1624
|
else if (d.kind === 'account' && gameVault(d.key, m.poolToken(sourceKey))) refuse('E-SCHEDULE', `${where}.to`, `${label(s)} pays the game’s own vault, where open orders, prizes set aside and bids wait: the program refuses it.`, 'Pay a pool or a wallet instead.');
|
|
1412
1625
|
warn('W-SCHEDULE-PAY', where, `${label(s)} pays out of “${sourceKey}” on a timer, but the pool stays the game’s: the authority can still spend it, and the owner can rewrite or stop the schedule. It is a promise the project keeps, not a vesting contract the recipient holds.`, 'Make it "sealed" (with "fund" and "maxRuns") for a vesting nobody can take back.');
|
|
1413
1626
|
}
|
|
@@ -1435,6 +1648,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1435
1648
|
} else if (l.price === undefined || units(l.price, ticket, `${where}.price`, `${label(l)}’s ticket`) === 0n)
|
|
1436
1649
|
warn('W-LOTTERY', `${where}.price`, `${label(l)}’s tickets are free: a buyer pays only the player fee (${fromUnits(FEES.player, 9)} SOL)${l.perWallet ? '' : ', and with no "perWallet" one wallet can hold most of the tickets'}. Its pot is only what is paid into it.`, 'Give a ticket a "price" if it should cost something, or set "perWallet".');
|
|
1437
1650
|
units(l.seed, potToken, `${where}.seed`, `${label(l)}’s seed`);
|
|
1651
|
+
if (l.roundRent !== undefined) units(l.roundRent, { decimals: 9, symbol: 'SOL' }, `${where}.roundRent`, `${label(l)}’s round rent`);
|
|
1438
1652
|
if (seconds(l.every, `${where}.every`) <= 0 && !l.firstDraw) warn('W-LOTTERY', `${where}.every`, `${label(l)} draws once (no "every"), and the draw is due at go-live.`, 'Give it a "firstDraw" (like "publish+1d"), or an "every" to draw again and again.');
|
|
1439
1653
|
// A round whose draw is more than five years off sells nothing (lottery.rs).
|
|
1440
1654
|
if (seconds(l.every, `${where}.every`) > MAX_CONTEST_SECONDS) refuse('E-LOTTERY', `${where}.every`, `${label(l)} draws every ${l.every}: a draw more than 5 years off sells no ticket (SalesClosed).`, 'Draw within 5 years.');
|
|
@@ -1446,19 +1660,47 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1446
1660
|
}
|
|
1447
1661
|
// A place takes a share of the pot, a roll of a box for its winner, or both (lottery.rs Place).
|
|
1448
1662
|
const placed = (l.places ?? [100]).map(placeOf);
|
|
1663
|
+
tooMany(placed.length, LIMITS.lotteryRuns, 'E-LOTTERY', `${where}.places`, `${label(l)} has ${placed.length} runs of places`, 'BadLottery', 'Put alike places into one run with "times".');
|
|
1449
1664
|
const places = placed.map((p, i) => percent(p.percent, `${where}.places[${i}]${typeof (l.places ?? [])[i] === 'object' ? '.percent' : ''}`));
|
|
1450
1665
|
for (const [i, p] of placed.entries()) {
|
|
1451
1666
|
const raw = (l.places ?? [])[i];
|
|
1452
1667
|
if (!places[i] && !p.prize) refuse('E-LOTTERY', `${where}.places[${i}]`, `${label(l)}’s place ${i + 1} wins nothing: no share of the pot and no prize (BadLottery).`, 'Give it a "percent" above 0, a "prize", or remove it.');
|
|
1453
1668
|
if (typeof raw === 'object' && raw?.prize) prizeCheck(raw.prize, `${where}.places[${i}].prize`, `${label(l)}’s place ${i + 1}`, 'E-LOTTERY');
|
|
1454
|
-
|
|
1669
|
+
// A run holds places alike: a named NFT or a roll is one place's.
|
|
1670
|
+
if (p.times > 1 && ((p.prize?.gives === 'nft') || p.prize?.gives === 'roll'))
|
|
1671
|
+
refuse('E-LOTTERY', `${where}.places[${i}].times`, `${label(l)}’s place ${i + 1} repeats ${p.times} times a prize one place gives (a named NFT, a roll) (BadLottery).`, 'Give it to one place, or make it a prize places can share alike (tokens, an item, an NFT drawn from the lottery’s stash).');
|
|
1672
|
+
// A run of places given one by one (anything but tokens: a collectible, a compressed NFT, a raffled NFT) holds at
|
|
1673
|
+
// most MAX_RUN_PLACES places: a pull walks the run from its first place (BadLottery).
|
|
1674
|
+
if (p.prize && p.prize.gives !== 'tokens' && p.times > MAX_RUN_PLACES)
|
|
1675
|
+
refuse('E-LOTTERY', `${where}.places[${i}].times`, `${label(l)}’s place ${i + 1} gives ${prizeWords(p.prize, m)} to ${p.times.toLocaleString('en-US')} places in a row: a run of places given one by one holds at most ${MAX_RUN_PLACES.toLocaleString('en-US')}, since each pull walks it from its first place (BadLottery).`, `Split it into runs of ${MAX_RUN_PLACES.toLocaleString('en-US')} places or fewer, one after another.`);
|
|
1676
|
+
// A run's token prize in all, each place's amount times its places, fits a u64 (BadLottery).
|
|
1677
|
+
const t = p.prize?.amount !== undefined ? prizeToken(p.prize, m) : null;
|
|
1678
|
+
let each = 0n;
|
|
1679
|
+
try {
|
|
1680
|
+
each = t ? toUnits(p.prize.amount, t.decimals ?? 0) : 0n;
|
|
1681
|
+
} catch {
|
|
1682
|
+
// An amount that doesn't read is refused where the prize is checked.
|
|
1683
|
+
}
|
|
1684
|
+
if (each * BigInt(p.times ?? 1) > U64_MAX)
|
|
1685
|
+
refuse('E-LOTTERY', `${where}.places[${i}].times`, `${label(l)}’s place ${i + 1} gives ${p.prize.amount} ${p.times} times: more tokens in all than the program can count (BadLottery).`, 'Fewer places in the run, or a smaller prize.');
|
|
1686
|
+
}
|
|
1687
|
+
// A raffle of many NFTs: its places draw from the lottery's own stash, which holds at least as many; it draws once,
|
|
1688
|
+
// and never also names NFTs of it (lottery.rs).
|
|
1689
|
+
const raffled = placed.filter((p) => p.prize?.gives === 'raffled').reduce((n, p) => n + p.times, 0);
|
|
1690
|
+
if (raffled && l.every) refuse('E-LOTTERY', `${where}.every`, `${label(l)} draws NFTs from its own stash, so it draws once (BadLottery).`, 'Leave "every" out.');
|
|
1691
|
+
if (raffled && placed.some((p) => p.prize?.held)) refuse('E-LOTTERY', `${where}.places`, `${label(l)} both names NFTs of its own stash and draws from it: a draw could take a named one (BadLottery).`, 'Raffle them all at random, or name each one.');
|
|
1692
|
+
if (raffled > (l.nfts ?? []).length) refuse('E-LOTTERY', `${where}.nfts`, `${label(l)} draws ${raffled} NFTs from its own stash, which holds ${(l.nfts ?? []).length} (NotInStash).`, 'List at least as many in "nfts".');
|
|
1455
1693
|
// A raffle's NFT: one place each, and a lottery that holds one draws once (lottery.rs).
|
|
1456
1694
|
const held = placed.map((p) => (p.prize?.held ? p.prize.nft : null)).filter(Boolean);
|
|
1457
1695
|
if (held.length && l.every) refuse('E-LOTTERY', `${where}.every`, `${label(l)} holds an NFT as a prize, so it draws once: a next round would sell a prize already won (BadLottery).`, 'Leave "every" out.');
|
|
1458
1696
|
if (new Set(held).size < held.length) refuse('E-LOTTERY', `${where}.places`, `${label(l)} gives the same NFT on two places: the second could never be given (BadLottery).`, 'Name each NFT once.');
|
|
1459
|
-
|
|
1697
|
+
// One pull takes every NFT its places win out of the stash, in place order (lottery.rs `assign_fits`).
|
|
1698
|
+
const rows = (l.nfts ?? []).length + held.length;
|
|
1699
|
+
const draw = 6_000 + 250 * Math.ceil(rows / 4_096);
|
|
1700
|
+
if (raffled * draw + held.length * 15 * rows > 1_300_000) refuse('E-LOTTERY', `${where}.places`, `${label(l)} draws ${raffled} and names ${held.length} NFTs of its own stash of ${rows}: one pull takes them all out, and that would pass what one transaction can compute (BadLottery).`, 'Fewer raffled places (about 200 at most in a small stash), or a smaller stash.');
|
|
1701
|
+
if (held.length) warn('W-RAFFLE', `${where}.places`, `${label(l)} holds ${held.length === 1 ? 'its NFT' : 'its NFTs'} in its own stash: nobody, the owner included, can take ${held.length === 1 ? 'it' : 'them'} out while a place names ${held.length === 1 ? 'it' : 'them'}, and the winner pulls ${held.length === 1 ? 'it' : 'them'} after the draw, in any order with the money. If the NFT's collection lets someone freeze or move it (a permanent freeze, transfer or burn delegate), they could stop the prize: anyone may then make it a hold for its winner, so the rest of the round pays out.`);
|
|
1460
1702
|
if (l.minSold && l.perWallet && !l.every && l.minSold > l.perWallet * 1000) warn('W-LOTTERY', `${where}.minSold`, `${label(l)} needs ${l.minSold} tickets sold, and each wallet may hold ${l.perWallet}: it takes at least ${Math.ceil(l.minSold / l.perWallet)} wallets.`);
|
|
1461
|
-
const total = places.reduce((a, b) => a + b, 0);
|
|
1703
|
+
const total = places.reduce((a, b, i) => a + b * placed[i].times, 0);
|
|
1462
1704
|
if (total > 10_000) refuse('E-LOTTERY', `${where}.places`, `${label(l)}’s places add up to ${total / 100}% of the pot, more than all of it.`, 'Make them add up to 100% or less.');
|
|
1463
1705
|
else if (total < 10_000) warn('W-LOTTERY', `${where}.places`, `${label(l)}’s places add up to ${total / 100}%: the other ${(10_000 - total) / 100}% of each pot stays in it for the next round, as do places nobody won.`);
|
|
1464
1706
|
const ticketSplit = lotterySplit(l);
|
|
@@ -1483,30 +1725,37 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1483
1725
|
// A farm that shares what arrives releases it to the seats over its release span (its longest lock, a day at
|
|
1484
1726
|
// least), by weight as it is released: nobody takes at once what landed before them. A seat alone still
|
|
1485
1727
|
// earns all that is released while it is alone, however small it is.
|
|
1486
|
-
|
|
1487
|
-
if (
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
const
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
if (
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1728
|
+
// Its rewards land in a vault of its own (`["rewards", farm]`): `rewards` names their token, never a pool.
|
|
1729
|
+
if (f.rewards && !m.tokens.has(f.rewards)) {
|
|
1730
|
+
if (m.pool.has(f.rewards)) refuse('E-FARM', `${where}.rewards`, `${label(f)} names the pool “${f.rewards}” for its rewards: a farm’s rewards land in a vault of its own now, and "rewards" names their token.`, `Write "rewards": "${m.pool.get(f.rewards).currency}", and send the farm what it shares with "farm:${f.key}" (in a split, or a schedule that pays it) or "seed".`);
|
|
1731
|
+
else token(f.rewards, `${where}.rewards`);
|
|
1732
|
+
}
|
|
1733
|
+
const rewardsToken = m.tokens.get(f.rewardsToken);
|
|
1734
|
+
const fills = farmFillers(m).filter((x) => x.farm === f.key);
|
|
1735
|
+
if (f.seed !== undefined) units(f.seed, rewardsToken, `${where}.seed`, `The seed of ${label(f)}`);
|
|
1736
|
+
if (!f.rate?.minted && Number(f.rate?.perDay ?? 1) > 0 && !fills.length)
|
|
1737
|
+
warn('W-FARM-DRY', `${where}`, `${label(f)} ${f.rate ? 'pays its rate from what its rewards vault holds free' : 'shares what lands in its rewards vault'}, and nothing in this spec sends it any: its seats earn nothing until someone does.`, `Seed it ("seed"), send it a share of a sale ("farm:${f.key}" in a split), or pay it with a schedule ("to": "farm:${f.key}").`);
|
|
1738
|
+
// A farm that shares what arrives releases it to the seats over its release span (its longest lock, a day at
|
|
1739
|
+
// least), by weight as it is released: nobody takes at once what landed before them. A seat alone still
|
|
1740
|
+
// earns all that is released while it is alone, however small it is.
|
|
1741
|
+
if (!f.rate && fills.length) warn('W-FARM-FIRST', `${where}.rewards`, `${label(f)} shares what lands in its rewards vault (${fills.map((x) => x.by).join(', ')}), released over ${releaseWords(f)} as seats stay: whoever stakes first is credited all that is released while they are the only one in, even with 1 unit.`, 'Have the owner hold a small seat from the start, or tell stakers early ones share the first rewards among few.');
|
|
1742
|
+
// A rewards mint that keeps a fee on transfers: what lands in the vault and what each seat is paid arrive less it.
|
|
1743
|
+
const feePercent = Number(rewardsToken?.powers?.transferFeePercent ?? 0);
|
|
1744
|
+
if (toBps(feePercent) > 0 && !f.rate?.minted)
|
|
1745
|
+
warn('W-FARM-FEE', `${where}.rewards`, `${label(f)}’s rewards keep ${feePercent}% of every transfer: what lands in its vault arrives less that, and so does what each seat is paid.`, 'Use a rewards token without a fee, or say so to stakers.');
|
|
1501
1746
|
if (!ofCollectibles) {
|
|
1502
1747
|
const tok = token(f.stakes, `${where}.stakes`);
|
|
1503
1748
|
if (tok?.wsol) warn('W-FARM', `${where}.stakes`, `${label(f)} stakes SOL: players stake wrapped SOL, which their wallet makes for them.`);
|
|
1504
1749
|
if (f.item) refuse('E-FARM', `${where}.item`, `${label(f)} stakes a token, so it takes no item.`, 'Remove "item", or make it a farm of collectibles ("stakes": "collectibles").');
|
|
1505
1750
|
if (f.custody === 'watch') refuse('E-FARM', `${where}.custody`, `${label(f)} stakes a token: its boosters can’t be left free and watched, or one sold could keep boosting.`, 'Use "custody": "escrow" or "freeze", or make it a farm of collectibles.');
|
|
1506
1751
|
}
|
|
1507
|
-
// Early exits
|
|
1752
|
+
// Early exits (farm.rs): a fee leaves to a burn, a pool of its token or the farm's own vault, checked once at open.
|
|
1508
1753
|
const exits = (f.tiers ?? []).some((t) => Number(t.exitFee ?? 0) > 0);
|
|
1509
1754
|
const sinkOk = (to, at, tokenKey, what) => {
|
|
1755
|
+
if (to === 'rewards') {
|
|
1756
|
+
if (tokenKey !== f.rewardsToken) refuse('E-FARM', at, `${label(f)} sends ${what} into its own rewards vault, but it stakes ${m.tokens.get(tokenKey)?.symbol ?? tokenKey} and its rewards are ${m.tokens.get(f.rewardsToken)?.symbol ?? f.rewardsToken} (WrongMint).`, 'Send it to a burn or a pool, or make its rewards its staked token.');
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1510
1759
|
if (to === 'burn') {
|
|
1511
1760
|
if (m.tokens.get(tokenKey)?.wsol) refuse('E-WSOL-BURN', at, `${label(f)} burns ${what} in SOL, and wrapped SOL cannot be burned: every exit or harvest that owes it would fail.`, 'Send it to a pool instead.');
|
|
1512
1761
|
return;
|
|
@@ -1515,37 +1764,35 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1515
1764
|
if (p && tokenKey && p.currency !== tokenKey) refuse('E-POOL-MINT', at, `${label(f)} sends ${what} to “${p.key}”, which holds ${m.tokens.get(p.currency)?.symbol ?? p.currency}, not ${m.tokens.get(tokenKey)?.symbol ?? tokenKey} (WrongMint).`, `Send it to a pool of ${m.tokens.get(tokenKey)?.symbol ?? tokenKey}.`);
|
|
1516
1765
|
};
|
|
1517
1766
|
if (ofCollectibles && exits) refuse('E-FARM', `${where}.tiers`, `${label(f)} stakes collectibles: there is no stake to take an exit fee from (BadFarm). A collectible leaves early by a recipe that lowers its lock.`, 'Remove "exitFee", and give the farm a "lockAttribute" and a recipe that lowers it.');
|
|
1518
|
-
if (ofCollectibles && f.
|
|
1519
|
-
else fourByteSeconds(f.unbond, `${where}.unbond`);
|
|
1520
|
-
if (!ofCollectibles && exits && !f.exitFeeTo) refuse('E-FARM', `${where}.exitFeeTo`, `${label(f)} has an early-exit fee but does not say where it goes (BadFarm).`, 'Add "exitFeeTo": "burn" or "pool:<key>".');
|
|
1767
|
+
if (!ofCollectibles && exits && !f.exitFeeTo) refuse('E-FARM', `${where}.exitFeeTo`, `${label(f)} has an early-exit fee but does not say where it goes (BadFarm).`, 'Add "exitFeeTo": "burn", "rewards" or "pool:<key>".');
|
|
1521
1768
|
if (!ofCollectibles && f.exitFeeTo) sinkOk(f.exitFeeTo, `${where}.exitFeeTo`, f.stakes, 'its exit fees');
|
|
1522
1769
|
(f.tiers ?? []).forEach((t, i) => percent(t.exitFee, `${where}.tiers[${i}].exitFee`));
|
|
1523
|
-
if (f
|
|
1524
|
-
|
|
1525
|
-
percent(f.toll.percent, `${where}.toll.percent`);
|
|
1526
|
-
}
|
|
1527
|
-
if (!ofCollectibles && exits) warn('W-FARM', `${where}.tiers`, `${label(f)}’s tiers with an early exit keep their rewards until the lock has run (no harvest or compound before), and leaving early gives up what the seat is owed as well as the fee: ${f.rate ? (f.rate.minted ? 'burned' : `sent back to “${f.rewards}”`) + ' in the same move, never to the other seats' : `back to “${f.rewards}”, released again with the rest`}.`);
|
|
1528
|
-
if (f.exitFeeTo === `pool:${f.rewards}`) warn('W-FARM', `${where}.exitFeeTo`, `${label(f)} sends early-exit fees to its own rewards pool: the stakers who stay share them.`);
|
|
1529
|
-
if (f.unpaid) warn('W-FARM', `${where}.rewards`, `${label(f)} pays nothing: its rewards pool “${f.rewards}” is opened empty, for staking only to qualify (a stake gate reads it).`);
|
|
1770
|
+
if (!ofCollectibles && exits) warn('W-FARM', `${where}.tiers`, `${label(f)}’s tiers with an early exit may leave before the lock for the fee, keeping the rewards they were credited (a tier's extra weight can be bought for its exit fee): pick the fee with that in mind.`);
|
|
1771
|
+
if (f.exitFeeTo === 'rewards') warn('W-FARM', `${where}.exitFeeTo`, `${label(f)} sends early-exit fees into its own rewards vault: the stakers who stay share them.`);
|
|
1530
1772
|
if (ofCollectibles && f.item) {
|
|
1531
1773
|
const it = item(f.item, `${where}.item`);
|
|
1532
1774
|
if (it && it.kind === 'normal') refuse('E-FARM', `${where}.item`, `${label(f)} takes ${label(it)}, a normal item: only collectibles can be staked.`, `Make ${label(it)} a collectible.`);
|
|
1533
1775
|
if (it?.soulbound && (f.custody ?? 'escrow') === 'escrow') refuse('E-FARM', `${where}.custody`, `${label(f)} holds collectibles in escrow, and ${label(it)} is soulbound: it can’t move to the game.`, 'Use "custody": "freeze", or a collectible that is not soulbound.');
|
|
1534
1776
|
}
|
|
1535
1777
|
if (f.custody === 'watch' && listOf(f.growth).some((g) => g.streak)) refuse('E-FARM', `${where}.growth`, `${label(f)} watches NFTs left free in their wallets and counts a streak: one leaves by being moved, with nothing to set its streak back to 0, so the program refuses it (BadFarm).`, 'Use "custody": "escrow" or "freeze" for a streak, or drop "streak".');
|
|
1536
|
-
if (f.custody === 'watch') warn('W-FARM-CUSTODY', `${where}.custody`, `${label(f)} watches NFTs left free in their wallets: one sold while staked stops earning for its staker (what its seat was owed ${f.rate ? (f.rate.minted ? 'is
|
|
1778
|
+
if (f.custody === 'watch') warn('W-FARM-CUSTODY', `${where}.custody`, `${label(f)} watches NFTs left free in their wallets: one sold while staked stops earning for its staker (what its seat was owed ${f.rate ? (f.rate.minted ? 'is never minted' : 'stays free in its vault') + ', never to the other seats' : 'goes back to its vault, released again with the rest'}), and its new holder can take the seat over, paying the last staker back the seat’s rent.`);
|
|
1537
1779
|
else if (f.custody === 'freeze') warn('W-FARM-CUSTODY', `${where}.custody`, `${label(f)} locks NFTs in their wallets (a Core freeze, Token Metadata’s lock, a compressed NFT’s freeze), with the game the only key that unlocks them. For ordinary Core assets, issuer metadata or plugin growth can make unfreezing fail; a soulbound asset needs no extra freeze. This also applies to token-seat boosters.`, 'Use escrow for transferable collectibles, or retain freeze only with a trusted issuer and controlled metadata changes.');
|
|
1538
1780
|
else if (ofCollectibles || (f.boosts ?? []).length) warn('W-FARM-CUSTODY', `${where}.custody`, `${label(f)} escrows collectibles. This avoids Core’s unfreeze operation, but issuer-controlled transfer rules can still block return. Soulbound collectibles require explicit freeze custody.`);
|
|
1539
|
-
pool(f.rewards, `${where}.rewards`);
|
|
1540
1781
|
const tiers = f.tiers ?? [{ lock: 0, multiplier: 1 }];
|
|
1541
1782
|
if (tiers.length > LIMITS.tiers) refuse('E-FARM', `${where}.tiers`, `${label(f)} has ${tiers.length} lock tiers; a farm has at most ${LIMITS.tiers}.`, 'Merge tiers.');
|
|
1783
|
+
tooMany((f.boosts ?? []).length, LIMITS.farmRules, 'E-FARM', `${where}.boosts`, `${label(f)} has ${(f.boosts ?? []).length} boosts`, 'BadFarm', 'Keep the boosts that matter.');
|
|
1784
|
+
tooMany(listOf(f.gate).length, LIMITS.farmRules, 'E-FARM', `${where}.gate`, `${label(f)}’s gate has ${listOf(f.gate).length} rules`, 'BadFarm', 'Keep the rules that matter.');
|
|
1785
|
+
tooMany(listOf(f.growth).length, LIMITS.farmRules, 'E-FARM', `${where}.growth`, `${label(f)} has ${listOf(f.growth).length} growths`, 'BadFarm', 'Keep the numbers that matter.');
|
|
1786
|
+
for (const [i, g] of listOf(f.growth).entries()) tooMany((g.levels ?? []).length, LIMITS.growthSteps, 'E-FARM', `${where}.growth[${i}].levels`, `${label(f)}’s growth of “${g.attribute ?? 'xp'}” has ${(g.levels ?? []).length} levels`, 'BadFarm', 'Use fewer levels.');
|
|
1542
1787
|
for (const [i, t] of tiers.entries()) {
|
|
1543
1788
|
fourByteSeconds(t.lock, `${where}.tiers[${i}].lock`);
|
|
1789
|
+
// A prize given on leaving needs a lock, or it would be given again and again (BadFarm).
|
|
1790
|
+
if (t.prize && !(toSeconds(t.lock ?? 0) > 0)) refuse('E-FARM', `${where}.tiers[${i}].lock`, `${label(f)}’s tier ${i} gives a prize with no lock: stakes could come and go, and take it again and again (BadFarm).`, 'Give the tier a "lock" above 0.');
|
|
1791
|
+
if (t.earnsFor !== undefined) fourByteSeconds(t.earnsFor, `${where}.tiers[${i}].earnsFor`);
|
|
1544
1792
|
const bps = Math.round((t.multiplier ?? 1) * 10_000);
|
|
1545
1793
|
if (bps <= 0 || bps > LIMITS.maxTierBps) refuse('E-FARM', `${where}.tiers[${i}].multiplier`, `${label(f)}’s tier ${i} multiplies by ${t.multiplier}: a tier is above 0 and at most x11.`, 'Use a multiplier above 0, up to 11.');
|
|
1546
1794
|
else if (bps < 10_000) warn('W-FARM', `${where}.tiers[${i}].multiplier`, `${label(f)}’s tier ${i} weighs x${t.multiplier}, less than what it stakes: a flexible tier that earns less.`);
|
|
1547
1795
|
}
|
|
1548
|
-
if ((f.boosts ?? []).length > LIMITS.boosts) refuse('E-FARM', `${where}.boosts`, `${label(f)} has ${f.boosts.length} boosts; a farm has at most ${LIMITS.boosts}.`, 'Merge boosts.');
|
|
1549
1796
|
for (const [i, b] of (f.boosts ?? []).entries()) {
|
|
1550
1797
|
rule(b.when, `${where}.boosts[${i}].when`, `${label(f)}’s boost`);
|
|
1551
1798
|
effect(b.add, `${where}.boosts[${i}].add`, `${label(f)}’s boost`);
|
|
@@ -1553,7 +1800,6 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1553
1800
|
boostPercent(f.maxBoostPercent, `${where}.maxBoostPercent`);
|
|
1554
1801
|
if ((f.boosts ?? []).length && !(f.maxBoostPercent > 0)) warn('W-FARM', `${where}.maxBoostPercent`, `${label(f)} has boosts but no "maxBoostPercent", so they add nothing (the cap is 0).`, 'Set "maxBoostPercent", for example 100 (at most double).');
|
|
1555
1802
|
// A fixed rate (`rate`), growth (`growth`) and missions (tiers' `prize`).
|
|
1556
|
-
const rewardsToken = m.poolToken(f.rewards);
|
|
1557
1803
|
const growths = listOf(f.growth);
|
|
1558
1804
|
const prized = tiers.some((t) => t.prize);
|
|
1559
1805
|
const rolls = tiers.some((t) => t.roll);
|
|
@@ -1561,12 +1807,14 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1561
1807
|
const perDay = units(f.rate.perDay, rewardsToken, `${where}.rate.perDay`, `${label(f)}’s rate`);
|
|
1562
1808
|
if (!(Number(f.rate.perDay) > 0)) {
|
|
1563
1809
|
if (!growths.length && !prized && !rolls) warn('W-FARM', `${where}.rate.perDay`, `${label(f)}’s fixed rate pays nothing a day, and it grows nothing, gives no prize and rolls no mission: staking there only counts (a stake gate can read it).`, 'Set "perDay" above 0, or give it "growth", a mission prize or a mission roll.');
|
|
1564
|
-
} else if (!ofCollectibles && perDay !== null && (perDay * 1_000_000n) / 10n ** BigInt(m.tokens.get(f.stakes)?.decimals ?? 0) === 0n) warn('W-FARM', `${where}.rate.perDay`, `${label(f)}’s rate of ${f.rate.perDay} a day is less than one base unit for each 1,000,000 base units staked.`, 'Raise "perDay".');
|
|
1565
|
-
if (f.rate.minted && rewardsToken && !rewardsToken.gameMints) refuse('E-MINT-NOT-OURS', `${where}.rate.minted`, `${label(f)} mints its rewards, but the project cannot mint ${rewardsToken.symbol}.`, 'Pay the rate from
|
|
1566
|
-
if (!f.rate.minted) warn('W-FARM-RATE', `${where}.rate`, `${label(f)} pays its fixed rate from
|
|
1810
|
+
} else if (!ofCollectibles && !f.rate.shared && perDay !== null && (perDay * 1_000_000n) / 10n ** BigInt(m.tokens.get(f.stakes)?.decimals ?? 0) === 0n) warn('W-FARM', `${where}.rate.perDay`, `${label(f)}’s rate of ${f.rate.perDay} a day is less than one base unit for each 1,000,000 base units staked.`, 'Raise "perDay".');
|
|
1811
|
+
if (f.rate.minted && rewardsToken && !rewardsToken.gameMints) refuse('E-MINT-NOT-OURS', `${where}.rate.minted`, `${label(f)} mints its rewards, but the project cannot mint ${rewardsToken.symbol}.`, 'Pay the rate from its rewards vault ("minted": false), or reward in a token with source "project".');
|
|
1812
|
+
if (!f.rate.minted) warn('W-FARM-RATE', `${where}.rate`, `${label(f)} pays its fixed rate from what its rewards vault holds free while it lasts: what it can’t cover is not owed later, and every seat earns less once it runs dry.`, `Keep it funded ("farm:${f.key}" in a split or a schedule), or mint the rewards.`);
|
|
1567
1813
|
else rateRoom(f, warn, m);
|
|
1814
|
+
if (f.rate.halveEvery !== undefined) fourByteSeconds(f.rate.halveEvery, `${where}.rate.halveEvery`);
|
|
1815
|
+
if (f.rate.shared) warn('W-FARM-RATE', `${where}.rate.shared`, `${label(f)}’s rate of ${f.rate.perDay} a day is the whole farm’s, shared by weight while anything is staked${f.rate.halveEvery ? `, halving every ${f.rate.halveEvery} from when it opens` : ''}: an emission budget, so each seat’s share falls as more stake.`);
|
|
1816
|
+
else if (f.rate.halveEvery) warn('W-FARM-RATE', `${where}.rate.halveEvery`, `${label(f)}’s rate halves every ${f.rate.halveEvery} from when it opens, for every seat.`);
|
|
1568
1817
|
}
|
|
1569
|
-
if (growths.length > LIMITS.growths) refuse('E-FARM', `${where}.growth`, `${label(f)} grows ${growths.length} things; a farm grows at most ${LIMITS.growths}.`, 'Merge them.');
|
|
1570
1818
|
const names = growths.flatMap((g) => [g.attribute ?? 'xp', ...((g.levels ?? []).length ? [g.level ?? 'level'] : [])]);
|
|
1571
1819
|
if (names.some((n, i) => names.indexOf(n) !== i)) refuse('E-FARM', `${where}.growth`, `${label(f)} grows the same attribute twice (${names.join(', ')}): each growth writes names of its own.`, 'Give each growth its own "attribute" (and "level").');
|
|
1572
1820
|
for (const [i, g] of growths.entries()) {
|
|
@@ -1583,7 +1831,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1583
1831
|
for (const [i, t] of tiers.entries())
|
|
1584
1832
|
if (toSeconds(t.lock ?? 0) > 0 && toSeconds(t.lock ?? 0) >= runs) warn('W-FARM', `${where}.tiers[${i}].lock`, `${label(f)} runs ${f.runs}, and its tier ${i} locks ${t.lock}: nobody could ever take it (a seat must be free by the farm’s end, and the farm takes none at its end).`, 'Shorten the lock, or let the farm run longer.');
|
|
1585
1833
|
const end = farmEndWords(f);
|
|
1586
|
-
warn('W-FARM', `${where}.runs`, `${label(f)} runs ${f.runs} from when it goes live: it takes no stake at its end or after it (nor one at a tier whose lock would run past it), and pays and grows nothing more; seats leave and harvest what they were owed as before.${end ? ` It shares
|
|
1834
|
+
warn('W-FARM', `${where}.runs`, `${label(f)} runs ${f.runs} from when it goes live: it takes no stake at its end or after it (nor one at a tier whose lock would run past it), and pays and grows nothing more; seats leave and harvest what they were owed as before.${end ? ` It shares what lands: ${end}.` : ''}`);
|
|
1587
1835
|
}
|
|
1588
1836
|
// Missions' prizes (prize.rs): a tier's, given as a collectible that stayed its lock leaves its seat.
|
|
1589
1837
|
if (f.prize) refuse('E-FARM', `${where}.prize`, `${label(f)} names where its prizes come from: each tier names its whole prize.`, 'Move it into each tier: "prize": { "gives": "tokens", "amount": n, "from": "mint" or "pool:<key>" } (or an item, an NFT, a roll of a chest).');
|
|
@@ -1615,7 +1863,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1615
1863
|
if (f.lockAttribute !== undefined && f.lockAttribute === f.upkeepAttribute) refuse('E-FARM', `${where}.upkeepAttribute`, `${label(f)} keeps its lock and its upkeep under the same name, “${f.lockAttribute}”.`, 'Give each its own name.');
|
|
1616
1864
|
if (f.upkeepAttribute !== undefined && ofCollectibles) {
|
|
1617
1865
|
const sells = m.recipes.some((r) => (r.raises ?? []).some((x) => x.key === f.upkeepAttribute && x.fromNow && toBy(x.by) > 0));
|
|
1618
|
-
warn('W-FARM-UPKEEP', `${where}.upkeepAttribute`, `${label(f)} pays and grows a collectible only until the time under “${f.upkeepAttribute}”: one staked dry counts for nothing until it is topped up and its seat refreshed, and
|
|
1866
|
+
warn('W-FARM-UPKEEP', `${where}.upkeepAttribute`, `${label(f)} pays and grows a collectible only until the time under “${f.upkeepAttribute}”: one staked dry counts for nothing until it is topped up and its seat refreshed, and a seat earns nothing after its upkeep runs out, however late it is next touched (no refresh is needed at its end).${sells ? '' : ' Nothing in this spec sells it: only the authority (Set) could.'}`, sells ? undefined : `Add a recipe that raises "${f.upkeepAttribute}" with "fromNow": true.`);
|
|
1619
1867
|
// A known limit of the program: a recipe with a pay line waits while the project is paused or suspended
|
|
1620
1868
|
// (craft.rs), and the upkeep's clock keeps running. With every top-up paid and capped below a mission's lock, a
|
|
1621
1869
|
// switch on for longer than a player could top up ahead dries the seat, and a seat that ran dry before its lock
|
|
@@ -1631,12 +1879,11 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1631
1879
|
}
|
|
1632
1880
|
if (f.gate && !ofCollectibles) refuse('E-FARM', `${where}.gate`, `${label(f)} stakes a token: a gate only says which collectibles a farm of collectibles takes.`, 'Remove the gate, or use a boost to favour holders.');
|
|
1633
1881
|
const gates = listOf(f.gate);
|
|
1634
|
-
if (gates.length > LIMITS.gates) refuse('E-FARM', `${where}.gate`, `${label(f)}’s gate has ${gates.length} rules; a farm’s has at most ${LIMITS.gates}.`, 'Keep the rules that matter.');
|
|
1635
1882
|
for (const [i, r] of gates.entries()) rule(r, Array.isArray(f.gate) ? `${where}.gate[${i}]` : `${where}.gate`, label(f));
|
|
1636
1883
|
// No lock rule for rewards that land: a sharing farm releases each over its release span, and a fixed rate pays by
|
|
1637
1884
|
// the second, so a stake just before a reward lands and gone right after takes only what was released meanwhile.
|
|
1638
1885
|
// A farm whose rewards are a share of what players pay hands holders the project's revenue.
|
|
1639
|
-
const revenue =
|
|
1886
|
+
const revenue = fills.filter((x) => /^(box|item|payment):/.test(x.by));
|
|
1640
1887
|
if (revenue.length)
|
|
1641
1888
|
warn('W-REVENUE-SHARE', where, `${label(f)} pays stakers a share of what players spend (${revenue.map((x) => x.by).join(', ')}). Paying holders out of a project’s revenue is what regulators look at when they decide whether a token is a security.`, 'Fine on devnet. Before mainnet, ask counsel, or reward stakers from emissions (a schedule that mints a game token) rather than from sales.');
|
|
1642
1889
|
}
|
|
@@ -1644,6 +1891,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1644
1891
|
// ---- contests (contest.rs)
|
|
1645
1892
|
for (const c of m.contests) {
|
|
1646
1893
|
const where = `economy.contests.${c.key}`;
|
|
1894
|
+
tooMany((c.deciders ?? []).length, LIMITS.deciders, 'E-CONTEST', `${where}.deciders`, `${label(c)} has ${(c.deciders ?? []).length} deciders`, 'BadContest', 'Name fewer deciders.');
|
|
1647
1895
|
const tok = token(c.currency, `${where}.currency`);
|
|
1648
1896
|
confidential(tok, `${where}.currency`, label(c), 'E-CONTEST');
|
|
1649
1897
|
startsFrozen(tok, `${where}.currency`, label(c), 'E-CONTEST', 'BadContest');
|
|
@@ -1670,10 +1918,9 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1670
1918
|
if (sharesOf(c.split).length) refuse('E-CONTEST', `${where}.split`, `${label(c)}’s entries stake NFTs: there is no money to take shares of (BadContest).`, 'Remove the split.');
|
|
1671
1919
|
if (c.stake !== undefined) refuse('E-CONTEST', `${where}.stake`, `${label(c)}’s entries stake one NFT each, not tokens (BadContest).`, 'Remove "stake".');
|
|
1672
1920
|
rule(c.stakeNft, `${where}.stakeNft`, `${label(c)}’s stake`);
|
|
1673
|
-
warn('W-NFT-PRICE', `${where}.stakeNft`, `${label(c)}’s entries each stake one NFT, and the NFTs are the pot: each
|
|
1921
|
+
warn('W-NFT-PRICE', `${where}.stakeNft`, `${label(c)}’s entries each stake one NFT, and the NFTs are the pot: each winner takes its own back and its share of the losers’ (${c.losersEach ? `at most ${c.losersEach}` : 'all of them, shared'}; the first to enter on the winning side take one more when they don’t divide evenly), each picking which, and the losers’ NFTs the winners leave go to whoever opened it. Losers send nothing. The project holds every NFT until it is claimed.`);
|
|
1674
1922
|
}
|
|
1675
1923
|
const deciders = c.deciders ?? [];
|
|
1676
|
-
if (deciders.length > LIMITS.deciders) refuse('E-CONTEST', `${where}.deciders`, `${label(c)} names ${deciders.length} deciders; a contest takes at most ${LIMITS.deciders}.`, 'Name fewer keys.');
|
|
1677
1924
|
deciders.forEach((k, i) => isAddress(k) || refuse('E-REF', `${where}.deciders[${i}]`, `${label(c)}’s decider “${k}” is not a public address.`, 'Write the results server’s public key (never its private key).'));
|
|
1678
1925
|
// A contest of NFTs counts its pot in NFTs, so its least pot is a whole number of them.
|
|
1679
1926
|
const minPot = c.stakeNft ? wholeCount(c.minPot) : units(c.minPot, tok, `${where}.minPot`, `${label(c)}’s least pot`);
|
|
@@ -1693,8 +1940,8 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1693
1940
|
startsFrozen(tok, `${where}.currency`, label(a), 'E-AUCTION', 'BadAuction');
|
|
1694
1941
|
const reserve = a.bidNfts ? BigInt(Number.isInteger(a.reserve) ? a.reserve : 1) : units(a.reserve, tok, `${where}.reserve`, `${label(a)}’s reserve`);
|
|
1695
1942
|
if (a.bidNfts) {
|
|
1696
|
-
// Bids of NFT bundles (auction.rs): no split, and a reserve a bid of up to
|
|
1697
|
-
if (a.reserve !== undefined && !(Number.isInteger(a.reserve) && a.reserve >= 1 && a.reserve <=
|
|
1943
|
+
// Bids of NFT bundles (auction.rs): no split, and a reserve a bid of up to three can reach.
|
|
1944
|
+
if (a.reserve !== undefined && !(Number.isInteger(a.reserve) && a.reserve >= 1 && a.reserve <= MAX_BID_NFTS)) refuse('E-AUCTION', `${where}.reserve`, `${label(a)}’s bids are bundles of NFTs: its reserve counts NFTs, from 1 to ${MAX_BID_NFTS}, the most one bid holds (BadAuction).`, `Write a reserve of 1 to ${MAX_BID_NFTS}.`);
|
|
1698
1945
|
if (sharesOf(a.split).length) refuse('E-AUCTION', `${where}.split`, `${label(a)}’s bids are NFTs: there is no money to take shares of (BadAuction).`, 'Remove the split.');
|
|
1699
1946
|
rule(a.bidNfts, `${where}.bidNfts`, `${label(a)}’s bids`);
|
|
1700
1947
|
outsideAttribute(a.bidNfts, `${where}.bidNfts`, `${label(a)}’s bids`);
|
|
@@ -1772,8 +2019,6 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1772
2019
|
if (gives.gives !== 'tokens') refuse('E-GRANT', `${where}.list[${i}].amount`, `${label(g)}’s list names an amount, but it gives ${gives.gives ?? 'nothing'}: only a grant of a fixed amount of tokens takes one.`, 'Remove "amount", or make the grant give tokens.');
|
|
1773
2020
|
else if (t && units(e.amount, t, `${where}.list[${i}].amount`, 'A listed amount') > units(gives.amount, t, `${where}.prize.amount`, label(g))) refuse('E-GRANT', `${where}.list[${i}].amount`, `${label(g)}’s list gives ${e.wallet} ${e.amount}, more than the grant’s ${gives.amount} (OverPrize).`, 'Raise the grant’s amount to the largest listed, or lower this one.');
|
|
1774
2021
|
}
|
|
1775
|
-
if ((vouchers.length || keys.length) && (!l.perWallet || l.perWallet > MAX_RECEIPT_ROUNDS))
|
|
1776
|
-
warn('W-GRANT-CAPACITY', `${where}.limits.perWallet`, `${label(g)} can pay each wallet at most ${MAX_RECEIPT_ROUNDS} distinct voucher rounds in one grant incarnation. Its round history is retained even after vouchers expire.`, `Use a wallet limit at or below ${MAX_RECEIPT_ROUNDS}, or start a new grant incarnation when the campaign ends.`);
|
|
1777
2022
|
// A grant with no voucher key, no key list and no list is open: any wallet claims it, within its limits (grant.rs).
|
|
1778
2023
|
if (open && showsNft(g.gate) && l.perPerson)
|
|
1779
2024
|
warn('W-GRANT', `${where}.limits.perPerson`, `${label(g)} is open: "perPerson" counts only vouchers that name a person, so here it limits nothing (the wallet limits count each collectible shown).`, 'Remove "perPerson".');
|
|
@@ -1875,8 +2120,8 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1875
2120
|
// A spec the steps can't be built from fails the same way when it is planned.
|
|
1876
2121
|
}
|
|
1877
2122
|
const ctx = setup && placeholderContext(setup.model);
|
|
1878
|
-
const lists = { box: m.box, item: m.item, recipe: m.recipe, schedule: new Map(m.schedules.map((x) => [x.key, x])), lottery: m.lottery, grant: m.grant, farm: m.farm, auction: m.auction, pool: m.pool, seed: m.pool, fund: m.grant, stash: m.stash };
|
|
1879
|
-
const paths = { box: 'boxes', item: 'items', recipe: 'recipes', schedule: 'schedules', lottery: 'lotteries', grant: 'grants', farm: 'farms', auction: 'auctions', pool: 'pools', seed: 'pools', fund: 'grants', stash: 'stashes', token: 'tokens', traits: 'traits' };
|
|
2123
|
+
const lists = { box: m.box, item: m.item, recipe: m.recipe, schedule: new Map(m.schedules.map((x) => [x.key, x])), lottery: m.lottery, grant: m.grant, farm: m.farm, auction: m.auction, pool: m.pool, seed: m.pool, fund: m.grant, stash: m.stash, table: m.table };
|
|
2124
|
+
const paths = { box: 'boxes', item: 'items', recipe: 'recipes', schedule: 'schedules', lottery: 'lotteries', grant: 'grants', farm: 'farms', auction: 'auctions', pool: 'pools', seed: 'pools', fund: 'grants', stash: 'stashes', table: 'tables', token: 'tokens', traits: 'traits' };
|
|
1880
2125
|
for (const step of setup?.steps ?? []) {
|
|
1881
2126
|
let bytes;
|
|
1882
2127
|
try {
|
|
@@ -1927,16 +2172,16 @@ function many(x) {
|
|
|
1927
2172
|
|
|
1928
2173
|
/**
|
|
1929
2174
|
* A sharing farm's rounding: a release is credited by weight in steps of the total
|
|
1930
|
-
* weight ÷ 10¹² base units of the reward, and what is below a step waits uncredited,
|
|
2175
|
+
* weight ÷ 10¹² base units of the reward, and what is below a step waits uncredited, freed in its vault when the
|
|
1931
2176
|
* last seat leaves or the farm ends. A staked token with a great many base units (many decimals, a huge supply: a
|
|
1932
2177
|
* memecoin) paying a reward of few units rounds every seat's share to nothing: an exact model of the farm found seats
|
|
1933
2178
|
* losing all of rewards of 194 to 2,155 units at total weights around 7×10¹⁵. The weight is the most that can
|
|
1934
2179
|
* plausibly be staked (the supply, else a billion whole tokens or the scenario's stake if larger) at the highest tier
|
|
1935
|
-
* and the largest boost; the reward, what lands in a release span (the
|
|
2180
|
+
* and the largest boost; the reward, what lands in a release span (the farm's seed and a span of the steady inflow).
|
|
1936
2181
|
*/
|
|
1937
2182
|
function farmRounding(f, row, warn, m) {
|
|
1938
|
-
if (!f || f.rate || f.
|
|
1939
|
-
const staked = m.tokens.get(f.stakes),
|
|
2183
|
+
if (!f || f.rate || f.stakes === 'collectibles') return;
|
|
2184
|
+
const staked = m.tokens.get(f.stakes), reward = m.tokens.get(f.rewardsToken);
|
|
1940
2185
|
if (staked?.decimals === undefined || reward?.decimals === undefined) return;
|
|
1941
2186
|
const scenario = Number(m.scenario.staked?.[f.key] ?? 0);
|
|
1942
2187
|
const whole = staked.supply !== undefined ? Number(staked.supply) : Math.max(1e9, scenario);
|
|
@@ -1946,18 +2191,18 @@ function farmRounding(f, row, warn, m) {
|
|
|
1946
2191
|
if (!(weight > 1e12)) return;
|
|
1947
2192
|
const step = weight / 1e12;
|
|
1948
2193
|
const span = releaseSeconds(f) / 86_400;
|
|
1949
|
-
const units = (Number(
|
|
2194
|
+
const units = (Number(f.seed ?? 0) + Number(row.rewardsPerDay ?? 0) * span) * 10 ** reward.decimals;
|
|
1950
2195
|
if (!(units > 0) || units >= 100 * step) return;
|
|
1951
|
-
const [ss, rs] = [staked.symbol ?? f.stakes, reward.symbol ??
|
|
2196
|
+
const [ss, rs] = [staked.symbol ?? f.stakes, reward.symbol ?? f.rewardsToken];
|
|
1952
2197
|
const basis = `${staked.supply !== undefined ? `its supply of ${many(whole)}` : whole > 1e9 ? `the scenario’s ${many(whole)} staked` : `a billion ${ss} (assumed when no supply is given)`} at ${staked.decimals} decimals${top > 1 ? `, at x${top}` : ''}${boost > 1 ? `, boosted +${f.maxBoostPercent}%` : ''}`;
|
|
1953
2198
|
const lost = units < step
|
|
1954
|
-
? 'each seat’s share of it rounds down to nothing, so no seat is credited any of it, and it
|
|
1955
|
-
: `up to a step of it at a time waits uncredited until more adds up to a step, and what waits
|
|
2199
|
+
? 'each seat’s share of it rounds down to nothing, so no seat is credited any of it, and it is freed in its vault when the last seat leaves or the farm ends'
|
|
2200
|
+
: `up to a step of it at a time waits uncredited until more adds up to a step, and what waits is freed in its vault when the last seat leaves or the farm ends, so the seats can lose up to ${Math.min(100, Math.round((step / units) * 100))}% of a release`;
|
|
1956
2201
|
warn('W-FARM-ROUNDING', `economy.farms.${f.key}.stakes`, `${label(f)} shares its rewards by weight, and the stakes in it can weigh up to about ${many(weight)} base units of ${ss} (${basis}). At that weight the program can share a release only in steps of about ${many(step)} base units of ${rs}, and about ${many(units)} land in it over a release (${releaseWords(f)}): ${lost}.`, `Stake a token with fewer base units (fewer decimals or a smaller supply), or fund larger rewards: about ${many(100 * step)} base units of ${rs} (${(100 * step) / 10 ** reward.decimals >= 1 ? many((100 * step) / 10 ** reward.decimals) : +((100 * step) / 10 ** reward.decimals).toPrecision(3)} ${rs}) or more a release keeps the rounding under 1%.`);
|
|
1957
2202
|
}
|
|
1958
2203
|
|
|
1959
2204
|
/**
|
|
1960
|
-
* A fixed rate that mints:
|
|
2205
|
+
* A fixed rate that mints: each payout mints what a seat is owed as far as the mint has room for under the most a
|
|
1961
2206
|
* mint can ever hold (u64::MAX base units, less its supply), and what it can't mint is never owed later. What the rate
|
|
1962
2207
|
* could mint is the rate times the most that can plausibly be staked (a token's supply, else a billion whole tokens or
|
|
1963
2208
|
* the scenario's stake if larger; a collectible's supply, else 10,000 or the scenario's count), at the highest tier and
|
|
@@ -1966,7 +2211,7 @@ function farmRounding(f, row, warn, m) {
|
|
|
1966
2211
|
*/
|
|
1967
2212
|
function rateRoom(f, warn, m) {
|
|
1968
2213
|
if (!(Number(f.rate.perDay) > 0)) return;
|
|
1969
|
-
const reward = m.
|
|
2214
|
+
const reward = m.tokens.get(f.rewardsToken);
|
|
1970
2215
|
if (reward?.decimals === undefined) return;
|
|
1971
2216
|
const scenario = Number(m.scenario.staked?.[f.key] ?? 0);
|
|
1972
2217
|
const ofCollectibles = f.stakes === 'collectibles';
|
|
@@ -1976,10 +2221,10 @@ function rateRoom(f, warn, m) {
|
|
|
1976
2221
|
const top = Math.max(1, ...(f.tiers ?? []).map((t) => Number(t.multiplier ?? 1)));
|
|
1977
2222
|
const boost = (f.boosts ?? []).length ? 1 + Number(f.maxBoostPercent ?? 0) / 100 : 1;
|
|
1978
2223
|
const days = f.runs ? toSeconds(f.runs) / 86_400 : 3650;
|
|
1979
|
-
const units = Number(f.rate.perDay) * count * top * boost * days * 10 ** reward.decimals;
|
|
2224
|
+
const units = Number(f.rate.perDay) * (f.rate.shared ? 1 : count * top * boost) * days * 10 ** reward.decimals;
|
|
1980
2225
|
const room = 2 ** 64;
|
|
1981
2226
|
if (!(units >= room / 10)) return;
|
|
1982
|
-
const [rs, unit] = [reward.symbol ??
|
|
2227
|
+
const [rs, unit] = [reward.symbol ?? f.rewardsToken, ofCollectibles ? 'collectible' : (staked?.symbol ?? f.stakes)];
|
|
1983
2228
|
const basis = `${known ? `a supply of ${many(count)}` : count > (ofCollectibles ? 10_000 : 1e9) ? `the scenario’s ${many(count)}` : ofCollectibles ? '10,000 collectibles' : `a billion ${unit}`} staked${top > 1 ? ` at x${top}` : ''}${boost > 1 ? `, boosted +${f.maxBoostPercent}%` : ''}, ${f.runs ? `over its ${f.runs}` : 'over ten years'}`;
|
|
1984
2229
|
const whole = room / 10 ** reward.decimals;
|
|
1985
2230
|
warn('W-FARM-MINT-ROOM', `economy.farms.${f.key}.rate`, `${label(f)} mints ${f.rate.perDay} ${rs} a day for each ${unit} at x1: at ${basis}, that is about ${many(units)} base units, ${units >= room ? 'more than' : `${Math.round((units / room) * 100)}% of`} the most a mint can ever hold (about ${many(room)} base units, ${many(whole)} ${rs} at ${reward.decimals} decimals). Each move mints only what the mint still has room for, and what it can’t mint is never owed later: once ${rs}’s supply nears that, every seat is paid less than the rate, then nothing.`, `Lower "perDay", give the farm an end ("runs"), or reward in a token with fewer decimals.`);
|