@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.
Files changed (70) hide show
  1. package/README.md +10 -10
  2. package/examples/coin-flip-arena.json +2 -7
  3. package/examples/elements-set.json +2 -9
  4. package/examples/expedition-guild.json +1 -8
  5. package/examples/guild-missions.json +3 -10
  6. package/examples/halving-seasons.json +4 -9
  7. package/examples/hero-trials.json +1 -4
  8. package/examples/merge-2048.json +5 -5
  9. package/examples/paper-hands-farm.json +4 -16
  10. package/examples/partner-vault.json +1 -4
  11. package/examples/stakers-draw.json +1 -8
  12. package/examples/star-fleet.json +1 -8
  13. package/examples/starforce.json +6 -6
  14. package/js/approve/details.mjs +8 -7
  15. package/js/approve/server.mjs +20 -5
  16. package/js/chain/borsh.js +9 -0
  17. package/js/chain/core.js +14 -2
  18. package/js/chain/lookup.js +8 -2
  19. package/js/chain/rpc.js +4 -2
  20. package/js/client/accounts.js +448 -116
  21. package/js/client/grant.js +125 -66
  22. package/js/client/idl/loot.js +1 -1
  23. package/js/client/idl/loot.json +2360 -2218
  24. package/js/client/index.js +1 -0
  25. package/js/client/ix.js +1204 -473
  26. package/js/client/pda.js +20 -16
  27. package/js/client/policy.js +44 -23
  28. package/js/client/random.js +70 -0
  29. package/js/client/sizes.js +255 -12
  30. package/js/economy/check.mjs +455 -210
  31. package/js/economy/cost.mjs +45 -30
  32. package/js/economy/examples.mjs +2 -2
  33. package/js/economy/explain.mjs +24 -20
  34. package/js/economy/guide.mjs +31 -30
  35. package/js/economy/live.mjs +30 -7
  36. package/js/economy/model.mjs +171 -56
  37. package/js/economy/plan.mjs +380 -112
  38. package/js/economy/schema.json +291 -143
  39. package/js/economy/simulate.mjs +154 -87
  40. package/js/mcp/tools.mjs +14 -8
  41. package/js/play/index.js +1121 -375
  42. package/js/play/keys.js +167 -0
  43. package/package.json +1 -1
  44. package/release.json +5 -5
  45. package/skills/xsolana-economy/SKILL.md +15 -11
  46. package/skills/xsolana-economy/references/after-launch.md +37 -27
  47. package/skills/xsolana-economy/references/apps.md +55 -19
  48. package/skills/xsolana-economy/references/sentence-to-spec.md +30 -22
  49. package/skills/xsolana-economy/references/telling-the-person.md +52 -11
  50. package/types/chain/core.d.ts +3 -1
  51. package/types/chain/lookup.d.ts +5 -2
  52. package/types/chain/rpc.d.ts +3 -1
  53. package/types/client/accounts.d.ts +195 -55
  54. package/types/client/grant.d.ts +72 -27
  55. package/types/client/idl/loot.d.ts +590 -559
  56. package/types/client/index.d.ts +1 -0
  57. package/types/client/ix.d.ts +3862 -234
  58. package/types/client/pda.d.ts +19 -15
  59. package/types/client/policy.d.ts +39 -14
  60. package/types/client/random.d.ts +26 -0
  61. package/types/client/sizes.d.ts +93 -5
  62. package/types/economy/check.d.mts +5 -10
  63. package/types/economy/cost.d.mts +2 -1
  64. package/types/economy/live.d.mts +16 -1
  65. package/types/economy/model.d.mts +105 -37
  66. package/types/economy/plan.d.mts +193 -192
  67. package/types/economy/simulate.d.mts +21 -13
  68. package/types/mcp/tools.d.mts +21 -13
  69. package/types/play/index.d.ts +450 -39
  70. package/types/play/keys.d.ts +51 -0
