@xgamesfun/solana 0.20.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +10 -10
  2. package/examples/coin-flip-arena.json +2 -7
  3. package/examples/elements-set.json +2 -9
  4. package/examples/expedition-guild.json +1 -8
  5. package/examples/guild-missions.json +3 -10
  6. package/examples/halving-seasons.json +4 -9
  7. package/examples/hero-trials.json +1 -4
  8. package/examples/merge-2048.json +5 -5
  9. package/examples/paper-hands-farm.json +4 -16
  10. package/examples/partner-vault.json +1 -4
  11. package/examples/stakers-draw.json +1 -8
  12. package/examples/star-fleet.json +1 -8
  13. package/examples/starforce.json +6 -6
  14. package/js/approve/details.mjs +8 -7
  15. package/js/approve/server.mjs +20 -5
  16. package/js/chain/borsh.js +9 -0
  17. package/js/chain/core.js +14 -2
  18. package/js/chain/lookup.js +8 -2
  19. package/js/chain/rpc.js +4 -2
  20. package/js/client/accounts.js +448 -116
  21. package/js/client/grant.js +125 -66
  22. package/js/client/idl/loot.js +1 -1
  23. package/js/client/idl/loot.json +2360 -2218
  24. package/js/client/index.js +1 -0
  25. package/js/client/ix.js +1204 -473
  26. package/js/client/pda.js +20 -16
  27. package/js/client/policy.js +44 -23
  28. package/js/client/random.js +70 -0
  29. package/js/client/sizes.js +255 -12
  30. package/js/economy/check.mjs +455 -210
  31. package/js/economy/cost.mjs +45 -30
  32. package/js/economy/examples.mjs +2 -2
  33. package/js/economy/explain.mjs +24 -20
  34. package/js/economy/guide.mjs +31 -30
  35. package/js/economy/live.mjs +30 -7
  36. package/js/economy/model.mjs +171 -56
  37. package/js/economy/plan.mjs +380 -112
  38. package/js/economy/schema.json +291 -143
  39. package/js/economy/simulate.mjs +154 -87
  40. package/js/mcp/tools.mjs +14 -8
  41. package/js/play/index.js +1121 -375
  42. package/js/play/keys.js +167 -0
  43. package/package.json +1 -1
  44. package/release.json +5 -5
  45. package/skills/xsolana-economy/SKILL.md +15 -11
  46. package/skills/xsolana-economy/references/after-launch.md +37 -27
  47. package/skills/xsolana-economy/references/apps.md +55 -19
  48. package/skills/xsolana-economy/references/sentence-to-spec.md +30 -22
  49. package/skills/xsolana-economy/references/telling-the-person.md +52 -11
  50. package/types/chain/core.d.ts +3 -1
  51. package/types/chain/lookup.d.ts +5 -2
  52. package/types/chain/rpc.d.ts +3 -1
  53. package/types/client/accounts.d.ts +195 -55
  54. package/types/client/grant.d.ts +72 -27
  55. package/types/client/idl/loot.d.ts +590 -559
  56. package/types/client/index.d.ts +1 -0
  57. package/types/client/ix.d.ts +3862 -234
  58. package/types/client/pda.d.ts +19 -15
  59. package/types/client/policy.d.ts +39 -14
  60. package/types/client/random.d.ts +26 -0
  61. package/types/client/sizes.d.ts +93 -5
  62. package/types/economy/check.d.mts +5 -10
  63. package/types/economy/cost.d.mts +2 -1
  64. package/types/economy/live.d.mts +16 -1
  65. package/types/economy/model.d.mts +105 -37
  66. package/types/economy/plan.d.mts +193 -192
  67. package/types/economy/simulate.d.mts +21 -13
  68. package/types/mcp/tools.d.mts +21 -13
  69. package/types/play/index.d.ts +450 -39
  70. package/types/play/keys.d.ts +51 -0
package/js/play/index.js CHANGED
@@ -20,7 +20,7 @@
20
20
  // `spec` is the live xsolana.json (its `live` section names the project). `wallet` is { address, signTransaction(wire) →
21
21
  // signed wire }: `walletStandardSigner` makes one from a Wallet Standard wallet (Phantom, Solflare, Backpack).
22
22
  import { decodeBase58, encodeBase58 } from '../chain/base58.js';
23
- import { ownedAssetFilters, parseCoreAsset } from '../chain/core.js';
23
+ import { ownedAssetFilters, parseCoreAsset, parseCoreCollection } from '../chain/core.js';
24
24
  import { ASSOCIATED_TOKEN_PROGRAM, associatedTokenAddress, COMPUTE_BUDGET_PROGRAM, NATIVE_MINT, SYSVAR_SLOT_HASHES, TOKEN_2022_PROGRAM, TOKEN_PROGRAM } from '../chain/pda.js';
25
25
  import { closeTokenAccount, computeUnitLimit, createAssociatedTokenAccountIdempotent, requestHeapFrame, syncNative, transferLamports, withSolWrap } from '../chain/programs.js';
26
26
  import { checkCluster, notMainnet, rpcClient } from '../chain/rpc.js';
@@ -28,8 +28,13 @@ import { MEMO_PROGRAM, mintPowers, resolveTransfers } from '../chain/token2022.j
28
28
  import { compileFitting, closeTableIx, deactivateTableIx, readTable, sendFitting, sendPlan, slotHashes, tablesOf, tableStep, transactionLocks, LOOKUP_TABLE_PROGRAM } from '../chain/lookup.js';
29
29
  import { compileMessage, readTransaction, wireTransaction } from '../chain/tx.js';
30
30
  import * as loot from '../client/index.js';
31
+ import { browserStore, keyring } from './keys.js';
31
32
 
32
33
  // The options of a call made without any: read-only, and typed so the generated declarations take any option.
34
+ /** A lottery's steps in `claims()`: each depends on the one before, so `claimAll` takes one of a lottery's a call. */
35
+ const LOTTERY_STEPS = new Set(['reveal', 'cancel-draw', 'pull', 'payout']);
36
+ /** The claims that are one transaction of the wallet's: `claimAll` signs them together. */
37
+ const BATCHED = new Set(['contest', 'release', 'lookup-table', 'close-leaf', ...LOTTERY_STEPS]);
33
38
  const NO_OPTIONS = Object.freeze(/** @type {Record<string, any>} */ ({}));
34
39
 
35
40
  const CHAIN = 'solana:devnet';
@@ -39,24 +44,30 @@ const NOBODY = '11111111111111111111111111111111';
39
44
  /** A table made this many slots ago whose transaction never landed was never sent: its blockhashes ran out long since. */
40
45
  const TABLE_UNSENT_SLOTS = 300n;
41
46
  /** A compressed NFT's `prove_leaf`: its record stays good while the leaf is unchanged, so it may go in a transaction of its own first. */
42
- const PROVE_LEAF = loot.IDL.instructions.find((i) => i.name === 'prove_leaf').discriminator;
43
- const isProveLeaf = (ix) => ix.programId === loot.LOOT_PROGRAM && PROVE_LEAF.every((b, i) => ix.data[i] === b);
47
+ const isProveLeaf = (ix) => ix.programId === loot.LOOT_PROGRAM && loot.instructionOf(ix.data)?.name === 'prove_leaf';
44
48
  /** A system account must keep this much (rent for 0 bytes) or be emptied. */
45
49
  const RENT_EMPTY = 890_880n;
46
50
  const SIGNATURE_FEE = 5_000n;
47
- /** What a box's open puts in its throwaway key: its one signature's fee, and the rent it must hold until then. */
48
- const BURNER_LAMPORTS = RENT_EMPTY + SIGNATURE_FEE;
49
51
 
50
- /** A signer from a Wallet Standard wallet and one of its accounts: the wallet signs, the app sends. */
52
+ /**
53
+ * A signer from a Wallet Standard wallet and one of its accounts: the wallet signs, the app sends. With the wallet's
54
+ * `solana:signMessage`, also `signMessage(bytes)`: the app's settle keys are made from one signature of it a session,
55
+ * so a page that closes can make them again (`recover`).
56
+ */
51
57
  export function walletStandardSigner(wallet, account, chain = CHAIN) {
52
58
  const feature = wallet.features?.['solana:signTransaction'];
53
59
  if (!feature) throw new Error(`${wallet.name} cannot sign transactions.`);
60
+ const messages = wallet.features?.['solana:signMessage'];
54
61
  return {
55
62
  address: account.address,
56
63
  async signTransaction(wire) {
57
64
  const [out] = await feature.signTransaction({ account, transaction: wire, chain });
58
65
  return out.signedTransaction;
59
66
  },
67
+ ...(messages ? { async signMessage(message) {
68
+ const [out] = await messages.signMessage({ account, message });
69
+ return out.signature;
70
+ } } : {}),
60
71
  };
61
72
  }
62
73
 
@@ -118,14 +129,20 @@ function devnetOnly(client) {
118
129
  * The player's side of a live economy. `rpc` may be given instead of `rpcUrl` (any object with the client's rpc
119
130
  * methods: accounts, account, latestBlockhash, send, confirm, programAccounts, and call(method, params), the raw JSON
120
131
  * RPC request the devnet check, the slot and the compressed-NFT reads go through). `now` is the clock auctions are read
121
- * by (Unix seconds). Devnet only (`devnetOnly`): mainnet waits for an audit.
132
+ * by (Unix seconds). Devnet only (`devnetOnly`): mainnet waits for an audit. `keyStore`: where a settle key that can't
133
+ * be made again from the wallet's signature is kept, encrypted (`browserStore()` in a browser; null keeps keys in memory
134
+ * only).
122
135
  */
123
- export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc = null, wallet, programs = {}, now = () => Math.floor(Date.now() / 1000) }) {
136
+ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc = null, wallet, programs = {}, now = () => Math.floor(Date.now() / 1000), keyStore = browserStore() }) {
124
137
  const live = spec?.live;
125
138
  if (!live?.project) throw new Error('This economy is not live yet: its xsolana.json has no "live" section.');
126
139
  if (rpc && typeof rpc.call !== 'function') throw new Error('The rpc given has no call(method, params): a custom rpc needs it (the devnet check, the slot and compressed NFTs are read through it), as well as accounts, account, latestBlockhash, send, confirm and programAccounts.');
127
140
  const client = devnetOnly(rpc ?? rpcClient(notMainnet(rpcUrl)));
128
141
  const project = live.project;
142
+ /** The settle keys, each named by its job's account (`keys.js`): made again after a reload (`recover`). */
143
+ const keys = wallet?.address ? keyring({ wallet, project, store: keyStore }) : null;
144
+ /** The key for a job about `name` (an order's, a hold's address): one that can be made again, or a fresh one. */
145
+ const jobKey = async (name) => (keys ? keys.key(name) : freshKey());
129
146
  /** The project's Core collections: an NFT of one of them is the project's own (a project may have none). */
130
147
  const collections = live.collections ?? (live.collection ? [live.collection] : []);
131
148
  const ours = (collection) => collection != null && collections.includes(collection);
@@ -159,10 +176,10 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
159
176
  return a?.owner === loot.LOOT_PROGRAM ? loot.decode(name, a.data) : null;
160
177
  };
161
178
  /** The key of the token a lottery's tickets are paid in: the spec's `ticketCurrency`, or the token of its mint. */
162
- const ticketCurrencyOf = (l, lottery) => l.ticketCurrency ?? [...tokens.keys()].find((k) => mintOf(k) === lottery.mint) ?? currencyKey;
179
+ const ticketCurrencyOf = (l, lottery) => l.ticketCurrency ?? [...tokens.keys()].find((k) => mintOf(k) === lottery.terms.mint) ?? currencyKey;
163
180
  /** Farm hooks shared by all actions. Static lists resolve automatically. For dynamic hooks,
164
181
  * transfers[mint] describes every actual transfer, including fee-adjusted reward credit/payout, and in a farm that
165
- * shares its pool the move's last one when a seat gave rewards back in it (an early exit, a lapsed upkeep, a watched
182
+ * shares its pool the move's last one when a seat gave rewards back in it (an early exit, a watched
166
183
  * NFT gone): from the farm's rewards vault back to its pool, the project the authority.
167
184
  * Explicit extra replaces resolution and must include all token hook/memo and Core adapter accounts. */
168
185
  const farmTokens = async (mints, create, { extra, transfers = {} } = {}) => {
@@ -255,17 +272,12 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
255
272
  * What `loot.prizeMetas` needs to give these prizes (`null`s are skipped): each pool's mint, each item (its kind, its
256
273
  * collection and its next serial; null for one never written), each NFT's kind (`nfts`, and `nft` for one), the
257
274
  * project's first collection, a compressed NFT's proof, which goes first in the transaction (`before`), and for a
258
- * roll its box (`boxes`, null for one never written) and the mint of each pool its prizes name (`rollPools`, null for
259
- * one never opened): a route the player pays or asks for passes the box's prize mints (`loot.rollMints`).
275
+ * roll its box, decoded (`boxes`, `boxesOf`).
260
276
  */
261
277
  async function prizeInputs(prizes, signer = wallet.address, winner = wallet.address) {
262
- const out = { poolMints: {}, items: {}, nfts: {}, nft: undefined, collection: collections[0], before: [], boxes: {}, rollPools: {} };
278
+ const out = { poolMints: {}, items: {}, nfts: {}, nft: undefined, collection: collections[0], before: [] };
279
+ out.boxes = await boxesOf(prizes.filter(Boolean).flatMap((p) => (p.Roll ? [p.Roll[0]] : [])));
263
280
  for (const prize of prizes.filter(Boolean)) {
264
- const rolled = prize.Roll?.[0];
265
- if (rolled !== undefined && !(rolled in out.boxes)) {
266
- const box = (out.boxes[rolled] = await readMaybe('LootBox', loot.pda.box(project, rolled)));
267
- if (box) Object.assign(out.rollPools, await poolMintsOf(box));
268
- }
269
281
  const pool = prize.Tokens?.from.Pool?.[0];
270
282
  if (pool !== undefined && !(pool in out.poolMints)) {
271
283
  const account = await client.account(loot.pda.pool(project, pool));
@@ -309,6 +321,14 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
309
321
  };
310
322
  /** The same for one the project holds: staked in escrow. */
311
323
  const escrowedIn = async (asset) => ((await ownerOf(asset).catch(() => null)) === project ? seatedIn(asset, ['escrow']) : null);
324
+ /** The same for one the project locks in the player's wallet: staked in a farm that freezes (a soulbound Core asset
325
+ * carries no lock to tell it is staked, so it goes alone). */
326
+ const lockedIn = async (asset) => {
327
+ const seated = await seatedIn(asset, ['freeze']);
328
+ if (!seated) return null;
329
+ const account = await client.account(asset);
330
+ return account?.owner === MPL_CORE && parseCoreAsset(account.data).soulbound ? null : seated;
331
+ };
312
332
  /** The player's collectibles of this project (in any of its collections), each with the item it is (its "item_id" attribute). */
313
333
  async function owned(owner = wallet?.address) {
314
334
  const found = (await Promise.all(collections.map((c) => client.programAccounts(MPL_CORE, ownedAssetFilters(owner, c))))).flat();
@@ -318,6 +338,12 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
318
338
  return { asset: pubkey, ...a, itemId, item: itemId === null ? null : itemKeyOf(itemId) };
319
339
  });
320
340
  }
341
+ /** A token seat's refresh (`seat` decoded), with its booster's accounts (a compressed one proven first), to go first in
342
+ * a stake or a compound's transaction (`loot.stakeRefreshFirst`). */
343
+ async function refreshOf(f, farm, seat) {
344
+ const { before, ...nft } = seat.asset !== NOBODY ? await nftOf(seat.asset) : { before: [] };
345
+ return [...before, loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by: seat.player, seat, ...nft })];
346
+ }
321
347
  /**
322
348
  * For a gate (the program's Gate: a Rule an NFT must pass, or a list of wallets), one of the player's NFTs that
323
349
  * passes it, so a page needn't find it; null when nothing is to be shown. A rule naming a collection the project has no
@@ -414,10 +440,30 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
414
440
  * Its size is checked before the wallet signs (`sendFitting`): a transaction too big for a legacy one goes as one v0
415
441
  * transaction with a lookup table the wallet makes first (one to three more approvals, and the table's rent until the
416
442
  * player closes it, which `claims()` lists); only when even that is too big does each compressed NFT's `prove_leaf`
417
- * go alone, after the table and before the rest.
443
+ * go alone, after the table and before the rest. A compressed NFT's record closes in the same transaction, its prover
444
+ * signing (`loot.withLeafCloses`), so its rent comes straight back.
418
445
  */
446
+ /**
447
+ * The project's standing lookup tables (made at go-live, `spec.live.tables`), each as the chain holds it now: one
448
+ * deactivated (or gone) is left out, and a transaction then makes a table of its own as before.
449
+ */
450
+ let standingTables = null;
451
+ async function standing() {
452
+ standingTables ??= (async () => {
453
+ const addresses = spec.live?.tables ?? [];
454
+ const found = addresses.length ? await client.accounts(addresses) : [];
455
+ return addresses.flatMap((address, i) => {
456
+ const table = readTable(found[i] && { ...found[i], owner: found[i].owner ?? LOOKUP_TABLE_PROGRAM });
457
+ return table && table.deactivationSlot === null ? [{ address, addresses: table.addresses }] : [];
458
+ });
459
+ })();
460
+ return standingTables;
461
+ }
462
+
419
463
  async function send(instructions, extra = [], { keepWrapped = false } = {}) {
420
464
  if (!wallet?.address) throw new Error('Connect a wallet first.');
465
+ // A compressed NFT's record is closed in the transaction that used it, its prover signing: its rent comes back.
466
+ instructions = loot.withLeafCloses(instructions);
421
467
  if (!keepWrapped) instructions = await unwrapAfter(instructions);
422
468
  const sign = async (message) => {
423
469
  const { signers } = readTransaction(wireTransaction(message));
@@ -425,7 +471,41 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
425
471
  return wallet.signTransaction(wireTransaction(message, sigs));
426
472
  };
427
473
  // The project first in a table, so claims() knows a table whose later steps were never sent as this project's.
428
- return (await sendFitting({ rpc: client, payer: wallet.address, instructions, sign, alone: isProveLeaf, first: [project] }).catch(inWords)).signature;
474
+ return (await sendFitting({ rpc: client, payer: wallet.address, instructions, sign, alone: isProveLeaf, first: [project], standing: await standing() }).catch(inWords)).signature;
475
+ }
476
+
477
+ const sendNow = (...args) => send(...args);
478
+
479
+ /**
480
+ * Several independent steps of the player's, each one transaction (`lists`: their instructions), signed in one wallet
481
+ * prompt when the wallet signs several at once (`wallet.signTransactions`), then sent in order; one by one otherwise,
482
+ * and for a step too big for one transaction. Returns each step's { signature } or { error }.
483
+ */
484
+ async function sendBatch(lists) {
485
+ const settled = (p) => p.then((signature) => ({ signature }), (e) => ({ error: e.message }));
486
+ let signed = null;
487
+ let messages = [];
488
+ let lastValidBlockHeight = null;
489
+ if (wallet?.signTransactions && lists.length > 1) {
490
+ const latest = await client.latestBlockhash();
491
+ lastValidBlockHeight = latest.lastValidBlockHeight;
492
+ const tables = await standing();
493
+ for (const l of lists) messages.push(compileFitting({ payer: wallet.address, blockhash: latest.blockhash, instructions: await unwrapAfter(loot.withLeafCloses(l)), tables }));
494
+ const wires = messages.filter(Boolean).map((m) => wireTransaction(m));
495
+ const back = wires.length > 1 ? await wallet.signTransactions(wires).catch(() => null) : null;
496
+ if (back) signed = messages.map((m) => (m ? back.shift() : null));
497
+ }
498
+ const out = [];
499
+ for (const [i, l] of lists.entries()) {
500
+ const wire = signed?.[i];
501
+ const confirmed = async () => {
502
+ const signature = await client.send(wire).catch(inWords);
503
+ await client.confirm?.(signature, { lastValidBlockHeight });
504
+ return signature;
505
+ };
506
+ out.push(await settled(wire ? confirmed() : send(l)));
507
+ }
508
+ return out;
429
509
  }
430
510
 
431
511
  /**
@@ -434,6 +514,9 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
434
514
  * logs, when it is one of loot's (Anchor's codes start at 6000; the token programs' are small).
435
515
  */
436
516
  function inWords(e) {
517
+ // The program logs an error's name only (its text is compiled out): the IDL's sentence for it.
518
+ const logged = e?.logs ? loot.lootError(e.logs) : null;
519
+ if (logged) throw Object.assign(new Error(`${logged.msg} (${logged.name}).`, { cause: e }), { code: logged.code, name: logged.name, logs: e.logs });
437
520
  const said = String(e?.message ?? '');
438
521
  const m = /custom program error: 0x([0-9a-f]+)/i.exec(said);
439
522
  const failed = e?.logs?.find((line) => / failed: custom program error: 0x/i.test(line));
@@ -443,11 +526,12 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
443
526
  }
444
527
 
445
528
  /**
446
- * Build, sign with `key` alone (it pays), send and confirm: a throwaway key's transaction. No wallet prompt. Legacy
447
- * only: the key holds no rent for a lookup table.
529
+ * Build, sign with `key` alone (it pays), send and confirm: a throwaway key's transaction. No wallet prompt. Legacy,
530
+ * or v0 with the project's standing tables (which cost it nothing); the key holds no rent for a table of its own.
448
531
  */
449
532
  async function sendAs(key, instructions) {
450
- return (await sendFitting({ rpc: client, payer: key.address, instructions, sign: async (message) => wireTransaction(message, [await key.sign(message)]), table: false }).catch(inWords)).signature;
533
+ instructions = loot.withLeafCloses(instructions);
534
+ return (await sendFitting({ rpc: client, payer: key.address, instructions, sign: async (message) => wireTransaction(message, [await key.sign(message)]), table: false, standing: await standing() }).catch(inWords)).signature;
451
535
  }
452
536
 
453
537
  /**
@@ -470,18 +554,112 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
470
554
  /** An NFT's kind as a route's `ticket` or `nft` takes it: without its asset. */
471
555
  const kindOnly = ({ asset: _, ...kind }) => kind;
472
556
 
