@xgamesfun/solana 0.25.4 → 0.26.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 (41) hide show
  1. package/README.md +8 -8
  2. package/js/approve/details.mjs +52 -2
  3. package/js/approve/live-check.mjs +0 -15
  4. package/js/approve/server.mjs +5 -4
  5. package/js/chain/token2022.js +3 -2
  6. package/js/client/accounts.js +67 -25
  7. package/js/client/grant.js +7 -2
  8. package/js/client/idl/loot.js +1 -1
  9. package/js/client/idl/loot.json +714 -182
  10. package/js/client/ix.js +617 -113
  11. package/js/client/sizes.js +35 -3
  12. package/js/economy/check.mjs +655 -242
  13. package/js/economy/cost.mjs +2 -4
  14. package/js/economy/explain.mjs +16 -2
  15. package/js/economy/guide.mjs +12 -12
  16. package/js/economy/model.mjs +133 -14
  17. package/js/economy/plan.mjs +36 -11
  18. package/js/economy/review.mjs +50 -8
  19. package/js/economy/schema.json +73 -29
  20. package/js/economy/simulate.mjs +116 -13
  21. package/js/mcp/tools.mjs +2 -2
  22. package/js/play/index.js +847 -296
  23. package/package.json +1 -1
  24. package/release.json +5 -5
  25. package/skills/xsolana-economy/SKILL.md +12 -4
  26. package/skills/xsolana-economy/references/after-launch.md +1 -1
  27. package/skills/xsolana-economy/references/apps.md +11 -2
  28. package/skills/xsolana-economy/references/sentence-to-spec.md +3 -3
  29. package/skills/xsolana-economy/references/telling-the-person.md +21 -4
  30. package/skills/xsolana-economy/references/trust-and-cheating.md +15 -5
  31. package/types/chain/token2022.d.ts +3 -2
  32. package/types/client/accounts.d.ts +34 -11
  33. package/types/client/idl/loot.d.ts +25 -4
  34. package/types/client/ix.d.ts +217 -45
  35. package/types/economy/check.d.mts +5 -0
  36. package/types/economy/model.d.mts +75 -12
  37. package/types/economy/plan.d.mts +22 -1
  38. package/types/economy/simulate.d.mts +12 -1
  39. package/types/mcp/server.d.mts +12 -1
  40. package/types/mcp/tools.d.mts +12 -1
  41. package/types/play/index.d.ts +210 -144