@@ -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 { MAX_RECEIPT_ROUNDS } from '../client/sizes.js';
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
- outcomeBurns,
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
- // ---------------------------------------------------------------- a player's craft, in bytes
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
- * The accounts a route the player pays or asks for (a craft, a buy, a grant's claim) passes after a roll's box and
70
- * order (`order::askable`, the client's `rollMints`): for each token prize of box `key`, in order, its pool and the
71
- * pool's mint, or the mint it mints from. Up to 10 distinct accounts a box.
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
- export function rollMintKeys(key, m) {
74
- return (m.box.get(key)?.outcomes ?? []).flatMap(outcomeParts).flatMap((o) => {
75
- if (o.gives === 'mint') return [`mint:${o.currency ?? m.currencyKey}`];
76
- if (o.gives === 'pay' || o.gives === 'share') return [`pda:pool/${o.pool}`, `mint:${m.pool.get(o.pool)?.currency ?? m.currencyKey}`];
77
- return [];
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 with its box's prize mints, `rollMintKeys`), a compute limit (a big recipe needs more
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, the order, then the box's prize mints (`rollMintKeys`).
121
- else if (line.gives === 'roll') rest.push(`pda:box/${line.box}`, `pda:order/${i}`, ...rollMintKeys(line.box, m));
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.gives === 'share' ? m.pool.get(line.pool)?.currency : line.from?.startsWith('pool:') ? m.pool.get(line.from.slice(5))?.currency : line.currency ?? m.currencyKey;
124
- const pool = line.gives === 'share' ? line.pool : line.from?.startsWith('pool:') ? line.from.slice(5) : null;
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: 8 + 4 + 8 }] }, detail);
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
- ...(rolls ? ['vrf:queue', 'pda:identity', 'program:vrf', 'sysvar:slot-hashes'] : [none, none, none, none]),
186
+ // Every open asks the oracle.
187
+ 'vrf:queue', 'pda:identity', 'program:vrf', 'sysvar:slot-hashes',
170
188
  chosenItem ? 'asset:chosen' : none,
171
- limited ? `pda:receipt/${b.key}` : none,
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 === 'share' || o.gives === 'mint') {
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 = 8 + 8 + 8 + (chosenItem || (b.split ?? []).some((s) => s.to === 'chosen') ? 33 : 1) + 4;
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 === 'share' || x.gives === 'mint') {
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 === 'share' || x.gives === 'mint') units += 30_000 * hooked(x.gives === 'mint' ? x.currency ?? m.currencyKey : m.pool.get(x.pool)?.currency);
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 37, the proof of a compressed NFT
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 = 37 + (compressedSubject ? LONGEST_PROOF : 0) + won + split + hooks + (memo ? 1 : 0);
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
- const settle = { program: 'loot', accounts: [...named, ...Array.from({ length: Math.max(0, entries - named.length) }, () => 'program:loot')], data: 8 };
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 with its
354
- * box's prize mints, `rollMintKeys`).
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 ? `pda:receipt/${b.key}` : none,
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
- outcomeParts(b.outcomes?.[0]).forEach((o, i) => {
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}`, ...rollMintKeys(o.box, m));
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 = 8 + 8 + (chosenItem || (b.split ?? []).some((s) => s.to === 'chosen') ? 33 : 1) + 4 + 8;
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
- const never = code === 'E-GRANT' || code === 'E-RECIPE' ? 'every claim or craft would be refused' : 'its winner would get nothing';
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
- const longest = (it.uri ?? '').replaceAll('{serial}', String(Number(it.supply ?? 0) || 4_294_967_295));
596
- if (utf8Bytes(longest) > LIMITS.uriBytes) refuse(code, `${at}.compressed`, `${label(it)}’s link, its serial written in, is longer than Bubblegum’s ${LIMITS.uriBytes} bytes: ${never}.`, 'Use a shorter link.');
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": "share", "pool": key, "percent": p }, { "gives": "nft", "stash": key, "nft": address } or { "gives": "roll", "box": key }.');
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' || !['tokens', 'share'].includes(line.gives)))
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 refuse(code, `${at}.gives`, `${what} gives “${line.gives}”, which no prize is.`, 'Use "item", "tokens", "share", "nft" or "roll".');
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 > LIMITS.gateWallets) refuse('E-RULE', `${where}.wallets`, `${what} lists ${r.wallets.length} wallets; a gate lists at most ${LIMITS.gateWallets}.`, 'List fewer wallets, or gate by a collectible they hold.');
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 4 shares and 100% (state.rs check_later_shares), pools of the same
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 up to eight collections (project.rs MAX_COLLECTIONS): the one it makes and the owner's others.
833
- if ((m.collection ? 1 : 0) + m.collections.length > 8) refuse('E-COLLECTION', 'economy.collections', `The project would take ${(m.collection ? 1 : 0) + m.collections.length} collections; it takes at most 8 (BadCollection).`, 'Name fewer collections.');
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
- if (utf8Bytes(it.name) > LIMITS.nameBytes) refuse('E-ITEM', `${where}.name`, `${label(it)} is longer than ${LIMITS.nameBytes} bytes, the most the program keeps.`, 'Shorten the name.');
844
- if (utf8Bytes(it.uri) > LIMITS.uriBytes) refuse('E-ITEM', `${where}.uri`, `${label(it)}’s link is longer than ${LIMITS.uriBytes} bytes.`, 'Use a shorter link.');
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
- const names = m.farms.some((f) => f.rewards === p.key && f.rate?.minted);
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 && !farm) {
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 contribution 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.');
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)) warn('W-STASH', `${where}.nfts`, `${label(s)} starts empty: an empty stash can’t be won, so its box gives its other outcomes (or sells nothing) until the owner or the authority puts NFTs in (\`loot.stashIx\`).`);
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 they make first: js/play from the player’s wallet (one to three more approvals to make the table, and about ${(Number(big.tableRent) / 1e9).toFixed(4)} SOL of its rent, which comes back once the table has cooled down, about two minutes later: claims() lists it), 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.');
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' || o.gives === 'share') ? ', and drain the pools its prizes come from' : ''}.`, 'Give it a price, a gate, or hand out openings through a grant.');
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' || o.gives === 'share') {
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 (o.gives === 'pay' && p) {
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', 'share', 'stash', 'nft'].includes(o.gives)).length;
1123
- const sources = new Set(parts.filter((o) => ['pay', 'share', 'mint'].includes(o.gives)).map((o) => (o.gives === 'mint' ? `mint:${o.currency ?? m.currencyKey}` : o.pool))).size;
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
- const { bytes, locks } = boxBuys(b) ? buySize(b, m, { detail: true }) : openBoxSize(b, m, { detail: true });
1131
- const verb = boxBuys(b) ? 'buy' : 'open';
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, `${boxBuys(b) ? '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.');
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, `${boxBuys(b) ? '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 with a lookup table the player’s wallet makes first (one to three more approvals, and the table’s rent until it is closed, which claims() offers about two minutes later). 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.');
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 box sells only while its pool covers every fixed prize (order.rs, PrizeNotCovered): the odds on the page are
1145
- // always odds it can pay. A prize won leaves the pool for the game's vault at the roll.
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 box sells only while its pool covers every fixed prize (order.rs, PrizeNotCovered): a pool whose seed is short of
1157
- // the largest prize a box pays from it holds up every box it pays.
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
- 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}. The program sells a box only while its pool holds every fixed prize, so ${label(box)} can’t be opened until the pool holds ${amount} ${sym}, and stops selling 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 held up the same way below its own largest)` : ''}.`, `Seed “${p.key}” with at least ${amount} ${sym}; more rides out a run of wins (the simulation suggests how much). For a prize that grows with sales, use a share of the pool ("gives": "share") instead.`);
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 order waiting for its roll is rolled only on the version it paid for, so
1170
- // an edit of the entries, odds, stock odds, the NFT it rolls on or the fates fails every roll of it that waits. A
1171
- // purchase gets its payment back; a roll won elsewhere (a prize, a mission's chest, an auction's lot) paid nothing in,
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). Every roll of it still waiting fails when the change lands${routes.length ? `: one won on another route (${routes.join(', ')}) paid nothing in, so it refunds 0 and its winner loses it` : ': a purchase gets its payment back, and a roll won on another route paid nothing in, so it refunds 0'}.`, '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.');
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
- const raised = (r.raises ?? []).filter((x) => x.place === taken).map((x) => x.key);
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
- if (custodial && places[line.on] === 'show')
1276
- warn('W-ROLL-CUSTODY', `${at}.on`, `${label(b)} can burn or give away the NFT ${label(r)} shows: the craft hands it to the game, 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"), or give the box only outcomes that move numbers.');
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 with a lookup table the player’s wallet makes first (one to three more approvals, and the table’s rent until it is closed, which claims() offers about two minutes later). 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.');
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
- const p = s.pool ? pool(s.pool, `${where}.pool`) : refuse('E-SCHEDULE', where, `${label(s)} mints but does not say into which pool.`, 'Add "pool".');
1337
- const tok = token(s.currency ?? p?.currency, `${where}.currency`);
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), or "account:<token account>".');
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
- if (d.kind === 'account') warn('W-SCHEDULE', `${where}.to`, `${label(s)} is sealed and pays a token account: it must be its holder's associated account of ${tok?.symbol ?? 'its token'}, or a token account the project holds with no close authority, when it goes live, or the write is refused (BadMove).`);
1367
- if (d.kind === 'account' && gameVault(d.key, tok)) refuse('E-SCHEDULE', `${where}.to`, `${label(s)} is sealed and pays the game’s own vault, where open orders, prizes set aside and bids wait: no run could (BadMove).`, 'Pay a wallet.');
1368
- if (!['wallet', 'account', 'pool', 'lottery', '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, a token account, a pool, a lottery's pot, the treasury or a burn (BadMove).`, 'Write "to": "wallet:<address>".');
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 (!['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 or a lottery’s pot.`, 'Write "to": "wallet:<address>", "account:<token account address>" or "holder:<asset address>".');
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
- 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 gets ${held.length === 1 ? 'it' : 'them'} before any ticket money moves. If the NFT's collection lets someone freeze or move it (a permanent freeze, transfer or burn delegate), they could stop the prize, and every ticket's money would wait in the lottery until they let it go.`);
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
- const waiting = !f.rate && poolFillers(m).filter((x) => x.pool === f.rewards);
1487
- if (waiting?.length) warn('W-FARM-FIRST', `${where}.rewards`, `${label(f)} shares what arrives in “${f.rewards}” (${waiting.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.');
1488
- // A rewards mint that keeps a fee on transfers rounds each fee up, by less than a base unit (Token-2022). A sharing
1489
- // farm's refresh that changes no weight releases only a chunk at a time, ⌈1,000,000 / fee_bps⌉ base units or a
1490
- // hundredth of its line, so the rounding stays small; a fixed rate from a pool pays on every move, with no chunk.
1491
- const feePercent = Number(m.tokens.get(m.pool.get(f.rewards)?.currency)?.powers?.transferFeePercent ?? 0);
1492
- // A fee finer than a hundredth of a percent is refused with its token (E-PERCENT).
1493
- const feeBps = toBps(feePercent);
1494
- if (feeBps > 0 && !f.rate?.minted && !f.unpaid) {
1495
- const chunk = Math.ceil(1_000_000 / feeBps);
1496
- warn('W-FARM-FEE', `${where}.rewards`, f.rate
1497
- ? `${label(f)} pays its rate from “${f.rewards}”, whose token keeps ${feePercent}% of every transfer, each fee rounded up: every move pays from the pool and can lose up to a base unit to the rounding, so a refresh anyone can send every few seconds turns a slow rate into fees (they go to the mint’s fee collector, not to anyone in the farm).`
1498
- : `${label(f)}’s rewards keep ${feePercent}% of every transfer, each fee rounded up: a refresh that changes no seat’s weight releases a chunk at a time, ${chunk.toLocaleString('en-US')} base units or a hundredth of what is being released if that is less, the rest waiting for the next move that releases it; a release of a few base units a second, or a watched NFT moved back and forth with a refresh each time, can still lose a base unit a move to the rounding.`,
1499
- 'Give the rewards token enough decimals that a second’s release is many base units, or use one without a fee.');
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, unbonding and tolls (farm.rs): a fee leaves to a burn or a pool of its token, checked once at open.
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.unbond) refuse('E-FARM', `${where}.unbond`, `${label(f)} stakes collectibles: unbonding is a stake of tokens' wait (BadFarm).`, 'Remove "unbond".');
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.toll) {
1524
- sinkOk(f.toll.to, `${where}.toll.to`, m.pool.get(f.rewards)?.currency, 'its toll');
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 burned' : `goes back to “${f.rewards}”`) + ', never to the other seats' : `goes back to “${f.rewards}”, released again with the rest`}), and its new holder can take the seat over, paying the last staker back the seat’s rent.`);
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 the pool ("minted": false), or reward in a token with source "project".');
1566
- if (!f.rate.minted) warn('W-FARM-RATE', `${where}.rate`, `${label(f)} pays its fixed rate from “${f.rewards}” while it lasts: what the pool can’t cover is not owed later, and every seat earns less once it runs dry.`, 'Keep the pool filled (a schedule), or mint the rewards.');
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 its pool: ${end}.` : ''}`);
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 what a seat is credited after its upkeep runs out is not paid (in a farm at a fixed rate it is burned or goes back where the rate came from, never to the other seats; in a farm that shares its pool it goes back to the pool and is released again with the rest).${sells ? '' : ' Nothing in this spec sells it: only the authority (Set) could.'}`, sells ? undefined : `Add a recipe that raises "${f.upkeepAttribute}" with "fromNow": true.`);
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 = fillers.filter((x) => x.pool === f.rewards && /^(box|item|payment):/.test(x.by));
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 winning entry takes its own back and one loser’s, in the order the entries are claimed (anyone may claim them), and the losers’ NFTs the winners leave go to whoever opened it. The project holds every NFT until its entry is claimed.`);
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 four can reach.
1697
- if (a.reserve !== undefined && !(Number.isInteger(a.reserve) && a.reserve >= 1 && a.reserve <= 3)) refuse('E-AUCTION', `${where}.reserve`, `${label(a)}’s bids are bundles of NFTs: its reserve counts NFTs, from 1 to 3, the most one bid holds (BadAuction).`, 'Write a reserve of 1 to 3.');
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, going back to the pool when the
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 pool's seed and a span of the steady inflow).
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.unpaid || f.stakes === 'collectibles') return;
1939
- const staked = m.tokens.get(f.stakes), pool = m.pool.get(f.rewards), reward = m.tokens.get(pool?.currency);
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(pool.seed ?? 0) + Number(row.rewardsPerDay ?? 0) * span) * 10 ** reward.decimals;
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 ?? pool.currency];
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 goes back to the pool when the last seat leaves or the farm ends'
1955
- : `up to a step of it at a time waits uncredited until more adds up to a step, and what waits goes back to the pool 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`;
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: every move mints the rate's due as far as the mint has room for under the most a
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.poolToken(f.rewards);
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 ?? m.pool.get(f.rewards)?.currency, ofCollectibles ? 'collectible' : (staked?.symbol ?? f.stakes)];
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.`);