473
- /** A throwaway key's leftover back to the player: all it holds less its own fee, so the key ends empty. */
474
- const giveBack = (burner) => transferLamports(burner.address, wallet.address, BURNER_LAMPORTS - SIGNATURE_FEE);
557
+ /**
558
+ * The key job rule (AGENTS.md). A step after the wallet's prompt (a settle, an ask for a roll, a release) is sent by a
559
+ * throwaway key made in memory for that one job. `keyFor(pays)` makes it, and the transfer that funds it, which goes
560
+ * in the wallet's own transaction: what its steps pay (`pays`: their fees, an ask's oracle fee, the rent of what a
561
+ * settle opens or mints, `loot.settleRent`) and the rent minimum read from the cluster, plus one spare fee so it can
562
+ * always empty itself. A key pays for an account only with lamports the wallet gave it for that, and never makes a
563
+ * proof record: then the wallet sends that step. `key`: one made already (a box's open names it before it knows how
564
+ * many settles it funds).
565
+ */
566
+ async function keyFor(pays = SIGNATURE_FEE, key = null) {
567
+ key ??= await freshKey();
568
+ return { key, fund: transferLamports(wallet.address, key.address, (await rentEmpty()) + BigInt(pays) + SIGNATURE_FEE) };
569
+ }
570
+
571
+ /** The rent minimum of an account of no data, as the cluster says it (once). A key must hold it, past its fee, to pay
572
+ * a transaction's fee: a fee payer stays rent-exempt or ends empty. */
573
+ let rentMinimum = null;
574
+ const rentEmpty = async () => (rentMinimum ??= BigInt(await client.call('getMinimumBalanceForRentExemption', [0]).catch(() => Number(RENT_EMPTY))));
575
+
576
+ /** Whether `instructions` would land as `payer` sends them now (a dry run, with the standing tables when they need
577
+ * them), and the accounts `watch` as they would be after: { ok, accounts, error }, `error` loot's when it refused
578
+ * ({ code, name, msg }, `loot.lootError`). */
579
+ async function dryRun(payer, instructions, watch = []) {
580
+ const { blockhash } = await client.latestBlockhash();
581
+ const message = compileFitting({ payer, blockhash, instructions, tables: await standing() });
582
+ if (!message) return { ok: false, accounts: [], error: null };
583
+ let error = null;
584
+ const r = await client.simulate(wireTransaction(message), { accounts: watch, replaceBlockhash: true }).catch((e) => {
585
+ error = loot.lootError(e?.logs ?? []);
586
+ return null;
587
+ });
588
+ return { ok: Boolean(r), accounts: r?.accounts ?? [], error };
589
+ }
590
+
591
+ /** A throwaway key empties itself back to the wallet: all it holds now, less its own fee. Nothing when it holds no more
592
+ * than that fee (a settle of its that failed on chain took its fee: the wallet then pays the move, one more prompt). */
593
+ async function emptyKey(key) {
594
+ const left = BigInt(await client.balance(key.address).catch(() => 0n));
595
+ if (left >= (await rentEmpty()) + SIGNATURE_FEE) await sendAs(key, [transferLamports(key.address, wallet.address, left - SIGNATURE_FEE)]).catch(() => null);
596
+ else if (left > 0n) await send([transferLamports(key.address, wallet.address, left)], [key]).catch(() => null);
597
+ // A key kept in storage is dropped once it holds nothing.
598
+ if (key.name && !(BigInt(await client.balance(key.address).catch(() => 1n)) > 0n)) keys?.forget(key.name);
599
+ }
600
+
601
+ /** A key's job: its step (`build(signer)`), simulated as the key sends it, then sent by the key, or by the wallet when
602
+ * the simulation fails (one more prompt); then the key empties itself back to the wallet. Returns the signature. */
603
+ async function keyRun(key, build) {
604
+ const steps = await build(key.address);
605
+ const signature = (await dryRun(key.address, steps)).ok ? await sendAs(key, steps).catch(() => null) : null;
606
+ const sent = signature ?? (await send(await build(wallet.address)));
607
+ await emptyKey(key);
608
+ return sent;
609
+ }
610
+
611
+ /**
612
+ * Rolls in one prompt: a step that makes orders waiting for their roll (a craft, a buy, a mission left, a grant or a
613
+ * contest prize, a lot won) asks for each in the same transaction, and a throwaway key it funds then settles each (the
614
+ * key job rule: funded with each settle's fees and what it may open or mint, `loot.settleRent`). A dry run finds them:
615
+ * the accounts the step writes that come out as orders waiting (a limit can hold a round back, so only what it would
616
+ * make). A box with nothing in stock refuses the ask, and with it the step (`SoldOut`): nothing is sent, and the
617
+ * player is told. When the step and its asks would not land together for any other reason (a missing prize vault,
618
+ * too big a transaction), the step goes alone and its rolls wait in `claims()`. Returns { signature, rolls }.
619
+ */
620
+ async function sendRolling(instructions) {
621
+ const watch = [...new Set(instructions.filter((ix) => ix.programId === loot.LOOT_PROGRAM).flatMap((ix) => ix.keys.filter((k) => k.isWritable && !k.isSigner).map((k) => k.pubkey)))];
622
+ const after = (await dryRun(wallet.address, instructions, watch)).accounts;
623
+ const waiting = watch.flatMap((address, i) => {
624
+ const a = after[i];
625
+ if (a?.owner !== loot.LOOT_PROGRAM || loot.accountKind(a.data) !== 'Order') return [];
626
+ const order = loot.decode('Order', a.data);
627
+ return order.status === 'Waiting' && order.player === wallet.address ? [[address, order]] : [];
628
+ });
629
+ if (!waiting.length) return { signature: await send(instructions), rolls: [] };
630
+ const asks = [];
631
+ const funds = [];
632
+ const burners = [];
633
+ for (const [orderAddress, order] of waiting) {
634
+ const box = await read('LootBox', order.loot_box);
635
+ // Each order its own key, named by it (it can be made again after a reload, `recover`): its settle's fee, a spare
636
+ // for its parts and its give-back, and what its settle may open or mint. The ask names it as the order's settler.
637
+ const { key, fund } = await keyFor(SIGNATURE_FEE * 3n + loot.settleRent(box, Number(order.opens ?? 1), await itemsOf(box)), await jobKey(orderAddress));
638
+ asks.push(...(await asksFor(wallet.address, orderAddress, order, key.address)));
639
+ funds.push(fund);
640
+ burners.push(key);
641
+ }
642
+ const all = [...instructions, ...asks, ...funds];
643
+ const tried = await dryRun(wallet.address, all);
644
+ if (tried.error?.name === 'SoldOut') throw Object.assign(new Error('The box this rolls has nothing left to give right now (SoldOut): nothing was sent. Try again once it is restocked.'), { code: tried.error.code, name: 'SoldOut' });
645
+ // Too heavy to be answered: a step sent alone would pay for a roll that can only fail.
646
+ if (tried.error?.name === 'PackTooLarge') throw Object.assign(new Error('The box this rolls is too heavy for the oracle\'s answer as it stands (PackTooLarge): nothing was sent.'), { code: tried.error.code, name: 'PackTooLarge' });
647
+ if (!tried.ok) return { signature: await send(instructions), rolls: [] };
648
+ const signature = await send(all);
649
+ const rolls = [];
650
+ for (const [k, [orderAddress]] of waiting.entries()) rolls.push(await collect(orderAddress, { burner: burners[k], opened: true }));
651
+ return { signature, rolls };
652
+ }
475
653
 
476
654
  /**
477
655
  * Waits for an order's roll (the oracle answers a moment after the open), then settles it. With `burner` (the
478
656
  * throwaway key the open funded), the burner signs and pays the settle, and gives the rest back: no wallet prompt.
479
- * Anyone may settle a rolled order: when someone did first, the burner only gives its lamports back. `putBack`: NFTs
657
+ * The order names it (`settler`), so it may settle; when the player or the project did first, the burner only gives its lamports back. `putBack`: NFTs
480
658
  * won that the player puts back into their stash in place of taking them (one whose collection now refuses to let it
481
659
  * move); the player signs that settle.
482
660
  */
483
661
  async function collect(orderAddress, options = {}) {
484
- only(options, ['timeoutMs', 'burner', 'putBack', 'opened', 'landed'], 'collect');
662
+ only(options, ['timeoutMs', 'burner', 'putBack', 'opened', 'landed', 'keepBurner'], 'collect');
485
663
  try {
486
664
  return await collectOnce(orderAddress, options);
487
665
  } catch (e) {
@@ -490,18 +668,13 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
490
668
  // needs no burner: the player signs that settle.
491
669
  const { burner } = options;
492
670
  if (!burner) throw e;
493
- // All it holds now. While it can pay its own fee and stay rent-exempt until the transfer empties it, it sends it
494
- // itself; after a settle of its that failed on chain (its fee taken) it holds only its rent, and the player's
495
- // wallet pays the fee of the transaction that empties it (one more prompt, only then).
496
- const left = BigInt(await client.balance(burner.address).catch(() => 0n));
497
- if (left >= BURNER_LAMPORTS) await sendAs(burner, [transferLamports(burner.address, wallet.address, left - SIGNATURE_FEE)]).catch(() => null);
498
- else if (left > 0n) await send([transferLamports(burner.address, wallet.address, left)], [burner]).catch(() => null);
671
+ await emptyKey(burner);
499
672
  if (!(await client.account(orderAddress))) return { order: orderAddress, settled: 'elsewhere', says: 'The order was settled already.' };
500
673
  throw e;
501
674
  }
502
675
  }
503
676
 