@@ -19,6 +19,7 @@ import {
19
19
  FEES,
20
20
  rent as rentOf,
21
21
  LIMITS,
22
+ ANSWER_HOLDS,
22
23
  RESERVED_ATTRIBUTES,
23
24
  destination,
24
25
  fromUnits,
@@ -45,6 +46,7 @@ import {
45
46
  releaseWords,
46
47
  farmEndWords,
47
48
  stashNfts,
49
+ stopsSales,
48
50
  poolTakers,
49
51
  sharesOf,
50
52
  toBps,
@@ -669,14 +671,18 @@ export function buySize(b, m, { detail = false, count = 1 } = {}) {
669
671
  * holds its key, and the approval page calls it "your wallet" until a wallet connects: no spec may name it. */
670
672
  export const STAND_IN_OWNER = encodeBase58(sha256(utf8('loot-plan:owner')));
671
673
 
672
- /** The longest a contest's result or a lottery's draw may be off, or an auction's end (contest.rs, lottery.rs, auction.rs). */
673
- const MAX_CONTEST_SECONDS = Number(IDL.constants.find(c => c.name === 'MAX_CONTEST_SECONDS')?.value ?? 157_680_000);
674
- /** A sealed schedule's last run comes within this of going live (schedule.rs). */
675
- const MAX_SCHEDULE_SECONDS = Number(IDL.constants.find(c => c.name === 'MAX_SCHEDULE_SECONDS')?.value ?? 315_360_000);
676
- /** The most runs a sealed schedule makes (schedule.rs). */
677
674
  /** The most places a run of a lottery's places given one by one holds (lottery.rs). */
678
- const MAX_RUN_PLACES = Number(IDL.constants.find(c => c.name === 'MAX_RUN_PLACES')?.value ?? 4_096);
679
- const MAX_SEALED_RUNS = Number(IDL.constants.find(c => c.name === 'MAX_SEALED_RUNS')?.value ?? 10_000);
675
+ const MAX_RUN_PLACES = Number(IDL.constants.find(c => c.name === 'MAX_RUN_PLACES').value);
676
+
677
+ /** How far off a time may land before the checker asks whether its unit is right: three years. The program
678
+ * takes any time a date holds; a time written in milliseconds lands thousands of years out and locks what waits on it. */
679
+ export const FAR_SECONDS = 3 * 365 * 86_400;
680
+ /** A Unix time in words, in English: "14 March 2031". Past what a date can show, the year alone. */
681
+ export function dateWords(seconds) {
682
+ const d = new Date(Number(seconds) * 1000);
683
+ if (Number.isNaN(d.getTime())) return `the year ${Math.floor(Number(seconds) / 31_557_600) + 1970}`;
684
+ return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
685
+ }
680
686
 
681
687
  /**
682
688
  * Checks an economy spec. `now` (Unix seconds) dates "publish".
@@ -852,6 +858,25 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
852
858
  );
853
859
  return s;
854
860
  };
861
+ /**
862
+ * A time that lands far off: the program takes it, and what waits on it (bids, tickets, NFTs, a vault) is
863
+ * locked until then. `at` is when it lands (Unix seconds), `written` what the spec says, `locks` what waits. Warned
864
+ * with the date in words, and as milliseconds when the number written reads as a time in milliseconds.
865
+ */
866
+ const farTime = (at, where, what, written, locks) => {
867
+ if (!(at - now > FAR_SECONDS)) return;
868
+ // A plain number this far off is most likely milliseconds: a thousandth of it is what was meant.
869
+ const millis = typeof written === 'number' || /^\d+$/.test(String(written ?? ''));
870
+ const meant = Math.round(Number(written) / 1000);
871
+ warn(
872
+ 'W-FAR-TIME',
873
+ where,
874
+ `${what} lands on ${dateWords(at)}, ${Math.floor((at - now) / 31_536_000).toLocaleString('en-US')} years after go-live${millis ? `: ${written} reads like milliseconds, and the program counts seconds (${meant.toLocaleString('en-US')} seconds is ${meant >= 86_400 ? `${Math.round(meant / 86_400).toLocaleString('en-US')} days` : `${Math.round(meant / 3_600).toLocaleString('en-US')} hours`})` : ''}. The program takes any time, so ${locks} until then.`,
875
+ millis
876
+ ? 'Write the time in seconds, or as a duration like "7d".'
877
+ : 'Check the unit and the date before you sign: a duration like "30d" or a date like "2027-01-01" is safest.',
878
+ );
879
+ };
855
880
  /** Refuses a list over one of the program's caps (`LIMITS`), which it would refuse when the owner signs. */
856
881
  const tooMany = (count, most, code, where, what, error, fix) => {
857
882
  if (count > most) refuse(code, where, `${what}; the program takes at most ${most} (${error}).`, fix);
@@ -1012,22 +1037,29 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
1012
1037
  }
1013
1038
  const p = t.powers ?? {};
1014
1039
  percent(p.transferFeePercent, `${where}.powers.transferFeePercent`);
1040
+ // A token that can't be transferred moves only by a mint or a burn: nothing can be paid in it, staked, lent or
1041
+ // sealed in a vault, and a prize of it given from a pool never arrives. The program takes it; the project hears it.
1015
1042
  if (p.nonTransferable)
1016
- refuse(
1017
- 'E-TOKEN',
1043
+ warn(
1044
+ 'W-MINT-POWERS',
1018
1045
  `${where}.powers`,
1019
- `“${t.key}” cannot be transferred, so it cannot pay for anything, fill a pool or be staked.`,
1020
- 'Use another token.',
1046
+ `“${t.key}” cannot be transferred: nothing can be paid in it, staked in a farm, lent, bid or sealed in a vault, and a prize of it can only be minted to its winner. A share of it, a refund or a payout that has to move it never arrives, and a split prize of it minted to a contest's winners must be given up by each one.`,
1047
+ 'Use it only as points the project mints to players, or use another token.',
1021
1048
  );
1049
+ // What each power lets its holder do to a farm's stakes, a vest's vault, a box's payments or a prize.
1022
1050
  const powers = [
1023
1051
  p.freeze &&
1024
- 'freeze any account holding it, including the game’s pools and the vault that holds open orders and bids (a frozen vault holds them until it thaws)',
1025
- p.permanentDelegate && 'move or burn it out of any account, the game’s pools included',
1052
+ 'freeze any account holding it, including the game’s pools and the vault that holds open orders and bids (a frozen vault holds them until it thaws), and so block a repayment or a refund',
1053
+ p.permanentDelegate &&
1054
+ 'move or burn it out of any account, the game’s pools, a farm’s stakes and a sealed vest’s vault included',
1055
+ p.closeAuthority && 'close the mint and make it again with other powers',
1026
1056
  p.transferHook &&
1027
- 'run its own program on every transfer, which can refuse one (a settle that pays a price or its shares in it waits until the hook allows it; a box that rolls, or that a recipe, a mission or a prize rolls, can’t give it as a prize)',
1057
+ 'run its own program on every transfer, which can refuse one and so block a repayment or a refund (a settle that pays a price or its shares in it waits until the hook allows it; a box that rolls, or that a recipe, a mission or a prize rolls, can’t give it as a prize)',
1028
1058
  p.transferFeePercent > 0 &&
1029
1059
  `keep ${p.transferFeePercent}% of every transfer (pools and prizes receive that much less)`,
1030
- p.pausable && 'pause every transfer of it',
1060
+ p.feeChangeable &&
1061
+ 'raise its transfer fee, up to 100%, and so take every stake, payment or prize as it leaves a vault',
1062
+ p.pausable && 'pause every transfer of it, and so block a repayment, a refund or an unstake',
1031
1063
  p.defaultFrozen && 'start every new account frozen',
1032
1064
  !p.defaultFrozen && p.defaultState && p.freeze && 'switch every new account to start frozen',
1033
1065
  p.mintAuthority === 'someone else' && 'mint more of it whenever its owner wants',
@@ -1036,8 +1068,8 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
1036
1068
  warn(
1037
1069
  'W-MINT-POWERS',
1038
1070
  `${where}.powers`,
1039
- `Whoever controls the mint of “${t.key}” can ${powers.join('; ')}. The program treats that as the token’s own business: players should know.`,
1040
- 'Use a mint without those powers, or say so where players buy.',
1071
+ `Whoever controls the mint of “${t.key}” can ${powers.join('; ')}. The program takes any token: a farm, a vest, a box or a prize in it is only as safe as the token, and players should know.`,
1072
+ 'Use a mint without those powers, or say so where players buy, stake or are paid.',
1041
1073
  );
1042
1074
  if (source === 'existing' && !t.powers)
1043
1075
  warn(
@@ -1209,7 +1241,15 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
1209
1241
  );
1210
1242
  const p = poolKey ? pool(poolKey, `${at}.from`) : null;
1211
1243
  const t = minted ? token(line.currency, `${at}.currency`) : p ? m.tokens.get(p.currency) : null;
1212
- if (minted && t && !t.gameMints)
1244
+ // A lottery's place passes over a prize it can't mint: taken, and said. Elsewhere the claim or craft fails.
1245
+ if (minted && t && !t.gameMints && code === 'E-LOTTERY')
1246
+ warn(
1247
+ 'W-MINT-NOT-OURS',
1248
+ `${at}.currency`,
1249
+ `${what} mints ${t.symbol}, which the project cannot mint: until the project is the mint’s authority, a pull by anyone but the place’s winner or the project’s keys fails, and the winner or the keys can pass it over for nothing.`,
1250
+ 'Make the project the mint’s authority before the draw, pay it from a pool ("from": "pool:<key>"), or use a token with source "project".',
1251
+ );
1252
+ else if (minted && t && !t.gameMints)
1213
1253
  refuse(
1214
1254
  'E-MINT-NOT-OURS',
1215
1255
  `${at}.currency`,
@@ -1223,8 +1263,14 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
1223
1263
  `${what} gives ${m.tokens.get(line.currency)?.symbol ?? line.currency} from “${p.key}”, which holds ${m.tokens.get(p.currency)?.symbol}.`,
1224
1264
  `Leave "currency" out: a pool’s prize is in what it holds.`,
1225
1265
  );
1266
+ // A prize of 0 tokens is given with nothing moved, on every route.
1226
1267
  if (t && units(line.amount, t, `${at}.amount`, `What ${what} gives`) === 0n)
1227
- refuse(code, `${at}.amount`, `${what} gives an amount of 0.`, 'Give it an amount above 0.');
1268
+ warn(
1269
+ 'W-ZERO',
1270
+ `${at}.amount`,
1271
+ `${what} gives 0 ${t.symbol ?? 'tokens'}: the prize is given, and its winner gets nothing.`,
1272
+ 'Give it an amount above 0, or leave the prize out.',
1273
+ );
1228
1274
  } else if (line.gives === 'nft' && line.held) {
1229
1275
  // An NFT a lottery's place holds in the lottery's own stash: a lottery's place only (prize.rs Prize::Held).
1230
1276
  if (code !== 'E-LOTTERY')
@@ -1297,31 +1343,34 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
1297
1343
  return line;
1298
1344
  };
1299
1345
  /**
1300
- * A mint whose holders can turn plain transfers away (confidential transfers): a lottery, a contest and an auction
1301
- * refuse it, since a bid or a payout sent back could never land. Only when the spec says so (`powers`).
1346
+ * A token a player can hold up a payment in: its new accounts start frozen or can be switched to, its
1347
+ * holders can turn plain transfers away (confidential transfers), or it can't be transferred at all. The program
1348
+ * takes it in an auction, an offer, a loan, a lottery and a contest; this says what a player can then do, by `kind`.
1349
+ * Only when the spec says so (`powers`).
1302
1350
  */
1303
- const confidential = (t, where, what, code) => {
1304
- if (t?.powers?.confidentialTransfers)
1305
- refuse(
1306
- code,
1307
- where,
1308
- `${what} is in ${t.symbol}, whose mint allows confidential transfers: any holder could turn plain transfers away, so a payout or a bid sent back might never land (the program refuses it).`,
1309
- 'Use a mint without confidential transfers.',
1310
- );
1311
- };
1312
- /**
1313
- * A mint whose new accounts start frozen, or can be made to (a default state and a freeze authority, which can switch
1314
- * it to frozen): a lottery, a contest, an auction and a sealed schedule refuse it as they open, since each reads the
1315
- * setting once, and a payout or a refund opened into a frozen account could never land. Only when the spec says so.
1316
- */
1317
- const startsFrozen = (t, where, what, code, error) => {
1351
+ const holdsUp = (t, where, what, kind) => {
1318
1352
  const p = t?.powers ?? {};
1319
- if (!p.defaultFrozen && !(p.defaultState && p.freeze)) return;
1320
- refuse(
1321
- code,
1353
+ const frozen = p.defaultFrozen || (p.defaultState && p.freeze);
1354
+ if (!frozen && !p.confidentialTransfers && !p.nonTransferable) return;
1355
+ const how = [
1356
+ p.defaultFrozen && 'its new accounts start frozen',
1357
+ !p.defaultFrozen && frozen && 'its new accounts can be switched to start frozen',
1358
+ p.confidentialTransfers && 'its holders can turn plain transfers away (confidential transfers)',
1359
+ p.nonTransferable && 'it cannot be transferred at all',
1360
+ ].filter(Boolean);
1361
+ const does = {
1362
+ auction:
1363
+ 'the highest bidder can close their own account of it so that every higher bid fails (a topped bid is sent back to that account), and win at their price. Tell the seller and every bidder.',
1364
+ lottery:
1365
+ 'a player who turns plain transfers into their own account off (or closes it) holds up only their own part, but the round cannot close meanwhile and its rent waits: keep spare SOL in the lottery ("roundRent") so no buyer fronts it. A pot or pool in a token that could not reach the round gives that round nothing.',
1366
+ contest:
1367
+ 'a winner who closes their own account of it holds up their own claim and the contest’s close; a split prize minted in a token that cannot be transferred must be given up by each winner.',
1368
+ }[kind];
1369
+ warn(
1370
+ 'W-HOLD-UP',
1322
1371
  where,
1323
- `${what} is in ${t.symbol}, whose new accounts ${p.defaultFrozen ? 'start frozen' : 'can be made to start frozen (its mint has a default account state and a freeze authority, which can switch it to frozen at any time)'}: a winner's, a refund's or a payout's account opened for them could never take what is owed (${error}).`,
1324
- 'Use a mint without a default account state, or one with no freeze authority.',
1372
+ `${what} is in ${t.symbol ?? t.key}, and ${how.join('; ')}: ${p.nonTransferable ? 'nothing can be paid in, so nobody can take part. ' : ''}${does}`,
1373
+ 'Use a token with none of those powers, or tell players before they take part.',
1325
1374
  );
1326
1375
  };
1327
1376
  /** Whether `address` is the game's own vault for `t` (where open orders, prizes set aside and bids wait), when both are known. */
@@ -1363,6 +1412,8 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
1363
1412
  };
1364
1413
  /** Whether the words on a Token Metadata print were said (once a spec). */
1365
1414
  let printsSaid = false;
1415
+ /** Whether the words on NFTs of a collection the project doesn't control were said (once a spec). */
1416
+ let outsideSaid = false;
1366
1417
  /** A rule on what an NFT says (rules.rs): an item of the game's must be a collectible; or a collection is named. */
1367
1418
  const rule = (r, where, what) => {
1368
1419
  if (!r) return;
@@ -1377,6 +1428,16 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
1377
1428
  'Nothing to change: tell holders of prints that only the original NFTs count.',
1378
1429
  );
1379
1430
  }
1431
+ // An NFT whose creator kept a permanent delegate can be pulled back out of what the project holds.
1432
+ if (r.collection && isAddress(r.collection) && !m.collections.includes(r.collection) && !outsideSaid) {
1433
+ outsideSaid = true;
1434
+ warn(
1435
+ 'W-OUTSIDE-NFT',
1436
+ `${where}.collection`,
1437
+ `${what} takes NFTs of ${r.collection}, a collection the project does not control. The creator of an NFT with a permanent delegate can pull it back out of a hold, a stake, a loan or a listing, and a record it leaves behind can later take the next owner’s deposit of the same NFT. This holds for every rule on such a collection in the spec.`,
1438
+ 'Before a player deposits one, the app looks for this project’s records that name the NFT (a listing, a hold) and warns when one is left from an earlier owner; prefer collections without a permanent delegate.',
1439
+ );
1440
+ }
1380
1441
  // A gate may list wallets instead (rules.rs Gate::Wallets): a duel's two players, a tournament's entrants.
1381
1442
  // Or a list of any size (rules.rs Gate::List): its root on chain, each player's proof passed as accounts.
1382
1443
  if (r.list !== undefined) {
@@ -1492,13 +1553,8 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
1492
1553
  `${what} names "items" and an item, a collection or an attribute: "items" alone means any of those items.`,
1493
1554
  'Keep "items", or the other form.',
1494
1555
  );
1495
- if (!Array.isArray(r.items) || !r.items.length || r.items.length > 16)
1496
- refuse(
1497
- 'E-RULE',
1498
- `${where}.items`,
1499
- `${what} names ${r.items?.length ?? 0} items: 1 to 16.`,
1500
- 'Name 1 to 16 items.',
1501
- );
1556
+ if (!Array.isArray(r.items) || !r.items.length)
1557
+ refuse('E-RULE', `${where}.items`, `${what} names no items.`, 'Name at least one item.');
1502
1558
  for (const [i, k] of (r.items ?? []).entries()) {
1503
1559
  const it = item(k, `${where}.items[${i}]`);
1504
1560
  if (it && !isCollectible(it))
@@ -1511,13 +1567,10 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
1511
1567
  }
1512
1568
  return;
1513
1569
  }
1514
- if (r.oneOf !== undefined && (!Array.isArray(r.oneOf) || !r.oneOf.length || r.oneOf.length > 16))
1515
- refuse(
1516
- 'E-RULE',
1517
- `${where}.oneOf`,
1518
- `${what} lists ${r.oneOf?.length ?? 0} texts in "oneOf": 1 to 16.`,
1519
- 'List 1 to 16 texts.',
1520
- );
1570
+ // "One of" takes as many texts as the record that holds the rule fits (rules.rs): bounded where it is read, a
1571
+ // lottery's NFT ticket and a box's gate and odds.
1572
+ if (r.oneOf !== undefined && (!Array.isArray(r.oneOf) || !r.oneOf.length))
1573
+ refuse('E-RULE', `${where}.oneOf`, `${what} lists no texts in "oneOf".`, 'List at least one text.');
1521
1574
  if (r.oneOf !== undefined && (r.equals !== undefined || r.atLeast !== undefined || r.time !== undefined))
1522
1575
  refuse(
1523
1576
  'E-RULE',
@@ -1651,6 +1704,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
1651
1704
  * treasury wallet's account of `tok`, whatever token it is. `chosen`: the buyer may name one share ("chosen",
1652
1705
  * "chosen:<item>"): a box's or a lottery's split; 'wallet': only "chosen", a wallet (a payment's split).
1653
1706
  */
1707
+ let holderSaid = false;
1654
1708
  const split = (list, tok, where, { what, chosen = false, single = false }) => {
1655
1709
  // Where share `i` is: one destination given alone (`single`) is the field itself.
1656
1710
  const to = i => (single ? where : `${where}[${i}].to`);
@@ -1718,6 +1772,15 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
1718
1772
  `Send it to a farm whose rewards are ${tok.symbol}.`,
1719
1773
  );
1720
1774
  }
1775
+ // A holder's share names the holder when it is paid.
1776
+ if ((s.kind === 'holder' || (s.kind === 'chosen' && s.key)) && !holderSaid) {
1777
+ holderSaid = true;
1778
+ warn(
1779
+ 'W-HOLDER-SHARE',
1780
+ to(i),
1781
+ `${what} pays a share to whoever holds a collectible: the settle names the holder at that moment, and if the holder moves the collectible first, the settle fails and is sent again with the holder read afresh.`,
1782
+ );
1783
+ }
1721
1784
  if (['account', 'holder', 'wallet'].includes(s.kind) && !isAddress(s.key))
1722
1785
  refuse(
1723
1786
  'E-REF',
@@ -1850,7 +1913,16 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
1850
1913
  warn(
1851
1914
  'W-COLLECTIONS',
1852
1915
  'economy.collections',
1853
- `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.`,
1916
+ `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 takes one whose update authority is your wallet, or a Core collection where the project is an update delegate (BadCollection otherwise). An item minted into one needs the project as an update delegate of it, which you add to the collection yourself.`,
1917
+ );
1918
+ // A Core collection where the project is only an update delegate: its owner and every other delegate can
1919
+ // write to it, and what they make reads as the project's own.
1920
+ if (m.collections.length)
1921
+ warn(
1922
+ 'W-COLLECTION-PARTNER',
1923
+ 'economy.collections',
1924
+ 'If the project is only an update delegate of a collection it names (someone else owns it), you trust that partner: every item they mint into it counts as your own, and they can take your access away at any time. Anyone who can write to that collection, its owner and every other update delegate they named, can make an NFT that reads as any of the project’s items, in any of its collections, by minting one there with that "item_id": it passes the project’s gates, boosts and odds for that item and takes its missions’ prizes. Two projects that name one shared collection read each other’s items of the same number as their own. Once the access is taken away, purchases of items in it are refused, a won item can only be settled by giving it up, a staked NFT in a farm that writes on it leaves only by forfeit, and a box that holds a player’s NFT to change its numbers cannot settle until the access is back.',
1925
+ 'Name only a collection whose every writer you trust, and never one another loot project also names.',
1854
1926
  );
1855
1927
  const given = new Set();
1856
1928
  for (const b of m.allBoxes)
@@ -1912,6 +1984,15 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
1912
1984
  'Shorten it.',
1913
1985
  );
1914
1986
  });
1987
+ // A row of weight 0 or stock 0 is never drawn; a table with none that can be is drawn from as an empty one.
1988
+ const dead = (t.rows ?? []).map((r, i) => [r, i]).filter(([r]) => r.weight === 0 || r.stock === 0);
1989
+ if (dead.length)
1990
+ warn(
1991
+ 'W-ZERO',
1992
+ `${where}.rows`,
1993
+ `${dead.length === (t.rows ?? []).length ? 'Every row' : `Row${dead.length === 1 ? '' : 's'} ${dead.map(([, i]) => i).join(', ')}`} of the table “${t.key}” ${dead.length === 1 ? 'has' : 'have'} a weight or a stock of 0: never drawn${dead.length === (t.rows ?? []).length ? ', so a draw from it finds nothing' : ''}.`,
1994
+ 'Give each row a weight and a stock above 0, or leave the row out.',
1995
+ );
1915
1996
  if (!m.items.some(it => (it.drawn ?? []).some(d => d.table === t.key)))
1916
1997
  warn(
1917
1998
  'W-TABLE',
@@ -2141,6 +2222,15 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
2141
2222
  }
2142
2223
  const users = stashUsers.get(s.key);
2143
2224
  const named = directs.filter(p => p.prize.stash === s.key);
2225
+ // NFTs of weight 0: never drawn, given only by name.
2226
+ const light = stashNfts(s).filter(n => n.weight === 0);
2227
+ if (light.length)
2228
+ warn(
2229
+ 'W-STASH',
2230
+ `${where}.nfts`,
2231
+ `${label(s)} holds ${light.length} NFT${light.length === 1 ? '' : 's'} of weight 0: never drawn, and given only by name. A raffle counts them among the stash’s NFTs, so a raffle with more places than NFTs that weigh something leaves the extra places with nothing${light.length === stashNfts(s).length ? '; with none that weighs, a draw from it finds nothing, and a box that stops selling when an outcome is out is not sold' : '; a box that stops selling when out may land on the stash and find nothing to draw'}.`,
2232
+ 'Give the NFTs to draw a weight above 0; keep weight 0 for NFTs a prize names.',
2233
+ );
2144
2234
  // A known limit of the program: a named NFT is found by reading the stash's list from its start, about 52
2145
2235
  // compute units for each NFT ahead of it, and a box's roll looks for every NFT it names, then takes the one won. A
2146
2236
  // stash that players fill (a box's fate, a recipe's trade-in, a lottery's NFT ticket) can grow past what fits.
@@ -2162,7 +2252,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
2162
2252
  }
