@xgamesfun/solana 0.26.0 → 0.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "address": "9cwqM1TXytPnFZsLfmUyv2dg4uW6Ch9C6UJVPns7NFQ",
3
3
  "metadata": {
4
4
  "name": "loot",
5
- "version": "0.26.0",
5
+ "version": "0.26.1",
6
6
  "spec": "0.1.0",
7
7
  "description": "Economy for Anything: one program every coin of a project passes through",
8
8
  "repository": "https://github.com/gamesfun/loot"
@@ -9551,7 +9551,7 @@
9551
9551
  {
9552
9552
  "code": 6141,
9553
9553
  "name": "PackTooLarge",
9554
- "msg": "A box's draws (one, or each open's times the opens bought together) must all fit one answer of the oracle's: fewer entries, prizes an entry or draws"
9554
+ "msg": "A box's draws (one, or each open's times the opens bought together) must all fit one answer of the oracle's, and the sales one buy adds up one transaction: fewer entries, prizes an entry, draws or opens"
9555
9555
  },
9556
9556
  {
9557
9557
  "code": 6142,
@@ -11575,7 +11575,8 @@
11575
11575
  "docs": [
11576
11576
  "The carry: rounding of a share over the total weight, times `PRECISION`. The next move before",
11577
11577
  "the farm's end credits it to the seats there once it adds up to a share; a move that leaves no",
11578
- "weight, or any move once the farm is over, frees its whole tokens (`FarmSide::settle`)."
11578
+ "weight, or any move once the farm is over, frees its whole tokens (`FarmSide::settle`), and so",
11579
+ "does a walk that begins with no weight or where the weight reaches 0 (`Farm::walk`)."
11579
11580
  ],
11580
11581
  "type": "u128"
11581
11582
  },
@@ -1,7 +1,10 @@
1
1
  // Rent sizes from the current IDL. Fixed allocations reserve the largest enum and option forms,
2
2
  // matching Anchor InitSpace. Dynamic records use their actual encoded fields.
3
+ import { addressBytes } from '../chain/base58.js';
3
4
  import { encodeAccount, encodeBorsh } from '../chain/borsh.js';
5
+ import { uintLE, utf8 } from '../chain/bytes.js';
4
6
  import IDL from './idl/loot.js';
7
+ import { lootAddress } from './pda.js';
5
8
 
6
9
  const scalar = { bool: 1, u8: 1, u16: 2, u32: 4, i32: 4, u64: 8, i64: 8, u128: 16, pubkey: 32 };
7
10
  // What a fixed-size account reserves for its lists and strings: a roll's fate (`Fate.shifts`, each key at most 32 bytes).
@@ -170,7 +173,7 @@ export function orderBytes(lootBox, shares = 0, opens = 1, items) {
170
173
 
171
174
  // What one oracle answer's roll spends, counted at no less than each part costs (`order.rs`'s `RollCost`, measured by
172
175
  // the program's `tests/roll_costs.rs` and `tests/roll_heap.rs`), against the compute and the heap the oracle gives its
173
- // answer, less a margin.
176
+ // answer, less a margin. Every number of the program's count is here, and only here: a change of one is one edit.
174
177
  const ROLL_BUDGET = 300_000n;
175
178
  const ROLL_HEAP = 32n * 1024n - 512n;
176
179
  const COST = {
@@ -184,7 +187,14 @@ const COST = {
184
187
  tokens: 6_000n,
185
188
  draw: 9_000n,
186
189
  group: 250n,
190
+ row: 10n,
191
+ // One try of an address search, what the runtime charges (`COST_TRY`).
192
+ try: 1_500n,
193
+ // Reading a prize of an entry after its first (`COST_PRIZE`).
194
+ prize: 300n,
187
195
  reread: 1_000n,
196
+ // A draw of an entry that gives nothing, beyond its weighing (`COST_NOTHING`).
197
+ nothing: 1_100n,
188
198
  finish: 15_000n,
189
199
  won: 150n,
190
200
  drawn: 50n,
@@ -195,6 +205,22 @@ const COST = {
195
205
  slotEntry: 350n,
196
206
  openSlot: 150n,
197
207
  };
208
+ /** The most draws one `open_box` takes in all, its count times the box's draws: what one answer could weigh, each draw
209
+ * at its weighing at least (`ROLL_BUDGET / COST_WEIGH`, 300). More is refused before any price is added up
210
+ * (`PackTooLarge`). */
211
+ export const MOST_DRAWS_A_PURCHASE = Number(ROLL_BUDGET / COST.weigh);
212
+ /** The compute one transaction has, and what adding up one sale's price costs at most (`catalog.rs` `COST_SALE`). */
213
+ const MAX_TX_CU = 1_400_000;
214
+ const COST_SALE = 55;
215
+ /** The most sales one `buy` takes (`count`): what one transaction can price (`MAX_TX_CU / COST_SALE`, rounded down:
216
+ * 25,454). More is refused before any price is added up (`PackTooLarge`). */
217
+ export const MOST_SALES_A_BUY = Math.floor(MAX_TX_CU / COST_SALE);
218
+ /**
219
+ * The tries counted for finding a pool's or a stash's address when the project is not known yet (a spec checked before
220
+ * any wallet is named): 2, what an address takes on average (half are found at the first try, a quarter at the second,
221
+ * and so on). With the project known, the count is exact: `256 - bump` tries an address.
222
+ */
223
+ export const ASSUMED_TRIES = 2;
198
224
  const BYTES = {
199
225
  start: 6_000n,
200
226
  prizeRead: 600n,
@@ -310,88 +336,152 @@ const accountOf = p => {
310
336
  };
311
337
 
312
338
  /**
313
- * Whether all of `opens` opens of `entries` by `slots` fit one oracle answer (`order::draws_fit`), each draw at the
314
- * dearest its slot could make, with what is known from the box alone (no table rows, a stash of one group), its `odds`,
315
- * `shares` and `fates` counted, and `bytes` (`boxBytes`) of heap reading the box takes beyond its entries. `slots` may
316
- * be a number: that many draws among every entry. The answer's heap counts for a box that rolls, or one sold through
317
- * the oracle (`oracle`: a box that doesn't roll but one `buy` can't carry, `buyFits`). The program refuses a box whose
318
- * one open doesn't fit at its upsert, and a purchase that doesn't at `open_box`, where it also reads what the accounts
319
- * hold (`PackTooLarge`).
339
+ * The tries finding a pool's (`pool` true) or a stash's address takes (`order::searched`: `256 - bump`), for `project`'s
340
+ * pool or stash `id`. With no project, `ASSUMED_TRIES`.
320
341
  */
321
- export function drawsFit(
322
- entries,
323
- slots,
324
- opens = 1,
325
- { odds = 0, shares = 0, fates = 0, bytes = 0n, oracle = false } = {},
326
- ) {
327
- const given = typeof slots === 'number' || typeof slots === 'bigint' ? [{ count: slots, only: [] }] : (slots ?? []);
328
- // A box with no slots draws once among every entry.
329
- const list = given.length ? given : [{ count: 1, only: [] }];
330
- const draws = list.reduce((n, s) => n + BigInt(s.count), 0n) * BigInt(opens);
331
- // A box of one draw is held to the same answer: one past it would be refused at every purchase.
342
+ export function searchTries(project, pool, id) {
343
+ if (!project) return ASSUMED_TRIES;
344
+ const [, bump] = pool ? lootAddress(pdaSeeds.pool(project, id)) : lootAddress(pdaSeeds.stash(project, id));
345
+ return 256 - bump;
346
+ }
347
+ const pdaSeeds = {
348
+ pool: (project, index) => [utf8('pool'), addressBytes(project), uintLE(index, 2)],
349
+ stash: (project, id) => [utf8('stash'), addressBytes(project), uintLE(id, 4)],
350
+ };
351
+
352
+ /**
353
+ * What a roll of `entries` spends, as the program's `RollCost::new` counts it from the box alone (no table rows, a
354
+ * stash of one group, a named NFT's stash of one row): `fixed` (reading the box, every entry's accounts, finding each
355
+ * pool's and stash's address), each entry's draw (`prizes`), its stock read again (`reread`), the prizes it gives, its
356
+ * heap, and the heap reading the box leaves used (`start`, the box's own `bytes` included). `project`: whose pools and
357
+ * stashes these are, to count each search exactly; without it, at `ASSUMED_TRIES` tries an address.
358
+ */
359
+ function rollCost(entries, { odds = 0, shares = 0, fates = 0, bytes = 0n, project = null }) {
332
360
  const n = BigInt(entries.length);
361
+ // Each address is found by what finding it takes, once a prize names it (`draws_fit`'s `reads`): what a search cost is
362
+ // kept by the address's kind and id; once past the answer's budget nothing more is searched for (the box is refused).
363
+ const found = new Map();
364
+ let searched = 0n;
365
+ const reading = p => {
366
+ const pool = p.Tokens?.from?.Pool;
367
+ const stash = p.Stash ?? p.Nft?.stash;
368
+ if (pool == null && stash == null) return 0n;
369
+ const key = pool != null ? `pool ${pool[0] ?? pool}` : `stash ${stash[0] ?? stash}`;
370
+ let cost = found.get(key);
371
+ if (cost === undefined) {
372
+ if (searched > ROLL_BUDGET) cost = 0n;
373
+ else {
374
+ const id = Number(pool != null ? (pool[0] ?? pool) : (stash[0] ?? stash));
375
+ cost = COST.try * BigInt(searchTries(project, pool != null, id));
376
+ found.set(key, cost);
377
+ }
378
+ }
379
+ searched += cost;
380
+ return cost;
381
+ };
382
+ let fixed = COST.fixed + COST.odds * BigInt(odds) + COST.share * BigInt(shares) + COST.fate * BigInt(fates);
383
+ let start = BYTES.start + BigInt(outgrown(entries.length, ENTRY_BYTES)) + BigInt(bytes);
384
+ const prizes = [];
385
+ const reread = [];
386
+ const heap = [];
387
+ for (const e of entries) {
388
+ let [set, again, used] = [0n, 0n, BYTES.draw];
389
+ fixed += e.prizes.length ? COST.entry : COST.blank;
390
+ start += BYTES.entryRead;
391
+ e.prizes.forEach((p, k) => {
392
+ fixed += reading(p) + (k > 0 ? COST.prize : 0n);
393
+ start += BYTES.prizeRead;
394
+ if (p.Item != null || p.Compressed) [set, again] = [set + COST.item, again + COST.reread];
395
+ else if (p.Tokens) [set, again] = [set + COST.tokens, again + (p.Tokens.from.Pool ? COST.reread : 0n)];
396
+ else if (p.Stash != null) [set, again] = [set + COST.draw + COST.group, again + COST.reread];
397
+ else if (p.Nft) {
398
+ // A named NFT is searched for row by row: when its stock is read, again, and when it is taken (one row here).
399
+ fixed += COST.row;
400
+ [set, again] = [set + COST.item + COST.row, again + COST.reread + COST.row];
401
+ } else set += COST.item;
402
+ used += p.Tokens ? (p.Tokens.from.Pool ? BYTES.pooled : BYTES.minted) : BYTES.prize;
403
+ });
404
+ // A draw that gives nothing still draws, and keeps its draw's lists.
405
+ prizes.push(e.prizes.length ? set : COST.nothing);
406
+ reread.push(again);
407
+ heap.push(used);
408
+ }
333
409
  const weigh = COST.weigh + COST.weighEntry * n;
334
- const reads = entries.map(e => e.prizes.map(accountOf));
335
- const cost = entries.map(e =>
336
- e.prizes.reduce(
337
- (c, p) => {
338
- if (p.Item != null || p.Compressed || p.Nft)
339
- return { set: c.set + COST.item, again: c.again + COST.reread, bytes: c.bytes + BYTES.prize };
340
- if (p.Tokens)
341
- return {
342
- set: c.set + COST.tokens,
343
- again: c.again + (p.Tokens.from.Pool ? COST.reread : 0n),
344
- bytes: c.bytes + (p.Tokens.from.Pool ? BYTES.pooled : BYTES.minted),
345
- };
346
- if (p.Stash != null)
347
- return { set: c.set + COST.draw + COST.group, again: c.again + COST.reread, bytes: c.bytes + BYTES.prize };
348
- return { set: c.set + COST.item, again: c.again, bytes: c.bytes + BYTES.prize };
349
- },
350
- { set: 0n, again: 0n, bytes: BYTES.draw },
351
- ),
352
- );
353
- const next = entries.map(
354
- (_, i) =>
355
- weigh +
356
- cost[i].set +
357
- cost.reduce((sum, c, j) => (j === i || reads[i].some(a => a && reads[j].includes(a)) ? sum + c.again : sum), 0n),
358
- );
359
410
  const most = BigInt(Math.max(1, ...entries.map(e => e.prizes.length)));
411
+ return { fixed, start, weigh, prizes, reread, heap, knowing: COST.pair * n * n * most * most };
412
+ }
413
+
414
+ /** The entries whose stock a draw of entry `i` reads again: itself, and each naming an account it names. */
415
+ const sharing = (entries, i) => {
416
+ const reads = entries.map(e => e.prizes.map(accountOf));
417
+ return entries.map((_, j) => j).filter(j => j === i || reads[i].some(a => a && reads[j].includes(a)));
418
+ };
419
+
420
+ /**
421
+ * Whether `opens` opens by `list` (slots, never empty) fit one answer (`RollCost::fits`): each draw at the dearest its
422
+ * slot could make, which entries share accounts found first when there are several draws. `next`: each entry's draw.
423
+ * `heap`: the answer's heap counts too.
424
+ */
425
+ function fitsOne(cost, entries, list, opens, next, heap) {
426
+ const n = BigInt(entries.length);
427
+ const draws = list.reduce((d, s) => d + BigInt(s.count), 0n) * BigInt(opens);
360
428
  let spent =
361
- COST.fixed +
362
- COST.pair * n * n * most * most +
363
- entries.reduce((c, e) => c + (e.prizes.length ? COST.entry : COST.blank), 0n);
364
- let used =
365
- BYTES.start +
366
- BigInt(outgrown(entries.length, ENTRY_BYTES)) +
367
- BigInt(bytes) +
368
- BYTES.entryRead * n +
369
- BYTES.prizeRead * BigInt(entries.reduce((c, e) => c + e.prizes.length, 0));
370
- let won = 0n;
371
- spent +=
372
- (COST.slot + COST.slotEntry * n) * BigInt(Math.max(1, list.length)) +
429
+ cost.fixed +
430
+ (COST.slot + COST.slotEntry * n) * BigInt(list.length) +
373
431
  COST.openSlot * BigInt(list.length) * BigInt(opens) +
374
- COST.odds * BigInt(odds) +
375
- COST.share * BigInt(shares) +
376
- COST.fate * BigInt(fates);
432
+ (draws > 1n ? cost.knowing : 0n);
433
+ let used = cost.start;
434
+ let won = 0n;
377
435
  for (const s of list) {
378
436
  const count = BigInt(s.count) * BigInt(opens);
379
437
  const among = entries.map((_, i) => i).filter(i => !(s.only ?? []).length || (s.only ?? []).includes(i));
380
438
  const dearest = f => among.reduce((m, i) => (f(i) > m ? f(i) : m), 0n);
381
439
  spent += count * dearest(i => next[i]);
382
- used += count * dearest(i => cost[i].bytes);
440
+ used += count * dearest(i => cost.heap[i]);
383
441
  won += count * dearest(i => BigInt(entries[i].prizes.length));
384
442
  }
385
- const rolls = oracle || entries.length > 1 || entries.some(e => e.prizes.some(p => p.Stash != null));
386
443
  return (
387
444
  spent + COST.finish + COST.won * won + COST.drawn * draws <= ROLL_BUDGET &&
388
- (!rolls || used + BYTES.finish + BYTES.won * won + draws <= ROLL_HEAP)
445
+ (!heap || used + BYTES.finish + BYTES.won * won + draws <= ROLL_HEAP)
389
446
  );
390
447
  }
391
448
 
449
+ /**
450
+ * Whether all of `opens` opens of `entries` by `slots` fit one oracle answer (`order::draws_fit`), each draw at the
451
+ * dearest its slot could make, with what is known from the box alone (no table rows, a stash of one group, a named
452
+ * NFT's stash of one row), its `odds`, `shares` and `fates` counted, and `bytes` (`boxBytes`) of heap reading the box
453
+ * takes beyond its entries. `slots` may be a number: that many draws among every entry. The answer's heap counts for a
454
+ * box that rolls, or one sold through the oracle (`oracle`: a box that doesn't roll but one `buy` can't carry,
455
+ * `buyFits`). `project`: the project the box is in, whose pools' and stashes' addresses the write searches for (each
456
+ * search counted at `256 - bump` tries of 1,500 units); without it, at `ASSUMED_TRIES` tries an address. The program
457
+ * refuses a box whose one open doesn't fit at its upsert (`PackTooLarge`). `purchase`: counted as `open_box` counts a
458
+ * purchase of `opens` instead (the answer's heap always; a draw's own stock read again only when there are several; at
459
+ * most `MOST_DRAWS_A_PURCHASE` draws in all), from what the box alone tells: the program reads the accounts too.
460
+ */
461
+ export function drawsFit(
462
+ entries,
463
+ slots,
464
+ opens = 1,
465
+ { odds = 0, shares = 0, fates = 0, bytes = 0n, oracle = false, project = null, purchase = false } = {},
466
+ ) {
467
+ const given = typeof slots === 'number' || typeof slots === 'bigint' ? [{ count: slots, only: [] }] : (slots ?? []);
468
+ // A box with no slots draws once among every entry.
469
+ const list = given.length ? given : [{ count: 1, only: [] }];
470
+ const draws = list.reduce((d, s) => d + BigInt(s.count), 0n) * BigInt(opens);
471
+ if (purchase && draws > BigInt(MOST_DRAWS_A_PURCHASE)) return false;
472
+ const cost = rollCost(entries, { odds, shares, fates, bytes, project });
473
+ const heap = purchase || oracle || entries.length > 1 || entries.some(e => e.prizes.some(p => p.Stash != null));
474
+ // Reading the box alone already past the answer: refused before its entries are compared.
475
+ if (cost.fixed > ROLL_BUDGET || (heap && cost.start > ROLL_HEAP)) return false;
476
+ // Each entry's draw reads again the stock of the entries sharing its accounts; a purchase of one draw, its own alone.
477
+ const again = i => (purchase && draws <= 1n ? [] : sharing(entries, i)).reduce((s, j) => s + cost.reread[j], 0n);
478
+ const next = entries.map((_, i) => cost.weigh + cost.prizes[i] + again(i));
479
+ return fitsOne(cost, entries, list, opens, next, heap);
480
+ }
481
+
392
482
  // What one `buy` takes, as the program counts it at the box's write (`catalog::buy_fits`): a transaction holds 64
393
483
  // accounts, 64 instructions and 1,400,000 compute units.
394
- const BUY = { accounts: 15, calls: 7, cu: 30_000, most: { accounts: 64, calls: 64, cu: 1_400_000 } };
484
+ const BUY = { accounts: 15, calls: 7, cu: 30_000, most: { accounts: 64, calls: 64, cu: MAX_TX_CU } };
395
485
  const sameJson = (a, b) =>
396
486
  JSON.stringify(a, (_, v) => (typeof v === 'bigint' ? String(v) : v)) ===
397
487
  JSON.stringify(b, (_, v) => (typeof v === 'bigint' ? String(v) : v));
@@ -444,6 +534,28 @@ export function buyFits(box, draws = box.slots?.length ? box.slots.reduce((n, s)
444
534
  return accounts <= BUY.most.accounts && calls <= BUY.most.calls && cu <= BUY.most.cu;
445
535
  }
446
536
 
537
+ /**
538
+ * Whether the program writes `box` (its args, as `upsertBoxIx` takes them, or a box read from the chain) as far as what
539
+ * it costs goes (`catalog::loot_box`, `PackTooLarge`): a box that doesn't roll sells by `buy` when one transaction carries
540
+ * the whole purchase (`buyFits`), else through the oracle, whose heap then counts and which can't give a roll; and every
541
+ * draw of one open fits one answer (`drawsFit`). `project`: whose pools and stashes the box names (a box read from the
542
+ * chain says); without it, each search at `ASSUMED_TRIES` tries.
543
+ */
544
+ export function boxFits(box, { project = box.project ?? null } = {}) {
545
+ const entries = box.entries ?? [];
546
+ const rolls = entries.length > 1 || entries.some(e => e.prizes.some(p => p.Stash != null));
547
+ const byBuy = rolls || box.subject != null || buyFits(box);
548
+ if (!byBuy && entries.some(e => e.prizes.some(p => p.Roll != null))) return false;
549
+ return drawsFit(entries, box.slots?.length ? box.slots : 1, 1, {
550
+ odds: (box.odds ?? []).length,
551
+ shares: (box.shares ?? []).length,
552
+ fates: (box.fates ?? []).length,
553
+ bytes: boxBytes(box),
554
+ oracle: !byBuy,
555
+ project,
556
+ });
557
+ }
558
+
447
559
  /** The most program rent and fee one order takes from its player, in lamports (0.01 SOL): a client cap (`opensAnOrder`). */
448
560
  export const ORDER_COST_CAP = 10_000_000n;
449
561
  /** Rent a byte, as devnet and mainnet-beta charge it ((bytes + 128) × 5,080 lamports). */
@@ -452,7 +564,8 @@ const RENT_PER_BYTE = 5_080n;
452
564
  const PLAYER_FEE = 2_000_000n;
453
565
 
454
566
  /**
455
- * The most opens of `lootBox` a client puts in one order: what one oracle answer draws (`drawsFit`), and what keeps the
567
+ * The most opens of `lootBox` a client puts in one order: what one oracle answer draws (`drawsFit` as a purchase counts
568
+ * it, the box's project's searches counted when the box names it: a box read from the chain does), and what keeps the
456
569
  * order's rent and the player's fee within `ORDER_COST_CAP` (`orderBytes` with `items`, so with the box's shares). At
457
570
  * least one: a box whose one open is over the cap is still bought one open an order. A purchase of more is several
458
571
  * orders, each its own answer.
@@ -467,10 +580,11 @@ export function opensAnOrder(
467
580
  shares,
468
581
  fates: (lootBox.fates ?? []).length,
469
582
  bytes: boxBytes(lootBox),
470
- oracle: true,
583
+ project: lootBox.project ?? null,
584
+ purchase: true,
471
585
  }) && (BigInt(orderBytes(lootBox, shares, n, items)) + 128n) * rentPerByte + PLAYER_FEE <= ORDER_COST_CAP;
472
586
  let n = 1;
473
- while (n < 4096 && fits(n + 1)) n += 1;
587
+ while (n < MOST_DRAWS_A_PURCHASE && fits(n + 1)) n += 1;
474
588
  return n;
475
589
  }
476
590
 
@@ -9,11 +9,12 @@ import { gateList } from '../client/grant.js';
9
9
  import { MAX_BID_NFTS, MAX_SHARES } from '../client/ix.js';
10
10
  import { MAX_POTS } from '../client/accounts.js';
11
11
  import { vault } from '../client/pda.js';
12
- import { boxBytes, buyFits, drawsFit, MAX_ORDER_BYTES, tableBudget } from '../client/sizes.js';
12
+ import { ASSUMED_TRIES, boxBytes, buyFits, drawsFit, MAX_ORDER_BYTES, tableBudget } from '../client/sizes.js';
13
13
  import { TOKEN_2022_PROGRAM, TOKEN_PROGRAM } from '../chain/pda.js';
14
14
  import { addressBytes, encodeBase58 } from '../chain/base58.js';
15
15
  import { isOnCurve } from '../chain/curve.js';
16
16
  import { utf8 } from '../chain/bytes.js';
17
+ import { METADATA_LIMITS } from '../chain/metadata.js';
17
18
  import { sha256 } from '../chain/sha256.js';
18
19
  import {
19
20
  FEES,
@@ -59,7 +60,7 @@ import {
59
60
  weights,
60
61
  } from './model.mjs';
61
62
  // plan.mjs imports check() in turn: each module only calls the other's functions, never at load time.
62
- import { boxArgs, boxOrderBytes, placeholderContext, recipeArgs, setupSteps, stepBytes } from './plan.mjs';
63
+ import { boxArgs, boxOrderBytes, placeholderContext, recipeArgs, setupSteps, stepAloneBytes } from './plan.mjs';
63
64
  import { findSecrets } from './secrets.mjs';
64
65
  import { simulate } from './simulate.mjs';
65
66
  import { BUDGET, MAX_TRANSACTION_BYTES, transactionLocks, transactionSize, WALLET_ROOM } from './txsize.mjs';
@@ -685,10 +686,16 @@ export function dateWords(seconds) {
685
686
  }
686
687
 
687
688
  /**
688
- * Checks an economy spec. `now` (Unix seconds) dates "publish".
689
+ * Checks an economy spec. `now` (Unix seconds) dates "publish". `project`: the project's address, once a wallet is named
690
+ * (the plan for a real setup passes it; a live economy's file names it): a box's write searches for each of its pools'
691
+ * and stashes' addresses, and the checker counts each search exactly with it, else at an assumed 2 tries an address
692
+ * (`ASSUMED_TRIES`).
689
693
  * Returns { ok, refusals: [{ code, where, message, fix }], warnings: [{ code, where, message, fix? }] }.
690
694
  */
691
- export function check(economy, { network = 'devnet', now = Math.floor(Date.now() / 1000) } = {}) {
695
+ export function check(
696
+ economy,
697
+ { network = 'devnet', now = Math.floor(Date.now() / 1000), project = economy?.live?.project ?? null } = {},
698
+ ) {
692
699
  const refusals = [];
693
700
  const warnings = [];
694
701
  const refuse = (code, where, message, fix) => refusals.push({ code, where, message, fix });
@@ -1007,6 +1014,33 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
1007
1014
  `“${t.key}” is a token the project makes when the economy goes live, but it names the mint ${t.mint}: the setup would never make it, and would mint from an address the project has no say over.`,
1008
1015
  'Remove "mint" for a token the setup makes, or make it an existing token (source "existing", with its "decimals").',
1009
1016
  );
1017
+ // A token the setup makes is named on chain as it is made (its Token Metadata record), and the record takes so
1018
+ // many bytes of each, not characters: a longer one would be refused on chain, in the middle of going live. One
1019
+ // with no name or ticker at all would be written blank, and show as an unknown token for the record's price. A
1020
+ // token that is live already is not named again: its words here stay labels, as they were.
1021
+ if (source === 'game' && !economy.live?.mints?.[t.key])
1022
+ for (const [field, what] of [
1023
+ ['name', 'name'],
1024
+ ['symbol', 'ticker'],
1025
+ ['uri', 'link'],
1026
+ ]) {
1027
+ const value = String(tok[field] ?? '');
1028
+ const bytes = utf8(value).length;
1029
+ if (bytes > METADATA_LIMITS[field])
1030
+ refuse(
1031
+ 'E-TOKEN',
1032
+ `${where}.${field}`,
1033
+ `The ${what} of “${t.key}” is ${bytes} bytes, and a token’s ${what} on chain is at most ${METADATA_LIMITS[field]} (a letter outside plain English takes two or more).`,
1034
+ `Shorten it to ${METADATA_LIMITS[field]} bytes${field === 'uri' ? ', for instance with a shorter path or host' : ''}.`,
1035
+ );
1036
+ else if (field !== 'uri' && !value.trim())
1037
+ refuse(
1038
+ 'E-TOKEN',
1039
+ `${where}.${field}`,
1040
+ `“${t.key}” has an empty ${what}: going live writes it on chain as it is, and wallets would show a token with no ${what}.`,
1041
+ `Give it a ${what}, or leave "${field}" out (${field === 'symbol' ? 'its key in capitals is used' : 'its ticker is used'}).`,
1042
+ );
1043
+ }
1010
1044
  if (t.supply !== undefined && source !== 'game')
1011
1045
  refuse(
1012
1046
  'E-TOKEN',
@@ -2398,7 +2432,9 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
2398
2432
  // rolls, or one outcome of items, with no outcome that burns the payment and no NFT to roll on (BadEntries).
2399
2433
  const slots = b.slots ?? [];
2400
2434
  const outs = b.outcomes ?? [];
2435
+ const heldBefore = refusals.length;
2401
2436
  answerHolds(b, where);
2437
+ const answerRefused = refusals.length > heldBefore;
2402
2438
  for (const [i, o] of outs.entries()) {
2403
2439
  const given = outcomeParts(o);
2404
2440
  tooMany(
@@ -2555,8 +2591,10 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
2555
2591
  // Every draw of one open fits one answer of the oracle's (PackTooLarge): a pack past it could only draw its first
2556
2592
  // cards, so the program refuses the box. A box that doesn't roll sells by `buy`, the whole purchase in one
2557
2593
  // transaction, or through the oracle when one `buy` can't carry it (`buyFits`): then the answer's heap counts, and it
2558
- // can't give a roll, which only `buy` gives (catalog.rs). The heap counts the box's own bytes too (`boxBytes`).
2559
- if (cards > 1 && outs.length) {
2594
+ // can't give a roll, which only `buy` gives (catalog.rs). The heap counts the box's own bytes too (`boxBytes`), and
2595
+ // the compute finding each pool's and stash's address its prizes name (exact with the project, else assumed).
2596
+ // A box of one draw is held to the same count: past it the program refuses the box when it is written.
2597
+ if (outs.length && (cards > 1 || !answerRefused)) {
2560
2598
  const args = (() => {
2561
2599
  try {
2562
2600
  return boxArgs(b, m);
@@ -2566,38 +2604,45 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
2566
2604
  })();
2567
2605
  const entries = args?.entries;
2568
2606
  if (entries) {
2569
- const byBuy = boxRolls(b, m) || Boolean(b.rollsOn) || buyFits(args, cards);
2607
+ const byBuy = boxRolls(b, m) || Boolean(b.rollsOn) || buyFits(args, Math.max(1, cards));
2570
2608
  const extras = {
2571
2609
  odds: args.odds.length,
2572
2610
  shares: args.shares.length,
2573
2611
  fates: args.fates.length,
2574
2612
  bytes: boxBytes(args),
2575
2613
  oracle: !byBuy,
2614
+ project,
2576
2615
  };
2616
+ const searches = entries.some(e => e.prizes.some(p => p.Tokens?.from?.Pool || p.Stash != null || p.Nft));
2617
+ const searched = searches
2618
+ ? project
2619
+ ? ' (finding the address of each pool and stash it names counted for this project)'
2620
+ : ` (finding the address of each pool and stash it names counted at ${ASSUMED_TRIES} tries each, as no wallet is named yet: the check with your wallet counts it exactly)`
2621
+ : '';
2622
+ const drawn = slots.length ? slots.map(s => ({ count: s.count, only: s.from ?? [] })) : 1;
2577
2623
  if (!byBuy && entries.some(e => e.prizes.some(p => p.Roll != null)))
2578
2624
  refuse(
2579
2625
  'E-ODDS',
2580
2626
  `${where}.slots`,
2581
- `${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).`,
2627
+ `${label(b)} gives a roll, which only a sale in one step gives, but ${Math.max(1, cards)} of what it gives don’t fit one transaction (its accounts, its instructions or its compute), so the program refuses the box (PackTooLarge).`,
2582
2628
  'Fewer cards an open, fewer prizes in its outcome, or fewer shares in its split.',
2583
2629
  );
2584
- else if (
2585
- !drawsFit(
2586
- entries,
2587
- slots.map(s => ({ count: s.count, only: s.from ?? [] })),
2588
- 1,
2589
- extras,
2590
- )
2591
- ) {
2630
+ else if (cards > 1 && !drawsFit(entries, drawn, 1, extras)) {
2592
2631
  let most = cards - 1;
2593
2632
  while (most > 1 && !drawsFit(entries, most, 1, extras)) most -= 1;
2594
2633
  refuse(
2595
2634
  'E-ODDS',
2596
2635
  `${where}.slots`,
2597
- `${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).`,
2636
+ `${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${searched}), so the program refuses the box (PackTooLarge).`,
2598
2637
  '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).',
2599
2638
  );
2600
- }
2639
+ } else if (cards <= 1 && !drawsFit(entries, drawn, 1, extras))
2640
+ refuse(
2641
+ 'E-ODDS',
2642
+ `${where}.outcomes`,
2643
+ `${label(b)} is too long for one answer of the oracle’s to draw it once${searched}, so the program refuses the box when it is written (PackTooLarge).`,
2644
+ 'Fewer outcomes, fewer prizes in an outcome, fewer pools or stashes named, or a smaller box (fewer odds, shares or slots). Put a long weighted list in a table or a stash, drawn by one outcome.',
2645
+ );
2601
2646
  }
2602
2647
  }
2603
2648
  if (cards > 1 && b.rollsOn)
@@ -5700,7 +5745,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
5700
5745
  for (const step of setup?.steps ?? []) {
5701
5746
  let bytes;
5702
5747
  try {
5703
- bytes = stepBytes(step.build(ctx), ctx);
5748
+ bytes = stepAloneBytes(step, ctx);
5704
5749
  } catch {
5705
5750
  continue;
5706
5751
  }
@@ -47,11 +47,19 @@ export function setupCost(plan, economy) {
47
47
  const m = model(economy);
48
48
  // An account that grows (a live stash taking more NFTs) pays rent for its new bytes only.
49
49
  const rentOf = c => (c.grows ? BigInt(c.bytes) * RENT_PER_BYTE : rent(c.bytes));
50
+ // `fee`: what the program that makes an account keeps in it on top of its rent (Token Metadata's, for a token's name
51
+ // on chain). It is not rent: it never comes back.
50
52
  const accounts = plan.transactions
51
53
  .flatMap(t => t.steps)
52
54
  .flatMap(s => s.creates ?? [])
53
- .map(c => ({ ...c, lamports: rentOf(c).toString(), sol: sol(rentOf(c)) }));
55
+ .map(c => ({
56
+ ...c,
57
+ lamports: rentOf(c).toString(),
58
+ sol: sol(rentOf(c)),
59
+ ...(c.fee ? { feeSol: sol(BigInt(c.fee)) } : {}),
60
+ }));
54
61
  const rentLamports = accounts.reduce((n, a) => n + BigInt(a.lamports), 0n);
62
+ const metadataLamports = accounts.reduce((n, a) => n + BigInt(a.fee ?? 0), 0n);
55
63
  const charged = plan.transactions.flatMap(t => t.steps).filter(s => !NO_FEE_STEP.test(s.id));
56
64
  const feeLamports = BigInt(charged.length) * FEES.project;
57
65
  const networkLamports = plan.transactions.reduce((n, t) => n + BigInt(t.signers.length) * SIGNATURE_FEE, 0n);
@@ -76,7 +84,7 @@ export function setupCost(plan, economy) {
76
84
  for (const l of m.lotteries) if (steps.has(`rent:lottery:${l.key}`)) seedLamports += roundRentOf(l, m);
77
85
  for (const sc of m.schedules)
78
86
  if (sc.sealed && (sc.fundFrom ?? 'wallet') === 'wallet') putIn(sc.currency, sc.fund, `fund:${sc.key}`);
79
- const total = rentLamports + feeLamports + networkLamports + seedLamports;
87
+ const total = rentLamports + metadataLamports + feeLamports + networkLamports + seedLamports;
80
88
  const notes = [];
81
89
  if (accounts.some(a => a.approx))
82
90
  notes.push('Accounts marked “about” are sized roughly (a Core collection or an external token account).');
@@ -96,6 +104,7 @@ export function setupCost(plan, economy) {
96
104
  return {
97
105
  accounts,
98
106
  rentSol: sol(rentLamports),
107
+ metadataFeesSol: sol(metadataLamports),
99
108
  programFeesSol: sol(feeLamports),
100
109
  networkFeesSol: sol(networkLamports),
101
110
  seeds,
@@ -483,6 +492,8 @@ export function explainCost(c) {
483
492
  ];
484
493
  lines.push(
485
494
  ` rent ${s.rentSol} SOL for ${s.accounts.length} accounts; program fees ${s.programFeesSol} SOL; network fees ${s.networkFeesSol} SOL${
495
+ s.metadataFeesSol ? `; Token Metadata’s fees ${s.metadataFeesSol} SOL (a token’s name on chain)` : ''
496
+ }${
486
497
  Object.keys(s.seeds).length
487
498
  ? `; seeds ${Object.entries(s.seeds)
488
499
  .map(([k, v]) => `${v} ${k}`)
@@ -490,7 +501,10 @@ export function explainCost(c) {
490
501
  : ''
491
502
  }.`,
492
503
  );
493
- for (const a of s.accounts) lines.push(` ${a.approx ? 'about ' : ''}${a.sol} SOL: ${a.what} (${a.bytes} bytes)`);
504
+ for (const a of s.accounts)
505
+ lines.push(
506
+ ` ${a.approx ? 'about ' : ''}${a.sol} SOL: ${a.what} (${a.bytes} bytes)${a.feeSol ? `, and ${a.feeSol} SOL Token Metadata keeps for it` : ''}`,
507
+ );
494
508
  lines.push('While it runs (for a player, quote the “in all” number: it is what leaves their wallet for good):');
495
509
  for (const r of c.actions) {
496
510
  const deposits = r.deposits.map(d => `${d.sol} SOL for ${d.what} (back: ${d.back})`).join('; ');
@@ -204,6 +204,18 @@ export function explainEconomy(economy, { now = Math.floor(Date.now() / 1000) }
204
204
  );
205
205
  if (economy.prompt) lines.push(`Asked for: “${economy.prompt}”`);
206
206
 
207
+ // A token the setup makes is named on chain as it is made (plan.mjs): what wallets will show, and that a picture
208
+ // needs its link before going live. One that is live already keeps what it has.
209
+ section(
210
+ 'The tokens going live makes',
211
+ [...m.tokens.values()]
212
+ .filter(t => t.source === 'game' && !economy.live?.mints?.[t.key])
213
+ .map(
214
+ t =>
215
+ `${t.symbol}${t.name !== t.symbol ? ` (“${t.name}”)` : ''}: ${t.fixedSupply ? `a fixed supply of ${num(t.supply)}, all of it to your wallet, and no more ever` : 'minted by the project as its rules say'}. It is named on chain as it is made, so wallets show ${t.uri ? 'its name, its ticker and the picture its link gives' : 'its name and ticker and no picture: for one, give it a `uri` (a link to a JSON file that names its `image`) before going live'}. Only your wallet can change the name, ticker and link later, and these tools don’t change a live token’s yet: get them right first.`,
216
+ ),
217
+ );
218
+
207
219
  const fee = num(sim.fees.playerSol);
208
220
  const actions = [];
209
221
  for (const it of sim.items) {
@@ -559,6 +571,8 @@ export function explainEconomy(economy, { now = Math.floor(Date.now() / 1000) }
559
571
  lines.push('', 'What setting it up costs');
560
572
  lines.push(
561
573
  `- About ${num(s.totalSol)} SOL in all, in ${plural(s.transactions, 'transaction')} (${plural(s.approvals, 'wallet approval')}): rent ${num(s.rentSol)} SOL for ${plural(s.accounts.length, 'account')}, program fees ${num(s.programFeesSol)} SOL, network fees ${num(s.networkFeesSol)} SOL${
574
+ s.metadataFeesSol ? `, Token Metadata’s fees ${num(s.metadataFeesSol)} SOL (a token’s name on chain)` : ''
575
+ }${
562
576
  Object.keys(s.seeds).length
563
577
  ? `, seeds ${Object.entries(s.seeds)
564
578
  .map(([k, v]) => `${num(v)} ${k}`)
@@ -10,7 +10,7 @@ const sol = lamports => Number(lamports) / Number(LAMPORTS_PER_SOL);
10
10
  export const PARTS = {
11
11
  tokens: {
12
12
  def: 'token',
13
- says: 'The currencies: SOL, any existing mint by address (classic or Token-2022, coins from a token launchpad or an AMM included), or a token the game makes when it goes live: one the game mints as its rules say (points, tickets, shards, an emission), or one with a fixed `supply` minted once to the owner with minting turned off for good (a community token you keep). loot does not put a token on a market: for trading, launch it on a token launchpad or an AMM, then name the mint.',
13
+ says: 'The currencies: SOL, any existing mint by address (classic or Token-2022, coins from a token launchpad or an AMM included), or a token the game makes when it goes live: one the game mints as its rules say (points, tickets, shards, an emission), or one with a fixed `supply` minted once to the owner with minting turned off for good (a community token you keep). A token the game makes is named on chain as it is made (`name`, `symbol`, and `uri`: a link to a JSON file naming its picture), which is what wallets show; only the owner’s wallet can change that later, and changing them in a spec that is already live changes nothing on chain yet. loot does not put a token on a market: for trading, launch it on a token launchpad or an AMM, then name the mint.',
14
14
  acts: 'nobody: a token is a fact',
15
15
  },
16
16
  currency: {
@@ -39,7 +39,7 @@ export const PARTS = {
39
39
  boxes: {
40
40
  def: 'box',
41
41
  nested: ['outcome', 'odds', 'gate', 'rule', 'effect', 'rise', 'fall', 'split', 'perWalletEach'],
42
- says: 'A paid (or free) roll on chain with MagicBlock’s verifiable randomness, optionally on an NFT (`rollsOn`: an enchant, a mission’s result, a risky forge), whose outcomes then move its numbers (`shifts`) and send it back, burn it or give it away (`then`; a box that can burn or give it away holds it until the roll settles): each outcome gives an item, a fixed prize from a pool, tokens minted (`tokens`), an NFT of a stash (picked at random, or a named one: `nft`), a roll of another box (`roll`), several of these together (`bundle`: { "gives": "bundle", "prizes": [...] }, a pack; it gives all it names or isn’t given, so it names a pool once, a stash’s random NFT beside nothing else of that stash, and two of an item need two left), or nothing. A box of one outcome with nothing random and no NFT it rolls on is bought in one step (`buy`): the price split and what it gives handed over at once, with no order and no oracle. A box takes an NFT as its price through `rollsOn` (which NFTs) and `then` (where the one paid goes: burned, given, or into a stash, a trade-in that becomes another player’s prize). A box is also the prize any route gives: a grant (`gives: { "roll": box }`), a lottery’s place (`{ "percent", "roll" }`), a contest’s winners (`roll`), an auction’s lot (`{ "roll": box }`), a mission’s tier and a recipe roll it for a winner, who gets whatever it gives; such a box is left `active: false`. How long a box can be is what one answer of the oracle’s holds (measured: 128 outcomes that give nothing, 29 of token amounts, 18 each of its own item; 41 slots; 72 odds rules; 16 token prizes or 18 collectibles in one outcome; 340 texts in a gate): a longer weighted list goes in a table or a stash, drawn by one outcome. A roll is on its written odds whatever is in stock, and `whenOut` says what a draw on an outcome that is out does: "stopSales" (the default) stops selling while one couldn’t be covered, a paid miss gives that draw’s worth back at the settle (with no draw made a paid open fails and is refunded), and a free roll goes back to waiting and is asked again until it hits; "drawLeft" draws among what is left; "giveNothing" keeps selling and a miss gives nothing, paid or free. Odds rules (`odds`) add any percent (99,900 makes an outcome a thousand times likelier; a weight stops at the largest, 4,294,967,295) and count on every route, read when a roll is first asked. A sale in one step sells only while its pools hold every fixed prize. A prize won is set aside for its winner at once. An edit of a box’s roll fails only a purchase still waiting (for its refund); any other roll waiting rolls on the box as edited. A gate (a collectible shown, a few wallets, or a list of any size: `{ "list": [address, …] }`, an allowlist whose proof each player passes) and odds can read what a player shows; `opens`/`closes` time a drop; `perWallet`/`perWalletEach` limit each wallet (one ticket per wallet). A split share "chosen" lets the buyer name a referrer; "chosen:<item>" the holder of a collectible they name (plot rent).',
42
+ says: 'A paid (or free) roll on chain with MagicBlock’s verifiable randomness, optionally on an NFT (`rollsOn`: an enchant, a mission’s result, a risky forge), whose outcomes then move its numbers (`shifts`) and send it back, burn it or give it away (`then`; a box that can burn or give it away holds it until the roll settles): each outcome gives an item, a fixed prize from a pool, tokens minted (`tokens`), an NFT of a stash (picked at random, or a named one: `nft`), a roll of another box (`roll`), several of these together (`bundle`: { "gives": "bundle", "prizes": [...] }, a pack; it gives all it names or isn’t given, so it names a pool once, a stash’s random NFT beside nothing else of that stash, and two of an item need two left), or nothing. A box of one outcome with nothing random and no NFT it rolls on is bought in one step (`buy`): the price split and what it gives handed over at once, with no order and no oracle. A box takes an NFT as its price through `rollsOn` (which NFTs) and `then` (where the one paid goes: burned, given, or into a stash, a trade-in that becomes another player’s prize). A box is also the prize any route gives: a grant (`gives: { "roll": box }`), a lottery’s place (`{ "percent", "roll" }`), a contest’s winners (`roll`), an auction’s lot (`{ "roll": box }`), a mission’s tier and a recipe roll it for a winner, who gets whatever it gives; such a box is left `active: false`. How long a box can be is what one answer of the oracle’s holds (measured: 128 outcomes that give nothing, 29 of token amounts, 18 each of its own item; 41 slots; 72 odds rules; 16 token prizes or 18 collectibles in one outcome; 340 texts in a gate; fewer where its outcomes name pools or stashes, each search for one’s address counted: sixteen stashes fit 9 draws an open where finding them takes 36 tries): a longer weighted list goes in a table or a stash, drawn by one outcome. A roll is on its written odds whatever is in stock, and `whenOut` says what a draw on an outcome that is out does: "stopSales" (the default) stops selling while one couldn’t be covered, a paid miss gives that draw’s worth back at the settle (with no draw made a paid open fails and is refunded), and a free roll goes back to waiting and is asked again until it hits; "drawLeft" draws among what is left; "giveNothing" keeps selling and a miss gives nothing, paid or free. Odds rules (`odds`) add any percent (99,900 makes an outcome a thousand times likelier; a weight stops at the largest, 4,294,967,295) and count on every route, read when a roll is first asked. A sale in one step sells only while its pools hold every fixed prize. A prize won is set aside for its winner at once. An edit of a box’s roll fails only a purchase still waiting (for its refund); any other roll waiting rolls on the box as edited. A gate (a collectible shown, a few wallets, or a list of any size: `{ "list": [address, …] }`, an allowlist whose proof each player passes) and odds can read what a player shows; `opens`/`closes` time a drop; `perWallet`/`perWalletEach` limit each wallet (one ticket per wallet). A split share "chosen" lets the buyer name a referrer; "chosen:<item>" the holder of a collectible they name (plot rent).',
43
43
  acts: `the player opens (${sol(FEES.player)} SOL + ${sol(ORACLE_FEE)} SOL to the oracle); its player, a key they named or the project’s keys settle. Chance that pays money is flagged`,
44
44
  },
45
45
  payments: {