504
- async function collectOnce(orderAddress, { timeoutMs = 60_000, burner = null, putBack = [], opened = false, landed = null } = {}) {
677
+ async function collectOnce(orderAddress, { timeoutMs = 60_000, burner = null, putBack = [], opened = false, landed = null, keepBurner = false } = {}) {
505
678
  const started = Date.now();
506
679
  // `landed`: the slot one's own open landed in; the order is read from a node that has seen it.
507
680
  const current = async () => {
@@ -516,8 +689,8 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
516
689
  order = await current();
517
690
  }
518
691
  // A roll a mission or a recipe made waits to be asked for (or asked again, the oracle never having answered):
519
- // anyone asks, here the player.
520
- if (order && loot.rollAskable(order, await slotNow())) {
692
+ // its player or the project's keys ask, here the player.
693
+ if (order && loot.rollAskable(order, await slotNow(), await chainNow())) {
521
694
  await askRoll(orderAddress);
522
695
  order = await current();
523
696
  }
@@ -525,8 +698,12 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
525
698
  await sleep(1500);
526
699
  order = await current();
527
700
  }
701
+ // A free roll whose answer found nothing at all in stock waits again: for a restock, or for its player to end it.
702
+ if (order?.status === 'Waiting') {
703
+ throw new Error('The box this rolls had nothing left to give when the roll landed, so the roll waits: collect it again once the box is restocked. Collected while the box is still empty, the player\'s own ask ends it with nothing.');
704
+ }
528
705
  if (!order) {
529
- if (burner) await sendAs(burner, [giveBack(burner)]);
706
+ if (burner && !keepBurner) await emptyKey(burner);
530
707
  // One's own open still not seen after that: say so plainly, never that it was settled (claims() lists it).
531
708
  if (opened) return { order: orderAddress, settled: null, says: 'The network doesn’t show this order yet: collect it again in a moment (claims() lists it).' };
532
709
  return { order: orderAddress, settled: 'elsewhere', says: 'The order was settled already.' };
@@ -544,64 +721,117 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
544
721
  // What the roll set aside: each item it gave (its kind and collection) and each NFT's kind (a compressed one
545
722
  // proven first).
546
723
  const items = {};
547
- for (const won of order.won) if (won.Item && !(won.Item.item in items)) items[won.Item.item] = await read('Item', loot.pda.item(project, won.Item.item));
724
+ for (const won of order.won) {
725
+ const id = won.Item?.item ?? won.Compressed?.item;
726
+ if (id !== undefined && !(id in items)) items[id] = await read('Item', loot.pda.item(project, id));
727
+ }
728
+ // A compressed prize is minted into the tree its box names (any of the project's with room), the next NFT it mints.
729
+ const treeOf = async () => {
730
+ if (!order.won.some((w) => w.Compressed)) return {};
731
+ const box = await read('LootBox', order.loot_box);
732
+ const tree = box.entries.flatMap((e) => e.prizes).find((p) => p.Compressed)?.Compressed.tree;
733
+ const config = (await client.account(loot.pda.treeConfig(tree)))?.data;
734
+ // An item's collection without Bubblegum's plugin mints nothing: the prizes after it take its place in the tree.
735
+ const unminted = [];
736
+ for (const c of new Set(order.won.filter((w) => w.Compressed).map((w) => items[w.Compressed.item]?.collection).filter(Boolean))) {
737
+ const data = (await client.account(c))?.data;
738
+ if (!data || !parseCoreCollection(data).bubblegum) unminted.push(c);
739
+ }
740
+ return { tree, unminted, treeMinted: config ? config.subarray(80, 88).reduce((n, b, i) => n | (BigInt(b) << BigInt(8 * i)), 0n) : 0n };
741
+ };
548
742
  const holders = await holdersOf(order.shares);
549
743
  // Given by its fate to a wallet that is an account of the loot program, the NFT goes back to the player.
550
744
  const gives = order.held ? order.fate?.then?.Give?.[0] : undefined;
551
745
  const handBack = gives !== undefined && loot.handsBack(await client.account(gives));
552
- /** The settle as `signer` sends it, and what goes before it: each compressed NFT's proof, a fresh token account. */
553
- const settleBy = async (signer) => {
746
+ /** The settle's part up to `upto` (`loot.SETTLE_LAST`: all that is left; 0: the split alone) as `signer` sends it,
747
+ * and what goes before it: each compressed NFT's proof, a fresh token account. */
748
+ const settleBy = async (signer, upto = loot.SETTLE_LAST) => {
554
749
  const nfts = {};
555
750
  const proving = [];
556
- for (const won of order.won) {
751
+ const end = Math.min(order.won.length, upto);
752
+ for (const won of order.won.slice(Number(order.settled ?? 0), end)) {
557
753
  if (won.Nft && !putBack.includes(won.Nft.nft)) {
558
- // A Token Metadata NFT's settle is the wallet's to send (it pays rent: `paysRent`), so the wallet opens any fresh account.
754
+ // A fresh token account a Token Metadata NFT needs (its holder's own can't take it) is the wallet's to open: a
755
+ // settle with anything before it is the wallet's to send.
559
756
  const { before, hasAuthority: _, ...kind } = await nftOf(won.Nft.nft, signer, { to: order.player, payer: wallet.address });
560
757
  nfts[won.Nft.nft] = kind;
561
758
  proving.push(...before);
562
759
  }
563
760
  }
564
761
  // The NFT the roll was on: its kind, and for a compressed one a proof first.
565
- const { before: subjectProof, ...subject } = order.subject !== NOBODY ? await nftOf(order.subject, signer) : { before: [] };
566
- const ix = loot.settleIx({ signer, orderAddress, order, projectState: g, items, nfts, putBack, subject, handBack, holders, programs });
762
+ const { before: subjectProof, ...subject } = order.subject && order.subject !== NOBODY ? await nftOf(order.subject, signer) : { before: [] };
763
+ const ix = loot.settleIx({ signer, orderAddress, order, projectState: g, items, nfts, putBack, subject, handBack, holders, programs, upto, ...(await treeOf()) });
567
764
  return { ix, nfts, before: [...subjectProof, ...proving].filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM) };
568
765
  };
766
+ // An order whose settle doesn't fit one transaction (the instruction trace, 64 accounts, 1,232 bytes) settles in
767
+ // parts, each its own transaction, from where the last stopped. The first splits the payment, its shares' accounts
768
+ // with it (alone, `upto` 0, when not even one prize fits beside them); parts that hand prizes over (`upto` where
769
+ // each stops), as many as the trace holds (`settlePartEnd`) and fewer until they fit; then a last part
770
+ // (`SETTLE_LAST`) with the NFT rolled on. The burner sends each, paying what each mints with what the open funded it
771
+ // with; the player's wallet, a part it can't.
772
+ const signatures = [];
773
+ // A key the order doesn't name can't settle it (`Order::settler`): then the player signs.
774
+ const by = burner && !putBack.length && order.settler === burner.address ? burner.address : wallet.address;
775
+ const fits = async (instructions) => Boolean(compileFitting({ payer: by, blockhash: NOBODY, instructions, tables: await standing() })) && transactionLocks(by, instructions) <= 64;
776
+ const part = async (end) => [computeUnitLimit(loot.settleComputeUnits(order)), (await settleBy(by, end)).ix];
777
+ const sendPart = async (end) => signatures.push(by === wallet.address ? await send(await part(end)) : await sendAs(burner, await part(end)));
778
+ for (;;) {
779
+ const from = Number(order.settled ?? 0);
780
+ if (from >= order.won.length) break;
781
+ let end = loot.settlePartEnd(order, from, items);
782
+ if (end >= order.won.length && (await fits(await part(loot.SETTLE_LAST)))) break;
783
+ while (end > from + 1 && !(await fits(await part(end)))) end--;
784
+ await sendPart(Number(order.drew ?? 0) > 0 && !(await fits(await part(end))) ? 0 : end);
785
+ order = await current();
786
+ if (!order) return { order: orderAddress, settled: 'elsewhere', says: 'The order was settled already.' };
787
+ }
569
788
  // Room for every move the shape makes (`loot.settleComputeUnits`), and for a settle of several NFTs the bigger heap
570
789
  // the program's allocator uses (`loot.settleHeapFrame`).
571
790
  const heap = loot.settleHeapFrame(order);
572
791
  const budget = [computeUnitLimit(loot.settleComputeUnits(order)), ...(heap ? [requestHeapFrame(heap)] : [])];
573
- // The settle opens any account it pays into that is not there, paid by whoever signs it. A burner holds no rent
574
- // to spare: the open made the player's accounts of the prizes' mints and of the payees, so it settles alone unless
575
- // the settle pays rent of its own (a roll won makes an order; a Token Metadata NFT goes into a new token account),
576
- // needs a proof first, or doesn't fit a legacy transaction (a lookup table's rent is the wallet's to pay). An NFT put
577
- // back into its stash (one its collection no longer lets move) is the player's own choice: they sign.
578
- let signature;
579
- const byBurner = burner && !putBack.length ? await settleBy(burner.address) : null;
580
- const paysRent = (built) => order.won.some((w) => w.Roll || (w.Nft && built.nfts[w.Nft.nft]?.tokenMetadata));
581
- const alone = byBurner && !paysRent(byBurner) && !byBurner.before.length ? [...budget, byBurner.ix, giveBack(burner)] : null;
582
- // The burner's transaction counts its compute limit: a settle within about 40 bytes of the legacy limit goes from the
583
- // wallet (one prompt) rather than without one, as a settle with the default limit can run out (205,794 units on
584
- // devnet) and the burner holds the fee of one try only.
585
- if (alone && compileFitting({ payer: burner.address, blockhash: NOBODY, instructions: alone })) signature = await sendAs(burner, alone);
586
- else {
792
+ // The settle opens or mints what the order won (a collectible, a token account, a Sheet, the fate's writes), paid by
793
+ // whoever signs it: the burner, with what the open funded it with (`loot.settleRent`). It settles alone unless the
794
+ // settle needs something first (a proof, a fresh token account) or doesn't fit a legacy transaction (a lookup
795
+ // table's rent is the wallet's to pay), and only when a dry run says it lands as the burner sends it; else the
796
+ // wallet settles (one more prompt). An NFT put back into its stash (one its collection no longer lets move) is the
797
+ // player's own choice: they sign.
798
+ let signature = null;
799
+ let emptied = false;
800
+ const byBurner = burner && !putBack.length && order.settler === burner.address ? await settleBy(burner.address) : null;
801
+ const alone = byBurner && !byBurner.before.length ? [...budget, byBurner.ix] : null;
802
+ const legacy = (instructions) => Boolean(compileFitting({ payer: burner.address, blockhash: NOBODY, instructions }));
803
+ const tried = alone && legacy(alone) ? await dryRun(burner.address, alone, [burner.address]) : null;
804
+ if (tried?.ok) {
805
+ // Its last settle gives back all the burner holds after it, in the same transaction, as the dry run says it will
806
+ // hold (its fee and what the settle opened taken); when that doesn't land as one, the burner empties itself after.
807
+ const left = BigInt(tried.accounts[0]?.lamports ?? 0);
808
+ const back = !keepBurner && left > 0n ? [...alone, transferLamports(burner.address, wallet.address, left)] : null;
809
+ if (back && legacy(back) && (await dryRun(burner.address, back)).ok) signature = await sendAs(burner, back).catch(() => null);
810
+ emptied = Boolean(signature);
811
+ signature ??= await sendAs(burner, alone).catch(() => null);
812
+ }
813
+ if (!signature) {
587
814
  // The wallet's settle: as a v0 transaction with a lookup table when it doesn't fit a legacy one (`send`).
588
815
  const { ix, before } = await settleBy(wallet.address);
589
816
  signature = await send([...budget, ...before, ix]);
590
- if (burner) await sendAs(burner, [giveBack(burner)]);
591
817
  }
592
- const assets = wonAssets(orderAddress, order).map((a) => (putBack.includes(a) ? null : a));
593
- return { order: orderAddress, won: order.won, assets, settled: signature, ...(assets.find(Boolean) ? { asset: assets.find(Boolean) } : {}) };
818
+ // The burner gives back what it holds after its last settle (a purchase of several orders: after the last order's).
819
+ if (burner && !keepBurner && !emptied) await emptyKey(burner);
820
+ else if (burner && emptied) keys?.forget(burner.name);
821
+ const assets = wonAssets(order).map((a) => (putBack.includes(a) ? null : a));
822
+ return { order: orderAddress, won: order.won, assets, settled: signature, ...(signatures.length ? { parts: [...signatures, signature] } : {}), ...(assets.find(Boolean) ? { asset: assets.find(Boolean) } : {}) };
594
823
  }
595
824
 
596
825
  /**
597
826
  * For each prize an order won, what it is now: the collectible minted (`["asset", item, serial]`), the NFT handed
598
- * over, the order a roll won is made in; null for tokens. A normal item is the project's backend's to deliver.
827
+ * over; null for tokens. A normal item is the project's backend's to deliver.
599
828
  */
600
- function wonAssets(orderAddress, order) {
601
- return order.won.map((won, k) => {
829
+ function wonAssets(order) {
830
+ return order.won.map((won) => {
602
831
  if (won.Item) return loot.pda.itemAsset(loot.pda.item(project, won.Item.item), won.Item.serial);
603
832
  if (won.Nft) return won.Nft.nft;
604
- if (won.Roll) return loot.pda.order(project, order.player, loot.rollNonce(orderAddress, order.requested_slot, k));
833
+ // A compressed NFT's asset id is the tree's count when it was minted: read from its settle's receipt or the
834
+ // player's assets (`claims`), not from the order.
605
835
  return null;
606
836
  });
607
837
  }
@@ -609,6 +839,23 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
609
839
  /** A nonce for an order no other order of the player's is at. */
610
840
  const freshNonce = () => BigInt(Date.now()) * 1000n + BigInt(Math.floor(Math.random() * 1000));
611
841
 
842
+ /** Each box of `ids`, decoded (null for one never written), by id. */
843
+ async function boxesOf(ids) {
844
+ const out = {};
845
+ for (const id of new Set(ids)) out[id] = await readMaybe('LootBox', loot.pda.box(project, id));
846
+ return out;
847
+ }
848
+
849
+ /** Each item a box's prizes name, decoded (null for one never written), by id: a fresh one's tables go with its roll. */
850
+ async function itemsOf(box) {
851
+ const out = {};
852
+ for (const p of box.entries.flatMap((e) => e.prizes)) {
853
+ const id = p.Item?.[0] ?? p.Compressed?.item;
854
+ if (id !== undefined && !(id in out)) out[id] = await readMaybe('Item', loot.pda.item(project, id));
855
+ }
856
+ return out;
857
+ }
858
+
612
859
  /** The mint of each pool a box's tokens come from (null for a pool not opened). */
613
860
  async function poolMintsOf(box) {
614
861
  const prizePools = [...new Set(box.entries.flatMap((e) => e.prizes).map((p) => p.Tokens?.from.Pool?.[0]).filter((p) => p !== undefined))];
@@ -616,12 +863,21 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
616
863
  return Object.fromEntries(prizePools.map((p, i) => [p, pools[i] ? encodeBase58(pools[i].data.subarray(0, 32)) : null]));
617
864
  }
618
865
 
619
- /** Asks the oracle for a roll a mission, a recipe or a prize made (`Waiting`): the player pays the oracle. */
620
- async function askRoll(orderAddress) {
621
- const order = await read('Order', orderAddress);
866
+ /** The instructions that ask the oracle for the roll of `order` (at `orderAddress`), `payer` paying it. Each prize
867
+ * mint's token program is read from the mint when the app wasn't given it (`programs`): the ask opens the project's
868
+ * vault of it. */
869
+ async function asksFor(payer, orderAddress, order, settler) {
622
870
  const box = await read('LootBox', order.loot_box);
623
871
  const poolMints = await poolMintsOf(box);
624
- return send([computeUnitLimit(400_000), ...loot.askRollIx({ payer: wallet.address, orderAddress, order, box, poolMints, programs })]);
872
+ const mints = [...Object.values(poolMints), ...box.entries.flatMap((e) => e.prizes.map((p) => p.Tokens?.from.Mint?.[0]))].filter((x) => x && !programs[x]);
873
+ const owners = mints.length ? await client.accounts(mints) : [];
874
+ const known = { ...programs, ...Object.fromEntries(mints.map((x, i) => [x, owners[i]?.owner ?? TOKEN_PROGRAM])) };
875
+ return loot.askRollIx({ payer, orderAddress, order, box, settler, poolMints, items: await itemsOf(box), programs: known });
876
+ }
877
+
878
+ /** Asks the oracle for a roll a mission, a recipe or a prize made (`Waiting`): the player pays the oracle. */
879
+ async function askRoll(orderAddress) {
880
+ return send([computeUnitLimit(400_000), ...(await asksFor(wallet.address, orderAddress, await read('Order', orderAddress)))]);
625
881
  }
626
882
 
627
883
  /**
@@ -651,19 +907,23 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
651
907
  throw new Error(`${method} doesn’t take ${unknown.map((k) => `“${k}”`).join(', ')}${hint}. It takes: ${takes.join(', ')}.`);
652
908
  }
653
909
  /** What `buy` and `openBox` take: a box bought at once (`buyBox`) or opened (`open`). */
654
- const BOX_OPTIONS = ['shown', 'listProof', 'maxPrice', 'listing', 'chosenRecipient', 'on', 'oneSignature'];
910
+ const BOX_OPTIONS = ['shown', 'listProof', 'maxPrice', 'listing', 'chosenRecipient', 'on', 'oneSignature', 'count'];
655
911
 
656
912
  /**
657
- * Buys a box that needs no roll (a shop item, a bundle: one entry, nothing random, no NFT rolled on) in one
658
- * transaction: the price is split and every prize given at once, with no order to settle. `shown` (an NFT for its
659
- * gate), `listProof` (for a list gate), `chosenRecipient` (the wallet, or collectible, a share the box lets the buyer
660
- * name goes to). Returns { signature, assets }: for each prize, the collectible minted or the NFT given (null for
661
- * tokens or a normal item), and for a roll the order it is made in.
913
+ * Buys `count` of a box that needs no roll (a shop item, a bundle, a fixed pack: one entry, nothing random, no NFT
914
+ * rolled on) in one transaction: the price is split and every prize given at once, once for each draw of each, with
915
+ * no order to settle. A roll the box gives (`Prize::Roll`) is asked for in the same transaction and settled by a
916
+ * throwaway key after (`sendRolling`: still one prompt). `shown` (an NFT for its gate), `listProof` (for a list gate),
917
+ * `chosenRecipient` (the wallet, or collectible, a share the box lets the buyer name goes to). Returns { signature,
918
+ * assets }: for each prize given, the collectible minted or the NFT given (null for tokens or a normal item), and for
919
+ * a roll the order it is made in; with `rolls`, what each roll gave. `orNull`: a pack (more than one draw in all)
920
+ * whose purchase doesn't fit one legacy transaction, or the instruction trace, returns null, sending nothing.
662
921
  */
663
- async function buyBox(boxId, part, { shown = null, listProof, maxPrice, listing, chosenRecipient = null } = {}) {
922
+ async function buyBox(boxId, part, { shown = null, listProof, maxPrice, listing, chosenRecipient = null, count = 1, orNull = false } = {}) {
664
923
  const g = await projectNow();
665
924
  const box = await read('LootBox', loot.pda.box(project, boxId));
666
- const bound = boundOf({ ...box, currencyKey: part?.currency }, part?.name ?? `box ${boxId}`, { maxPrice, listing }, part?.key);
925
+ if (!Number.isInteger(count) || count < 1) throw new Error('Buy a whole number, one at least (count).');
926
+ const bound = boundOf({ ...box, currencyKey: part?.currency }, part?.name ?? `box ${boxId}`, { maxPrice, listing }, part?.key) * BigInt(count);
667
927
  shown ??= await passing(box.gate, 'buy this');
668
928
  listProof = listProofOf(box.gate, part?.gate?.list, listProof);
669
929
  const mint = box.currency;
@@ -672,9 +932,10 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
672
932
  const nonce = freshNonce();
673
933
  const choosesHolder = box.shares.some((s) => s.to.Chosen && s.to.Chosen.item != null);
674
934
  const holders = await holdersOf(loot.chosenShares(box.shares, chosenRecipient));
675
- const ix = loot.buyIx({ player: wallet.address, project, box, maxPrice: bound, treasury: g.treasury, chosenRecipient, chosen: choosesHolder ? chosenRecipient : null, nonce, shown, listProof, items: given.items, poolMints: given.poolMints, nfts: given.nfts, boxes: given.boxes, rollPools: given.rollPools, holders, programs });
935
+ const ix = loot.buyIx({ player: wallet.address, project, box, maxPrice: bound, count, treasury: g.treasury, chosenRecipient, chosen: choosesHolder ? chosenRecipient : null, nonce, shown, listProof, items: given.items, poolMints: given.poolMints, nfts: given.nfts, holders, programs });
676
936
  const made = {};
677
- const assets = entry.prizes.map((prize, k) => {
937
+ const gives = loot.buyGives(box, count);
938
+ const assets = gives.map(({ prize, k }) => {
678
939
  if (prize.Item) {
679
940
  const id = prize.Item[0];
680
941
  const item = given.items[id];
@@ -685,8 +946,14 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
685
946
  if (prize.Roll) return loot.pda.order(project, wallet.address, loot.prizeRollNonce(nonce + BigInt(k)));
686
947
  return null;
687
948
  });
688
- const signature = await send(withSolWrap(wallet.address, mint, box.price, [computeUnitLimit(600_000), ...given.before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), ix]));
689
- return { signature, assets, ...(assets.find(Boolean) ? { asset: assets.find(Boolean) } : {}) };
949
+ const rollsMade = gives.filter((x) => x.prize.Roll).length;
950
+ const instructions = withSolWrap(wallet.address, mint, bound, [computeUnitLimit(rollsMade || gives.length > 2 ? 1_400_000 : 600_000), ...given.before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), ix]);
951
+ if (orNull && gives.length > 1) {
952
+ const fits = compileFitting({ payer: wallet.address, blockhash: NOBODY, instructions }) && transactionLocks(wallet.address, instructions) <= 64;
953
+ if (!fits || !(await dryRun(wallet.address, instructions)).ok) return null;
954
+ }
955
+ const { signature, rolls } = rollsMade ? await sendRolling(instructions) : { signature: await send(instructions), rolls: [] };
956
+ return { signature, assets, ...(assets.find(Boolean) ? { asset: assets.find(Boolean) } : {}), ...(rolls.length ? { rolls } : {}) };
690
957
  }
691
958
 
692
959
  /**
@@ -696,10 +963,14 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
696
963
  * referrer, a plot). `on`: the NFT a box that rolls on one takes (an NFT whose price it is, a sword to enchant).
697
964
  * `listProof`: for a list gate.
698
965
  */
699
- async function open(boxId, part, { shown = null, on = null, maxPrice, listing, oneSignature = true, chosenRecipient = null, listProof } = {}) {
966
+ async function open(boxId, part, { shown = null, on = null, maxPrice, listing, oneSignature = true, chosenRecipient = null, listProof, count = 1 } = {}) {
700
967
  const g = await projectNow();
701
968
  const box = await read('LootBox', loot.pda.box(project, boxId));
702
- const bound = boundOf({ ...box, currencyKey: part?.currency }, part?.name ?? `box ${boxId}`, { maxPrice, listing }, part?.key);
969
+ // `count` packs in one purchase: one wallet prompt, one oracle answer, each at the price the one before it left.
970
+ if (!Number.isInteger(count) || count < 1) throw new Error('Open a whole number of packs, one at least (count).');
971
+ const bound = boundOf({ ...box, currencyKey: part?.currency }, part?.name ?? `box ${boxId}`, { maxPrice, listing }, part?.key) * BigInt(count);
972
+ // Each item a prize names: a fresh one's tables go with the purchase.
973
+ const items = await itemsOf(box);
703
974
  // A box that rolls on an NFT takes the one the player brings (`on`), whatever its kind.
704
975
  if (box.subject && !on) throw new Error('This box rolls on an NFT: say which (on).');
705
976
  const brought = box.subject ? await nftOf(on) : { before: [] };
@@ -716,15 +987,22 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
716
987
  const prizeMints = [...new Set([...Object.values(poolMints).filter(Boolean), ...minted])];
717
988
  const choosesHolder = box.shares.some((s) => s.to.Chosen && s.to.Chosen.item != null);
718
989
  const chosen = chosenRecipient && choosesHolder ? chosenRecipient : null;
719
- const ix = loot.openBoxIx({ player: wallet.address, project, box, nonce, maxPrice: bound, shown, listProof, subject, chosenRecipient, chosen, poolMints, programs });
990
+ // A purchase of more opens than one order takes (`loot.opensAnOrder`: what one oracle answer draws, and an order's
991
+ // rent and fee within 0.01 SOL) is several orders, each its own answer, all in this one transaction: one prompt.
992
+ const each = loot.opensAnOrder(box, { items });
993
+ const orders = [];
994
+ for (let left = count, k = 0n; left > 0; left -= each, k++) {
995
+ const n = Math.min(each, left);
996
+ // Its key, named by the order as its settler (made again after a reload: `recover`), when the settle needs no prompt.
997
+ const settlerKey = oneSignature ? ((await jobKey(loot.pda.order(project, wallet.address, nonce + k))) ?? (await freshKey())) : null;
998
+ orders.push({ nonce: nonce + k, n, settlerKey, ix: loot.openBoxIx({ settler: settlerKey?.address, player: wallet.address, project, box, nonce: nonce + k, maxPrice: (bound / BigInt(count)) * BigInt(n), count: n, items, shown, listProof, subject, chosenRecipient, chosen, poolMints, programs }) });
999
+ }
720
1000
  const before = [...proving, createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, mint, programOf(mint))];
721
1001
  // (A compressed NFT's proof brings its own compute limit, the transaction's only one.)
722
- let burner = null;
723
1002
  let solPrize = false;
724
1003
  if (oneSignature) {
725
- // The burner's settle pays no rent: the player opens here every account it will pay into (their prize's, and
726
- // each wallet a share of the sale pays: a referrer, a plot's holder, the treasury).
727
- burner = await freshKey();
1004
+ // The player opens here every account the settle will pay into (their prize's, and each wallet a share of the
1005
+ // sale pays: a referrer, a plot's holder, the treasury), so the burner's settle opens none of them.
728
1006
  for (const prizeMint of prizeMints) {
729
1007
  solPrize ||= prizeMint === NATIVE_MINT;
730
1008
  before.push(createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, prizeMint, programOf(prizeMint)));
@@ -738,31 +1016,28 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
738
1016
  // account: it stays open for the settle too.
739
1017
  solPrize ||= mint === NATIVE_MINT && paid.has(wallet.address);
740
1018
  }
741
- const after = burner ? [transferLamports(wallet.address, burner.address, BURNER_LAMPORTS)] : [];
1019
+ // Each order's burner, named by the order (made again after a reload: `recover`), holds the fee of each settle part
1020
+ // its order may take (`settleParts`) and of its give-back, and what its settle may mint or open (`settleRent`: the
1021
+ // order lends nothing); it gives back what it doesn't use.
1022
+ const addresses = orders.map((o) => loot.pda.order(project, wallet.address, o.nonce));
1023
+ const burners = [];
1024
+ const after = [];
1025
+ if (oneSignature) {
1026
+ for (const o of orders) {
1027
+ const { key, fund } = await keyFor(SIGNATURE_FEE * BigInt(loot.settleParts(box, o.n) + 1) + loot.settleRent(box, o.n, items), o.settlerKey);
1028
+ burners.push(key);
1029
+ after.push(fund);
1030
+ }
1031
+ }
742
1032
  // A SOL prize, or a share of the sale, lands in the player's wrapped-SOL account, which the prompt-less settle
743
1033
  // can't open: it stays open, and the player's next transaction unwraps it.
744
- let instructions = withSolWrap(wallet.address, mint, box.price, [...before, ix, ...after]);
1034
+ let instructions = withSolWrap(wallet.address, mint, BigInt(box.price) * BigInt(count), [...before, ...orders.map((o) => o.ix), ...after]);
745
1035
  if (solPrize) instructions = instructions.filter((x) => !(x.programId === TOKEN_PROGRAM && x.data?.[0] === 9 && x.keys[0]?.pubkey === associatedTokenAddress(wallet.address, NATIVE_MINT)));
746
1036
  const signature = await send(instructions, [], { keepWrapped: solPrize });
747
1037
  // The slot it landed in: the order is read from a node that has seen it (a load-balanced RPC's node lagging
748
1038
  // behind answers nothing for an order just opened).
749
1039
  const landed = (await client.signatureStatus?.(signature).catch(() => null))?.slot ?? null;
750
- return { signature, orderAddress: loot.pda.order(project, wallet.address, nonce), burner, landed };
751
- }
752
-
753
- /**
754
- * A prize of a share of a pool that comes to nothing is refused when it is given at once (a buy's, a craft's:
755
- * PrizeNotCovered): say the pool is empty before the player signs.
756
- */
757
- async function poolShareGiven(prizes) {
758
- for (const p of prizes) {
759
- const bps = p?.Tokens?.amount.Bps?.[0];
760
- const pool = p?.Tokens?.from.Pool?.[0];
761
- if (bps === undefined || pool === undefined) continue;
762
- const data = (await client.account(loot.pda.pool(project, pool)))?.data;
763
- const held = data ? data.subarray(64, 72).reduce((n, b, i) => n | (BigInt(b) << BigInt(8 * i)), 0n) : 0n;
764
- if ((held * BigInt(bps)) / 10_000n === 0n) throw new Error(`Its prize is a share of pool ${pool}, and the pool is empty: nothing to give yet.`);
765
- }
1040
+ return { signature, orderAddress: addresses[0], orderAddresses: addresses, burner: burners[0] ?? null, burners, landed };
766
1041
  }
767
1042
 
768
1043
  /**
@@ -780,18 +1055,25 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
780
1055
  if (was.some((b, i) => b !== is[i])) throw new Error('This listing has changed since the player was shown it: show them its terms as they are now first.');
781
1056
  }
782
1057
 
783
- /** A box bought at once when it needs no roll (`buyBox`), or opened and collected. */
1058
+ /** A box bought at once when it needs no roll (`buyBox`: a shop item, a bundle, a fixed pack of any count), or
1059
+ * opened and collected: one that rolls, gives a fresh item (its traits are rolled), or rolls on an NFT. A fixed pack
1060
+ * too big for one transaction is opened instead (the oracle answers it like any box). */
784
1061
  async function buyOrOpen(boxId, part, options) {
785
1062
  const box = await read('LootBox', loot.pda.box(project, boxId));
786
- if (!loot.boxRolls(box.entries) && !box.subject) {
787
- await poolShareGiven(box.entries.flatMap((e) => e.prizes ?? []));
788
- return buyBox(boxId, part, options);
1063
+ if (!loot.boxRolls(box.entries) && !box.subject && !loot.boxFresh(box.entries, await itemsOf(box))) {
1064
+ const { on: _on, oneSignature: _one, ...buying } = options ?? {};
1065
+ const bought = await buyBox(boxId, part, { ...buying, orNull: true });
1066
+ if (bought) return bought;
789
1067
  }
790
- const { orderAddress, burner, landed } = await open(boxId, part, options);
791
- return collect(orderAddress, { burner, opened: true, landed });
1068
+ const { orderAddresses, burners, landed } = await open(boxId, part, options);
1069
+ if (orderAddresses.length === 1) return collect(orderAddresses[0], { burner: burners[0] ?? null, opened: true, landed });
1070
+ // Several orders of one purchase: each collected in turn by its own burner.
1071
+ const got = [];
1072
+ for (const [k, address] of orderAddresses.entries()) got.push(await collect(address, { burner: burners[k] ?? null, opened: true, landed }));
1073
+ return { ...got.at(-1), orders: got, won: got.flatMap((g) => g.won ?? []), assets: got.flatMap((g) => g.assets ?? []), parts: got.flatMap((g) => g.parts ?? [g.settled]) };
792
1074
  }
793
1075
 
794
- /** The current slot (an order's deadlines count in slots). */
1076
+ /** The current slot (a later ask or a refund of a failed order needs a later slot; an order's deadlines count in seconds: `chainNow`). */
795
1077
  const slotNow = async () => BigInt(await client.call('getSlot', [{ commitment: 'confirmed' }]));
796
1078
  /** The slots the SlotHashes sysvar holds now (a lookup table closes once its deactivation slot has left them). */
797
1079
  const recentSlots = async () => slotHashes((await client.account(SYSVAR_SLOT_HASHES))?.data);
@@ -803,6 +1085,25 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
803
1085
  return v >= 1n << 63n ? v - (1n << 64n) : v;
804
1086
  };
805
1087
 
1088
+ /** How many ends farm `f` has passed and not walked yet (`loot.endsToWalk`), and its decoded record: 0 for one that keeps none. */
1089
+ async function endsOf(f, farm) {
1090
+ if (!farm.keeps_ends) return { left: 0, ends: [] };
1091
+ const data = (await client.account(loot.pda.ends(loot.pda.farm(project, f.id))))?.data;
1092
+ const ends = data ? loot.decodeEnds(data) : [];
1093
+ return { left: loot.endsToWalk(farm, ends, await chainNow()), ends };
1094
+ }
1095
+
1096
+ /**
1097
+ * A move of farm `f` as it can land: every move walks the ends the farm has passed first, each some compute, so one with
1098
+ * ends to walk asks for all a transaction has, and one with more than that holds is refused in words, nothing signed.
1099
+ */
1100
+ async function walking(f, farm, instructions) {
1101
+ const { left } = await endsOf(f, farm);
1102
+ if (!left) return instructions;
1103
+ if (left > loot.MOVE_WALK_ROOM) throw Object.assign(new Error(`“${f.name ?? f.key}” has passed ${left} times seats stop earning since anyone last moved it, more than one transaction can walk: walk it first (app.walkFarm: anyone may, it is free, and nothing is lost by the wait).`), { name: 'FarmNeedsWalk' });
1104
+ return [computeUnitLimit(loot.WALK_COMPUTE_UNITS), ...instructions.filter((x) => !(x.programId === COMPUTE_BUDGET_PROGRAM && x.data[0] === 2))];
1105
+ }
1106
+
806
1107
  /**
807
1108
  * Refuses a stake a farm would refuse (`FarmOver`) before anything is signed: a farm with an end takes none at its end
808
1109
  * or after it, nor one at a tier whose lock would run past it. `f` is the spec's farm, `farm` the decoded one.
@@ -823,19 +1124,149 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
823
1124
  }
824
1125
 
825
1126
  /**
826
- * A lottery's current round: the lottery, its purchases ([{ address, value }], each a LotteryContribution; every one
827
- * still there is of this round), and for a drawn round whose purchases are all finished, the winning purchases still
828
- * to be paid ([{ contribution, winner }]).
1127
+ * A lottery and its rounds that have a record: the current one (null when nobody bought into it yet), and the two
1128
+ * before it while they wait for their draw or still pay out (the ask moves the lottery on, so a round drawing or
1129
+ * owing parts is an older one). Each { address, round } (`loot.decodeRound`).
829
1130
  */
830
1131
  async function roundOf(l) {
831
1132
  const address = loot.pda.lottery(project, l.id);
832
1133
  const lottery = await read('Lottery', address);
833
- const found = await client.programAccounts(loot.LOOT_PROGRAM, [{ memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS.LotteryContribution)) } }, { memcmp: { offset: 1, bytes: address } }]);
834
- const contributions = found.map(({ pubkey, account }) => ({ address: pubkey, value: loot.decode('LotteryContribution', account.data) }));
835
- if (lottery.status !== 'Drawn' || BigInt(lottery.pending) > 0n) return { lottery, contributions, winners: [] };
836
- // Once every purchase is finished, those left are the winners not paid yet.
837
- const winners = contributions.filter(({ value }) => value.done).map((contribution) => ({ contribution, winner: contribution.value.player }));
838
- return { lottery, contributions, winners };
1134
+ const back = [1, 2].filter((k) => lottery.round >= k).map((k) => loot.pda.round(address, lottery.incarnation, lottery.round - k));
1135
+ const keys = [loot.currentRound(address, lottery), ...back];
1136
+ const found = await client.accounts(keys);
1137
+ const rounds = keys.map((key, i) => ({ address: key, round: found[i]?.owner === loot.LOOT_PROGRAM ? loot.decodeRound(found[i].data) : null }));
1138
+ return { address, lottery, current: rounds[0].round, rounds: rounds.filter((r) => r.round) };
1139
+ }
1140
+
1141
+ /**
1142
+ * The tail a round's transfers need: each mint's transfer-hook accounts (its extra-account metas, read as every
1143
+ * hooked transfer of the client reads them), once each; none for a mint with no hook. A hook whose list names each
1144
+ * transfer can't be resolved ahead and is left out (the call then fails on it).
1145
+ */
1146
+ async function hookTail(mints) {
1147
+ const out = new Map();
1148
+ for (const mint of new Set(mints.filter((x) => x && x !== NOBODY))) {
1149
+ const resolved = await resolveTransfers(client, mint).catch(() => null);
1150
+ for (const m of resolved?.extraAccounts ?? []) {
1151
+ const had = out.get(m.pubkey);
1152
+ out.set(m.pubkey, had ? { ...had, isWritable: had.isWritable || m.isWritable } : m);
1153
+ }
1154
+ }
1155
+ return [...out.values()];
1156
+ }
1157
+ /** The mints a round's ask and pulls move: its pot's, its payment's and each token run's. */
1158
+ const roundMints = (r, poolMints = {}) => [r.terms.mint, loot.paymentMint(r.terms), ...r.terms.places.map((p) => p.prize?.Tokens?.from).filter(Boolean).map((from) => (from.Pool ? poolMints[from.Pool[0]] : from.Mint?.[0] ?? from.Vest?.[0]))];
1159
+
1160
+ /** Each pool a round's places give tokens from, mapped to its mint (one never opened: the economy's currency). */
1161
+ async function roundPoolMints(terms) {
1162
+ const out = {};
1163
+ for (const p of terms.places) {
1164
+ const pool = p.prize?.Tokens?.from.Pool?.[0];
1165
+ if (pool === undefined || pool in out) continue;
1166
+ const account = await client.account(loot.pda.pool(project, pool));
1167
+ out[pool] = account ? encodeBase58(account.data.subarray(0, 32)) : mintOf();
1168
+ }
1169
+ return out;
1170
+ }
1171
+
1172
+ /** The ask for a round's draw (`round` decoded, `key` its address; null for the current round with no record). */
1173
+ async function drawStep(l, lottery, key, round, signer) {
1174
+ const terms = round?.terms ?? lottery.terms;
1175
+ const poolMints = await roundPoolMints(terms);
1176
+ const extra = await hookTail(roundMints({ terms }, poolMints));
1177
+ return [computeUnitLimit(600_000), loot.drawLotteryIx({ payer: signer, project, id: l.id, lottery, round, roundAddress: key, poolMints, extra })];
1178
+ }
1179
+
1180
+ /**
1181
+ * When a draw of round `r` (at `key`) would cancel it (an open round short of its least tickets; a waiting one past
1182
+ * the timeout), the pulls of `who`'s own parts as the cancelled round owes them (a few entries' worth), to go right
1183
+ * after the draw in its transaction; none otherwise. The round's close rides on them when they are all it owes.
1184
+ */
1185
+ async function cancelPulls(key, r, who) {
1186
+ const short = r.status === 'Open' && BigInt(r.sold) < BigInt(r.terms.min_sold ?? 0) && BigInt(now()) >= BigInt(r.terms.draws_at);
1187
+ if (!short && !loot.lotteryCancellable(r, await chainNow())) return [];
1188
+ // As the program cancels it: every entry's own part owed, and what the ask moved in owed back.
1189
+ const potOwed = BigInt(r.pot_arrived) > BigInt(r.to_pot) ? BigInt(r.pot_arrived) - BigInt(r.to_pot) : 0n;
1190
+ const pools = r.prizes.filter((p) => !p.minted && BigInt(p.arrived) > 0n).length;
1191
+ const cancelled = { ...r, status: 'Cancelled', pot_owed: potOwed, owed: Number(r.owns) + (potOwed > 0n ? 1 : 0) + pools };
1192
+ const mine = r.entries.flatMap((e, i) => (e.buyer === who && loot.partsOwed(cancelled, i).length ? [{ entry: i, parts: loot.partsOwed(cancelled, i) }] : [])).slice(0, 4);
1193
+ const settles = mine.reduce((n, { entry, parts }) => n + parts.reduce((m, p) => m + pullSettles(cancelled, entry, p), 0), 0);
1194
+ const out = [];
1195
+ for (const [k, { entry, parts }] of mine.entries()) out.push(...(await pulls(key, cancelled, entry, parts, who, { closes: settles >= cancelled.owed && k === mine.length - 1 })).instructions);
1196
+ return out;
1197
+ }
1198
+
1199
+ /** The nonce a pull of entry `i` of the round at `key` names: a place's roll is made at `prizeRollNonce(nonce + place)`. */
1200
+ const pullNonce = (key, i) => loot.prizeNonce(key, BigInt(i));
1201
+
1202
+ /** How many of a round's owed parts a pull of `part` (of entry `i`) settles. */
1203
+ const pullSettles = (r, i, part) => {
1204
+ if (part === 'Own' || part.Common) return 1;
1205
+ const run = Number((part.Pot ?? part.Prize)[0]);
1206
+ return part.Pot || loot.tokenRun(r, run) ? loot.placesPulled(r, i, run, Boolean(part.Prize)) : loot.prizePlacesOwed(r, i, run).length;
1207
+ };
1208
+
1209
+ /**
1210
+ * The pulls of `parts` of entry `i` of a round (`partsOwed`; `i` null for parts owed in common, `commonOwed`), signed by
1211
+ * `signer`, and the rolls they make ([{ order, box }], `loot.pullRolls`). With `closes`, the last pull carries the
1212
+ * round's close (when the parts it pays are the last owed).
1213
+ */
1214
+ async function pulls(key, r, i, parts, signer, { closes = false } = {}) {
1215
+ const e = i == null ? null : r.entries[i];
1216
+ const out = [];
1217
+ const rolls = [];
1218
+ const g = await projectNow();
1219
+ // A run's token prize is in its own mint: the prizes the round recorded name each.
1220
+ const extra = await hookTail([...roundMints(r), ...r.prizes.map((p) => p.mint)]);
1221
+ for (const [k, part] of parts.entries()) {
1222
+ const more = {};
1223
+ if (part === 'Own' && e.nft !== NOBODY) {
1224
+ const then = r.status === 'Cancelled' || r.terms.returns ? null : r.terms.nft_ticket?.then;
1225
+ more.handBack = Boolean(then?.Give) && loot.handsBack(await client.account(then.Give[0]));
1226
+ const to = !then || more.handBack ? e.buyer : then.Give ? then.Give[0] : undefined;
1227
+ const { before = [], hasAuthority: _, ...kind } = await nftOf(e.nft, signer, { to });
1228
+ out.push(...before);
1229
+ more.ticket = kind;
1230
+ } else if (part.Prize && !loot.tokenRun(r, Number(part.Prize[0]))) {
1231
+ // Each of the entry's places in the run: an NFT of the lottery's own stash (which one, as the pull takes them
1232
+ // out), a project stash's NFT, an item, or a roll (asked in the same transaction).
1233
+ const run = Number(part.Prize[0]);
1234
+ const given = r.terms.places[run].prize;
1235
+ const inputs = await prizeInputs([given], signer, e.buyer);
1236
+ out.push(...inputs.before);
1237
+ const stash = loot.ownStash(r.terms.places[run]) ? loot.decodeStash((await client.account(loot.pda.lotteryStash(r.lottery)))?.data ?? null) : null;
1238
+ if (stash) {
1239
+ const predicted = loot.stashNfts(r, stash);
1240
+ for (const place of loot.prizePlacesOwed(r, i, run)) {
1241
+ const nft = predicted[loot.nftSlot(r, place)];
1242
+ if (!nft || inputs.nfts[nft]) continue;
1243
+ const { before, hasAuthority: _, ...kind } = await nftOf(nft, signer, { to: e.buyer });
1244
+ inputs.nfts[nft] = kind;
1245
+ out.push(...before);
1246
+ }
1247
+ }
1248
+ const stashes = given.Nft ? { [given.Nft.stash]: loot.decodeStash((await client.account(loot.pda.stash(project, given.Nft.stash)))?.data ?? null) ?? { nfts: [] } } : {};
1249
+ more.prize = { stash, stashes, nfts: inputs.nfts, collection: inputs.collection, poolMints: inputs.poolMints, items: inputs.items, projectMint: mintOf() };
1250
+ more.nonce = pullNonce(key, i);
1251
+ rolls.push(...loot.pullRolls({ project, round: r, entry: i, part, nonce: more.nonce }));
1252
+ } else if (part.Common) {
1253
+ // Where a part owed in common goes: a share's payee (a collectible's holder now), the pot, a pool; one that can't
1254
+ // take it (a pool never opened, the pot closed since, an account gone) sends it to the treasury.
1255
+ const c = Number(part.Common[0]);
1256
+ const n = r.terms.shares.length;
1257
+ const to = c < n ? r.terms.shares[c].to : c === n ? { Account: [loot.pda.pot(r.lottery)] } : r.terms.places[Number(r.prizes[c - n - 1].run)].prize.Tokens.from;
1258
+ if (to.Holder) more.holders = await holdersOf([{ to }]);
1259
+ const target = to.Pool ? loot.pda.pool(project, to.Pool[0]) : to.Account?.[0];
1260
+ // A pot or pool made again in another mint can't take it either: the program sends it to the treasury.
1261
+ const mint = c < n ? loot.paymentMint(r.terms) : c === n ? r.terms.mint : r.prizes[c - n - 1].mint;
1262
+ const held = target ? await client.account(target) : null;
1263
+ if (target && (!held || encodeBase58(held.data.subarray(0, 32)) !== mint)) more.toTreasury = true;
1264
+ }
1265
+ const last = k === parts.length - 1;
1266
+ const round = closes && last ? { ...r, owed: pullSettles(r, i, part) } : { ...r, owed: Number.MAX_SAFE_INTEGER };
1267
+ out.push(loot.pullIx({ payer: signer, project, roundAddress: key, round, entry: i ?? 0, part, projectState: g, programs, extra, ...more }));
1268
+ }
1269
+ return { instructions: out, rolls };
839
1270
  }
840
1271
 
841
1272
  return {
@@ -856,14 +1287,15 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
856
1287
  });
857
1288
  },
858
1289
 
859
- /** The boxes: price now and each outcome's chance. */
1290
+ /** The boxes: price now, each outcome's chance as written, and what the box does once an outcome runs out
1291
+ * (`whenOut`: 'stopSales', it isn't sold until restocked; 'drawLeft', it draws among what is left). */
860
1292
  async boxes() {
861
1293
  const list = spec.boxes ?? [];
862
1294
  const accounts = await client.accounts(list.map((b) => loot.pda.box(project, b.id)));
863
1295
  return list.map((b, i) => {
864
1296
  const box = accounts[i] && loot.decode('LootBox', accounts[i].data);
865
1297
  const total = box ? box.entries.reduce((n, e) => n + e.weight, 0) : 0;
866
- return { key: b.key, name: b.name, price: box ? show(box.price, decimalsOf(b.currency)) : null, priceUnits: box?.price ?? null, currency: tokens.get(b.currency ?? currencyKey)?.symbol, active: Boolean(box?.active), outcomes: (b.outcomes ?? []).map((o, j) => ({ ...o, chancePercent: box ? (100 * box.entries[j].weight) / total : o.chance })) };
1298
+ return { key: b.key, name: b.name, price: box ? show(box.price, decimalsOf(b.currency)) : null, priceUnits: box?.price ?? null, currency: tokens.get(b.currency ?? currencyKey)?.symbol, active: Boolean(box?.active), whenOut: box?.when_out === 'DrawLeft' ? 'drawLeft' : 'stopSales', outcomes: (b.outcomes ?? []).map((o, j) => ({ ...o, chancePercent: box ? (100 * box.entries[j].weight) / total : o.chance })) };
867
1299
  });
868
1300
  },
869
1301
 
@@ -881,7 +1313,8 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
881
1313
 
882
1314
  /**
883
1315
  * Opens a box (shows a collectible for its gate or odds when given): a box that needs no roll is bought at once
884
- * (`buy`); one that rolls waits for the roll and collects it.
1316
+ * (`buy`); one that rolls waits for the roll and collects it, settling a pack in as many parts as it needs. `count`
1317
+ * opens several packs in one purchase: one wallet prompt and one oracle answer (`maxPrice` is a pack's).
885
1318
  */
886
1319
  async openBox(boxKey, options = {}) {
887
1320
  only(options, BOX_OPTIONS, 'openBox');
@@ -892,6 +1325,49 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
892
1325
  /** Collects an order opened earlier (its roll arrived after the app stopped waiting). */
893
1326
  collect,
894
1327
 
1328
+ /**
1329
+ * Finds the settle keys a page that closed left holding the player's SOL, and finishes their jobs: a key whose order
1330
+ * has rolled settles it (no prompt); one whose order is gone, failed or due its refund sends its SOL back; one whose
1331
+ * order still waits for its roll keeps it for later. Each key is made again from the wallet's signature (asked once
1332
+ * a session, `walletStandardSigner`'s `signMessage`), or read from the page's storage (`keyStore`) when the wallet
1333
+ * signs no messages. Returns [{ name, key, settled } | { name, key, swept } | { name, key, waiting }].
1334
+ */
1335
+ async recover() {
1336
+ if (!keys) throw new Error('Connect a wallet first.');
1337
+ const me = wallet.address;
1338
+ const tag = (t) => ({ memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS[t])) } });
1339
+ const at = (offset, bytes) => ({ memcmp: { offset, bytes } });
1340
+ const orders = new Map();
1341
+ for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Order'), at(1, project), at(33, me)])) orders.set(pubkey, loot.decode('Order', account.data));
1342
+ const holds = (await client.programAccounts(loot.LOOT_PROGRAM, [tag('Hold'), at(1, project), at(65, me)])).map((h) => h.pubkey);
1343
+ // Keys are named by an order of the wallet's, a hold (a loan's release), or an auction (the releases after its end).
1344
+ const names = [...new Set([...orders.keys(), ...holds, ...(spec.auctions ?? []).map((a) => auctionAt(a)), ...keys.stored()])];
1345
+ if (!names.length) return [];
1346
+ const made = (await Promise.all(names.map((name) => keys.again(name)))).filter(Boolean);
1347
+ const held = made.length ? await client.accounts(made.map((k) => k.address)) : [];
1348
+ const [slot, time] = [await slotNow(), await chainNow()];
1349
+ const out = [];
1350
+ for (const [i, key] of made.entries()) {
1351
+ const lamports = BigInt(held[i]?.lamports ?? 0);
1352
+ if (lamports === 0n) {
1353
+ keys.forget(key.name);
1354
+ continue;
1355
+ }
1356
+ const order = orders.get(key.name);
1357
+ const due = order ? loot.orderRefundable(order, slot, time) : false;
1358
+ if (order?.status === 'Rolled' && slot > BigInt(order.requested_slot) && !due) {
1359
+ const got = await collect(key.name, { burner: key }).catch((e) => ({ error: e.message }));
1360
+ out.push({ name: key.name, key: key.address, ...(got.error ? { error: got.error } : { settled: got.settled, won: got.won }) });
1361
+ } else if (order && ['Pending', 'Asked', 'Waiting'].includes(order.status) && !due) {
1362
+ out.push({ name: key.name, key: key.address, waiting: order.status });
1363
+ } else {
1364
+ await emptyKey(key);
1365
+ out.push({ name: key.name, key: key.address, swept: lamports });
1366
+ }
1367
+ }
1368
+ return out;
1369
+ },
1370
+
895
1371
  /** Gives an order's payment back once its time has come (a roll that failed or never came). */
896
1372
  async refund(orderAddress) {
897
1373
  const order = await read('Order', orderAddress);
@@ -900,7 +1376,9 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
900
1376
  return send([...before, loot.refundIx({ signer: wallet.address, orderAddress, order, subject, programs })]);
901
1377
  },
902
1378
 
903
- /** Asks the oracle for a roll a mission, a recipe or a prize made (anyone may; the asker pays the oracle). */
1379
+ /** Asks the oracle for a roll a mission, a recipe or a prize made (anyone may; the asker pays the oracle). On a box
1380
+ * with nothing in stock only the order's player may ask, a slot after it was made or last asked, and that ends the
1381
+ * roll there with nothing, no oracle (a lot its winner paid for fails, for its refund). */
904
1382
  askRoll: (orderAddress) => askRoll(orderAddress),
905
1383
 
906
1384
  /**
@@ -956,16 +1434,22 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
956
1434
  */
957
1435
  async lotteries() {
958
1436
  const list = spec.lotteries ?? [];
959
- const accounts = await client.accounts(list.map((l) => loot.pda.lottery(project, l.id)));
960
- return list.map((l, i) => {
961
- const lottery = accounts[i]?.owner === loot.LOOT_PROGRAM ? loot.decode('Lottery', accounts[i].data) : null;
1437
+ const out = [];
1438
+ for (const l of list) {
1439
+ const there = await client.account(loot.pda.lottery(project, l.id));
1440
+ // The terms on sale are the round's own copy once someone bought into it, the lottery's before.
1441
+ const { lottery, current } = there?.owner === loot.LOOT_PROGRAM ? await roundOf(l) : { lottery: null, current: null };
1442
+ const copy = current ?? lottery;
1443
+ const terms = copy?.terms;
962
1444
  const currency = lottery ? ticketCurrencyOf(l, lottery) : l.ticketCurrency ?? currencyKey;
963
- return {
964
- key: l.key, name: l.name, price: lottery ? show(lottery.price, decimalsOf(currency)) : null, priceUnits: lottery?.price ?? null,
965
- currency: tokens.get(currency)?.symbol ?? currency, status: lottery?.status ?? null, round: lottery?.round ?? null, sold: lottery?.sold ?? null,
966
- drawsAt: lottery ? Number(lottery.draws_at) : null, nftTicket: Boolean(lottery?.nft_ticket), incarnation: lottery?.incarnation ?? null, revision: lottery?.revision ?? null,
967
- };
968
- });
1445
+ const ended = lottery && Number(lottery.every) === 0 && lottery.round > 0;
1446
+ out.push({
1447
+ key: l.key, name: l.name, price: terms ? show(terms.price, decimalsOf(currency)) : null, priceUnits: terms?.price ?? null,
1448
+ currency: tokens.get(currency)?.symbol ?? currency, status: !lottery ? null : ended ? 'Ended' : current?.status ?? 'Open', round: lottery?.round ?? null, sold: lottery ? current?.sold ?? 0n : null,
1449
+ drawsAt: terms ? Number(terms.draws_at) : null, nftTicket: Boolean(lottery?.terms.nft_ticket), incarnation: lottery?.incarnation ?? null, revision: copy?.revision ?? null,
1450
+ });
1451
+ }
1452
+ return out;
969
1453
  },
970
1454
 
971
1455
  /**
@@ -984,46 +1468,60 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
984
1468
  if (listing && listing.key !== undefined && listing.key !== l.key) throw new Error(`That listing is “${listing.key}”, not “${l.key}”.`);
985
1469
  const given = maxPrice ?? listing?.priceUnits;
986
1470
  if (given === undefined || given === null) throw new Error(`Say the terms the player was shown for “${name}”: pass the lottery lotteries() showed (listing), or maxPrice (a ticket's most, in base units). The price read now is never the bound.`);
987
- const current = await read('Lottery', loot.pda.lottery(project, l.id));
988
- // The terms signed are those shown: a lottery written again or changed since is refused here, and by the
989
- // program (TermsChanged) if it changes after this read.
1471
+ const { lottery: onChain, current: openRound } = await roundOf(l);
1472
+ // A round whose draw is due takes no more tickets: its draw goes first, in this same transaction (the buyer pays
1473
+ // the oracle's fee for it), and moves the lottery on, so this purchase is the next round's first. A lottery that
1474
+ // draws once has no next round.
1475
+ const due = openRound?.status === 'Open' && BigInt(now()) >= BigInt(openRound.terms.draws_at) ? openRound : null;
1476
+ if (due && Number(onChain.every) === 0) throw new Error(`The one draw of “${l.name}” is due: it takes no more tickets. Reveal it (claims() lists it).`);
1477
+ const draw = due ? (await drawStep(l, onChain, null, due, wallet.address)).filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM) : [];
1478
+ const current = due ? { ...onChain, round: onChain.round + 1 } : onChain;
1479
+ const round = due ? null : openRound;
1480
+ // The terms signed are those shown: the round's own copy once someone bought into it, the lottery's before. A
1481
+ // lottery written again or changed since is refused here, and by the program (TermsChanged) if it changes after
1482
+ // this read.
1483
+ const { terms, revision } = round ?? current;
990
1484
  if (listing && (listing.incarnation == null || listing.revision == null)) throw new Error(`That listing of “${name}” names no terms (incarnation, revision): pass the lottery lotteries() showed.`);
991
- if (listing && (BigInt(listing.incarnation) !== BigInt(current.incarnation) || Number(listing.revision) !== Number(current.revision))) throw new Error(`“${name}” has changed since the player was shown it: show them the lottery as it is now first.`);
992
- const lottery = listing ? { ...current, incarnation: BigInt(listing.incarnation), revision: Number(listing.revision) } : current;
1485
+ if (listing && (BigInt(listing.incarnation) !== BigInt(current.incarnation) || Number(listing.revision) !== Number(revision))) throw new Error(`“${name}” has changed since the player was shown it: show them the lottery as it is now first.`);
1486
+ const lottery = current;
993
1487
  const currency = ticketCurrencyOf(l, lottery);
994
- if (BigInt(lottery.price) > BigInt(given)) throw new Error(`A ticket of “${name}” now costs ${show(lottery.price, decimalsOf(currency))}, more than the ${show(BigInt(given), decimalsOf(currency))} the player was shown: show them the new price first.`);
995
- if (lottery.status === 'Ended') throw new Error(`“${l.name}” has drawn and ended.`);
1488
+ if (BigInt(terms.price) > BigInt(given)) throw new Error(`A ticket of “${name}” now costs ${show(terms.price, decimalsOf(currency))}, more than the ${show(BigInt(given), decimalsOf(currency))} the player was shown: show them the new price first.`);
1489
+ if (Number(lottery.every) === 0 && lottery.round > 0) throw new Error(`“${l.name}” has drawn and ended.`);
996
1490
  shown ??= await passing(lottery.gate, 'buy a ticket');
997
1491
  listProof = listProofOf(lottery.gate, l.gate?.list, listProof);
998
1492
  let nft;
999
1493
  const before = [];
1000
- if (lottery.nft_ticket) {
1494
+ if (terms.nft_ticket) {
1001
1495
  if (!paidWith) throw new Error(`A ticket of “${l.name}” costs an NFT: say which (nft).`);
1002
1496
  if (BigInt(count) !== 1n) throw new Error('An NFT pays for one ticket at a time.');
1003
1497
  const { before: proof, ...kind } = await nftOf(paidWith);
1004
1498
  nft = { asset: paidWith, ...kind };
1005
1499
  before.push(...proof);
1006
1500
  }
1007
- const mint = l.ticketCurrency ? mintOf(l.ticketCurrency) : lottery.mint;
1008
- const choosesHolder = lottery.shares.some((s) => s.to.Chosen && s.to.Chosen.item != null);
1009
- const nonce = freshNonce();
1010
- const ix = loot.buyTicketsIx({ player: wallet.address, project, id: l.id, lottery, mint, count: BigInt(count), nonce, shown, listProof, chosenRecipient, chosen: choosesHolder ? chosenRecipient : null, nft, programs });
1011
- // A round that reached its date with no ticket sold moves on by itself when a ticket is bought.
1012
- const due = lottery.status === 'Open' && BigInt(now()) >= BigInt(lottery.draws_at);
1013
- if (due && BigInt(lottery.sold) > 0n) throw new Error('This round’s draw is due: reveal it first (claims() lists it), then buy for the next one.');
1014
- // A ticket paid with an NFT: its way out must land too, the NFT handed on at the draw or back to its
1015
- // buyer when the round is cancelled, each sized before the NFT goes in.
1501
+ const mint = loot.paymentMint(terms);
1502
+ const { shares } = terms;
1503
+ const choosesHolder = shares.some((s) => s.to.Chosen && s.to.Chosen.item != null);
1504
+ // A share the buyer chooses is paid now, from their own account: to the holder of the collectible they chose (read
1505
+ // now), the wallet they named, or with no choice the treasury.
1506
+ const holders = choosesHolder && chosenRecipient ? await holdersOf([{ to: { Holder: [chosenRecipient] } }]) : {};
1507
+ // A payment mint with a transfer hook: its accounts ride along.
1508
+ const extra = await hookTail([mint]);
1509
+ const ix = loot.buyTicketsIx({ player: wallet.address, project, id: l.id, lottery, round, mint, count: BigInt(count), shown, listProof, chosenRecipient, chosen: choosesHolder ? chosenRecipient : null, nft, holders, treasury: (await projectNow()).treasury, programs, extra });
1510
+ // An overdue round nobody bought into moves to its next date by itself when a ticket is bought.
1511
+ // A ticket paid with an NFT: its way out must land too, the NFT handed on once the round is drawn or back to its
1512
+ // buyer when it is cancelled, each sized before the NFT goes in.
1016
1513
  if (nft) {
1017
1514
  const g = await projectNow();
1018
- const contribution = { player: wallet.address, asset: paidWith, done: false, round: lottery.round, count: 1n, shares: lottery.shares };
1019
- const contributionAddress = loot.pda.contribution(loot.pda.lottery(project, l.id), wallet.address, nonce);
1515
+ const entry = { buyer: wallet.address, nft: paidWith, start: 0n, end: 1n, amount: 0n, paid: 0n };
1020
1516
  for (const status of ['Drawn', 'Cancelled']) {
1021
- const exit = [computeUnitLimit(1_400_000), ...before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), loot.finishLotteryContributionIx({ payer: wallet.address, project, id: l.id, lottery: { ...lottery, status }, contributionAddress, contribution, treasury: g.treasury, ticket: kindOnly(nft), programs })];
1517
+ const r = { lottery: loot.pda.lottery(project, l.id), status, terms, accounts: [], owed: 2, opener: wallet.address, entries: [entry] };
1518
+ const exit = [computeUnitLimit(1_400_000), ...before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), loot.pullIx({ payer: wallet.address, project, roundAddress: loot.currentRound(r.lottery, lottery), round: r, entry: 0, part: 'Own', projectState: g, ticket: kindOnly(nft), programs })];
1022
1519
  if (!sendPlan({ payer: wallet.address, instructions: exit, alone: isProveLeaf }))
1023
- throw new Error(`Not bought: this NFT couldn't come out of “${l.name}” again (${status === 'Drawn' ? 'handed on at the draw' : 'back to you if the round is cancelled'}): that transaction would lock more accounts than one can (${transactionLocks(wallet.address, exit, { asV0: true })} with its lookup table; at most 64), so it would stay with the lottery.`);
1520
+ throw new Error(`Not bought: this NFT couldn't come out of “${l.name}” again (${status === 'Drawn' ? 'handed on once the round is drawn' : 'back to you if the round is cancelled'}): that transaction would lock more accounts than one can (${transactionLocks(wallet.address, exit, { asV0: true })} with its lookup table; at most 64), so it would stay with the lottery.`);
1024
1521
  }
1025
1522
  }
1026
- return send([...before, ...withSolWrap(wallet.address, mint, lottery.price * BigInt(count), [ix])]);
1523
+ const buy = [...before, ...withSolWrap(wallet.address, mint, BigInt(terms.price) * BigInt(count), [ix])];
1524
+ return send(draw.length ? [computeUnitLimit(800_000), ...draw, ...buy.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM)] : buy);
1027
1525
  },
1028
1526
 
1029
1527
  /**
@@ -1048,33 +1546,25 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1048
1546
  shown ??= await passing(farm.gates, 'stake here');
1049
1547
  const mint = farm.farmed.Tokens.mint;
1050
1548
  const value = units(amount, decimalsOf(f.stakes));
1051
- // Moving to, or topping up on, a tier whose rewards wait for its lock (an early exit) first takes what the seat
1052
- // may take now (HarvestFirst), in the same transaction; a seat can't move to a larger exit fee while locked.
1549
+ // While its lock runs, a seat moves only to a tier with no early exit, or one whose early exit costs at least as
1550
+ // much as its own (`ExitWhileLocked`): never to a way out it didn't have, or a cheaper one.
1053
1551
  const mine = await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), wallet.address));
1054
- let harvestFirst = false;
1055
- if (mine && BigInt(mine.amount) > 0n) {
1056
- const [from, to] = [farm.tiers[mine.tier], farm.tiers[tier]];
1057
- const locked = (await chainNow()) < BigInt(mine.since) + BigInt(from.lock);
1058
- if (locked && Number(to.exit_bps) > Number(from.exit_bps)) throw new Error('While its lock runs, this stake can\'t move to a tier with a larger early-exit fee.');
1059
- harvestFirst = Number(to.exit_bps) > 0 && !(Number(from.exit_bps) > 0 && locked);
1552
+ if (mine && BigInt(mine.amount) > 0n && Number(mine.tier) !== Number(tier)) {
1553
+ const [from, to] = [Number(farm.tiers[mine.tier].exit_bps), Number(farm.tiers[tier].exit_bps)];
1554
+ const locked = (await chainNow()) < BigInt(mine.since) + BigInt(farm.tiers[mine.tier].lock);
1555
+ if (locked && !(to === 0 || (from > 0 && to >= from))) throw new Error('While its lock runs, this stake can only move to a tier with no early exit, or one whose early exit costs at least as much as its own.');
1060
1556
  }
1061
- const token = await farmTokens([mint, farm.rewards_mint], harvestFirst ? [farm.rewards_mint] : [], { extra, transfers });
1557
+ const token = await farmTokens([mint], [], { extra, transfers });
1062
1558
  const ix = loot.stakeTokensIx({ player: wallet.address, project, id: f.id, farm, amount: value, tier, shown, ...token });
1063
- const harvest = harvestFirst ? [...token.before, loot.harvestIx({ player: wallet.address, project, id: f.id, farm, ...token })] : [];
1064
- // A new tier on a seat with a booster that grows: its growth so far is written first, in the same transaction.
1065
- const seat = farm.growth.length ? await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), wallet.address)) : null;
1066
- const before = [];
1067
- if (seat && seat.tier !== tier && seat.asset !== NOBODY) {
1068
- const { before: proof, ...nft } = await nftOf(seat.asset);
1069
- before.push(...proof, loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by: wallet.address, seat, ...nft }));
1070
- }
1071
- return send([...before, ...harvest, ...withSolWrap(wallet.address, mint, value, [ix])]);
1559
+ // A seat with a booster on (its boost is read again), or one whose growth the stake changes, is refreshed first, in
1560
+ // the same transaction.
1561
+ const before = loot.stakeRefreshFirst(farm, mine, { tier, now: await chainNow() }) ? await refreshOf(f, farm, mine) : [];
1562
+ return send(await walking(f, farm, [...before, ...withSolWrap(wallet.address, mint, value, [ix])]));
1072
1563
  },
1073
1564
 
1074
1565
  /**
1075
1566
  * Takes `amount` out once its lock has run (0: only rewards); `forfeit` gives up rewards that cannot move. Before the
1076
- * lock, a tier with an early exit lets it leave for its fee, and gives up what the seat is owed; in a farm that
1077
- * unbonds, what is taken out waits first (`withdraw` takes it out once free).
1567
+ * lock, a tier with an early exit lets it leave for its fee, keeping what the seat was credited.
1078
1568
  */
1079
1569
  async unstake(farmKey, amount = 0, { forfeit = false, extra, transfers, ...others } = NO_OPTIONS) {
1080
1570
  only(others, ['forfeit', 'extra', 'transfers'], 'unstake');
@@ -1083,7 +1573,15 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1083
1573
  const value = units(amount, decimalsOf(f.stakes));
1084
1574
  const mints = [...(value > 0n ? [farm.farmed.Tokens.mint] : []), ...(!forfeit ? [farm.rewards_mint] : [])];
1085
1575
  const token = await farmTokens(mints, mints, { extra, transfers });
1086
- return send([...token.before, loot.unstakeTokensIx({ player: wallet.address, project, id: f.id, farm, amount: value, forfeit, ...token })]);
1576
+ // An exit that would lose what the seat grew since its last refresh writes it first, in the same transaction.
1577
+ const by = wallet.address;
1578
+ const seat = await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), by));
1579
+ const first = [];
1580
+ if (seat && loot.refreshFirst(farm, seat, value, forfeit)) {
1581
+ const { before, ...nft } = seat.asset !== NOBODY ? await nftOf(seat.asset) : { before: [] };
1582
+ first.push(...before, loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by, seat, ...nft, extra: token.extra }));
1583
+ }
1584
+ return send(await walking(f, farm, [...token.before, ...first, loot.unstakeTokensIx({ player: wallet.address, project, id: f.id, farm, amount: value, forfeit, ...token })]));
1087
1585
  },