2163
2253
  if (!(s.nfts ?? []).length && !intoStash.has(s.key)) {
2164
2254
  // A box that stops selling when an outcome runs out (the default) isn't sold at all while it is empty.
2165
- const stopped = users.filter(b => b.active !== false && b.whenOut !== 'drawLeft' && !boxBuys(b, m));
2255
+ const stopped = users.filter(b => b.active !== false && stopsSales(b) && !boxBuys(b, m));
2166
2256
  warn(
2167
2257
  'W-STASH',
2168
2258
  `${where}.nfts`,
@@ -2222,6 +2312,82 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
2222
2312
  }
2223
2313
 
2224
2314
  // ---- boxes (catalog.rs, order.rs)
2315
+ /**
2316
+ * What one answer of the oracle's holds: the program refuses a box when it is written (PackTooLarge) when one
2317
+ * answer can't hold a draw of it, a box of one draw included. Each measured number (`ANSWER_HOLDS`) is the most of
2318
+ * one thing in a box otherwise small: past one, the box is refused here; a box that mixes several near their numbers
2319
+ * is warned of, since together they fit less.
2320
+ */
2321
+ const answerHolds = (b, where) => {
2322
+ const outs = b.outcomes ?? [];
2323
+ const texts = r => (r?.oneOf ?? []).length + (r?.items ?? []).length;
2324
+ const parts = outs.map(outcomeParts);
2325
+ const tokenOutcomes = parts.filter(p => p.some(x => x.gives === 'pay' || x.gives === 'mint')).length;
2326
+ const itemOutcomes = parts.filter(p => p.some(x => x.gives === 'item')).length;
2327
+ const gateTexts = texts(b.gate);
2328
+ const oddsTexts = (b.odds ?? []).reduce((n, o) => n + texts(o.when), 0);
2329
+ const over = [
2330
+ [outs.length, ANSWER_HOLDS.entriesOfNothing, `${outs.length} outcomes`, `${where}.outcomes`],
2331
+ [(b.slots ?? []).length, ANSWER_HOLDS.slots, `${(b.slots ?? []).length} slots`, `${where}.slots`],
2332
+ [(b.odds ?? []).length, ANSWER_HOLDS.odds, `${(b.odds ?? []).length} odds rules`, `${where}.odds`],
2333
+ [gateTexts, ANSWER_HOLDS.gateTexts, `${gateTexts} texts in its gate`, `${where}.gate`],
2334
+ ...parts.flatMap((p, i) => [
2335
+ [
2336
+ p.filter(x => x.gives === 'pay' || x.gives === 'mint').length,
2337
+ ANSWER_HOLDS.entryTokens,
2338
+ `${p.filter(x => x.gives === 'pay' || x.gives === 'mint').length} token prizes in outcome ${i}`,
2339
+ `${where}.outcomes[${i}]`,
2340
+ ],
2341
+ [
2342
+ p.filter(x => x.gives === 'item').length,
2343
+ ANSWER_HOLDS.entryCollectibles,
2344
+ `${p.filter(x => x.gives === 'item').length} items in outcome ${i}`,
2345
+ `${where}.outcomes[${i}]`,
2346
+ ],
2347
+ ]),
2348
+ ].filter(([n, most]) => n > most);
2349
+ for (const [, most, what, at] of over)
2350
+ refuse(
2351
+ 'E-ODDS',
2352
+ at,
2353
+ `${label(b)} has ${what}: one answer of the oracle’s holds at most ${most} of that, as measured, and the program refuses a box when it is written that one answer can’t draw (PackTooLarge).`,
2354
+ 'Put a long weighted list in a table (an item that draws a trait from it) or a stash, drawn by one outcome; give fewer prizes in one outcome; keep a gate’s "oneOf" short.',
2355
+ );
2356
+ if (over.length) return;
2357
+ // How much of one answer the box takes, each part at its measured number: a guide, not the program's count.
2358
+ const used =
2359
+ (outs.length - tokenOutcomes - itemOutcomes) / ANSWER_HOLDS.entriesOfNothing +
2360
+ tokenOutcomes / ANSWER_HOLDS.entriesOfTokens +
2361
+ itemOutcomes / ANSWER_HOLDS.entriesOfItems +
2362
+ (b.slots ?? []).length / ANSWER_HOLDS.slots +
2363
+ (b.odds ?? []).length / ANSWER_HOLDS.odds +
2364
+ gateTexts / ANSWER_HOLDS.gateTexts;
2365
+ if (used > 0.75)
2366
+ warn(
2367
+ 'W-ANSWER',
2368
+ where,
2369
+ `${label(b)} is long: by the measured numbers it takes about ${Math.round(used * 100)}% of what one answer of the oracle’s holds. One answer holds 128 outcomes that give nothing, 29 of token amounts or 18 each of its own item; 41 slots; 72 odds rules; 16 token prizes or 18 collectibles in one outcome; 340 texts in a box’s gate. A box that mixes them fits less, and one that does not fit is refused when it is written (PackTooLarge). A box written from a wallet carries about 960 bytes; a longer one needs a program call (a multisig).`,
2370
+ 'Put a long weighted list in a table or a stash, drawn by one outcome.',
2371
+ );
2372
+ // An outcome of 15 to 18 collectibles fits an answer, but not a sale in one step.
2373
+ const many = parts.findIndex(p => p.filter(x => x.gives === 'item').length >= 15);
2374
+ if (many >= 0 && outs.length === 1 && boxBuys(b, m))
2375
+ warn(
2376
+ 'W-ANSWER',
2377
+ `${where}.outcomes[${many}]`,
2378
+ `${label(b)} gives ${parts[many].filter(x => x.gives === 'item').length} collectibles in one outcome: a box of one outcome with 15 to 18 collectibles is too wide for a sale in one step and sells only through the oracle (an open, then a settle).`,
2379
+ 'Fine as it is; or split the bundle in two boxes to sell each in one step.',
2380
+ );
2381
+ if (oddsTexts > 200)
2382
+ warn(
2383
+ 'W-ANSWER',
2384
+ `${where}.odds`,
2385
+ `${label(b)}’s odds rules list ${oddsTexts} "one of" texts in all: the oracle’s answer reads them all, and past a few hundred it cannot land, so every purchase is refunded after 10 minutes.`,
2386
+ 'Keep "one of" lists in odds rules short, or raise the odds by a number (an attribute "atLeast").',
2387
+ );
2388
+ };
2389
+ /** Every prize a route gives (a grant, a place, a mission, a recipe...), for what a box shares with them. */
2390
+ const routePrizes = prizes(m);
2225
2391
  let chance = false;
2226
2392
  let randomItems = false;
2227
2393
  /** Per pool, the largest fixed prize a box pays from it, and every box that pays one: warned of once a pool, below. */
@@ -2232,43 +2398,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
2232
2398
  // rolls, or one outcome of items, with no outcome that burns the payment and no NFT to roll on (BadEntries).
2233
2399
  const slots = b.slots ?? [];
2234
2400
  const outs = b.outcomes ?? [];
2235
- tooMany(
2236
- outs.length,
2237
- LIMITS.boxEntries,
2238
- 'E-ODDS',
2239
- `${where}.outcomes`,
2240
- `${label(b)} has ${outs.length} outcomes`,
2241
- 'BadEntries',
2242
- 'Use fewer outcomes, or roll on a table of rows ("drawn").',
2243
- );
2244
- tooMany(
2245
- slots.length,
2246
- LIMITS.boxSlots,
2247
- 'E-ODDS',
2248
- `${where}.slots`,
2249
- `${label(b)} has ${slots.length} slots`,
2250
- 'BadEntries',
2251
- 'Put alike draws into one slot with "count".',
2252
- );
2253
- tooMany(
2254
- (b.odds ?? []).length,
2255
- LIMITS.boxOdds,
2256
- 'E-ODDS',
2257
- `${where}.odds`,
2258
- `${label(b)} has ${(b.odds ?? []).length} odds that read an NFT`,
2259
- 'BadEntries',
2260
- 'Keep the ones that matter.',
2261
- );
2262
- const fated = outs.filter(o => (o.shifts ?? []).length || (o.then ?? b.then ?? 'back') !== 'back').length;
2263
- tooMany(
2264
- fated,
2265
- LIMITS.boxFates,
2266
- 'E-ROLL',
2267
- `${where}.outcomes`,
2268
- `${fated} outcomes of ${label(b)} act on the NFT it rolls on`,
2269
- 'BadEntries',
2270
- 'Have fewer outcomes act on it.',
2271
- );
2401
+ answerHolds(b, where);
2272
2402
  for (const [i, o] of outs.entries()) {
2273
2403
  const given = outcomeParts(o);
2274
2404
  tooMany(
@@ -2280,6 +2410,14 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
2280
2410
  'BadEntries',
2281
2411
  'Give fewer, or make it two outcomes.',
2282
2412
  );
2413
+ // What a roll changes on the NFT it is on is written by the settle, with the NFT's own attributes.
2414
+ if ((o.shifts ?? []).length > 16)
2415
+ warn(
2416
+ 'W-ANSWER',
2417
+ `${where}.outcomes[${i}].shifts`,
2418
+ `An outcome of ${label(b)} changes ${(o.shifts ?? []).length} numbers on the NFT it rolls on. The settle writes them with the NFT’s own attributes, and together they must stay under about 60, or the settle cannot land and a held NFT waits with the project.`,
2419
+ 'Change fewer numbers in one outcome, and keep the NFTs it rolls on to few attributes.',
2420
+ );
2283
2421
  // An outcome gives all it names or isn't given (order.rs): what it names is refused at the write when it can clash.
2284
2422
  for (const [j, x] of given.entries()) {
2285
2423
  const clash = given
@@ -2302,14 +2440,14 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
2302
2440
  warn(
2303
2441
  'W-OUT-OF-STOCK',
2304
2442
  `${where}.outcomes[${i}]`,
2305
- `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'}.`,
2443
+ `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${stopsSales(b) && 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'}.`,
2306
2444
  'Give fewer, or raise its supply.',
2307
2445
  );
2308
2446
  }
2309
2447
  }
2310
2448
  // A box that stops selling once an outcome is out covers each purchase's draws, and a random draw of a stash could
2311
2449
  // take one of its NFTs another outcome names: it can't list both (catalog.rs, BadEntries).
2312
- if (b.whenOut !== 'drawLeft') {
2450
+ if (stopsSales(b)) {
2313
2451
  // One outcome naming both is refused above already.
2314
2452
  const parts = outs.map(outcomeParts);
2315
2453
  const random = parts.flatMap((p, i) => p.filter(x => x.gives === 'stash').map(x => [i, x.stash]));
@@ -2325,7 +2463,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
2325
2463
  'E-ODDS',
2326
2464
  `${where}.outcomes`,
2327
2465
  `${label(b)} gives an NFT of the stash “${key}” at random and, in another outcome, one of its NFTs by name, and it stops selling once an outcome is out ("whenOut": "stopSales", the default): a random draw could take the named one, so nothing could cover it, and the program refuses the box (BadEntries).`,
2328
- `Make it "whenOut": "drawLeft", or give the named NFT from a stash of its own.`,
2466
+ `Make it "whenOut": "drawLeft" or "giveNothing", or give the named NFT from a stash of its own.`,
2329
2467
  );
2330
2468
  }
2331
2469
  // A fresh outcome's tables come with the answer while they fit what the box's own accounts leave, in outcome order
@@ -2799,11 +2937,12 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
2799
2937
  : refuse('E-ODDS', at, `An outcome of ${label(b)} pays from a pool but does not say which.`, 'Add "pool".');
2800
2938
  if (p) {
2801
2939
  const amount = units(o.amount, m.tokens.get(p.currency), `${at}.amount`, `${label(b)}’s prize`);
2940
+ // A prize of 0 tokens is given with nothing moved.
2802
2941
  if (amount === 0n)
2803
- refuse(
2804
- 'E-ODDS',
2942
+ warn(
2943
+ 'W-ZERO',
2805
2944
  `${at}.amount`,
2806
- `An outcome of ${label(b)} pays nothing (BadEntries).`,
2945
+ `An outcome of ${label(b)} pays 0: it is drawn and given, and its winner gets nothing.`,
2807
2946
  'Give it an amount above 0, or make it "nothing".',
2808
2947
  );
2809
2948
  // In base units, exact (null when it is not an amount, refused above).
@@ -2909,6 +3048,54 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
2909
3048
  `${label(b)} sells in one step, all of it or nothing, so "whenOut" changes nothing.`,
2910
3049
  'Leave "whenOut" out.',
2911
3050
  );
3051
+ // What a draw on an outcome that is out does, by the setting the project chose, and who can make it happen by
3052
+ // emptying a source this box shares with another route.
3053
+ const runsDry = o =>
3054
+ outcomeParts(o).some(
3055
+ x =>
3056
+ x.gives === 'pay' ||
3057
+ x.gives === 'stash' ||
3058
+ x.gives === 'nft' ||
3059
+ (x.gives === 'item' && Number(m.item.get(x.item)?.supply ?? 0) > 0),
3060
+ );
3061
+ const rolledHere = !boxBuys(b, m) || rolls.some(r => r.box === b.key);
3062
+ if (b.whenOut === 'giveNothing' && rolledHere)
3063
+ warn(
3064
+ 'W-WHEN-OUT',
3065
+ `${where}.whenOut`,
3066
+ `${label(b)} gives nothing for a draw on an outcome that is out ("whenOut": "giveNothing"): a paid draw is not refunded, a free roll is over, and the box keeps selling. Anyone who empties what an outcome gives (a pool, an item’s units, a stash), by this box or another route that takes from it, makes its rolls miss.`,
3067
+ 'Tell players a draw can miss, keep each outcome in stock, or keep "stopSales" (a paid miss refunded) or "drawLeft" (a draw among what is left).',
3068
+ );
3069
+ const sharedSources = outs
3070
+ .flatMap(outcomeParts)
3071
+ .map(x =>
3072
+ x.gives === 'pay'
3073
+ ? ['pool', x.pool]
3074
+ : x.gives === 'stash' || x.gives === 'nft'
3075
+ ? ['stash', x.stash]
3076
+ : x.gives === 'item'
3077
+ ? ['item', x.item]
3078
+ : null,
3079
+ )
3080
+ .filter(Boolean)
3081
+ .filter(([kind, key]) =>
3082
+ kind === 'pool'
3083
+ ? takers.some(t => t.pool === key && t.by !== `box:${b.key}`)
3084
+ : kind === 'stash'
3085
+ ? routePrizes.some(p => p.prize.stash === key) ||
3086
+ m.allBoxes.some(x => x !== b && (x.outcomes ?? []).flatMap(outcomeParts).some(y => y.stash === key))
3087
+ : Number(m.item.get(key)?.supply ?? 0) > 0 &&
3088
+ (Number(m.item.get(key)?.price ?? 0) > 0 ||
3089
+ routePrizes.some(p => p.prize.item === key) ||
3090
+ m.allBoxes.some(x => x !== b && (x.outcomes ?? []).flatMap(outcomeParts).some(y => y.item === key))),
3091
+ );
3092
+ if (sharedSources.length && rolledHere && outs.some(runsDry) && b.active !== false)
3093
+ warn(
3094
+ 'W-SHARED-SOURCE',
3095
+ `${where}.outcomes`,
3096
+ `${label(b)} draws from ${[...new Set(sharedSources.map(([kind, key]) => `the ${kind} “${key}”`))].join(', ')}, which another route also takes from. ${stopsSales(b) ? 'A buyer can open the box, empty that source by the other route before the answers land, and be refunded every miss' : b.whenOut === 'giveNothing' ? 'Anyone emptying that source makes this box’s rolls miss, and a miss gives nothing' : 'Anyone emptying that source moves this box’s odds to what is left'}.`,
3097
+ 'Give the box sources of its own; cap a fresh item’s supply at what its table holds.',
3098
+ );
2912
3099
  if (b.whenOut === 'drawLeft' && boxRolls(b, m)) {
2913
3100
  // The rarest outcome that gives something, against the likeliest outcome that can run out.
2914
3101
  const w = weights(b);
@@ -2950,7 +3137,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
2950
3137
  const others = [...boxes].filter(x => x !== box).map(label);
2951
3138
  // A box that stops selling when an outcome is out (the default) needs the prize for this purchase's draws and every
2952
3139
  // draw still waiting for the oracle before it sells at all.
2953
- const stopped = [...boxes].filter(x => x.active !== false && x.whenOut !== 'drawLeft' && boxRolls(x, m));
3140
+ const stopped = [...boxes].filter(x => x.active !== false && stopsSales(x) && boxRolls(x, m));
2954
3141
  const stops = stopped.length
2955
3142
  ? ` ${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.`
2956
3143
  : '';
@@ -3017,22 +3204,32 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
3017
3204
  };
3018
3205
  }
3019
3206
  })();