1088
1586
 
1089
1587
  /**
@@ -1100,23 +1598,18 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1100
1598
  const { before, ...nft } = await nftOf(asset);
1101
1599
  const seat = farm.custody === 'Watch' && farm.farmed === 'Collectibles' ? await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), asset)) : null;
1102
1600
  const previous = seat && seat.player !== wallet.address ? seat.player : null;
1103
- // A booster boosts a stake: a farm of tokens takes one only on a seat with tokens staked (NotFarmed). Putting
1104
- // one on starts the lock again, so on a tier whose rewards wait for its lock what the seat may take now is
1105
- // harvested first, in the same transaction (HarvestFirst).
1106
- const harvest = [];
1601
+ // A booster boosts a stake: a farm of tokens takes one only on a seat with tokens staked (NotFarmed). In a farm
1602
+ // whose growth writes the profile, the seat is refreshed first, in the same transaction (`boosterRefreshFirst`).
1603
+ const refresh = [];
1107
1604
  if (farm.farmed.Tokens) {
1108
1605
  const mine = await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), wallet.address));
1109
1606
  if (!mine || BigInt(mine.amount) === 0n) throw new Error(`Stake some ${f.stakes} in “${f.name ?? f.key}” first: a booster boosts a stake.`);
1110
- const t = farm.tiers[mine.tier];
1111
- if (Number(t.exit_bps) > 0 && (await chainNow()) >= BigInt(mine.since) + BigInt(t.lock)) {
1112
- const rewards = await farmTokens([farm.rewards_mint], [farm.rewards_mint], { extra, transfers });
1113
- harvest.push(...rewards.before, loot.harvestIx({ player: wallet.address, project, id: f.id, farm, ...rewards }));
1114
- }
1607
+ if (loot.boosterRefreshFirst(farm)) refresh.push(...(await refreshOf(f, farm, mine)));
1115
1608
  }
1116
1609
  // Its way out must land too: its unstake from this tier, with the tier's
1117
1610
  // prize and as a forfeit, is sized before it goes in, and an NFT that could never come back out isn't staked.
1118
1611
  if (!farm.farmed.Tokens) await exitFits(f, farm, asset, nft, before, tier, token);
1119
- return send([...before, ...harvest, loot.stakeNftIx({ player: wallet.address, project, id: f.id, farm, asset, ...nft, previous, tier, ...token })]);
1612
+ return send(await walking(f, farm, [...refresh, ...before, loot.stakeNftIx({ player: wallet.address, project, id: f.id, farm, asset, ...nft, previous, tier, ...token })]));
1120
1613
  },
1121
1614
 
1122
1615
  /**
@@ -1139,12 +1632,49 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1139
1632
  const prize = farm.tiers[seat.tier]?.prize ?? null;
1140
1633
  // A forfeit takes no prize: none of its accounts (`unstakeNftIx`).
1141
1634
  const given = await prizeInputs([forfeit ? null : prize]);
1142
- // A mission's roll on the NFT is at the NFT's nonce (`nonceOn`): one at a time on it.
1635
+ // A mission's roll on the NFT: on a box that holds it, at the NFT's own nonce (one at a time on it), else at
1636
+ // `prizeRollNonce` of the one named (`namedRollNonce`).
1143
1637
  const nonce = roll != null ? loot.nonceOn(asset) : freshNonce();
1144
- const ix = loot.unstakeNftIx({ player: wallet.address, project, id: f.id, farm, asset, ...nft, forfeit, roll, nonce, tier: seat.tier, projectCollection: given.collection, poolMints: given.poolMints, items: given.items, nft: given.nft, ...token });
1145
- const signature = await send([...before, ...given.before, ...token.before, ix]);
1146
- if (roll != null) return { signature, roll: loot.pda.order(project, wallet.address, nonce) };
1147
- return prize?.Roll && !forfeit ? { signature, roll: loot.pda.order(project, wallet.address, loot.prizeRollNonce(nonce + 1n)) } : signature;
1638
+ const boxes = roll != null ? { [roll]: await read('LootBox', loot.pda.box(project, roll)) } : {};
1639
+ // A roll still at the NFT's order address blocks the leave, unless it was asked and came back with nothing to give
1640
+ // (Waiting, asked once): that one stands in for this. Any other is asked (its player or the project's keys), answered and settled first.
1641
+ if (roll != null) {
1642
+ const at = loot.pda.order(project, wallet.address, loot.namedRollNonce(boxes[roll], asset, nonce));
1643
+ const there = await readMaybe('Order', at);
1644
+ if (there && !(there.status === 'Waiting' && BigInt(there.asked_first) !== 0n))
1645
+ throw new Error(`This NFT's last mission roll (${at}) hasn't finished: it must be asked, answered and settled before the NFT can leave. claims() lists the step; collect it (app.collect), then leave.`);
1646
+ }
1647
+ const ix = loot.unstakeNftIx({ player: wallet.address, project, id: f.id, farm, asset, ...nft, forfeit, roll, nonce, tier: seat.tier, projectCollection: given.collection, poolMints: given.poolMints, items: given.items, nft: given.nft, boxes, ...token });
1648
+ const { signature, rolls } = await sendRolling(await walking(f, farm, [...before, ...given.before, ...token.before, ix]));
1649
+ const rolled = rolls.length ? { rolled: rolls[0] } : {};
1650
+ if (roll != null) return { signature, roll: loot.pda.order(project, wallet.address, loot.namedRollNonce(boxes[roll], asset, nonce)), ...rolled };
1651
+ return prize?.Roll && !forfeit ? { signature, roll: loot.pda.order(project, wallet.address, loot.prizeRollNonce(nonce + 1n)), ...rolled } : signature;
1652
+ },
1653
+
1654
+ /**
1655
+ * What farm `farmKey` has to walk: `{ ends, needsWalk, parts }`, the times its seats stop earning it has passed and not
1656
+ * yet credited up to, whether a move can no longer do them in its own transaction, and how many transactions
1657
+ * `walkFarm` would take. Every move walks them first; nothing is lost however long they wait.
1658
+ */
1659
+ async farmWalk(farmKey) {
1660
+ const f = part('farms', farmKey);
1661
+ const { left } = await endsOf(f, await read('Farm', loot.pda.farm(project, f.id)));
1662
+ return { ends: left, needsWalk: left > loot.MOVE_WALK_ROOM, parts: Math.ceil(left / loot.WALK_PER_TX) };
1663
+ },
1664
+
1665
+ /**
1666
+ * Walks a farm up to now in parts (`walk_farm`), for one that has passed more ends than a move can walk: one transaction
1667
+ * for every `loot.WALK_PER_TX` ends, oldest first. Anyone may send it, and it costs only the network fee. Returns their
1668
+ * signatures, none when there is nothing to walk.
1669
+ */
1670
+ async walkFarm(farmKey, { perTx = loot.WALK_PER_TX, ...others } = NO_OPTIONS) {
1671
+ only(others, ['perTx'], 'walkFarm');
1672
+ const f = part('farms', farmKey);
1673
+ const farm = await read('Farm', loot.pda.farm(project, f.id));
1674
+ const { ends } = await endsOf(f, farm);
1675
+ const out = [];
1676
+ for (const ix of loot.walkFarmParts({ project, id: f.id, farm, ends, now: await chainNow(), perTx })) out.push(await send([computeUnitLimit(loot.WALK_COMPUTE_UNITS), ix]));
1677
+ return out;
1148
1678
  },