3020
- for (const [kind, words] of [
3021
- ['inputs', 'lines in'],
3022
- ['outputs', 'lines out'],
3023
- ['needs', 'needs'],
3024
- ['keeps', 'numbers it carries over'],
3025
- ['raises', 'raises'],
3026
- ])
3027
- tooMany(
3028
- lines[kind].length,
3029
- LIMITS.recipeLines,
3030
- 'E-RECIPE',
3031
- where,
3032
- `${label(r)} has ${lines[kind].length} ${words}`,
3033
- 'BadRecipe',
3034
- 'Split it into two recipes.',
3035
- );
3207
+ // A recipe takes any number of lines; what one craft can carry is measured: past it, the recipe is written
3208
+ // and never crafts.
3209
+ {
3210
+ const pays = ins.filter(l => l.takes === 'pay');
3211
+ const coins = new Set(pays.map(l => l.currency ?? m.currencyKey)).size;
3212
+ const pooled = (r.out ?? []).filter(l => l.gives === 'tokens' && prizePool(l)).length;
3213
+ const raised = lines.raises.length + lines.needs.length;
3214
+ const past = [
3215
+ coins > 1 &&
3216
+ pays.length > ANSWER_HOLDS.recipeCoins &&
3217
+ `${pays.length} payments in ${coins} coins (at most ${ANSWER_HOLDS.recipeCoins})`,
3218
+ coins <= 1 &&
3219
+ pays.length > ANSWER_HOLDS.recipePayments &&
3220
+ `${pays.length} payments (at most ${ANSWER_HOLDS.recipePayments} in one coin)`,
3221
+ pooled > ANSWER_HOLDS.recipePoolOutputs &&
3222
+ `${pooled} outputs from a pool (at most ${ANSWER_HOLDS.recipePoolOutputs})`,
3223
+ raised > ANSWER_HOLDS.recipeRaises && `${raised} raises and needs (at most ${ANSWER_HOLDS.recipeRaises})`,
3224
+ ].filter(Boolean);
3225
+ if (past.length)
3226
+ warn(
3227
+ 'W-ANSWER',
3228
+ where,
3229
+ `${label(r)} has ${past.join(', ')}: the program writes it, but one craft can’t carry that much, so it never crafts.`,
3230
+ 'Split it into two recipes.',
3231
+ );
3232
+ }
3036
3233
  const outs = r.out ?? [];
3037
3234
  if (!ins.length && !outs.length)
3038
3235
  warn(
@@ -3226,13 +3423,28 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
3226
3423
  const places = ins.flatMap(l =>
3227
3424
  l.takes === 'burn' ? Array(l.count ?? 1).fill('burn') : l.takes === 'pay' ? [] : [l.takes],
3228
3425
  );
3426
+ // Each roll is its own order, at the craft's nonce plus its count (craft.rs); its receipts must fit the log.
3229
3427
  const rolls = outs.filter(l => l.gives === 'roll');
3230
- if (rolls.length > 1)
3231
- refuse(
3232
- 'E-RECIPE',
3428
+ if (rolls.length > ANSWER_HOLDS.recipeRolls)
3429
+ warn(
3430
+ 'W-ANSWER',
3233
3431
  `${where}.out`,
3234
- `${label(r)} rolls ${rolls.length} boxes: a craft makes one roll.`,
3235
- 'Keep one roll.',
3432
+ `${label(r)} gives ${rolls.length} rolls: past ${ANSWER_HOLDS.recipeRolls}, a craft’s receipts run past what a transaction’s log holds (10,000 bytes), and its Crafted receipt is cut, so an indexer or a results page can’t read what it made.`,
3433
+ `Give at most ${ANSWER_HOLDS.recipeRolls} rolls a craft, or roll a box that gives a pack.`,
3434
+ );
3435
+ // Two rolls on one NFT whose box holds it while it rolls: the craft fails whole.
3436
+ const held = rolls.filter(
3437
+ l =>
3438
+ l.on !== undefined &&
3439
+ (m.box.get(l.box)?.outcomes ?? []).some(o => (o.shifts ?? []).length || (o.then && o.then !== 'back')),
3440
+ );
3441
+ const twice = held.find((l, i) => held.slice(0, i).some(x => x.on === l.on));
3442
+ if (twice)
3443
+ warn(
3444
+ 'W-RECIPE',
3445
+ `${where}.out`,
3446
+ `${label(r)} gives two rolls on collectible ${twice.on}, and a box that holds the NFT while it rolls: the second can’t take the NFT the first holds, so every craft of it fails whole.`,
3447
+ 'Give one roll on each collectible.',
3236
3448
  );
3237
3449
  for (const [i, line] of outs.entries()) {
3238
3450
  if (line.gives !== 'roll') continue;
@@ -3394,7 +3606,15 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
3394
3606
  if (fromNowSeconds(s.every, `${where}.every`) <= 0)
3395
3607
  refuse('E-SCHEDULE', `${where}.every`, `${label(s)} never comes round (every 0).`, 'Give it a time like "1d".');
3396
3608
  try {
3397
- toTime(s.start, now);
3609
+ farTime(toTime(s.start, now), `${where}.start`, `${label(s)}’s start`, s.start, 'it runs nothing');
3610
+ if (!s.sealed)
3611
+ farTime(
3612
+ now + seconds(s.every, `${where}.every`),
3613
+ `${where}.every`,
3614
+ `${label(s)}’s next run`,
3615
+ s.every,
3616
+ 'it runs nothing',
3617
+ );
3398
3618
  } catch (err) {
3399
3619
  refuse(err.code, `${where}.start`, err.message, err.fix);
3400
3620
  }
@@ -3481,16 +3701,17 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
3481
3701
  'Leave "active" out.',
3482
3702
  );
3483
3703
  const d = destination(s.to ?? '');
3484
- // A wallet that is a program's address (off the curve) nobody can sign for: a run would pay where nobody can
3485
- // spend it, or pass it over (BadMove).
3704
+ // A payee nobody signs for (an address off the curve): a multisig's vault is one, and so is an address of this
3705
+ // program, where a record made later would make every run pass it over. The program refuses only an
3706
+ // account this program already owns (BadMove), which it checks when the schedule is written.
3486
3707
  if (d.kind === 'wallet' && isAddress(d.key) && !isOnCurve(addressBytes(d.key)))
3487
- refuse(
3488
- 'E-SCHEDULE',
3708
+ warn(
3709
+ 'W-SCHEDULE-PAYEE',
3489
3710
  `${where}.to`,
3490
- `${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).`,
3491
- 'Pay the wallet of a person (its public key).',
3711
+ `${label(s)} is sealed and pays ${d.key}, an address nobody holds a key of: a program's address. A multisig's vault is fine; an address of the loot program is not: a record made there later would make every run pass it over, and the vault would never empty. Nothing can change the payee once it is sealed.`,
3712
+ 'Check the address is a wallet or a multisig vault you can spend from, before you sign.',
3492
3713
  );
3493
- // Its last run within 10 years of going live.
3714
+ // Its last run: far off is taken, and warned of with its date.
3494
3715
  if (s.maxRuns > 0) {
3495
3716
  let first = now + seconds(s.every, `${where}.every`);
3496
3717
  try {
@@ -3498,21 +3719,23 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
3498
3719
  } catch {
3499
3720
  // A start that doesn't read is refused where the schedule's times are checked.
3500
3721
  }
3501
- if (first + seconds(s.every, `${where}.every`) * (s.maxRuns - 1) - now > MAX_SCHEDULE_SECONDS)
3722
+ const last = first + seconds(s.every, `${where}.every`) * (s.maxRuns - 1);
3723
+ if (last > Number.MAX_SAFE_INTEGER / 1000)
3502
3724
  refuse(
3503
3725
  'E-SCHEDULE',
3504
3726
  `${where}.maxRuns`,
3505
- `${label(s)} is sealed and its last run would come more than 10 years after go-live (BadSchedule).`,
3727
+ `${label(s)} is sealed and its last run would come further off than a date holds (BadSchedule).`,
3506
3728
  'Fewer runs, a shorter "every", or an earlier "start".',
3507
3729
  );
3730
+ else
3731
+ farTime(
3732
+ last,
3733
+ `${where}.maxRuns`,
3734
+ `${label(s)}’s last run`,
3735
+ s.every,
3736
+ 'the vault keeps what is left, and nobody can take it out early',
3737
+ );
3508
3738
  }
3509
- if (s.maxRuns > MAX_SEALED_RUNS)
3510
- refuse(
3511
- 'E-SCHEDULE',
3512
- `${where}.maxRuns`,
3513
- `${label(s)} is sealed with ${s.maxRuns} runs; a sealed schedule runs at most ${MAX_SEALED_RUNS} times (BadSchedule).`,
3514
- 'Fewer runs, each larger.',
3515
- );
3516
3739
  // 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
3517
3740
  // authority moves a pool; an account's holder may close it).
3518
3741
  if (!['wallet', 'treasury', 'burn'].includes(d.kind) || !s.to)
@@ -3529,14 +3752,34 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
3529
3752
  `${label(s)} burns SOL, and wrapped SOL cannot be burned.`,
3530
3753
  'Pay it somewhere instead.',
3531
3754
  );
3532
- startsFrozen(tok, `${where}.currency`, `${label(s)} (sealed)`, 'E-SCHEDULE', 'BadSchedule');
3533
- if (tok?.powers?.permanentDelegate || tok?.powers?.nonTransferable || tok?.powers?.confidentialTransfers)
3755
+ // A vault that pays a wallet or the treasury moves its tokens by a transfer: a token that can't be transferred
3756
+ // would sit in it for good. The program takes it (only a burn of a token whose burn needs its issuer is refused).
3757
+ if (tok?.powers?.nonTransferable && d.kind !== 'burn')
3534
3758
  refuse(
3535
3759
  'E-SCHEDULE',
3536
3760
  `${where}.currency`,
3537
- `${label(s)} is sealed in ${tok.symbol}, whose mint ${tok.powers.permanentDelegate ? 'has a permanent delegate, who could take the tokens out of the vault' : 'can’t move tokens out of a vault by a plain transfer'} (BadSchedule).`,
3761
+ `${label(s)} is sealed in ${tok.symbol}, which cannot be transferred: no run could ever pay ${d.kind === 'treasury' ? 'the treasury' : 'the wallet'}, and the vault, which nobody can open, would keep it all for good.`,
3538
3762
  'Seal it in another token.',
3539
3763
  );
3764
+ // A vest is only as safe as its token.
3765
+ const p = tok?.powers ?? {};
3766
+ const powers = [
3767
+ p.permanentDelegate && 'a permanent delegate can take the tokens out of the vault',
3768
+ p.feeChangeable && 'its issuer can raise the transfer fee to 100% and take each run as it leaves',
3769
+ p.closeAuthority && 'its mint can be closed and made again with other powers',
3770
+ (p.freeze || p.defaultFrozen || (p.defaultState && p.freeze)) &&
3771
+ 'a freeze can hold the vault or the payee’s account',
3772
+ p.pausable && 'a pause can hold every run',
3773
+ p.transferHook && 'its transfer hook can refuse a run',
3774
+ p.confidentialTransfers && 'a payee can turn plain transfers away',
3775
+ ].filter(Boolean);
3776
+ if (powers.length)
3777
+ warn(
3778
+ 'W-SCHEDULE',
3779
+ `${where}.currency`,
3780
+ `${label(s)} is sealed in ${tok.symbol}: a vest is only as safe as its token, and ${powers.join('; ')}.`,
3781
+ 'Seal it in a token without those powers, or tell whoever it pays.',
3782
+ );
3540
3783
  if (byShare && !(percent(s.percent, `${where}.percent`) > 0))
3541
3784
  refuse('E-SCHEDULE', `${where}.percent`, `${label(s)} pays 0%.`, 'Give it a percent above 0.');
3542
3785
  else if (!byShare && units(s.amount, tok, `${where}.amount`, `What ${label(s)} pays`) === 0n)
@@ -3667,11 +3910,8 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
3667
3910
  const where = `economy.lotteries.${l.key}`;
3668
3911
  const potToken = token(l.currency, `${where}.currency`);
3669
3912
  const ticket = l.ticketCurrency ? token(l.ticketCurrency, `${where}.ticketCurrency`) : potToken;
3670
- confidential(potToken, `${where}.currency`, label(l), 'E-LOTTERY');
3671
- if (ticket !== potToken) confidential(ticket, `${where}.ticketCurrency`, label(l), 'E-LOTTERY');
3672
- startsFrozen(potToken, `${where}.currency`, label(l), 'E-LOTTERY', 'BadLottery');
3673
- if (ticket !== potToken)
3674
- startsFrozen(ticket, `${where}.ticketCurrency`, `${label(l)}’s ticket`, 'E-LOTTERY', 'BadLottery');
3913
+ holdsUp(potToken, `${where}.currency`, label(l), 'lottery');
3914
+ if (ticket !== potToken) holdsUp(ticket, `${where}.ticketCurrency`, `${label(l)}’s ticket`, 'lottery');
3675
3915
  if (l.ticketCurrency && ticket?.wsol)
3676
3916
  refuse(
3677
3917
  'E-LOTTERY',
@@ -3717,12 +3957,14 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
3717
3957
  single: true,
3718
3958
  });
3719
3959
  }