1149
1679
 
1150
1680
  /**
@@ -1152,16 +1682,16 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1152
1682
  * and reads its boosts again. Anyone may send it; the player pays for what the write adds. `by`: the collectible,
1153
1683
  * or the player for their token seat's booster.
1154
1684
  */
1155
- async refresh(farmKey, by, { extra, transfers, ...others } = NO_OPTIONS) {
1685
+ async refresh(farmKey, by, { extra, ...others } = NO_OPTIONS) {
1686
+ // `transfers` (a token's transfer plan) is taken and needs nothing: a refresh moves no tokens.
1156
1687
  only(others, ['extra', 'transfers'], 'refresh');
1157
1688
  const f = part('farms', farmKey);
1158
1689
  const farm = await read('Farm', loot.pda.farm(project, f.id));
1159
1690
  const seat = await read('Seat', loot.pda.seat(loot.pda.farm(project, f.id), by));
1160
1691
  const held = seat.asset !== NOBODY;
1161
1692
  const { before, ...nft } = held ? await nftOf(seat.asset) : { before: [], hasAuthority: true };
1162
- // Like every move, it brings the farm's rewards in (and sends back what a seat gave up): the mint's hook accounts.
1163
- const token = await farmTokens([farm.rewards_mint], [], { extra, transfers });
1164
- return send([...before, loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by, seat, ...nft, extra: token.extra })]);
1693
+ // A refresh moves no tokens (a farm's rewards are numbers until a seat is paid): only a collectible's own tail.
1694
+ return send(await walking(f, farm, [...before, loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by, seat, ...nft, extra: extra ?? [] })]));
1165
1695
  },
1166
1696
 
1167
1697
  /**
@@ -1172,13 +1702,6 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1172
1702
  only(others, ['compound', 'extra', 'transfers'], 'harvest');
1173
1703
  const f = part('farms', farmKey);
1174
1704
  const farm = await read('Farm', loot.pda.farm(project, f.id));
1175
- // A tier with an early exit keeps its rewards until its lock has run (RewardsLocked).
1176
- if (farm.farmed.Tokens) {
1177
- const seat = await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), by));
1178
- const tier = seat && farm.tiers[seat.tier];
1179
- const ends = tier ? BigInt(seat.since) + BigInt(tier.lock) : 0n;
1180
- if (tier && Number(tier.exit_bps) > 0 && (await chainNow()) < ends) throw new Error(`This stake can leave early, so its rewards wait for its lock: harvest after ${new Date(Number(ends) * 1000).toISOString()}. Leaving early gives them up.`);
1181
- }
1182
1705
  const token = await farmTokens([farm.rewards_mint], [farm.rewards_mint], { extra, transfers });
1183
1706
  // In a farm that watches: what shows the seat's NFT is still the player's (a compressed one proven first).
1184
1707
  let [held, tree, before] = [null, null, []];
@@ -1191,31 +1714,28 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1191
1714
  before = nft.compressed ? nft.before : [];
1192
1715
  }
1193
1716
  }
1194
- return send([...before, ...token.before, loot.harvestIx({ player: wallet.address, project, id: f.id, farm, by, held, tree, compound, ...token })]);
1717
+ // A compound stakes more: a seat with a booster on, or whose growth reads the amount, is refreshed first.
1718
+ const seat = compound ? await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), by)) : null;
1719
+ const first = loot.stakeRefreshFirst(farm, seat, { compound: true }) ? await refreshOf(f, farm, seat) : [];
1720
+ return send(await walking(f, farm, [...before, ...token.before, ...first, loot.harvestIx({ player: wallet.address, project, id: f.id, farm, by, held, tree, compound, ...token })]));
1195
1721
  },
1196
1722
 
1197
1723
  /**
1198
1724
  * Adds what a token seat is owed to its stake, without restarting its lock (a farm of tokens whose rewards are the
1199
- * staked token). The farm's toll, if any, is taken first.
1725
+ * staked token).
1200
1726
  */
1201
1727
  async compound(farmKey, opts = {}) {
1202
1728
  only(opts, ['extra', 'transfers'], 'compound');
1203
1729
  return this.harvest(farmKey, wallet.address, { ...opts, compound: true });
1204
1730
  },
1205
1731
 
1206
- /** Takes out the tokens whose unbonding wait is over (a farm with `unbond`), and what the seat is owed. */
1207
- async withdraw(farmKey, opts = {}) {
1208
- only(opts, ['forfeit', 'extra', 'transfers'], 'withdraw');
1209
- return this.unstake(farmKey, 0, opts);
1210
- },
1211
-
1212
1732
  /**
1213
1733
  * The player's seats in a farm (the wallet's, or `player`'s): each one's NFT (`asset`, null for a token seat with
1214
1734
  * no booster), what it holds staked (`amount`, in whole units of the staked token, a number for display that can
1215
1735
  * round past 2^53 base units; `amountUnits`, in base units exactly, a bigint; 1 for an NFT), its tier, when it
1216
1736
  * came in (`since`) and when its lock is over (`freeAt`, unix seconds; null when the NFT says it, `lockKey`), its
1217
- * boost in percent, and what it is owed now: `waiting` (whole units of the rewards, `rewards` their token) and
1218
- * `owedUnits` (base units). What it is owed is the program's own sum, from a refresh of the seat dry-run on the
1737
+ * boost in percent, until when it earns (`earnsUntil`, unix seconds, null for no end: a tier's term, an upkeep), and
1738
+ * what it is owed now: `waiting` (whole units of the rewards, `rewards` their token) and `owedUnits` (base units). What it is owed is the program's own sum, from a refresh of the seat dry-run on the
1219
1739
  * network (nothing is signed or sent): what the farm released or its rate paid since its last move is in it, by the
1220
1740
  * farm's rounding. When the dry run fails, it is what the seat was last credited, and `counted` is false.
1221
1741
  * One search of the chain, and one dry run per seat: read it when the page opens and after a stake or a harvest.
@@ -1227,12 +1747,14 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1227
1747
  const farm = await read('Farm', farmAddress);
1228
1748
  const rewards = [...tokens.values()].find((t) => mintOf(t.key) === farm.rewards_mint);
1229
1749
  const nfts = f.stakes === 'collectibles';
1230
- const found = await client.programAccounts(loot.LOOT_PROGRAM, [
1750
+ // A seat names its player (offset 1), not its farm: a seat of this farm is the one at its own address here.
1751
+ const found = (await client.programAccounts(loot.LOOT_PROGRAM, [
1231
1752
  { memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS.Seat)) } },
1232
- { memcmp: { offset: 1, bytes: farmAddress } },
1233
- { memcmp: { offset: 33, bytes: player } },
1234
- ]);
1235
- const token = await farmTokens([farm.rewards_mint], []).catch(() => ({ extra: [] }));
1753
+ { memcmp: { offset: 1, bytes: player } },
1754
+ ])).filter(({ pubkey, account }) => {
1755
+ const seat = loot.decode('Seat', account.data);
1756
+ return pubkey === loot.pda.seat(farmAddress, nfts ? seat.asset : player);
1757
+ });
1236
1758
  const { blockhash } = await client.latestBlockhash();
1237
1759
  // Who the dry run names as the refresh's signer and fee payer: the network wants one that holds SOL, whoever it is
1238
1760
  // (nothing is signed): the player, else the wallet looking, else the project's authority.
@@ -1250,7 +1772,7 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1250
1772
  payer ??= await funded();
1251
1773
  const { before, ...nft } = held ? await nftOf(seat.asset, payer) : { before: [] };
1252
1774
  const by = nfts ? seat.asset : player;
1253
- const ix = loot.refreshSeatIx({ signer: payer, project, id: f.id, farm, by, seat, ...nft, extra: token.extra });
1775
+ const ix = loot.refreshSeatIx({ signer: payer, project, id: f.id, farm, by, seat, ...nft });
1254
1776
  const message = compileMessage({ payer, blockhash, instructions: [...before, ix] });
1255
1777
  const after = (await client.simulate(wireTransaction(message), { accounts: [pubkey], replaceBlockhash: true })).accounts[0];
1256
1778
  if (after?.data) [seat, counted] = [loot.decode('Seat', after.data), true];
@@ -1270,6 +1792,8 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1270
1792
  rewards: rewards?.key ?? null,
1271
1793
  waiting: show(seat.owed, rewards ? decimalsOf(rewards.key) : 0),
1272
1794
  owedUnits: BigInt(seat.owed),
1795
+ // Until when it earns and grows (unix seconds): its tier's term or its fuel, the sooner; null for no end.
1796
+ earnsUntil: BigInt(seat.earns_until) >= loot.NO_END ? null : Number(seat.earns_until),
1273
1797
  counted,
1274
1798
  });
1275
1799
  }
@@ -1290,31 +1814,33 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1290
1814
  * Crafts a recipe, handing in `assets` (the collectibles it burns or uses, in the recipe's order). `version`: the
1291
1815
  * recipe's version the page showed the player (`recipes()`, or the listing itself as `listing`), so a recipe the
1292
1816
  * owner changed since is refused rather than crafted on terms the player never saw. It is required: the version
1293
- * read now is never signed in its place.
1817
+ * read now is never signed in its place. `extra`: the tail a Core collection's plugins need when the craft moves or
1818
+ * rolls on a Core NFT (sent after the Memo program's account, as on every other Core move).
1294
1819
  */