3720
- if (l.noLoss)
3721
- refuse(
3722
- 'E-LOTTERY',
3723
- `${where}.noLoss`,
3724
- `${label(l)} gives every ticket back, so a round has no ticket money of its own for its places: a no-loss lottery pays from a pot (BadLottery).`,
3725
- 'Write "pot": 0 in place of "rest", and fill the pot ("seed", a sponsor, "lottery:<key>" in a split or a schedule).',
3960
+ // A no-loss lottery that pays from its own money: every ticket goes back, so its places take no money and give
3961
+ // only their prizes.
3962
+ if (l.noLoss && (l.places ?? [100]).some(p => placeOf(p).percent > 0))
3963
+ warn(
3964
+ 'W-LOTTERY',
3965
+ `${where}.places`,
3966
+ `${label(l)} gives every ticket back and pays each round from that round's own ticket money ("rest"): there is no money left for its places, so a place's percent wins nothing from it, and only its prize is won.`,
3967
+ 'Give the places prizes, or keep a pot ("pot": 0) that the owner or other sales fill.',
3726
3968
  );
3727
3969
  if (Number(l.seed ?? 0) > 0)
3728
3970
  refuse(
@@ -3798,26 +4040,58 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
3798
4040
  `${label(l)} draws once (no "every"), and the draw is due at go-live.`,
3799
4041
  'Give it a "firstDraw" (like "publish+1d"), or an "every" to draw again and again.',
3800
4042
  );
3801
- // A round whose draw is more than five years off sells nothing (lottery.rs).
3802
- if (seconds(l.every, `${where}.every`) > MAX_CONTEST_SECONDS)
3803
- refuse(
3804
- 'E-LOTTERY',
3805
- `${where}.every`,
3806
- `${label(l)} draws every ${l.every}: a draw more than 5 years off sells no ticket (SalesClosed).`,
3807
- 'Draw within 5 years.',
3808
- );
4043
+ // A draw far off is taken, and warned of with its date: the tickets wait until then.
4044
+ const every = seconds(l.every, `${where}.every`);
4045
+ let firstDraw = now + every;
3809
4046
  try {
3810
- if (l.firstDraw && toTime(l.firstDraw, now) - now > MAX_CONTEST_SECONDS)
3811
- refuse(
3812
- 'E-LOTTERY',
3813
- `${where}.firstDraw`,
3814
- `${label(l)} first draws more than 5 years after go-live: no ticket sells until the draw is within 5 years (SalesClosed).`,
3815
- 'Draw sooner.',
3816
- );
3817
- toTime(l.firstDraw, now);
4047
+ if (l.firstDraw) firstDraw = toTime(l.firstDraw, now);
4048
+ farTime(
4049
+ firstDraw,
4050
+ l.firstDraw ? `${where}.firstDraw` : `${where}.every`,
4051
+ `${label(l)}’s first draw`,
4052
+ l.firstDraw ?? l.every,
4053
+ 'its tickets and their money wait in it',
4054
+ );
3818
4055
  } catch (err) {
3819
4056
  refuse(err.code, `${where}.firstDraw`, err.message, err.fix);
3820
4057
  }
4058
+ // Sales close this long before each draw (lottery.rs `closes_before`): never a whole rhythm or more before it.
4059
+ const closesBefore = seconds(l.closesBefore, `${where}.closesBefore`);
4060
+ if (every > 0 && closesBefore >= every)
4061
+ refuse(
4062
+ 'E-LOTTERY',
4063
+ `${where}.closesBefore`,
4064
+ `${label(l)} draws every ${l.every} and closes its sales ${l.closesBefore} before each draw: sales would never open (BadLottery).`,
4065
+ `Close sales less than ${l.every} before the draw.`,
4066
+ );
4067
+ else if (closesBefore > 0 && firstDraw - closesBefore <= now)
4068
+ warn(
4069
+ 'W-LOTTERY',
4070
+ `${where}.closesBefore`,
4071
+ `${label(l)} closes its sales ${l.closesBefore} before its first draw, which is sooner than that after go-live: its first round sells no ticket (SalesClosed).`,
4072
+ 'Draw later, or close sales closer to the draw.',
4073
+ );
4074
+ // Tickets weighed by the time they are held (lottery.rs `time_weighted`): a no-loss lottery only (BadLottery).
4075
+ if (l.timeWeighted && !l.noLoss)
4076
+ refuse(
4077
+ 'E-LOTTERY',
4078
+ `${where}.timeWeighted`,
4079
+ `${label(l)} weighs its tickets by the time they are held, but keeps its ticket money: only a lottery that gives every ticket back may weigh them by time (BadLottery).`,
4080
+ 'Make it "noLoss": true, or leave "timeWeighted" out.',
4081
+ );
4082
+ else if (l.timeWeighted)
4083
+ warn(
4084
+ 'W-LOTTERY-WEIGHT',
4085
+ `${where}.timeWeighted`,
4086
+ `${label(l)} weighs a purchase by its tickets times the seconds from it to the close of sales${closesBefore ? ` (${l.closesBefore} before each draw)` : ' (at the draw: set "closesBefore" to the close you mean)'}: a ticket’s chance grows with the time it is held. Tell every buyer, on the buy screen, what their purchase weighs now and that the same tickets bought later weigh less. ${Number(l.price ?? 0) > 0 || l.ticketNft ? '' : 'Tickets are free: give it a "perWallet" limit or a gate, as any free lottery wants. '}"winners", "minSold" and "perWallet" still count tickets.`,
4087
+ );
4088
+ else if (l.noLoss && !closesBefore)
4089
+ warn(
4090
+ 'W-LOTTERY',
4091
+ `${where}.closesBefore`,
4092
+ `${label(l)} gives every ticket back and sells until the draw: tickets bought seconds before it have the same chance at the pot as tickets held all period, and their price comes back.`,
4093
+ 'Set "closesBefore" (sales close that long before the draw), or "timeWeighted": true so a ticket counts for the time it is held.',
4094
+ );
3821
4095
  // A place takes a share of the pot, a roll of a box for its winner, or both (lottery.rs Place).
3822
4096
  const placed = (l.places ?? [100]).map(placeOf);
3823
4097
  tooMany(
@@ -3843,13 +4117,21 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
3843
4117
  );
3844
4118
  if (typeof raw === 'object' && raw?.prize)
3845
4119
  prizeCheck(raw.prize, `${where}.places[${i}].prize`, `${label(l)}’s place ${i + 1}`, 'E-LOTTERY');
3846
- // A run holds places alike: a named NFT or a roll is one place's.
3847
- if (p.times > 1 && (p.prize?.gives === 'nft' || p.prize?.gives === 'roll'))
4120
+ // A run holds places alike: a named NFT is one place's (a roll may repeat: each place its own roll).
4121
+ if (p.times > 1 && p.prize?.gives === 'nft')
3848
4122
  refuse(
3849
4123
  'E-LOTTERY',
3850
4124
  `${where}.places[${i}].times`,
3851
- `${label(l)}’s place ${i + 1} repeats ${p.times} times a prize one place gives (a named NFT, a roll) (BadLottery).`,
3852
- '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).',
4125
+ `${label(l)}’s place ${i + 1} repeats ${p.times} times a named NFT, which one place gives (BadLottery).`,
4126
+ 'Give it to one place, or make it a prize places can share alike (tokens, an item, a roll, an NFT drawn from the lottery’s stash).',
4127
+ );
4128
+ // What a place of tokens from a pool gets when the pool is short (lottery.rs `WhenShort`).
4129
+ if (raw?.whenShort !== undefined && !(p.prize?.gives === 'tokens' && prizePool(p.prize)))
4130
+ warn(
4131
+ 'W-LOTTERY',
4132
+ `${where}.places[${i}].whenShort`,
4133
+ `${label(l)}’s place ${i + 1} says what it gets when its pool is short, but it is not paid in tokens from a pool: "whenShort" changes nothing there.`,
4134
+ 'Leave "whenShort" out.',
3853
4135
  );
3854
4136
  // A run of places given one by one (anything but tokens: a collectible, a compressed NFT, a raffled NFT) holds at
3855
4137
  // most MAX_RUN_PLACES places: a pull walks the run from its first place (BadLottery).
@@ -3916,15 +4198,62 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
3916
4198
  `${label(l)} gives the same NFT on two places: the second could never be given (BadLottery).`,
3917
4199
  'Name each NFT once.',
3918
4200
  );
3919
- // One pull takes every NFT its places win out of the stash, in place order (lottery.rs `assign_fits`).
3920
- const rows = (l.nfts ?? []).length + held.length;
3921
- const draw = 6_000 + 250 * Math.ceil(rows / 4_096);
3922
- if (raffled * draw + held.length * 15 * rows > 1_300_000)
4201
+ // The places from its own stash, raffled or named, are what its draw's answer loads (lottery.rs `MOST_OWN`).
4202
+ if (raffled + held.length > LIMITS.ownStashPlaces)
3923
4203
  refuse(
3924
4204
  'E-LOTTERY',
3925
4205
  `${where}.places`,
3926
- `${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).`,
3927
- 'Fewer raffled places (about 200 at most in a small stash), or a smaller stash.',
4206
+ `${label(l)} has ${raffled + held.length} places from its own stash: a raffle has at most ${LIMITS.ownStashPlaces}, the most the oracle’s answer loads on the chain’s default memory (BadLottery).`,
4207
+ `Raffle at most ${LIMITS.ownStashPlaces} NFTs a lottery: run a second lottery for the rest.`,
4208
+ );
4209
+ // Every run paying from one pool says the same about a short pool (lottery.rs, BadLottery), and the project hears
4210
+ // what its setting does.
4211
+ const byPool = new Map();
4212
+ for (const [i, p] of placed.entries()) {
4213
+ const key = p.prize?.gives === 'tokens' ? prizePool(p.prize) : null;
4214
+ if (!key) continue;
4215
+ byPool.set(key, [...(byPool.get(key) ?? []), [i, p.whenShort ?? 'inOrder']]);
4216
+ }
4217
+ for (const [key, runs] of byPool) {
4218
+ const said = [...new Set(runs.map(([, w]) => w))];
4219
+ if (said.length > 1)
4220
+ refuse(
4221
+ 'E-LOTTERY',
4222
+ `${where}.places`,
4223
+ `${label(l)}’s places paid from the pool “${key}” say different things about a short pool (${said.join(', ')}): every run paying from one pool must say the same (BadLottery).`,
4224
+ `Give each place paid from “${key}” the same "whenShort".`,
4225
+ );
4226
+ else
4227
+ warn(
4228
+ 'W-LOTTERY-POOL',
4229
+ `${where}.places`,
4230
+ `${label(l)}’s places paid from the pool “${key}” read it once, when the draw is asked, and the project can spend the pool until then. When it holds less than the places with a winner win, each place gets what "whenShort" says, here ${said[0] === 'inProportion' ? '"inProportion": each place a share in proportion to its prize' : said[0] === 'nothing' ? '"nothing": no place of that pool gets anything' : '"inOrder" (the default): the first places in full, the rest what is left'}. Prizes of 1,000, 500 and 500 from a pool holding 1,700 give 1,000, 500 and 0 in order, 850, 425 and 425 in proportion, or nothing.`,
4231
+ 'Keep the pool stocked for every place before the draw, or pay from the lottery’s own pot.',
4232
+ );
4233
+ }
4234
+ // A named NFT of a stash the project holds: the first winner whose prize is pulled gets it.
4235
+ const named = placed.filter(p => p.prize?.gives === 'nft' && !p.prize.held);
4236
+ const nftNames = named.map(p => p.prize.nft);
4237
+ if (named.length && (every > 0 || new Set(nftNames).size < nftNames.length))
4238
+ warn(
4239
+ 'W-LOTTERY',
4240
+ `${where}.places`,
4241
+ `${label(l)} ${every > 0 ? 'draws again and again and names' : 'names'} ${new Set(nftNames).size < nftNames.length ? 'one NFT of a stash for several places' : 'NFTs of a stash as prizes'}: the first winner whose prize is pulled gets the NFT, and later winners get nothing.`,
4242
+ 'Raffle NFTs from the lottery’s own stash ("raffled", or "raffle"), or give each place its own NFT in a lottery that draws once.',
4243
+ );
4244
+ if (l.ticketNft?.rule?.oneOf && l.ticketNft.rule.oneOf.length > LIMITS.ticketOneOf)
4245
+ refuse(
4246
+ 'E-LOTTERY',
4247
+ `${where}.ticketNft.rule.oneOf`,
4248
+ `${label(l)}’s NFT ticket rule lists ${l.ticketNft.rule.oneOf.length} "one of" texts: the oracle’s answer reads the rule, and holds at most ${LIMITS.ticketOneOf} (BadLottery).`,
4249
+ `List at most ${LIMITS.ticketOneOf}, or test a number ("atLeast") instead.`,
4250
+ );
4251
+ if (l.ticketNft?.rule?.items && l.ticketNft.rule.items.length > LIMITS.ticketOneOf)
4252
+ refuse(
4253
+ 'E-LOTTERY',
4254
+ `${where}.ticketNft.rule.items`,
4255
+ `${label(l)}’s NFT ticket rule names ${l.ticketNft.rule.items.length} items: the oracle’s answer reads the rule, and holds at most ${LIMITS.ticketOneOf} (BadLottery).`,
4256
+ `Name at most ${LIMITS.ticketOneOf}.`,
3928
4257
  );
3929
4258
  if (held.length)
3930
4259
  warn(
@@ -3946,7 +4275,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
3946
4275
  `${label(l)}’s places add up to ${total / 100}% of the pot, more than all of it.`,
3947
4276
  'Make them add up to 100% or less.',
3948
4277
  );
3949
- else if (total < 10_000)
4278
+ else if (total < 10_000 && !(l.noLoss && !keepsPot(l)))
3950
4279
  warn(
3951
4280
  'W-LOTTERY',
3952
4281
  `${where}.places`,
@@ -3997,8 +4326,10 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
3997
4326
  warn(
3998
4327
  'W-LOTTERY',
3999
4328
  where,
4000
- `${label(l)} loses nobody their tickets: every ticket goes back to its buyer once the round is drawn (anyone can finish each purchase), and the winners take only the pot, what is paid into it (the seed, a sponsor, a share of other sales). Ticket money waits in the lottery’s own vault until then. Tickets are not weighted by how long they were held: someone may buy most of a round’s tickets right before the draw and risk only the fees.`,
4001
- 'A "perWallet" limit bounds one wallet, not many; keep the pot small next to what a late buyer would put in, or gate it (a stake, a list).',
4329
+ `${label(l)} loses nobody their tickets: every ticket goes back to its buyer once the round is drawn (anyone can finish each purchase), and the winners take only the pot, what is paid into it (the seed, a sponsor, a share of other sales). Ticket money waits in the lottery’s own vault until then.${l.timeWeighted ? '' : ` Tickets are not weighted by how long they were held: someone may buy most of a round’s tickets ${closesBefore ? `just before sales close (${l.closesBefore} before the draw)` : 'right before the draw'} and risk only the fees.`}`,
4330
+ l.timeWeighted
4331
+ ? undefined
4332
+ : 'Weigh tickets by the time they are held ("timeWeighted": true), close sales a while before the draw ("closesBefore"), keep the pot small next to what a late buyer would put in, or gate it (a stake, a list).',
4002
4333
  );
4003
4334
  }
4004
4335
  rule(l.gate, `${where}.gate`, label(l));
@@ -4225,13 +4556,13 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
4225
4556
  );
4226
4557
  for (const [i, t] of tiers.entries()) {
4227
4558
  fourByteSeconds(t.lock, `${where}.tiers[${i}].lock`);
4228
- // A prize given on leaving needs a lock, or it would be given again and again (BadFarm).
4229
- if (t.prize && !(toSeconds(t.lock ?? 0) > 0))
4230
- refuse(
4231
- 'E-FARM',
4559
+ // A mission with no lock is the project's choice: one NFT can come and go and take it each time.
4560
+ if ((t.prize || t.roll) && !(toSeconds(t.lock ?? 0) > 0))
4561
+ warn(
4562
+ 'W-FARM-MISSION',
4232
4563
  `${where}.tiers[${i}].lock`,
4233
- `${label(f)}’s tier ${i} gives a prize with no lock: stakes could come and go, and take it again and again (BadFarm).`,
4234
- 'Give the tier a "lock" above 0.',
4564
+ `No lock on ${label(f)}’s mission tier ${i}: one player can stake and leave one NFT again and again and ${t.prize ? 'take the prize' : 'roll the box'} each time.`,
4565
+ 'Give the tier a "lock" above 0, such as "1d".',
4235
4566
  );
4236
4567
  if (t.earnsFor !== undefined) fourByteSeconds(t.earnsFor, `${where}.tiers[${i}].earnsFor`);
4237
4568
  const bps = Math.round((t.multiplier ?? 1) * 10_000);
@@ -4239,8 +4570,16 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
4239
4570
  refuse(
4240
4571
  'E-FARM',
4241
4572
  `${where}.tiers[${i}].multiplier`,
4242
- `${label(f)}’s tier ${i} multiplies by ${t.multiplier}: a tier is above 0 and at most x11.`,
4243
- 'Use a multiplier above 0, up to 11.',
4573
+ `${label(f)}’s tier ${i} multiplies by ${t.multiplier}: a tier is above 0 and at most x${LIMITS.maxTierBps / 10_000} (BadFarm).`,
4574
+ `Use a multiplier above 0, up to ${LIMITS.maxTierBps / 10_000}.`,
4575
+ );
4576
+ // A heavy tier a seat can leave early for a fee: the weight is bought for the fee.
4577
+ else if (bps >= 30_000 && Number(t.exitFee ?? 0) > 0 && !ofCollectibles)
4578
+ warn(
4579
+ 'W-FARM',
4580
+ `${where}.tiers[${i}].exitFee`,
4581
+ `${label(f)}’s tier ${i} weighs x${t.multiplier} and lets a seat leave early for ${t.exitFee}%: a player can take the large weight, earn at it, and leave for the fee.`,
4582
+ 'Check the fee is worth what the tier pays over its lock, or drop the early exit on heavy tiers.',
4244
4583
  );
4245
4584
  else if (bps < 10_000)
4246
4585
  warn(
@@ -4252,6 +4591,15 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
4252
4591
  for (const [i, b] of (f.boosts ?? []).entries()) {
4253
4592
  rule(b.when, `${where}.boosts[${i}].when`, `${label(f)}’s boost`);
4254
4593
  effect(b.add, `${where}.boosts[${i}].add`, `${label(f)}’s boost`);
4594
+ // A boost per point adds its percent times its most points, at most +100,000% (rules.rs `Boost::check`).
4595
+ const most = Number(b.add?.perPointPercent ?? 0) * Number(b.add?.upTo ?? 0);
4596
+ if (b.add?.perPointPercent !== undefined && Math.round(most * 100) > LIMITS.maxBoostBps)
4597
+ refuse(
4598
+ 'E-FARM',
4599
+ `${where}.boosts[${i}].add`,
4600
+ `${label(f)}’s boost adds ${b.add.perPointPercent}% a point up to ${b.add.upTo} points: +${most.toLocaleString('en-US')}% at most, past the +${(LIMITS.maxBoostBps / 100).toLocaleString('en-US')}% a farm’s boost may add (BadFarm).`,
4601
+ 'Add less a point, or count fewer points ("upTo").',
4602
+ );
4255
4603
  }
4256
4604
  boostPercent(f.maxBoostPercent, `${where}.maxBoostPercent`);
4257
4605
  if ((f.boosts ?? []).length && !(f.maxBoostPercent > 0))
@@ -4287,12 +4635,20 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
4287
4635
  `${label(f)}’s rate of ${f.rate.perDay} a day is less than one base unit for each 1,000,000 base units staked.`,
4288
4636
  'Raise "perDay".',
4289
4637
  );
4638
+ // A farm that mints while the project is not the mint's authority credits nothing, and what it would have paid is
4639
+ // not owed later; the program takes it, so the mint can be handed over after.
4290
4640
  if (f.rate.minted && rewardsToken && !rewardsToken.gameMints)
4291
- refuse(
4292
- 'E-MINT-NOT-OURS',
4641
+ warn(
4642
+ 'W-FARM-AUTHORITY',
4643
+ `${where}.rate.minted`,
4644
+ `${label(f)} mints its rewards, but the project is not the authority of ${rewardsToken.symbol}’s mint. It pays nothing until you set the mint’s authority to the project’s address; rewards for that time are lost, not paid later. When you hand the mint over, send \`walk_farm\` in the same transaction, just before it: then nothing from before the hand-over is ever paid; without it, the first move after the hand-over credits all the time since the farm’s last move.`,
4645
+ 'Reward in a token the project makes (source "project"), pay the rate from its rewards vault ("minted": false), or hand the mint to the project before the farm opens.',
4646
+ );
4647
+ if (f.rate.minted && rewardsToken)
4648
+ warn(
4649
+ 'W-FARM',
4293
4650
  `${where}.rate.minted`,
4294
- `${label(f)} mints its rewards, but the project cannot mint ${rewardsToken.symbol}.`,
4295
- 'Pay the rate from its rewards vault ("minted": false), or reward in a token with source "project".',
4651
+ `${label(f)} mints ${rewardsToken.symbol}: if the project ever moves the mint’s authority away, stakers cannot harvest what they are owed and can only leave by giving it up.`,
4296
4652
  );
4297
4653
  if (!f.rate.minted)
4298
4654
  warn(
@@ -4349,6 +4705,14 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
4349
4705
  if (!ofCollectibles && !(f.boosts ?? []).length)
4350
4706
  warn('W-FARM', at, `${label(f)} stakes a token: only a collectible put on a seat as its booster grows.`);
4351
4707
  }
4708
+ // A farm whose seats stop earning at times of their own keeps one entry a different second.
4709
+ if (tiers.some(t => t.earnsFor !== undefined) || f.upkeepAttribute !== undefined)
4710
+ warn(
4711
+ 'W-FARM-ENDS',
4712
+ `${where}.tiers`,
4713
+ `${label(f)} keeps one entry for each different second at which a seat stops earning, 238,312 at most; the first staker on a new second pays about 0.0003 SOL for it. When it is full, a stake at a new end time fails until older seats are touched. A stake in a large farm needs more than the default compute (an insert took 145,279 units at the full list).`,
4714
+ 'Expect it with many stakers: the app asks for more compute on a stake, and anyone may touch old seats for free.',
4715
+ );
4352
4716
  if (f.runs !== undefined) {
4353
4717
  const runs = fromNowSeconds(f.runs, `${where}.runs`);
4354
4718
  for (const [i, t] of tiers.entries())
@@ -4392,13 +4756,6 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
4392
4756
  `${label(f)} stakes a token: a mission’s prize is for a collectible that stays its tier’s lock.`,
4393
4757
  'Make it a farm of collectibles, or remove the prizes.',
4394
4758
  );
4395
- if (toSeconds(t.lock ?? 0) === 0)
4396
- refuse(
4397
- 'E-FARM',
4398
- at,
4399
- `${label(f)}’s tier ${i} gives a prize with no lock: staking and leaving at once would give it again and again.`,
4400
- 'Give the mission a lock, such as "1d".',
4401
- );
4402
4759
  }
4403
4760
  for (const [i, t] of tiers.entries()) {
4404
4761
  if (!t.roll) continue;
@@ -4410,13 +4767,6 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
4410
4767
  `${label(f)} stakes a token: a mission’s roll is for a collectible leaving its seat.`,
4411
4768
  'Make it a farm of collectibles, or remove the roll.',
4412
4769
  );
4413
- if (toSeconds(t.lock ?? 0) === 0)
4414
- refuse(
4415
- 'E-FARM',
4416
- at,
4417
- `${label(f)}’s tier ${i} rolls with no lock: staking and leaving at once would roll it again and again.`,
4418
- 'Give the mission a lock, such as "1d".',
4419
- );
4420
4770
  if (!m.box.get(t.roll))
4421
4771
  refuse('E-FARM', at, `${label(f)}’s tier ${i} rolls “${t.roll}”, which is no box.`, 'Name a box of this spec.');
4422
4772
  else if (!m.box.get(t.roll).rollsOn)
@@ -4539,8 +4889,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
4539
4889
  'Name fewer deciders.',
4540
4890
  );
4541
4891
  const tok = token(c.currency, `${where}.currency`);
4542
- confidential(tok, `${where}.currency`, label(c), 'E-CONTEST');
4543
- startsFrozen(tok, `${where}.currency`, label(c), 'E-CONTEST', 'BadContest');
4892
+ holdsUp(tok, `${where}.currency`, label(c), 'contest');
4544
4893
  // Entries close no later than the result is due, and with deciders before it: a decider's verdict is taken only
4545
4894
  // once entries close (contest.rs, BadContest). With deciders, a side left out is an hour from the other (plan.mjs
4546
4895
  // `contestArgs`).
@@ -4553,13 +4902,6 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
4553
4902
  `${label(c)}’s entries close after its result is due (BadContest).`,
4554
4903
  'Close entries before the result is due.',
4555
4904
  );