1295
- async craft(recipeKey, assets = [], times = 1, { version, listing, ...others } = NO_OPTIONS) {
1296
- only(others, ['version', 'listing'], 'craft');
1820
+ async craft(recipeKey, assets = [], times = 1, { version, listing, extra = [], ...others } = NO_OPTIONS) {
1821
+ only(others, ['version', 'listing', 'extra'], 'craft');
1297
1822
  const r = part('recipes', recipeKey);
1298
1823
  version ??= listing?.version ?? undefined;
1299
1824
  if (version === undefined || version === null) throw new Error(`Say which version of “${r.name ?? recipeKey}” the player was shown: pass the listing recipes() showed (listing), or its version. The version read now is never signed in its place.`);
1300
1825
  const current = await read('Recipe', loot.pda.recipe(project, r.id));
1301
1826
  const recipe = { ...current, version };
1302
- await poolShareGiven(recipe.outputs.filter((o) => o.Prize).map((o) => o.Prize[0] ?? o.Prize));
1303
1827
  const pays = recipe.inputs.filter((i) => i.Pay).map((i) => i.Pay);
1304
1828
  const holders = await holdersOf(pays.map((p) => ({ to: p.to })));
1305
1829
  const treasury = (await projectNow()).treasury;
1306
1830
  // An NFT a `show` line takes, by its address: as the project's, a Core asset of another collection, or a Token
1307
1831
  // Metadata mint, which the craft reads differently.
1308
1832
  // Where the collectibles of input `i` start among those handed in.
1309
- const at = (i) => recipe.inputs.slice(0, i).reduce((n, x) => n + (x.Burn?.count ?? (x.Use || x.Renew || x.Show || x.Staked || x.Trade ? 1 : 0)), 0);
1833
+ const at = (i) => recipe.inputs.slice(0, i).reduce((n, x) => n + (x.Burn?.count ?? (x.Use || x.Show || x.Staked || x.Trade ? 1 : 0)), 0);
1310
1834
  const shownAt = new Set(recipe.inputs.flatMap((input, i) => (input.Show ? [at(i)] : [])));
1311
1835
  // An NFT a `trade` line takes in, any kind: read for its kind (a compressed one proven first).
1312
1836
  const tradedAt = new Set(recipe.inputs.flatMap((input, i) => (input.Trade ? [at(i)] : [])));
1313
1837
  const proving = [];
1314
1838
  // A collectible used or renewed that is staked in escrow, in the project's hands: its farm, found by its seat.
1315
- const keptAt = new Set(recipe.inputs.flatMap((input, i) => (input.Use || input.Renew ? [at(i)] : [])));
1839
+ const keptAt = new Set(recipe.inputs.flatMap((input, i) => (input.Use ? [at(i)] : [])));
1316
1840
  // One a `staked` line takes: on its seat in the farm the line names, escrowed or frozen there.
1317
1841
  const stakedAt = new Map(recipe.inputs.flatMap((input, i) => (input.Staked ? [[at(i), input.Staked.farm]] : [])));
1842
+ // The places a raise lowers a number on.
1843
+ const lowered = new Set((recipe.raises ?? []).filter((raise) => BigInt(raise.shift.by) < 0n).map((raise) => Number(raise.place)));
1318
1844
  const handed = await Promise.all(assets.map(async (nft, i) => {
1319
1845
  if (stakedAt.has(i) && typeof nft === 'string') {
1320
1846
  const farm = stakedAt.get(i);
@@ -1324,21 +1850,25 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1324
1850
  }
1325
1851
  return { asset: nft, farm };
1326
1852
  }
1327
- if (keptAt.has(i) && typeof nft === 'string') return (await escrowedIn(nft)) ?? nft;
1853
+ if (keptAt.has(i) && typeof nft === 'string') return (await escrowedIn(nft)) ?? (await lockedIn(nft)) ?? nft;
1328
1854
  if (tradedAt.has(i) && typeof nft === 'string') {
1329
1855
  const { before, ...kind } = await nftOf(nft);
1330
1856
  proving.push(...before);
1331
1857
  return { asset: nft, ...kind };
1332
1858
  }
1333
1859
  if (!shownAt.has(i) || typeof nft !== 'string') return nft;
1860
+ // Shown on a seat of a farm that freezes, with a raise lowering a number on it: its seat and farm go with it.
1861
+ const seated = lowered.has(i) ? await lockedIn(nft) : null;
1862
+ const on = seated ? { farm: seated.farm } : {};
1334
1863
  const account = await client.account(nft);
1335
- if (account?.owner !== MPL_CORE) return { mint: nft, owner: wallet.address };
1864
+ if (account?.owner !== MPL_CORE) return { mint: nft, owner: wallet.address, ...on };
1336
1865
  const theirs = await collectionOf(nft);
1337
- return ours(theirs) ? nft : { asset: nft, collection: theirs };
1866
+ return ours(theirs) ? (seated ? { asset: nft, ...on } : nft) : { asset: nft, collection: theirs, ...on };
1338
1867
  }));
1339
1868
  // A recipe that rolls makes its roll in an order of the player's, which `collect` then finishes.
1340
1869
  const rolls = recipe.outputs.some((o) => o.RollOn || o.Prize?.[0]?.Roll);
1341
- // A roll on a collectible kept is at that collectible's nonce (`nonceOn`): one at a time on it.
1870
+ // A roll on a collectible kept is named by that collectible's nonce (`nonceOn`): on a box that holds it, its
1871
+ // order is there, one at a time on it (`namedRollNonce`).
1342
1872
  const rollOn = recipe.outputs.find((o) => o.RollOn)?.RollOn;
1343
1873
  const nonce = rollOn ? loot.nonceOn(typeof assets[rollOn.place] === 'string' ? assets[rollOn.place] : assets[rollOn.place].asset) : freshNonce();
1344
1874
  // A roll on a kept collectible (`RollOn`) passes its box's prize mints as a roll given does.
@@ -1358,12 +1888,12 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1358
1888
  return [loot.pda.itemAsset(loot.pda.item(project, id), Number(items[id].issued) + counts[id])];
1359
1889
  });
1360
1890
  const before = [...proving, ...given.before].filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM);
1361
- let instructions = [computeUnitLimit(1_000_000), ...before, loot.craftIx({ player: wallet.address, project, collection: collections[0], id: r.id, recipe, assets: handed, times, nonce, holders, programs, treasury, poolMints: given.poolMints, items, nfts: given.nfts, boxes: given.boxes, rollPools: given.rollPools })];
1891
+ let instructions = [computeUnitLimit(rolls ? 1_400_000 : 1_000_000), ...before, loot.craftIx({ player: wallet.address, project, collection: collections[0], id: r.id, recipe, assets: handed, times, nonce, holders, programs, treasury, poolMints: given.poolMints, items, nfts: given.nfts, boxes: given.boxes, extra })];
1362
1892
  // Fuel bought for a staked collectible counts once its seat is refreshed: in the same transaction.
1363
1893
  for (const raise of recipe.raises ?? []) {
1364
1894
  const asset = assets[raise.place];
1365
1895
  if (typeof asset !== 'string') continue;
1366
- for (const f of (spec.farms ?? []).filter((x) => x.upkeepAttribute === raise.key)) {
1896
+ for (const f of (spec.farms ?? []).filter((x) => x.upkeepAttribute === raise.shift.key)) {
1367
1897
  const seat = await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), asset));
1368
1898
  if (!seat) continue;
1369
1899
  const farm = await read('Farm', loot.pda.farm(project, f.id));
@@ -1372,9 +1902,12 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1372
1902
  }
1373
1903
  }
1374
1904
  for (const p of pays) instructions = withSolWrap(wallet.address, p.mint, p.amount * BigInt(times), instructions);
1375
- const signature = await send(instructions);
1376
- // A roll on a collectible kept is at its nonce; a prize's roll at `prizeRollNonce` of the craft's.
1377
- return { signature, made, ...(rolls ? { roll: loot.pda.order(project, wallet.address, rollOn ? nonce : loot.prizeRollNonce(nonce)) } : {}) };
1905
+ // A roll on a collectible kept is at its nonce; a prize's roll at `prizeRollNonce` of the craft's. Asked for in the
1906
+ // craft's own transaction and settled by a key after: one prompt.
1907
+ const on = rollOn && (typeof assets[rollOn.place] === 'string' ? assets[rollOn.place] : assets[rollOn.place].asset);
1908
+ const roll = rolls ? loot.pda.order(project, wallet.address, rollOn ? loot.namedRollNonce(given.boxes[rollOn.box_id], on, nonce) : loot.prizeRollNonce(nonce)) : null;
1909
+ const { signature, rolls: rolled } = await sendRolling(instructions);
1910
+ return { signature, made, ...(roll ? { roll } : {}), ...(rolled.length ? { rolled: rolled[0] } : {}) };
1378
1911
  },
1379
1912
 
1380
1913
  /**
@@ -1432,6 +1965,50 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1432
1965
  return { signature, due, hold: loot.pda.hold(asset, due) };
1433
1966
  },
1434
1967
 
1968
+ /**
1969
+ * The loan on `asset` (held until `due`), as a page shows it before anyone repays: { lender, mint, repay (base units),
1970
+ * due, paid }. `repay` is what `repay` then takes as the amount shown. Null when it holds no loan.
1971
+ */
1972
+ async loan(asset, due) {
1973
+ const hold = await readMaybe('Hold', loot.pda.hold(asset, due));
1974
+ if (!hold?.debt) return null;
1975
+ return { lender: hold.debt.lender, mint: hold.debt.mint, repay: BigInt(hold.debt.repay), due: Number(hold.free_at), borrower: hold.player };
1976
+ },
1977
+
1978
+ /**
1979
+ * Repays the loan on `asset` (held until `due`), the player's own or a friend's, then sends the NFT home to its
1980
+ * borrower. `repay`: the debt the payer was shown (`loan()`'s, in base units); the program refuses any other
1981
+ * (`TermsChanged`). The repay goes in the wallet's transaction alone, with what it opens (the borrower's Token
1982
+ * Metadata account), so a release that fails never undoes a repay that must land before the loan is due; the same
1983
+ * transaction funds a throwaway key that sends the release (anyone may), a pNFT's or a compressed NFT's too: the key
1984
+ * is funded for what that release opens (a pNFT's token account and record, a compressed NFT's proof record, which
1985
+ * comes back in the same transaction). Returns { signature, released }.
1986
+ */
1987
+ async repay(asset, due, { repay, extra = [], ...others } = NO_OPTIONS) {
1988
+ only(others, ['repay', 'extra'], 'repay');
1989
+ const hold = await read('Hold', loot.pda.hold(asset, due));
1990
+ if (!hold.debt) throw new Error('This NFT is held for no loan left to repay.');
1991
+ if (repay === undefined || repay === null) throw new Error('Say the debt the payer was shown (repay, from loan()): the amount read now is never paid in its place.');
1992
+ if (BigInt(repay) !== BigInt(hold.debt.repay)) throw new Error(`This loan now asks ${hold.debt.repay}, not the ${repay} shown: show the payer the loan as it is now first.`);
1993
+ const { before, hasAuthority: _, ...nft } = await nftOf(asset, wallet.address, { to: hold.player });
1994
+ const collection = nft.collection ?? (await collectionOf(asset)) ?? MPL_CORE;
1995
+ // A pNFT's or a compressed NFT's release opens something (a token account and record, a proof record): its key
1996
+ // is funded for it, and makes its own proof and accounts as it sends the release.
1997
+ const heavy = before.some(isProveLeaf) || Boolean(nft.tokenMetadata?.programmable);
1998
+ const opens = heavy ? [] : before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM);
1999
+ const at = loot.pda.hold(asset, due);
2000
+ const { key, fund } = await keyFor(heavy ? loot.SETTLE_RENT_UNIT + SIGNATURE_FEE : SIGNATURE_FEE, await jobKey(at));
2001
+ const repaid = loot.repayIx({ payer: wallet.address, project, hold, repay, programs, extra });
2002
+ const signature = await send(withSolWrap(wallet.address, hold.debt.mint, BigInt(hold.debt.repay), [...opens, repaid, fund]));
2003
+ // Repaid, the hold is a plain one that is over (no debt, `free_at` 0) at the address its loan named.
2004
+ const release = async (signer) => {
2005
+ const { before: first, hasAuthority: _h, ...kind } = heavy ? await nftOf(asset, signer, { to: hold.player }) : { before: [], ...nft };
2006
+ return [...first, loot.releaseIx({ signer, project, asset, ...kind, collection, hold: { ...hold, debt: null, free_at: 0 }, address: at })];
2007
+ };
2008
+ const released = await keyRun(key, release);
2009
+ return { signature, released };
2010
+ },
2011
+
1435
2012
  /**
1436
2013
  * Backs `side` of contest number `id` (the authority opens contests while the project runs) with `amount`; a contest
1437
2014
  * of NFTs takes one of the player's NFTs that passes its rule instead (`nft`, its address), held until it is
@@ -1486,7 +2063,10 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1486
2063
  secondsLeft: Number(ends > at ? ends - at : 0n),
1487
2064
  open: nextBid !== null && at >= x.starts_at && at < ends,
1488
2065
  over: at >= ends,
1489
- ...(x.nft_bids ? { nftBids: true, held: x.held } : {}),
2066
+ // The lot went at its end, and its seller's account couldn't take the money then: the next end pays it and closes it.
2067
+ ...(BigInt(x.proceeds_owed) > 0n ? { delivered: true, proceedsOwed: show(x.proceeds_owed, dec) } : {}),
2068
+ // A bid of NFTs: a picker of 1 to `maxNfts` (and at least `nextBid`) of the player's NFTs.
2069
+ ...(x.nft_bids ? { nftBids: true, held: x.held, maxNfts: loot.MAX_BID_NFTS } : {}),
1490
2070
  ...(tokens.get(a.currency ?? currencyKey)?.source === 'sol' ? { waiting } : {}),
1491
2071
  };
1492
2072
  });
@@ -1509,6 +2089,8 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1509
2089
  if (auction.nft_bids) {
1510
2090
  const bundle = nfts ?? (Array.isArray(amount) ? amount : null);
1511
2091
  if (!bundle?.length) throw new Error('This auction takes NFTs as bids: say which (nfts).');
2092
+ if (bundle.length > loot.MAX_BID_NFTS) throw new Error(`A bid holds at most ${loot.MAX_BID_NFTS} NFTs.`);
2093
+ if (auction.held.length >= loot.MAX_BID_NFTS) throw new Error(`The high bid holds ${loot.MAX_BID_NFTS} NFTs, the most a bid holds: it can't be topped.`);
1512
2094
  const before = [];
1513
2095
  const kindOf = async (asset, to) => {
1514
2096
  const { before: proof, ...kind } = await nftOf(asset, wallet.address, { to });
@@ -1542,16 +2124,22 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1542
2124
  /**
1543
2125
  * Ends an auction that is over (anyone can; the winner usually does): the lot to the highest bidder, a new
1544
2126
  * collectible minted for them at the address its item and serial name, however long after the end it is sent. A
1545
- * roll lot makes the winner's roll of its box (`order`), which `collect` asks for and settles.
2127
+ * roll lot makes the winner's roll of its box (`order`), which `collect` asks for and settles. An end too big for one
2128
+ * transaction makes each NFT a hold and sends each home with a release after it (`releases`).
1546
2129
  */
1547
2130
  async endAuction(auctionKey, { holders, ...others } = NO_OPTIONS) {
1548
2131
  only(others, ['holders'], 'endAuction');
1549
2132
  const a = part('auctions', auctionKey);
1550
2133
  const auction = await read('Auction', auctionAt(a));
1551
2134
  const g = await projectNow();
2135
+ // The lot went at an earlier end: this one pays what its seller is owed and closes the auction.
2136
+ if (BigInt(auction.proceeds_owed) > 0n) {
2137
+ const memo = programOf(auction.mint) === TOKEN_2022_PROGRAM ? [{ pubkey: MEMO_PROGRAM, isSigner: false, isWritable: false }] : [];
2138
+ return { signature: await send([loot.endAuctionIx({ signer: wallet.address, project, projectState: g, id: a.id, auction, programs, extra: memo })]), owedPaid: auction.proceeds_owed };
2139
+ }
1552
2140
  const sold = auction.high > 0n;
1553
2141
  const item = auction.lot.Item ? await read('Item', loot.pda.item(project, auction.lot.Item[0])) : null;
1554
- const made = sold && item?.kind === 'Collectible' ? loot.pda.itemAsset(loot.pda.item(project, auction.lot.Item[0]), auction.serial) : null;
2142
+ const made = sold && item?.kind === 'Collectible' ? loot.pda.itemAsset(loot.pda.item(project, auction.lot.Item[0]), auction.pin) : null;
1555
2143
  const memo = programOf(auction.mint) === TOKEN_2022_PROGRAM ? [{ pubkey: MEMO_PROGRAM, isSigner: false, isWritable: false }] : [];
1556
2144
  holders ??= sold ? await holdersOf(auction.shares) : {};
1557
2145
  const lot = auction.lot.Asset?.[0];
@@ -1569,18 +2157,46 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1569
2157
  }
1570
2158
  }
1571
2159
  const ix = loot.endAuctionIx({ signer: wallet.address, project, projectState: g, id: a.id, auction, item, ...nft, collection, held, holders, programs, extra: memo });
1572
- const signature = await send([computeUnitLimit(800_000), ...before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), ix]);
1573
- // A roll lot sold: the winner's roll, made at the default nonce, waits for anyone to ask it (`collect`).
2160
+ let all = [computeUnitLimit(800_000), ...before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), ix];
2161
+ // An end that can't fit one transaction (a compressed lot's long proof, a hooked mint, many shares) makes each NFT
2162
+ // a hold for whoever it goes to, then sends each home with its own release: anyone may, and the rent comes back.
2163
+ const holds = lot || (sold && auction.nft_bids) ? !sendPlan({ payer: wallet.address, instructions: all, alone: isProveLeaf }) : false;
2164
+ if (holds) {
2165
+ // The end's own transaction funds a throwaway key that sends each release after it (anyone may): each one's
2166
+ // fees, and what it may open (a pNFT's token account and record, a compressed NFT's proof record). One the key
2167
+ // can't send, the wallet does.
2168
+ const outs = [...(lot ? [[lot, sold ? auction.high_bidder : auction.seller]] : []), ...(sold && auction.nft_bids ? auction.held.map((x) => [x, proceeds]) : [])];
2169
+ const { key, fund } = await keyFor(BigInt(outs.length) * (2n * SIGNATURE_FEE + loot.SETTLE_RENT_UNIT), await jobKey(auctionAt(a)));
2170
+ all = [computeUnitLimit(800_000), loot.endAuctionIx({ signer: wallet.address, project, projectState: g, id: a.id, auction, item, ...nft, collection, held, hold: true, holders, programs, extra: memo }), fund];
2171
+ const signature = await send(all);
2172
+ const releases = [];
2173
+ for (const [asset, to] of outs) {
2174
+ const release = async (signer) => {
2175
+ const hold = await read('Hold', loot.pda.hold(asset, 0));
2176
+ const { before: proof, hasAuthority: _h, ...kind } = await nftOf(asset, signer, { to });
2177
+ return [...proof, loot.releaseIx({ signer, project, asset, ...kind, collection: kind.compressed || kind.tokenMetadata ? undefined : (await collectionOf(asset)) ?? MPL_CORE, hold, to })];
2178
+ };
2179
+ const steps = await release(key.address);
2180
+ const byKey = (await dryRun(key.address, steps)).ok ? await sendAs(key, steps).catch(() => null) : null;
2181
+ releases.push(byKey ?? (await send(await release(wallet.address))));
2182
+ }
2183
+ await emptyKey(key);
2184
+ return { signature, releases, ...(sold ? { winner: auction.high_bidder } : {}) };
2185
+ }
2186
+ // A roll lot sold: the winner's roll, made at the default nonce, waits for its winner or the project's keys to ask it (`collect`); the winner
2187
+ // ending it asks in the same transaction and a key settles it.
1574
2188
  const roll = sold && auction.lot.Roll ? loot.pda.order(project, auction.high_bidder, loot.prizeRollNonce(loot.prizeNonce(auctionAt(a), BigInt(auction.last_bid_at)))) : null;
1575
- return { signature, ...(sold ? { winner: auction.high_bidder } : {}), ...(made ? { asset: made } : {}), ...(roll ? { order: roll } : {}) };
2189
+ const { signature, rolls } = roll && auction.high_bidder === wallet.address ? await sendRolling(all) : { signature: await send(all), rolls: [] };
2190
+ return { signature, ...(sold ? { winner: auction.high_bidder } : {}), ...(made ? { asset: made } : {}), ...(roll ? { order: roll } : {}), ...(rolls.length ? { rolled: rolls[0] } : {}) };
1576
2191
  },
1577
2192
 
1578
2193
  /**
1579
- * Runs a schedule that is due (an emission into a pool, a rent or a drip): anyone may, and nothing is paid to
1580
- * whoever sends it but the network fee. `holders`: asset → holder, for a schedule that pays a collectible's holder.
2194
+ * Runs a schedule that is due (an emission into a pool, a rent or a drip): the project's owner or authority, or who it
2195
+ * pays, may (a sealed burn: anyone), and nothing is paid to whoever sends it but the network fee. A run missed is missed:
2196
+ * this pays once, for the latest run due, and the runs before it lapse; only a sealed fixed amount pays every run due. `holders`: asset → holder, for a schedule that pays a collectible's holder.
1581
2197
  * Returns null when it isn't due yet.
1582
2198
  */
1583
- async runSchedule(scheduleKey, { holders, most = 20, ...others } = NO_OPTIONS) {
2199
+ async runSchedule(scheduleKey, { holders, most, ...others } = NO_OPTIONS) {
1584
2200
  only(others, ['holders', 'most'], 'runSchedule');
1585
2201
  const sc = part('schedules', scheduleKey);
1586
2202
  const address = loot.pda.schedule(project, sc.id);
@@ -1589,13 +2205,15 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1589
2205
  // A sealed schedule's vault: past its last run, whatever arrived since is due once more.
1590
2206
  const vault = from.Vest ? await client.account(loot.pda.vest(address)) : null;
1591
2207
  const held = vault ? vault.data.subarray(64, 72).reduce((n, b, i) => n | (BigInt(b) << BigInt(8 * i)), 0n) : 0n;
1592
- // Runs due by the chain's clock, each its own instruction: one too many would fail them all.
1593
- const due = Math.min(loot.runsDue(schedule, await chainNow(), held), most);
2208
+ // Every run due by the chain's clock (`most`: at most that many of a sealed fixed amount's, the only one paid run by
2209
+ // run; a long backlog of those takes a few calls).
2210
+ const due = Math.min(loot.runsDue(schedule, await chainNow(), held), most ?? Infinity);
1594
2211
  if (!due) return null;
1595
2212
  const poolMint = from.Pool ? encodeBase58((await client.account(loot.pda.pool(project, from.Pool[0]))).data.subarray(0, 32)) : null;
1596
2213
  holders ??= await holdersOf([{ to: schedule.action.to }]);
1597
2214
  const treasury = (await projectNow()).treasury;
1598
- return send(Array.from({ length: due }, () => loot.runScheduleIx({ payer: wallet.address, project, id: sc.id, schedule, poolMint, holders, programs, treasury })));
2215
+ // A sealed fixed amount's backlog is a step for each halving: room for the longest the program takes a call.
2216
+ return send([computeUnitLimit(1_400_000), loot.runScheduleIx({ payer: wallet.address, project, id: sc.id, schedule, poolMint, holders, programs, treasury, upto: Number(schedule.runs) + due })]);
1599
2217
  },
1600
2218
 
1601
2219
  // ---------------------------------------------------------------- listings
@@ -1696,18 +2314,20 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1696
2314
  only(others, ['price', 'currency', 'drawsAt', 'minSold', 'perWallet', 'shares'], 'raffle');
1697
2315
  const kind = await nftOf(nft);
1698
2316
  const { before, hasAuthority: _, ...piece } = kind;
1699
- await send([...before, loot.stashIx({ signer: wallet.address, project, lottery: id, asset: nft, ...piece })]);
2317
+ const lotteryState = await readMaybe('Lottery', loot.pda.lottery(project, id));
2318
+ await send([...before, loot.stashIx({ signer: wallet.address, project, lottery: id, lotteryState, asset: nft, ...piece })]);
1700
2319
  const args = {
1701
2320
  price: units(price, decimalsOf(currency)), mint: mintOf(currency), shares, places: [{ bps: 0, prize: { Held: [nft] } }],
1702
2321
  ticket_mint: NOBODY, every: 0n, draws_at: BigInt(drawsAt), gate: null, per_wallet: perWallet, min_sold: BigInt(minSold),
1703
2322
  };
1704
- return send([loot.upsertIx({ owner: wallet.address, project, def: { Lottery: { id, args } } })]);
2323
+ return send([loot.upsertIx({ owner: wallet.address, project, def: { Lottery: { id, args } }, lotteryState })]);
1705
2324
  },
1706
2325
 
1707
2326
  /** The raffle's owner takes the NFT back out of lottery `id`'s own stash once nothing promises it (the lottery ended, or no place names it). */
1708
2327
  async unraffle(id, nft) {
1709
2328
  const { before, hasAuthority: _, ...piece } = await nftOf(nft, wallet.address, { to: wallet.address });
1710
- return send([...before, loot.stashIx({ signer: wallet.address, project, lottery: id, asset: nft, out: wallet.address, ...piece })]);
2329
+ const lotteryState = await readMaybe('Lottery', loot.pda.lottery(project, id));
2330
+ return send([...before, loot.stashIx({ signer: wallet.address, project, lottery: id, lotteryState, asset: nft, out: wallet.address, ...piece })]);
1711
2331
  },
1712
2332
 
1713
2333
  /** Turns what waits in the player's wrapped-SOL account (a bid that was topped) into plain SOL. */
@@ -1720,7 +2340,7 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1720
2340
  /**
1721
2341
  * Claims a grant for the connected wallet: from the approved list (the proof comes from xsolana.json, or is given:
1722
2342
  * `proof`, `personHash` and the `round` the list pays, as a results server's list answers them), or with a voucher
1723
- * a server signed ({ publicKey, signature, incarnation, round, expires, personHash, amount }). A list in xsolana.json names
2343
+ * a server signed ({ publicKey, signature, incarnation, round and amount, or rounds: [{ id, amount }], expires, personHash }). A list in xsolana.json names
1724
2344
  * no round (zeros). `amount` (a voucher's, or `options.amount` for a list's leaf): the tokens this claim gives, in
1725
2345
  * base units, up to the grant's own, for a grant of a fixed amount of tokens (a reward by rank); absent or 0 gives
1726
2346
  * the grant's prize as it is. A grant that gives a roll of a box makes the wallet's roll, which `claims()` then
@@ -1734,8 +2354,12 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1734
2354
  const grant = await read('Grant', address);
1735
2355
  shown ??= await passing(grant.gate, 'claim this');
1736
2356
  const gateProof = listProofOf(grant.gate, gspec.gate?.list, options.listProof);
1737
- const receipt = await client.account(loot.pda.grantReceipt(address, grant.incarnation, wallet.address));
1738
- const count = loot.readReceiptAccount(receipt)?.count ?? 0;
2357
+ // What the grant has paid this wallet: its count of claims (an open claim's roll is named by it), and the rounds
2358
+ // paid (one paid already is skipped).
2359
+ const bits = grant.shard_bits;
2360
+ const [claimsShard, roundsShard] = await client.accounts([loot.pda.claims(address, grant.incarnation, loot.shardOf(loot.limitKey('wallet', wallet.address), bits)), loot.pda.rounds(address, grant.incarnation, loot.walletShard(grant, wallet.address))]);
2361
+ const count = loot.readLimit(loot.readListAccount(claimsShard), loot.limitKey('wallet', wallet.address)).count;
2362
+ const paidRounds = loot.readListAccount(roundsShard);
1739
2363
  // What its prize needs: a pool's mint, the item, an NFT's kind (a compressed one proven first).
1740
2364
  const prized = await prizeInputs([grant.gives]);
1741
2365
  const pool = grant.gives.Tokens?.from.Pool?.[0];
@@ -1746,7 +2370,7 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1746
2370
  const keys = gspec.keys ?? [];
1747
2371
  if (voucher) {
1748
2372
  if (voucher.incarnation == null || BigInt(voucher.incarnation) !== grant.incarnation) throw new Error('This voucher belongs to a different grant incarnation.');
1749
- const message = loot.voucherBytes({ grant: address, incarnation: grant.incarnation, wallet: wallet.address, personHash: voucher.personHash, round: voucher.round, expires: voucher.expires, amount: BigInt(voucher.amount ?? 0) });
2373
+ const message = loot.voucherBytes({ grant: address, incarnation: grant.incarnation, wallet: wallet.address, personHash: voucher.personHash, rounds: voucher.rounds, round: voucher.round, expires: voucher.expires, amount: BigInt(voucher.amount ?? 0) });
1750
2374
  const signature = typeof voucher.signature === 'string' ? decodeBase58(voucher.signature) : voucher.signature;
1751
2375
  instructions.push(loot.ed25519Ix({ publicKey: voucher.publicKey, signature, message }));
1752
2376
  // A key under the grant's key root (a promo code's) proves it is one of them.
@@ -1769,18 +2393,17 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1769
2393
  } else if ((gspec.vouchers ?? []).length || keys.length) {
1770
2394
  throw new Error('This grant pays a wallet a voucher key signs for: pass the voucher.');
1771
2395
  }
1772
- // A share of a pool that comes to nothing is refused (PrizeNotCovered): say the pool is empty instead.
1773
- if (grant.gives.Tokens?.amount.Bps && pool !== undefined) {
1774
- const held = (await client.account(loot.pda.pool(project, pool)))?.data;
1775
- const amount = held ? held.subarray(64, 72).reduce((n, b, i) => n | (BigInt(b) << BigInt(8 * i)), 0n) : 0n;
1776
- if (amount * BigInt(grant.gives.Tokens.amount.Bps[0]) / 10_000n === 0n) throw new Error('This grant pays a share of its pool, and the pool is empty: nothing to claim yet.');
1777
- }
1778
2396
  // What this claim gives: the amount the voucher or the list's leaf names (0: the grant's prize as it is).
1779
2397
  const amount = BigInt((voucher ? voucher.amount : listAmount) ?? 0);
1780
- const most = grant.gives.Tokens?.amount.Fixed?.[0];
2398
+ const most = grant.gives.Tokens?.amount;
1781
2399
  if (amount > 0n && (most === undefined || amount > BigInt(most))) throw new Error(`This claim names ${amount} tokens, more than this grant gives${most === undefined ? ' (it gives no fixed amount of tokens)' : ` (${most})`}.`);
1782
- instructions.push(loot.claimGrantIx({ project, id: gspec.id, grant, wallet: wallet.address, signer: wallet.address, count, round: voucher?.round ?? listRound, expires: voucher?.expires, personHash: voucher?.personHash ?? listPerson, amount, proof, poolMint, item, nft: prized.nft, boxes: prized.boxes, rollPools: prized.rollPools, shown, listProof: gateProof, programs, voucherKey: voucher?.publicKey }));
1783
- return send(instructions);
2400
+ instructions.push(loot.claimGrantIx({ project, id: gspec.id, grant, wallet: wallet.address, signer: wallet.address, count, rounds: voucher?.rounds, round: voucher?.round ?? listRound, expires: voucher?.expires, personHash: voucher?.personHash ?? listPerson, amount, proof, paidRounds, poolMint, item, nft: prized.nft, shown, listProof: gateProof, programs, voucherKey: voucher?.publicKey }));
2401
+ // A claim's records and prizes can pass the 200,000 compute units a transaction has by default.
2402
+ const all = [computeUnitLimit(grant.gives.Roll ? 1_400_000 : loot.CLAIM_COMPUTE_UNITS), ...instructions.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM)];
2403
+ if (!grant.gives.Roll) return send(all);
2404
+ // A roll's orders are asked for in the claim's own transaction and settled by a key after: one prompt.
2405
+ const { signature, rolls } = await sendRolling(all);
2406
+ return rolls.length ? { signature, rolls } : signature;
1784
2407
  },
1785
2408
 
1786
2409
  /**
@@ -1836,14 +2459,17 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1836
2459
  }
1837
2460
  };
1838
2461
  const claimed = async (grantId, incarnation, round) => {
1839
- const grant = loot.pda.grant(project, grantId);
1840
- incarnation ??= (await read('Grant', grant)).incarnation;
1841
- return loot.readReceiptAccount(await client.account(loot.pda.grantReceipt(grant, incarnation, wallet.address)))?.claimed(round) ?? false;
2462
+ const address = loot.pda.grant(project, grantId);
2463
+ const grant = await read('Grant', address);
2464
+ if (incarnation != null && BigInt(incarnation) !== BigInt(grant.incarnation)) return false;
2465
+ const shard = await client.account(loot.pda.rounds(address, grant.incarnation, loot.walletShard(grant, wallet.address)));
2466
+ return loot.roundPaid(loot.readListAccount(shard), wallet.address, round);
1842
2467
  };
1843
2468
  const out = [];
1844
2469
  const take = async (round, grant, claim) => {
1845
2470
  try {
1846
- out.push({ round, grant, signature: await claim() });
2471
+ const r = await claim();
2472
+ out.push({ round, grant, ...(typeof r === 'string' ? { signature: r } : r) });
1847
2473
  } catch (e) {
1848
2474
  out.push({ round, grant, error: e.message });
1849
2475
  }
@@ -1883,7 +2509,7 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1883
2509
  /**
1884
2510
  * What this player can finish now, themselves: a box to settle or refund, a contest entry to claim, a held
1885
2511
  * collectible to take home, an auction they won (or one off by the clock: their bid back), a lottery place they
1886
- * won, a draw that is due (anyone may reveal it, or ask again when the oracle never answered; it takes 0.0005
2512
+ * won, a draw that is due (anyone may reveal it, or ask again when the oracle never answered; it takes 0.0008
1887
2513
  * SOL), and a drawn lottery's unpaid places, which anyone may pay so the next round can open. [{ kind, says, … }];
1888
2514
  * pass one to `claim`.
1889
2515
  */
@@ -1894,80 +2520,124 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1894
2520
  const at = (offset, bytes) => ({ memcmp: { offset, bytes } });
1895
2521
  const slot = await slotNow();
1896
2522
  const time = BigInt(now());
2523
+ const chainTime = await chainNow();
1897
2524
  for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Order'), at(1, project), at(33, me)])) {
1898
2525
  const o = loot.decode('Order', account.data);
1899
2526
  const opened = BigInt(o.requested_slot);
1900
- const refundAt = loot.orderRefundSlot(o);
1901
- if (refundAt !== null && slot >= refundAt) out.push({ kind: 'refund', order: pubkey, says: 'Your payment back for an order that never finished.' });
2527
+ if (loot.orderRefundable(o, slot, chainTime)) out.push({ kind: 'refund', order: pubkey, says: 'Your payment back for an order that never finished.' });
1902
2528
  else if (o.status === 'Rolled' && slot > opened) out.push({ kind: 'settle', order: pubkey, says: 'What your box rolled.' });
1903
- else if (loot.rollAskable(o, slot)) out.push({ kind: 'settle', order: pubkey, says: 'A roll you won (a mission’s, a recipe’s, a prize’s): ask for it and see what it gives (one whose box has nothing left to give waits until it has).' });
2529
+ else if (loot.rollAskable(o, slot, chainTime)) out.push({ kind: 'settle', order: pubkey, says: 'A roll you won (a mission’s, a recipe’s, a prize’s): ask for it and see what it gives (one whose box has nothing left to give waits until it has).' });
1904
2530
  }
1905
- for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Entry'), at(33, me)])) {
1906
- const entry = loot.decode('Entry', account.data);
1907
- const c = await client.account(entry.contest);
1908
- if (!c) continue;
1909
- const contest = loot.decode('Contest', c.data);
2531
+ for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Contest'), at(1, project)])) {
2532
+ const contest = loot.decode('Contest', account.data);
1910
2533
  const off = loot.contestOff(contest, time);
1911
- if (contest.project !== project || !(off || contest.verdict === 'Won')) continue;
1912
- const won = contest.winner === entry.side;
1913
- // A loser's NFT waits for the winners: it goes to the opener once they have all claimed.
1914
- if (contest.nft && !off && !won && entry.asset !== NOBODY && BigInt(contest.unclaimed) > 0n) continue;
1915
- const says = off ? 'Your entry back: the contest is off.' : won ? (contest.nft ? 'Your NFT back, and one of the losers’.' : contest.prize != null ? 'Your share of the pot, and your prize.' : 'Your share of the pot.') : 'Close your entry (your side lost).';
1916
- out.push({ kind: 'contest', entry: pubkey, id: contest.id, side: entry.side, says });
2534
+ if (!(off || contest.verdict === 'Won')) continue;
2535
+ const list = await client.account(loot.contestEntries(pubkey, contest));
2536
+ const entry = list?.owner === loot.LOOT_PROGRAM ? loot.decodeEntries(list.data).find((e) => e.player === me) : null;
2537
+ if (!entry) continue;
2538
+ const won = !off && Number(contest.winner) === Number(entry.side);
2539
+ const first = won && !(entry.bits & loot.CLAIMED);
2540
+ // Owed: its money back (off) or its part of the pot (won), its own NFT while the contest holds it, a winner's
2541
+ // prize with its first claim, and the losers' NFTs its share still lets it pick. A loser of a contest of tokens
2542
+ // is owed nothing, and never sends a transaction.
2543
+ const picks = won && contest.nft && !(entry.bits & loot.ENDED) && Number(entry.taken) < loot.shareOf(contest, entry);
2544
+ const owed = (contest.nft ? Number(entry.amount) === 1 && (off || won) : BigInt(entry.amount) > 0n && (off || first)) || first || picks;
2545
+ if (!owed) continue;
2546
+ const says = off ? 'Your entry back: the contest is off.' : contest.nft ? `Your NFT back${picks ? ', and your pick of the losers’' : ''}.` : contest.prize != null ? 'Your share of the pot, and your prize.' : 'Your share of the pot.';
2547
+ out.push({ kind: 'contest', id: contest.id, side: entry.side, says });
2548
+ }
2549
+ // The player's holds; the project's treasury wallet's also those owed to the treasury (a hold whose player is the
2550
+ // project's own address: a dropped stash NFT, a lottery's or an auction's NFT owed to it).
2551
+ const g = await projectNow();
2552
+ const holdsOf = [me, ...(g.treasury === me ? [project] : [])];
2553
+ const holds = (await Promise.all(holdsOf.map((who) => client.programAccounts(loot.LOOT_PROGRAM, [tag('Hold'), at(1, project), at(65, who)])))).flat();
2554
+ for (const { pubkey, account } of holds) {
2555
+ const hold = loot.decode('Hold', account.data);
2556
+ // A repaid loan is a plain hold that is over (no debt, `free_at` 0): its NFT goes home whenever. An unpaid one's
2557
+ // is the lender's once due.
2558
+ if (!hold.debt && time >= BigInt(hold.free_at)) out.push({ kind: 'release', asset: hold.asset, hold: pubkey, says: hold.player === project ? 'An NFT owed to the treasury: send it there.' : 'Your collectible back: its hold is over.' });
1917
2559
  }
1918
- for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Hold'), at(1, project), at(65, me)])) {
2560
+ // A loan this wallet made that is overdue and unpaid: its NFT is the lender's to take.
2561
+ for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Hold'), at(1, project)])) {
1919
2562
  const hold = loot.decode('Hold', account.data);