4556
- else if (Math.max(closes, due) > MAX_CONTEST_SECONDS)
4557
- refuse(
4558
- 'E-CONTEST',
4559
- where,
4560
- `${label(c)}’s result is due more than 5 years after it opens: the program refuses to open it (BadContest).`,
4561
- 'Have its result due within 5 years.',
4562
- );
4563
4905
  else if ((c.deciders ?? []).length && c.entriesClose !== undefined && c.resultDue !== undefined && closes >= due)
4564
4906
  refuse(
4565
4907
  'E-CONTEST',
@@ -4567,6 +4909,36 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
4567
4909
  `${label(c)} has deciders, whose verdict is taken only once entries close, and its entries close when its result is due: the program refuses to open it (BadContest).`,
4568
4910
  'Close entries before the result is due, or leave one of "entriesClose" and "resultDue" out (an hour apart).',
4569
4911
  );
4912
+ // A result far off is taken, and warned of with its date: the stakes wait until then.
4913
+ farTime(
4914
+ now + Math.max(closes, due),
4915
+ c.resultDue !== undefined ? `${where}.resultDue` : `${where}.entriesClose`,
4916
+ `${label(c)}’s result`,
4917
+ c.resultDue ?? c.entriesClose,
4918
+ 'every entry’s stake waits in its pot',
4919
+ );
4920
+ // Whoever names the winner, and how the pot is paid.
4921
+ warn(
4922
+ 'W-CONTEST',
4923
+ where,
4924
+ `${label(c)}’s winner is named by ${(c.deciders ?? []).length ? 'its deciders’ verdict' : 'the project’s keys'}, who can also enter it: players trust them with the result. The pot is the contest’s own account, paid only by each entry’s claim.`,
4925
+ );
4926
+ // One prize a winning entry, and nothing that costs an entry anything: one person's many wallets take one each.
4927
+ const stakeUnits = c.stakeNft ? 1n : quietUnits(c.stake, tok);
4928
+ if (c.prize && !c.prize.split && !c.gate && !c.stakeNft && !(stakeUnits > 0n))
4929
+ warn(
4930
+ 'W-CONTEST-PRIZE',
4931
+ `${where}.prize`,
4932
+ `${label(c)} gives a prize to each winning entry, with no fixed stake and no gate: one person with many wallets enters from each and collects one prize a wallet.`,
4933
+ 'Set a "stake" that makes an entry cost something, gate it, or split one prize by stake ("split": true).',
4934
+ );
4935
+ if (c.stakeNft && c.losersEach === 0)
4936
+ warn(
4937
+ 'W-ZERO',
4938
+ `${where}.losersEach`,
4939
+ `${label(c)} takes 0 losers’ NFTs a winner: every loser’s NFT goes to whoever opened it, and the winners get only their own back.`,
4940
+ 'Give "losersEach" a number above 0, or leave it out for no cap.',
4941
+ );
4570
4942
  if (c.sideNames && c.sideNames.length !== c.sides)
4571
4943
  warn(
4572
4944
  'W-CONTEST',
@@ -4577,6 +4949,15 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
4577
4949
  split(c.split, tok, `${where}.split`, { what: label(c) });
4578
4950
  rule(c.gate, `${where}.gate`, label(c));
4579
4951
  const cp = c.prize ? prizeCheck(c.prize, `${where}.prize`, label(c), 'E-CONTEST') : null;
4952
+ // A prize split among the winners is set aside when the winner is named, and the program sets aside only an amount
4953
+ // above 0 (contest.rs `set_aside`: PrizeNotCovered). The contest opens, but no winner can ever be named.
4954
+ if (cp && c.prize.split && cp.gives === 'tokens' && Number(cp.amount) === 0)
4955
+ refuse(
4956
+ 'E-CONTEST',
4957
+ `${where}.prize.amount`,
4958
+ `${label(c)} splits a prize of 0 among its winners: the program opens the contest but can never name its winner (PrizeNotCovered), so it runs past its result and is called off, every entry refunded.`,
4959
+ 'Give the prize an amount above 0, or leave the prize out.',
4960
+ );
4580
4961
  // One NFT, the last units of an item, a pool's prize: the winning entries share it in the order they are claimed.
4581
4962
  const scarce =
4582
4963
  cp &&
@@ -4664,8 +5045,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
4664
5045
  for (const a of m.auctions) {
4665
5046
  const where = `economy.auctions.${a.key}`;
4666
5047
  const tok = token(a.currency, `${where}.currency`);
4667
- confidential(tok, `${where}.currency`, label(a), 'E-AUCTION');
4668
- startsFrozen(tok, `${where}.currency`, label(a), 'E-AUCTION', 'BadAuction');
5048
+ holdsUp(tok, `${where}.currency`, label(a), 'auction');
4669
5049
  const reserve = a.bidNfts
4670
5050
  ? BigInt(Number.isInteger(a.reserve) ? a.reserve : 1)
4671
5051
  : units(a.reserve, tok, `${where}.reserve`, `${label(a)}’s reserve`);
@@ -4716,31 +5096,35 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
4716
5096
  `${label(a)} would be over before it goes live (BadAuction).`,
4717
5097
  'Start it at "publish", or later.',
4718
5098
  );
4719
- // Nothing but a higher bid hands a high bid back before the end: an end, or a push of it, more than five years off
4720
- // would hold the bid and the lot that long, and the program refuses it (auction.rs).
4721
- if (runs > 0 && start + runs - now > MAX_CONTEST_SECONDS)
4722
- refuse(
4723
- 'E-AUCTION',
4724
- `${where}.${start - now >= MAX_CONTEST_SECONDS ? 'start' : 'runs'}`,
4725
- `${label(a)} ends more than 5 years after go-live: until its end only a higher bid hands a high bid back, so a bid and the lot would be held that long (BadAuction).`,
4726
- 'End it within 5 years.',
4727
- );
4728
- if (extend > MAX_CONTEST_SECONDS)
4729
- refuse(
4730
- 'E-AUCTION',
4731
- `${where}.extendBy`,
4732
- `${label(a)} moves its end by more than 5 years after a bid: one bid would hold the lot and itself that long (BadAuction).`,
4733
- 'Use an extension of minutes, such as "10m".',
5099
+ // Nothing but a higher bid hands a high bid back before the end: an end, or a push of it, far off holds the bid and
5100
+ // the lot that long. The program takes it; the date is said in words.
5101
+ if (runs > 0)
5102
+ farTime(
5103
+ start + runs,
5104
+ `${where}.${start - now > FAR_SECONDS ? 'start' : 'runs'}`,
5105
+ `${label(a)}’s end`,
5106
+ start - now > FAR_SECONDS ? a.start : a.runs,
5107
+ 'the high bid and the lot are held',
5108
+ );
5109
+ farTime(
5110
+ now + extend,
5111
+ `${where}.extendBy`,
5112
+ `${label(a)}’s end, pushed by one bid,`,
5113
+ a.extendBy,
5114
+ 'that bid and the lot are held',
5115
+ );
5116
+ // No extension or no least raise: two wallets topping each other by a unit in the last seconds keep every other bid
5117
+ // out.
5118
+ const raise = Number(a.minRaise ?? 5);
5119
+ if (!a.bidNfts && (extend < 60 || raise < 1))
5120
+ warn(
5121
+ 'W-AUCTION',
5122
+ `${where}.${extend < 60 ? 'extendBy' : 'minRaise'}`,
5123
+ `${label(a)} ${extend < 60 ? `moves its end by ${extend ? `only ${extend} seconds` : 'nothing'} after a late bid` : `takes a bid that tops the last by ${raise ? `only ${raise}%` : 'a single unit'}`}: ${extend === 0 && raise === 0 ? 'with both at zero, ' : ''}two wallets topping each other in the last seconds keep every other bid out.`,
5124
+ 'Give it an extension of 60 seconds or more ("extendBy": "1m") and a least raise of 1% or more ("minRaise": 1).',
4734
5125
  );
4735
5126
  if (a.lot?.item) {
4736
5127
  const it = item(a.lot.item, `${where}.lot.item`);
4737
- if (it && it.active === false)
4738
- refuse(
4739
- 'E-AUCTION',
4740
- `${where}.lot.item`,
4741
- `${label(a)} sells ${label(it)}, which is switched off (LotRefused).`,
4742
- `Switch ${label(it)} on.`,
4743
- );
4744
5128
  if (it?.kind === 'normal')
4745
5129
  warn(
4746
5130
  'W-AUCTION',
@@ -4758,6 +5142,15 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
4758
5142
  );
4759
5143
  randomItems = true;
4760
5144
  } else if (a.lot?.asset) {
5145
+ // An NFT outside the project's collections: whoever controls its collection can stop it moving or take it
5146
+ // back while the project holds it.
5147
+ if (a.lot.collection && !m.collections.includes(a.lot.collection))
5148
+ warn(
5149
+ 'W-AUCTION-OUTSIDE',
5150
+ `${where}.lot.collection`,
5151
+ `${label(a)} sells an NFT of ${a.lot.collection}, outside the project’s collections: whoever controls that collection can stop the NFT moving or take it back while the project holds it. The seller is paid only when the lot reaches the winner; while it cannot move, the winning bid waits in the project’s vault until it can or until the project’s keys call the sale off, and a lot taken back or burned sends the bid back. A bidder’s money can be tied up, not lost. Tell every bidder before a bid.`,
5152
+ 'Check the NFT and its collection for a permanent freeze, transfer or burn delegate (Core), a rule set (a programmable Token Metadata NFT), or such delegates on a compressed NFT’s collection, before it opens.',
5153
+ );
4761
5154
  if (!a.lot.collection && !m.live.project)
4762
5155
  refuse(
4763
5156
  'E-AUCTION',
@@ -4810,7 +5203,19 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
4810
5203
  `${label(h)} holds ${label(it)}, which is ${it.soulbound ? 'soulbound' : 'not a collectible'}: only a collectible that can move can be held.`,
4811
5204
  'Hold a collectible that is not soulbound.',
4812
5205
  );
4813
- seconds(h.for, `${where}.for`);
5206
+ // While a hold runs, the project's authority alone decides where the NFT goes; a far end is taken.
5207
+ farTime(
5208
+ now + seconds(h.for, `${where}.for`),
5209
+ `${where}.for`,
5210
+ `${label(h)}’s holds`,
5211
+ h.for,
5212
+ 'each NFT waits with the project',
5213
+ );
5214
+ warn(
5215
+ 'W-HOLD',
5216
+ where,
5217
+ `${label(h)}: while a hold runs, the project’s authority alone decides where its NFT goes, and it runs as long as the project sets. Players trust the project with the NFT until then.`,
5218
+ );
4814
5219
  if (h.until !== undefined) {
4815
5220
  try {
4816
5221
  if (toTime(h.until, now) < now)
@@ -4850,6 +5255,14 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
4850
5255
  const keys = g.keys ?? [];
4851
5256
  const l = g.limits ?? {};
4852
5257
  const open = !vouchers.length && !keys.length && !(g.list ?? []).length;
5258
+ // A grant written again with the same list lets its wallets claim again.
5259
+ if ((g.list ?? []).length)
5260
+ warn(
5261
+ 'W-GRANT',
5262
+ `${where}.list`,
5263
+ `${label(g)} pays the wallets on its list: if it is ever made again with the same list (closed and opened anew), they can claim again.`,
5264
+ 'For a second round, change the list or the amounts; leave it as it is to pay each wallet once.',
5265
+ );
4853
5266
  // List entries that name their own amount: tokens of a fixed amount only, each at most the grant's (OverPrize).
4854
5267
  for (const [i, e] of (g.list ?? []).entries()) {
4855
5268
  if (typeof e !== 'object' || e.amount === undefined) continue;