1920
- // A repaid loan's NFT goes home whenever; an unpaid one's is the lender's once due.
1921
- if (hold.debt?.paid) out.push({ kind: 'release', asset: hold.asset, hold: pubkey, says: 'Your collectible back: your loan is repaid.' });
1922
- else if (!hold.debt && time >= BigInt(hold.free_at)) out.push({ kind: 'release', asset: hold.asset, hold: pubkey, says: 'Your collectible back: its hold is over.' });
2563
+ if (hold.debt?.lender === me && time >= BigInt(hold.free_at)) out.push({ kind: 'release', asset: hold.asset, hold: pubkey, says: 'A loan you made is overdue and unpaid: take its NFT.' });
2564
+ }
2565
+ // A compressed NFT's proof record this wallet paid for and left open: closing it brings its rent back.
2566
+ for (const { account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Proven')])) {
2567
+ const leaf = (() => {
2568
+ try {
2569
+ return loot.decodeProven(account.data);
2570
+ } catch {
2571
+ return null;
2572
+ }
2573
+ })();
2574
+ if (leaf?.payer === me) out.push({ kind: 'close-leaf', asset: leaf.asset, tree: leaf.tree, says: 'A compressed NFT’s proof record you paid for: close it, and its rent comes back.' });
1923
2575
  }
1924
2576
  const auctions = spec.auctions ?? [];
1925
2577
  const found = await client.accounts(auctions.map((a) => auctionAt(a)));
1926
2578
  auctions.forEach((a, i) => {
1927
2579
  const x = found[i] && loot.decode('Auction', found[i].data);
1928
- if (!x || x.high_bidder !== me || time < loot.auctionEnds(x)) return;
1929
- out.push({ kind: 'auction', key: a.key, says: `You won “${a.name}”: take it.` });
2580
+ if (!x || time < loot.auctionEnds(x)) return;
2581
+ // The lot went at its end, and its seller's account couldn't take the money then: only the money is left to pay.
2582
+ if (BigInt(x.proceeds_owed) > 0n) {
2583
+ if (x.seller === me) out.push({ kind: 'auction', key: a.key, says: `The winner of “${a.name}” has the lot, and your account couldn’t take the money then: end it again once it can, and it is paid to you.` });
2584
+ return;
2585
+ }
2586
+ if (x.high_bidder === me) out.push({ kind: 'auction', key: a.key, says: `You won “${a.name}”: take it.` });
2587
+ // The seller's own auction, over: ending it pays them (or brings an unsold lot home).
2588
+ else if (x.seller === me) out.push({ kind: 'auction', key: a.key, says: BigInt(x.high) > 0n ? `Your auction “${a.name}” is over: end it, and the winning bid is paid to you.` : `Your auction “${a.name}” ended with no bid: take your lot back.` });
1930
2589
  });
1931
2590
  for (const l of spec.lotteries ?? []) {
1932
2591
  // A lottery the page names but nobody has written yet (a raffle still to open) has nothing to finish.
1933
2592
  if (!(await client.account(loot.pda.lottery(project, l.id)))) continue;
1934
- const { lottery, contributions, winners } = await roundOf(l);
1935
- if (loot.lotteryCancellable(lottery, slot)) out.push({ kind: 'cancel-draw', key: l.key, says: `The oracle never answered the draw of “${l.name}”: make ticket payments refundable.` });
1936
- else if (lottery.status === 'Open' && time >= BigInt(lottery.draws_at) && BigInt(lottery.sold) > 0n) out.push({ kind: 'reveal', key: l.key, says: `The draw of “${l.name}” is due: reveal the winners.` });
1937
- else if (loot.lotteryDrawStuck(lottery, slot)) out.push({ kind: 'reveal', key: l.key, says: `The oracle never answered the draw of “${l.name}”: ask it again.` });
1938
- const open = contributions.filter(({ value }) => !value.done);
1939
- // A drawn round gives every place's prize first; its money moves after.
1940
- const owed = contributions.filter(({ value }) => loot.lotteryStep(lottery, value) === 'prizes');
1941
- if (owed.length) {
1942
- const mine = owed.find(({ value }) => value.player === me);
1943
- out.push({ kind: 'lottery-prize', key: l.key, contribution: (mine ?? owed[0]).address,
1944
- says: mine ? `Take your prize in “${l.name}”.` : `Give the winners of “${l.name}” their prizes, so its draw can complete.` });
1945
- } else if (lottery.status === 'Drawn' && open.length) {
1946
- out.push({ kind: 'complete-draw', key: l.key, contribution: open[0].address, says: `Complete the draw of “${l.name}” so its winners can collect.` });
2593
+ const { current, rounds } = await roundOf(l);
2594
+ const bought = (r) => r.entries.some((e) => e.buyer === me);
2595
+ if (current?.status === 'Open' && time >= BigInt(current.terms.draws_at)) {
2596
+ const short = BigInt(current.sold) < BigInt(current.terms.min_sold ?? 0);
2597
+ out.push({ kind: 'reveal', key: l.key, says: short ? `“${l.name}” sold fewer tickets than it needs: call it off${bought(current) ? ' and take your ticket payment back, in one step' : ''}.` : `The draw of “${l.name}” is due: reveal the winners.` });
1947
2598
  }
1948
- if (lottery.status === 'Cancelled' && open.length) {
1949
- const mine = open.find(({ value }) => value.player === me) ?? open[0];
1950
- out.push({ kind: 'ticket-refund', key: l.key, contribution: mine.address,
1951
- says: mine.value.player === me ? `Recover your ticket payment for “${l.name}”.` : `Return a ticket payment for “${l.name}” so its next round can open.` });
1952
- }
1953
- if (winners.some((w) => w.winner === me)) out.push({ kind: 'prize', key: l.key, says: `You won a place in “${l.name}”.` });
1954
- else if (winners.length) {
1955
- // A place that rolls makes its winner's roll, whose order the payer funds (its rent and SOL go to the winner).
1956
- const rolls = lottery.places.some((p) => p.prize?.Roll) ? ', and about 0.005 SOL (0.011 when its box can give an item) for each place won that gives a roll, which goes to the winner' : '';
1957
- out.push({ kind: 'payout', key: l.key, says: `Pay the winners of “${l.name}” so its next round can open (about 0.002 SOL of account rent for each winner who has no account for the prize yet${rolls}; it stays with the winner).` });
2599
+ for (const { address: round, round: r } of rounds) {
2600
+ // The ask moved the lottery on: a round waiting for the oracle is an older one, asked again by its address,
2601
+ // and after the timeout the next ask cancels it.
2602
+ if (loot.lotteryCancellable(r, chainTime)) out.push({ kind: 'cancel-draw', key: l.key, round, says: `The oracle never answered the draw of “${l.name}”: call it off${bought(r) ? ' and take your ticket payment back, in one step' : ', so every ticket is refunded'}.` });
2603
+ else if (loot.lotteryDrawStuck(r, chainTime)) out.push({ kind: 'reveal', key: l.key, round, says: `The oracle never answered the draw of “${l.name}”: ask it again.` });
2604
+ if (r.status !== 'Drawn' && r.status !== 'Cancelled') continue;
2605
+ // What this wallet is owed: a refund, its NFT ticket, its places' parts of the pot and their prizes.
2606
+ const mine = r.entries.flatMap((e, i) => {
2607
+ const parts = e.buyer === me ? loot.partsOwed(r, i) : [];
2608
+ return parts.length ? [{ entry: i, parts }] : [];
2609
+ });
2610
+ if (mine.length) out.push({ kind: 'pull', key: l.key, round, pulls: mine, says: r.status === 'Cancelled' ? `Recover your ticket payment for “${l.name}”.` : `Take what you are owed in “${l.name}”.` });
2611
+ // Anyone may pay everyone else what they are owed, and the parts owed in common (about 0.002 SOL of account
2612
+ // rent for each payee who has no account for it yet, which stays with them): the last pull closes the round,
2613
+ // and its rent goes back to whoever fronted it (the lottery, or its first buyer).
2614
+ const others = r.entries.flatMap((e, i) => (loot.partsOwed(r, i).length && !mine.some((m) => m.entry === i) ? [i] : []));
2615
+ const fronted = r.opener === me;
2616
+ if (others.length || loot.commonOwed(r).length || (loot.roundFinished(r) && !mine.length))
2617
+ out.push({ kind: 'payout', key: l.key, round, says: fronted ? `Pay everyone of “${l.name}” what they are owed: the round then closes, and your deposit for it comes back.` : `Pay the others of “${l.name}” what they are owed, so its round can close.` });
1958
2618
  }
1959
2619
  }
1960
- // Tokens whose unbonding wait is over, in a farm of tokens.
1961
- const chainTime = await chainNow();
1962
- for (const f of (spec.farms ?? []).filter((x) => x.stakes !== 'collectibles')) {
1963
- const seat = await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), me));
1964
- const free = seat ? loot.withdrawable(seat, chainTime) : 0n;
1965
- if (free > 0n) out.push({ kind: 'withdraw', key: f.key, says: `Your tokens leaving “${f.name ?? f.key}” are free: take them out.` });
2620
+ // A farm idle past more ends than a move can walk: anyone may walk it, in parts.
2621
+ for (const f of spec.farms ?? []) {
2622
+ const farm = await readMaybe('Farm', loot.pda.farm(project, f.id));
2623
+ if (!farm?.keeps_ends) continue;
2624
+ const { left } = await endsOf(f, farm);
2625
+ if (left > loot.MOVE_WALK_ROOM) out.push({ kind: 'walk', key: f.key, says: `“${f.name ?? f.key}” must be caught up before anyone can move it: ${left} seats’ end times to walk, in ${Math.ceil(left / loot.WALK_PER_TX)} transactions. It is free but for the network fee, and nothing is lost by the wait.` });
1966
2626
  }
1967
2627
  // A sealed schedule that pays this wallet: its runs that are due (vesting nobody can take back).
1968
2628
  for (const sc of spec.schedules ?? []) {
1969
2629
  const schedule = await readMaybe('Schedule', loot.pda.schedule(project, sc.id));
1970
- if (!schedule?.action.from.Vest || schedule.action.to.Wallet?.[0] !== me) continue;
2630
+ if (!schedule) continue;
2631
+ if (!schedule.action.from.Vest) {
2632
+ // Any other schedule that is due: it waits for the project's keys or whoever it pays.
2633
+ const state = await projectNow();
2634
+ const to = schedule.action.to;
2635
+ if ([state.owner, state.authority].includes(me) || to.Wallet?.[0] === me || (to.Treasury !== undefined && state.treasury === me)) {
2636
+ if (loot.runsDue(schedule, chainTime) > 0) out.push({ kind: 'schedule', key: sc.key, says: `“${sc.name ?? sc.key}” is due to run: send it.` });
2637
+ }
2638
+ continue;
2639
+ }
2640
+ if (schedule.action.to.Wallet?.[0] !== me) continue;
1971
2641
  const vault = await client.account(loot.pda.vest(loot.pda.schedule(project, sc.id)));
1972
2642
  const held = vault ? vault.data.subarray(64, 72).reduce((n, b, i) => n | (BigInt(b) << BigInt(8 * i)), 0n) : 0n;
1973
2643
  if (loot.runsDue(schedule, chainTime, held) > 0) out.push({ kind: 'vest', key: sc.key, says: `What has vested of “${sc.name ?? sc.key}”: claim it.` });
@@ -1987,7 +2657,8 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1987
2657
  const recent = mineTables.length ? await recentSlots() : [];
1988
2658
  for (const { pubkey, account } of mineTables) {
1989
2659
  const table = readTable({ ...account, owner: account.owner ?? LOOKUP_TABLE_PROGRAM });
1990
- if (!table || table.authority !== me) continue;
2660
+ // The project's standing tables are never a transaction's own: never retired here.
2661
+ if (!table || table.authority !== me || (spec.live?.tables ?? []).includes(pubkey)) continue;
1991
2662
  const step = tableStep(table, slot, recent);
1992
2663
  const rent = show(BigInt(account.lamports ?? 0), 9);
1993
2664
  if (step === 'close') out.push({ kind: 'lookup-table', table: pubkey, says: `${rent} SOL of rent back from a lookup table a large transaction of yours used.` });
@@ -2002,48 +2673,69 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2002
2673
  * which loser's NFT they take (`{ take: asset }`, the NFT of a losing entry that still holds one; the first found by default):
2003
2674
  * only the winner can, while any are left.
2004
2675
  */
2005
- async claim(c, { take, ...others } = NO_OPTIONS) {
2006
- only(others, ['take'], 'claim');
2676
+ async claim(c, { take, build = false, ...others } = NO_OPTIONS) {
2677
+ only(others, ['take', 'build'], 'claim');
2007
2678
  const me = wallet.address;
2679
+ // `build`: its instructions, unsent, for a step that is one transaction of the wallet's (`claimAll` signs several
2680
+ // at once); null for one that is not (a roll to wait for, a refund, a step of another flow).
2681
+ if (build && !BATCHED.has(c.kind)) return null;
2682
+ const send = build ? async (instructions) => instructions : sendNow;
2008
2683
  const g = await projectNow();
2009
- if (c.kind === 'settle') return (await collect(c.order)).settled;
2010
- if (c.kind === 'refund') return this.refund(c.order);
2684
+ if (c.kind === 'settle' || c.kind === 'refund') {
2685
+ // A settle key this order's open funded (made again from this session's signature, or stored): it settles with
2686
+ // no prompt, or gives its SOL back before the refund.
2687
+ const key = keys ? await keys.again(c.order, { ask: false }) : null;
2688
+ const funded = key && BigInt(await client.balance(key.address).catch(() => 0n)) > 0n ? key : null;
2689
+ if (c.kind === 'settle') return (await collect(c.order, funded ? { burner: funded } : {})).settled;
2690
+ if (funded) await emptyKey(funded);
2691
+ return this.refund(c.order);
2692
+ }
2011
2693
  if (c.kind === 'contest') {
2012
2694
  const contestAddress = loot.pda.contest(project, c.id);
2013
2695
  const contest = await read('Contest', contestAddress);
2014
- const won = contest.verdict === 'Won' && contest.winner === c.side;
2015
- const given = await prizeInputs([won ? contest.prize : null]);
2016
- // A contest of NFTs: the entry's own NFT, and for a winner while losers' NFTs are left, a losing entry's.
2017
- const nfts = {};
2696
+ const entries = loot.decodeEntries((await client.account(loot.contestEntries(contestAddress, contest))).data);
2697
+ const i = entries.findIndex((e) => e.player === me);
2698
+ const own = entries[i];
2699
+ const won = contest.verdict === 'Won' && Number(contest.winner) === Number(own.side);
2700
+ const given = await prizeInputs([won && !(own.bits & loot.CLAIMED) ? contest.prize : null]);
2701
+ // A contest of NFTs: the entry's own NFT while the contest holds it, and for a winner the losers' NFTs its share
2702
+ // lets it pick (`take`: one asset, or several; the first left by default), as many as fit.
2703
+ const nfts = [];
2704
+ const kinds = {};
2018
2705
  if (contest.nft) {
2019
- const entry = await read('Entry', loot.pda.entry(contestAddress, me));
2020
- nfts.entry = entry;
2021
- const kindOf = async (asset, to = me) => {
2706
+ const kindOf = async (asset, to) => {
2022
2707
  const { before, hasAuthority: _, ...kind } = await nftOf(asset, me, { to });
2023
2708
  given.before.push(...before);
2024
- return kind;
2709
+ kinds[asset] = kind;
2025
2710
  };
2026
- // A losing entry's own NFT goes to the contest's opener once winners have taken theirs.
2027
- const lost = contest.verdict === 'Won' && !won;
2028
- if (entry.asset !== NOBODY) nfts.own = await kindOf(entry.asset, lost ? contest.opener : me);
2029
- if (won && Number(contest.losers) > 0) {
2030
- const found = await client.programAccounts(loot.LOOT_PROGRAM, [{ memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS.Entry)) } }, { memcmp: { offset: 1, bytes: contestAddress } }]);
2031
- const left = found.map(({ account }) => loot.decode('Entry', account.data)).filter((e) => e.side !== contest.winner && e.asset !== NOBODY);
2032
- const losing = take ? left.find((e) => e.asset === take) : left[0];
2033
- if (take && !losing) throw new Error(`${take} is not a loser’s NFT left in this contest.`);
2034
- if (losing) nfts.taking = { player: losing.player, side: losing.side, asset: losing.asset, kind: await kindOf(losing.asset) };
2711
+ if (Number(own.amount) === 1) {
2712
+ nfts.push(i);
2713
+ await kindOf(own.nft, me);
2714
+ }
2715
+ const share = won && !(own.bits & loot.ENDED) ? loot.shareOf(contest, own) - Number(own.taken) : 0;
2716
+ const left = entries.map((e, j) => ({ e, j })).filter(({ e }) => Number(e.side) !== Number(contest.winner) && Number(e.amount) === 1);
2717
+ const wanted = take ? [take].flat() : [];
2718
+ const picked = (wanted.length ? left.filter(({ e }) => wanted.includes(e.nft)) : left).slice(0, Math.min(share, 4));
2719
+ if (wanted.length && picked.length < Math.min(wanted.length, share)) throw new Error(`${wanted.join(', ')} is not a loser’s NFT left in this contest for you to pick.`);
2720
+ for (const { e, j } of picked) {
2721
+ nfts.push(j);
2722
+ await kindOf(e.nft, me);
2035
2723
  }
2036
2724
  }
2037
- return send([computeUnitLimit(800_000), ...given.before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), loot.claimIx({ payer: me, project, id: c.id, contest, player: me, side: c.side, collection: given.collection, poolMints: given.poolMints, items: given.items, nft: given.nft, ...nfts, programs })]);
2725
+ const claimed = [computeUnitLimit(800_000), ...given.before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), loot.claimIx({ payer: me, project, projectState: g, id: c.id, contest, entries, player: me, nfts: contest.nft ? nfts : undefined, kinds, collection: given.collection, poolMints: given.poolMints, items: given.items, nft: given.nft, programs })];
2726
+ // A prize that rolls: asked for in the claim's own transaction and settled by a key after (one prompt).
2727
+ return contest.prize?.Roll && !build ? (await sendRolling(claimed)).signature : send(claimed);
2038
2728
  }
2039
2729
  if (c.kind === 'release') {
2040
2730
  const hold = await read('Hold', c.hold);
2041
2731
  const { before, hasAuthority: _, ...nft } = await nftOf(c.asset, me, { to: me });
2042
- return send([...before, loot.releaseIx({ signer: me, project, asset: c.asset, ...nft, collection: (await collectionOf(c.asset)) ?? MPL_CORE, hold })]);
2732
+ // An overdue loan's NFT goes to its lender.
2733
+ return send([...before, loot.releaseIx({ signer: me, project, asset: c.asset, ...nft, collection: (await collectionOf(c.asset)) ?? MPL_CORE, hold, address: c.hold, treasury: g.treasury, ...(hold.debt ? { to: hold.debt.lender } : {}) })]);
2043
2734
  }
2044
2735
  if (c.kind === 'auction') return (await this.endAuction(c.key)).signature;
2045
- if (c.kind === 'withdraw') return this.withdraw(c.key);
2046
- if (c.kind === 'vest') return this.runSchedule(c.key);
2736
+ if (c.kind === 'close-leaf') return send([loot.closeLeafIx({ asset: c.asset, tree: c.tree, payer: me })]);
2737
+ if (c.kind === 'walk') return this.walkFarm(c.key);
2738
+ if (c.kind === 'vest' || c.kind === 'schedule') return this.runSchedule(c.key);
2047
2739
  if (c.kind === 'unlist') return this.unlist(c.asset);
2048
2740
  if (c.kind === 'lookup-table') {
2049
2741
  const table = readTable(await client.account(c.table));
@@ -2051,41 +2743,95 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2051
2743
  if (!step || step === 'cooling') return null;
2052
2744
  return send([step === 'close' ? closeTableIx({ table: c.table, authority: me }) : deactivateTableIx({ table: c.table, authority: me })], [], { keepWrapped: true });
2053
2745
  }
2746
+ if (!LOTTERY_STEPS.has(c.kind)) throw new Error(`Nothing to do for a claim of kind ${c.kind}.`);
2054
2747
  const l = part('lotteries', c.key);
2055
- if (c.kind === 'reveal') return send([loot.drawLotteryIx({ payer: me, project, id: l.id })]);
2056
- if (c.kind === 'cancel-draw') return send([loot.cancelLotteryIx({ project, id: l.id })]);
2057
- if (c.kind === 'lottery-prize') {
2058
- const { lottery, contributions } = await roundOf(l);
2059
- const found = contributions.find(({ address }) => address === c.contribution);
2060
- if (!found || loot.lotteryStep(lottery, found.value) !== 'prizes') return null;
2061
- const given = await prizeInputs(lottery.places.map((p) => p.prize), me, found.value.player);
2062
- return send([computeUnitLimit(600_000), ...given.before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), loot.finishLotteryContributionIx({ payer: me, project, id: l.id, lottery, contributionAddress: found.address, contribution: found.value, collection: given.collection, poolMints: given.poolMints, items: given.items, nfts: given.nfts, programs })]);
2748
+ // An older round, named by its address, needs no lottery: one closed since never strands it.
2749
+ const lottery = c.round ? await readMaybe('Lottery', loot.pda.lottery(project, l.id)) : await read('Lottery', loot.pda.lottery(project, l.id));
2750
+ const key = c.round ?? loot.currentRound(loot.pda.lottery(project, l.id), lottery);
2751
+ const found = await client.account(key);
2752
+ const r = found?.owner === loot.LOOT_PROGRAM ? loot.decodeRound(found.data) : null;
2753
+ // The ask: the current round's once due, or an older round's again (after the timeout, it cancels the round).
2754
+ if (c.kind === 'reveal' || c.kind === 'cancel-draw') {
2755
+ const steps = await drawStep(l, lottery, c.round ?? null, r, me);
2756
+ // A draw that cancels the round (short of its least tickets, or the oracle never answered by the timeout)
2757
+ // refunds in the same transaction: this wallet's own parts are pulled right after it.
2758
+ const refunds = r ? await cancelPulls(key, r, me) : [];
2759
+ if (refunds.length) {
2760
+ const all = [computeUnitLimit(1_400_000), ...[...steps, ...refunds].filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM)];
2761
+ if ((await dryRun(me, all)).ok) return build ? all : send(all);
2762
+ }
2763
+ return build ? steps : send(steps);
2764
+ }
2765
+ if (!r) return null;
2766
+ // Pulls: this wallet's own parts, or (`payout`) everyone's and the parts owed in common, a few to a transaction;
2767
+ // when they are the last owed, the last pull closes the round. A round that owes nothing is only closed.
2768
+ // A named stash NFT that is gone refuses a stranger's pull: only its winner or a project key passes it over.
2769
+ const projectKey = me === g.owner || me === g.authority;
2770
+ const stranger = async (i, p) => {
2771
+ const named = p.Prize ? r.terms.places[Number(p.Prize[0])].prize?.Nft : null;
2772
+ if (!named || projectKey || r.entries[i].buyer === me) return false;
2773
+ const stash = loot.decodeStash((await client.account(loot.pda.stash(project, named.stash)))?.data ?? null);
2774
+ return !stash?.nfts.includes(named.nft);
2775
+ };
2776
+ const theirs = [];
2777
+ if (c.kind !== 'pull') for (const i of r.entries.keys()) {
2778
+ const parts = [];
2779
+ for (const p of loot.partsOwed(r, i)) if (!(await stranger(i, p))) parts.push(p);
2780
+ if (parts.length) theirs.push({ entry: i, parts });
2063
2781
  }
2064
- if (c.kind === 'complete-draw' || c.kind === 'ticket-refund') {
2065
- const { lottery, contributions } = await roundOf(l);
2066
- const found = contributions.find(({ address }) => address === c.contribution);
2067
- if (!found || found.value.done || lottery.status !== (c.kind === 'ticket-refund' ? 'Cancelled' : 'Drawn') || !loot.lotteryStep(lottery, found.value)) return null;
2068
- // A ticket an NFT paid for passes that NFT, which the project holds: back to its buyer when the round is
2069
- // cancelled or gives tickets back, to the wallet its price names when it is given on.
2070
- const then = lottery.status === 'Drawn' ? lottery.nft_ticket?.then : null;
2071
- // Given to a wallet that is an account of the loot program, it goes back to its buyer.
2072
- const handBack = Boolean(then?.Give) && loot.handsBack(await client.account(then.Give[0]));
2073
- const to = !then || handBack ? found.value.player : then.Give ? then.Give[0] : undefined;
2074
- const { before = [], hasAuthority: _, ...ticket } = found.value.asset !== NOBODY ? await nftOf(found.value.asset, me, { to }) : {};
2075
- return send([...before, loot.finishLotteryContributionIx({ payer: me, project, id: l.id, lottery, contributionAddress: found.address, contribution: found.value, treasury: g.treasury, ticket, handBack, programs })]);
2782
+ const owed = c.kind === 'pull' ? c.pulls : [...loot.commonOwed(r).map((p) => ({ entry: null, parts: [p] })), ...theirs];
2783
+ if (!owed.length) {
2784
+ if (!loot.roundFinished(r)) return null;
2785
+ const close = [loot.pullIx({ payer: me, project, roundAddress: key, round: r, projectState: g, programs })];
2786
+ return build ? close : send(close);
2076
2787
  }
2077
- if (c.kind === 'prize' || c.kind === 'payout') {
2078
- // Every unpaid winner, three a transaction, each paid every place's share of the pot they won (their prizes went
2079
- // first): the round ends once all are paid, and the next opens. Each winner's account of the pot's mint is
2080
- // opened when they have none, paid by whoever sends it.
2081
- const { lottery, winners } = await roundOf(l);
2082
- const mine = winners.filter((w) => w.winner === me);
2083
- const chosen = [...mine, ...winners.filter((w) => w.winner !== me)].slice(0, 3);
2084
- if (!chosen.length) return null;
2085
- const pay = (w) => loot.finishLotteryContributionIx({ payer: me, project, id: l.id, lottery, contributionAddress: w.contribution.address, contribution: w.contribution.value, treasury: g.treasury, programs });
2086
- return send(chosen.map(pay));
2788
+ const batch = owed.slice(0, 4);
2789
+ const settles = batch.reduce((n, { entry, parts }) => n + parts.reduce((m, p) => m + pullSettles(r, entry, p), 0), 0);
2790
+ const closes = settles >= Number(r.owed);
2791
+ const instructions = [computeUnitLimit(1_400_000)];
2792
+ const rolls = [];
2793
+ for (const [k, { entry, parts }] of batch.entries()) {
2794
+ const built = await pulls(key, r, entry, parts, me, { closes: closes && k === batch.length - 1 });
2795
+ instructions.push(...built.instructions.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM));
2796
+ rolls.push(...built.rolls);
2087
2797
  }
2088
- throw new Error(`Nothing to do for a claim of kind ${c.kind}.`);
2798
+ // A place's roll is asked for in the pull's own transaction (catalog P1): the winner's own, with a key that settles
2799
+ // it after (one prompt); someone else's, asked for when the box can give, else left for its winner to ask.
2800
+ if (!rolls.length) return build ? instructions : send(instructions);
2801
+ if (c.kind === 'pull' && !build) return (await sendRolling(instructions)).signature;
2802
+ const asks = [];
2803
+ for (const { order, box } of rolls) asks.push(...(await asksFor(me, order, { project, loot_box: loot.pda.box(project, box) })));
2804
+ const all = [...instructions, ...asks];
2805
+ const asked = (await dryRun(me, all)).ok ? all : instructions;
2806
+ return build ? asked : send(asked);
2807
+ },
2808
+
2809
+ /**
2810
+ * Everything in `list` (by default `claims()`) taken in as few prompts as it can: each step that is one transaction
2811
+ * of the wallet's is built first and the lot signed in one prompt when the wallet signs several at once
2812
+ * (`wallet.signTransactions`); a lottery's steps depend on each other, so one of each lottery a call. The rest (a roll
2813
+ * to settle, a refund, an auction to end…) go one by one after. Returns [{ claim, signature } | { claim, error }].
2814
+ */
2815
+ async claimAll(list) {
2816
+ list ??= await this.claims();
2817
+ const [built, alone, lotteries] = [[], [], new Set()];
2818
+ const out = [];
2819
+ for (const c of list) {
2820
+ if (!BATCHED.has(c.kind)) {
2821
+ alone.push(c);
2822
+ continue;
2823
+ }
2824
+ const lottery = LOTTERY_STEPS.has(c.kind) ? c.key : null;
2825
+ if (lottery !== null && lotteries.has(lottery)) continue;
2826
+ const instructions = await this.claim(c, { build: true }).catch((e) => (out.push({ claim: c, error: e.message }), null));
2827
+ if (!instructions?.length) continue;
2828
+ if (lottery !== null) lotteries.add(lottery);
2829
+ built.push([c, instructions]);
2830
+ }
2831
+ const sent = await sendBatch(built.map(([, instructions]) => instructions));
2832
+ out.push(...built.map(([claim], i) => ({ claim, ...sent[i] })));
2833
+ for (const claim of alone) out.push(await this.claim(claim).then((signature) => ({ claim, signature }), (e) => ({ claim, error: e.message })));
2834
+ return out;
2089
2835
  },
2090
2836
 
2091
2837
  /** The player's collectibles of this project: [{ asset, item, itemId, name, uri, attributes, frozen }]. `item` is its key in the spec. */