@xgamesfun/solana 0.20.1 → 0.25.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 (112) hide show
  1. package/README.md +95 -52
  2. package/bin/xsolana.mjs +23 -8
  3. package/examples/blind-forge.json +2 -4
  4. package/examples/coin-flip-arena.json +4 -12
  5. package/examples/duel-arena.json +1 -4
  6. package/examples/elements-set.json +2 -9
  7. package/examples/expedition-guild.json +1 -8
  8. package/examples/fair-presale.json +1 -3
  9. package/examples/guild-missions.json +3 -10
  10. package/examples/halving-seasons.json +4 -9
  11. package/examples/hero-trials.json +15 -6
  12. package/examples/holder-den.json +38 -0
  13. package/examples/holders-drop.json +105 -0
  14. package/examples/ideas.json +788 -788
  15. package/examples/match-predictions.json +24 -0
  16. package/examples/merge-2048.json +5 -5
  17. package/examples/nft-raffle.json +1 -0
  18. package/examples/pack-league.json +108 -0
  19. package/examples/paper-hands-farm.json +4 -16
  20. package/examples/partner-vault.json +20 -7
  21. package/examples/sacrifice-raffle.json +2 -10
  22. package/examples/shelter-fair.json +1 -0
  23. package/examples/stakers-draw.json +4 -15
  24. package/examples/star-fleet.json +1 -8
  25. package/examples/starforce.json +6 -6
  26. package/examples/stride-sneakers.json +164 -0
  27. package/examples/trade-in-shop.json +2 -3
  28. package/js/approve/details.mjs +122 -52
  29. package/js/approve/ed25519.mjs +5 -1
  30. package/js/approve/live-check.mjs +83 -26
  31. package/js/approve/page.html +217 -78
  32. package/js/approve/page.js +43 -15
  33. package/js/approve/server.mjs +290 -73
  34. package/js/chain/base58.js +7 -1
  35. package/js/chain/borsh.js +24 -11
  36. package/js/chain/bytes.js +11 -8
  37. package/js/chain/core.js +35 -7
  38. package/js/chain/curve.js +1 -1
  39. package/js/chain/lookup.js +124 -36
  40. package/js/chain/mint.js +6 -2
  41. package/js/chain/pda.js +2 -2
  42. package/js/chain/programs.js +6 -6
  43. package/js/chain/rpc.js +44 -16
  44. package/js/chain/token.js +13 -2
  45. package/js/chain/token2022.js +55 -16
  46. package/js/chain/tx.js +32 -22
  47. package/js/client/accounts.js +653 -131
  48. package/js/client/compressed.js +4 -3
  49. package/js/client/grant.js +267 -83
  50. package/js/client/idl/loot.js +1 -1
  51. package/js/client/idl/loot.json +2406 -2176
  52. package/js/client/index.js +1 -0
  53. package/js/client/ix.js +3059 -664
  54. package/js/client/pda.js +39 -33
  55. package/js/client/policy.js +52 -24
  56. package/js/client/random.js +70 -0
  57. package/js/client/sizes.js +414 -16
  58. package/js/client/traits.js +14 -5
  59. package/js/connect/connect.mjs +59 -17
  60. package/js/economy/check.mjs +4153 -725
  61. package/js/economy/cost.mjs +403 -64
  62. package/js/economy/examples.mjs +176 -35
  63. package/js/economy/explain.mjs +466 -111
  64. package/js/economy/guide.mjs +296 -72
  65. package/js/economy/legal.mjs +12 -6
  66. package/js/economy/live.mjs +83 -19
  67. package/js/economy/model.mjs +462 -124
  68. package/js/economy/plan.mjs +1306 -219
  69. package/js/economy/schema.json +390 -484
  70. package/js/economy/secrets.mjs +17 -7
  71. package/js/economy/simulate.mjs +883 -223
  72. package/js/economy/txsize.mjs +15 -3
  73. package/js/economy/validate.mjs +61 -24
  74. package/js/mcp/server.mjs +42 -14
  75. package/js/mcp/tools.mjs +307 -88
  76. package/js/play/index.js +2864 -619
  77. package/js/play/keys.js +193 -0
  78. package/js/results/index.js +223 -30
  79. package/js/results/server.js +230 -0
  80. package/js/results/wire.js +64 -16
  81. package/package.json +6 -4
  82. package/release.json +5 -5
  83. package/skills/xsolana-economy/SKILL.md +39 -21
  84. package/skills/xsolana-economy/references/after-launch.md +58 -38
  85. package/skills/xsolana-economy/references/apps.md +97 -50
  86. package/skills/xsolana-economy/references/sentence-to-spec.md +148 -121
  87. package/skills/xsolana-economy/references/telling-the-person.md +61 -17
  88. package/types/chain/core.d.ts +4 -2
  89. package/types/chain/lookup.d.ts +5 -2
  90. package/types/chain/rpc.d.ts +3 -1
  91. package/types/client/accounts.d.ts +217 -56
  92. package/types/client/grant.d.ts +72 -27
  93. package/types/client/idl/loot.d.ts +602 -549
  94. package/types/client/index.d.ts +1 -0
  95. package/types/client/ix.d.ts +3884 -247
  96. package/types/client/pda.d.ts +22 -16
  97. package/types/client/policy.d.ts +39 -14
  98. package/types/client/random.d.ts +26 -0
  99. package/types/client/sizes.d.ts +93 -5
  100. package/types/economy/check.d.mts +5 -10
  101. package/types/economy/cost.d.mts +2 -1
  102. package/types/economy/guide.d.mts +5 -0
  103. package/types/economy/live.d.mts +16 -1
  104. package/types/economy/model.d.mts +151 -38
  105. package/types/economy/plan.d.mts +237 -194
  106. package/types/economy/simulate.d.mts +21 -13
  107. package/types/mcp/tools.d.mts +21 -13
  108. package/types/play/index.d.ts +453 -53
  109. package/types/play/keys.d.ts +51 -0
  110. package/types/results/index.d.ts +7 -6
  111. package/types/results/server.d.ts +126 -0
  112. package/types/results/wire.d.ts +1 -1
package/js/play/index.js CHANGED
@@ -20,43 +20,86 @@
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';
24
- import { ASSOCIATED_TOKEN_PROGRAM, associatedTokenAddress, COMPUTE_BUDGET_PROGRAM, NATIVE_MINT, SYSVAR_SLOT_HASHES, TOKEN_2022_PROGRAM, TOKEN_PROGRAM } from '../chain/pda.js';
25
- import { closeTokenAccount, computeUnitLimit, createAssociatedTokenAccountIdempotent, requestHeapFrame, syncNative, transferLamports, withSolWrap } from '../chain/programs.js';
23
+ import { ownedAssetFilters, parseCoreAsset, parseCoreCollection } from '../chain/core.js';
24
+ import {
25
+ ASSOCIATED_TOKEN_PROGRAM,
26
+ associatedTokenAddress,
27
+ COMPUTE_BUDGET_PROGRAM,
28
+ NATIVE_MINT,
29
+ SYSVAR_SLOT_HASHES,
30
+ TOKEN_2022_PROGRAM,
31
+ TOKEN_PROGRAM,
32
+ } from '../chain/pda.js';
33
+ import {
34
+ closeTokenAccount,
35
+ computeUnitLimit,
36
+ createAssociatedTokenAccountIdempotent,
37
+ requestHeapFrame,
38
+ syncNative,
39
+ transferLamports,
40
+ withSolWrap,
41
+ } from '../chain/programs.js';
26
42
  import { checkCluster, notMainnet, rpcClient } from '../chain/rpc.js';
27
43
  import { MEMO_PROGRAM, mintPowers, resolveTransfers } from '../chain/token2022.js';
28
- import { compileFitting, closeTableIx, deactivateTableIx, readTable, sendFitting, sendPlan, slotHashes, tablesOf, tableStep, transactionLocks, LOOKUP_TABLE_PROGRAM } from '../chain/lookup.js';
44
+ import {
45
+ compileFitting,
46
+ closeTableIx,
47
+ deactivateTableIx,
48
+ readTable,
49
+ sendFitting,
50
+ sendPlan,
51
+ slotHashes,
52
+ tablesOf,
53
+ tableStep,
54
+ transactionLocks,
55
+ LOOKUP_TABLE_PROGRAM,
56
+ } from '../chain/lookup.js';
29
57
  import { compileMessage, readTransaction, wireTransaction } from '../chain/tx.js';
30
58
  import * as loot from '../client/index.js';
59
+ import { browserStore, keyring } from './keys.js';
31
60
 
32
61
  // The options of a call made without any: read-only, and typed so the generated declarations take any option.
62
+ /** A lottery's steps in `claims()`: each depends on the one before, so `claimAll` takes one of a lottery's a call. */
63
+ const LOTTERY_STEPS = new Set(['reveal', 'cancel-draw', 'pull', 'payout']);
64
+ /** The claims that are one transaction of the wallet's: `claimAll` signs them together. */
65
+ const BATCHED = new Set(['contest', 'release', 'lookup-table', 'close-leaf', ...LOTTERY_STEPS]);
33
66
  const NO_OPTIONS = Object.freeze(/** @type {Record<string, any>} */ ({}));
34
67
 
35
68
  const CHAIN = 'solana:devnet';
36
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
69
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
37
70
  const MPL_CORE = loot.MPL_CORE_PROGRAM;
38
71
  const NOBODY = '11111111111111111111111111111111';
39
72
  /** A table made this many slots ago whose transaction never landed was never sent: its blockhashes ran out long since. */
40
73
  const TABLE_UNSENT_SLOTS = 300n;
41
74
  /** 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);
75
+ const isProveLeaf = ix => ix.programId === loot.LOOT_PROGRAM && loot.instructionOf(ix.data)?.name === 'prove_leaf';
44
76
  /** A system account must keep this much (rent for 0 bytes) or be emptied. */
45
77
  const RENT_EMPTY = 890_880n;
46
78
  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
79
 
50
- /** A signer from a Wallet Standard wallet and one of its accounts: the wallet signs, the app sends. */
80
+ /**
81
+ * A signer from a Wallet Standard wallet and one of its accounts: the wallet signs, the app sends. With the wallet's
82
+ * `solana:signMessage`, also `signMessage(bytes)`: the app's settle keys are made from one signature of it a session,
83
+ * so a page that closes can make them again (`recover`).
84
+ */
51
85
  export function walletStandardSigner(wallet, account, chain = CHAIN) {
52
86
  const feature = wallet.features?.['solana:signTransaction'];
53
87
  if (!feature) throw new Error(`${wallet.name} cannot sign transactions.`);
88
+ const messages = wallet.features?.['solana:signMessage'];
54
89
  return {
55
90
  address: account.address,
56
91
  async signTransaction(wire) {
57
92
  const [out] = await feature.signTransaction({ account, transaction: wire, chain });
58
93
  return out.signedTransaction;
59
94
  },
95
+ ...(messages
96
+ ? {
97
+ async signMessage(message) {
98
+ const [out] = await messages.signMessage({ account, message });
99
+ return out.signature;
100
+ },
101
+ }
102
+ : {}),
60
103
  };
61
104
  }
62
105
 
@@ -64,7 +107,10 @@ export function walletStandardSigner(wallet, account, chain = CHAIN) {
64
107
  async function freshKey() {
65
108
  const pair = await globalThis.crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify']);
66
109
  const raw = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', pair.publicKey));
67
- return { address: encodeBase58(raw), sign: async (message) => new Uint8Array(await globalThis.crypto.subtle.sign('Ed25519', pair.privateKey, message)) };
110
+ return {
111
+ address: encodeBase58(raw),
112
+ sign: async message => new Uint8Array(await globalThis.crypto.subtle.sign('Ed25519', pair.privateKey, message)),
113
+ };
68
114
  }
69
115
 
70
116
  /**
@@ -73,17 +119,28 @@ async function freshKey() {
73
119
  * some other amount.
74
120
  */
75
121
  export const units = (amount, decimals) => {
76
- const text = typeof amount === 'number' && Number.isFinite(amount) && amount >= 0 ? plainNumber(amount) : typeof amount === 'string' ? amount.trim() : null;
122
+ const text =
123
+ typeof amount === 'number' && Number.isFinite(amount) && amount >= 0
124
+ ? plainNumber(amount)
125
+ : typeof amount === 'string'
126
+ ? amount.trim()
127
+ : null;
77
128
  const m = text === null ? null : /^(\d+)(?:\.(\d+))?$/.exec(text);
78
- if (!m) throw new Error(`${typeof amount === 'string' ? JSON.stringify(amount) : String(amount)} is not an amount: write a plain number like 12 or 0.5.`);
129
+ if (!m)
130
+ throw new Error(
131
+ `${typeof amount === 'string' ? JSON.stringify(amount) : String(amount)} is not an amount: write a plain number like 12 or 0.5.`,
132
+ );
79
133
  const fraction = m[2] ?? '';
80
- if (fraction.length > decimals && /[1-9]/.test(fraction.slice(decimals))) throw new Error(`${text} has more decimals than the token's ${decimals}.`);
134
+ if (fraction.length > decimals && /[1-9]/.test(fraction.slice(decimals)))
135
+ throw new Error(`${text} has more decimals than the token's ${decimals}.`);
81
136
  return BigInt(m[1] + fraction.padEnd(decimals, '0').slice(0, decimals));
82
137
  };
83
138
  /** A number as plain decimal text (1e-7 → "0.0000001"). */
84
- const plainNumber = (n) => (/e/i.test(String(n)) ? n.toFixed(20).replace(/\.?0+$/, '') : String(n));
139
+ const plainNumber = n => (/e/i.test(String(n)) ? n.toFixed(20).replace(/\.?0+$/, '') : String(n));
85
140
  const show = (u, decimals) => {
86
- const s = BigInt(u).toString().padStart(decimals + 1, '0');
141
+ const s = BigInt(u)
142
+ .toString()
143
+ .padStart(decimals + 1, '0');
87
144
  const f = s.slice(s.length - decimals).replace(/0+$/, '');
88
145
  return `${s.slice(0, s.length - decimals)}${f ? `.${f}` : ''}`;
89
146
  };
@@ -99,17 +156,20 @@ function devnetOnly(client) {
99
156
  if (!/^(https?|wss?):/i.test(String(client.url ?? ''))) return client;
100
157
  let checked = null;
101
158
  // A check that could not be made (the network did not answer) is made again on the next request.
102
- const ready = () => (checked ??= checkCluster(client).catch((e) => {
103
- checked = null;
104
- throw e;
105
- }));
159
+ const ready = () =>
160
+ (checked ??= checkCluster(client).catch(e => {
161
+ checked = null;
162
+ throw e;
163
+ }));
106
164
  return new Proxy(client, {
107
165
  get(target, key) {
108
166
  const value = Reflect.get(target, key, target);
109
- return typeof value === 'function' ? async (...args) => {
110
- await ready();
111
- return value.apply(target, args);
112
- } : value;
167
+ return typeof value === 'function'
168
+ ? async (...args) => {
169
+ await ready();
170
+ return value.apply(target, args);
171
+ }
172
+ : value;
113
173
  },
114
174
  });
115
175
  }
@@ -118,32 +178,54 @@ function devnetOnly(client) {
118
178
  * The player's side of a live economy. `rpc` may be given instead of `rpcUrl` (any object with the client's rpc
119
179
  * methods: accounts, account, latestBlockhash, send, confirm, programAccounts, and call(method, params), the raw JSON
120
180
  * 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.
181
+ * by (Unix seconds). Devnet only (`devnetOnly`): mainnet waits for an audit. `keyStore`: where a settle key that can't
182
+ * be made again from the wallet's signature is kept, encrypted (`browserStore()` in a browser; null keeps keys in memory
183
+ * only).
122
184
  */
123
- export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc = null, wallet, programs = {}, now = () => Math.floor(Date.now() / 1000) }) {
185
+ export function solanaApp({
186
+ spec,
187
+ rpcUrl = 'https://api.devnet.solana.com',
188
+ rpc = null,
189
+ wallet,
190
+ programs = {},
191
+ now = () => Math.floor(Date.now() / 1000),
192
+ keyStore = browserStore(),
193
+ }) {
124
194
  const live = spec?.live;
125
195
  if (!live?.project) throw new Error('This economy is not live yet: its xsolana.json has no "live" section.');
126
- 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.');
196
+ if (rpc && typeof rpc.call !== 'function')
197
+ throw new Error(
198
+ '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.',
199
+ );
127
200
  const client = devnetOnly(rpc ?? rpcClient(notMainnet(rpcUrl)));
128
201
  const project = live.project;
202
+ /** The settle keys, each named by its job's account (`keys.js`): made again after a reload (`recover`). */
203
+ const keys = wallet?.address ? keyring({ wallet, project, store: keyStore }) : null;
204
+ /** The key for a job about `name` (an order's, a hold's address): one that can be made again, or a fresh one. */
205
+ const jobKey = async name => (keys ? keys.key(name) : freshKey());
129
206
  /** The project's Core collections: an NFT of one of them is the project's own (a project may have none). */
130
207
  const collections = live.collections ?? (live.collection ? [live.collection] : []);
131
- const ours = (collection) => collection != null && collections.includes(collection);
132
- const tokens = new Map((spec.tokens ?? []).map((t) => [t.key, t]));
208
+ const ours = collection => collection != null && collections.includes(collection);
209
+ const tokens = new Map((spec.tokens ?? []).map(t => [t.key, t]));
133
210
  const currencyKey = spec.currency;
134
- const mintOf = (key) => {
211
+ const mintOf = key => {
135
212
  const t = tokens.get(key ?? currencyKey);
136
213
  if (!t) throw new Error(`No token “${key}”.`);
137
- return t.source === 'sol' ? NATIVE_MINT : spec.live.mints?.[t.key] ?? t.mint;
214
+ return t.source === 'sol' ? NATIVE_MINT : (spec.live.mints?.[t.key] ?? t.mint);
138
215
  };
139
- const decimalsOf = (key) => {
216
+ const decimalsOf = key => {
140
217
  const t = tokens.get(key ?? currencyKey);
141
- return t?.source === 'sol' ? 9 : t?.decimals ?? 0;
218
+ return t?.source === 'sol' ? 9 : (t?.decimals ?? 0);
219
+ };
220
+ programs = {
221
+ ...Object.fromEntries(
222
+ [...tokens.values()].map(t => [mintOf(t.key), t.program === 'token-2022' ? TOKEN_2022_PROGRAM : TOKEN_PROGRAM]),
223
+ ),
224
+ ...programs,
142
225
  };
143
- programs = { ...Object.fromEntries([...tokens.values()].map(t => [mintOf(t.key), t.program === 'token-2022' ? TOKEN_2022_PROGRAM : TOKEN_PROGRAM])), ...programs };
144
- const programOf = (mint) => programs[mint] ?? TOKEN_PROGRAM;
226
+ const programOf = mint => programs[mint] ?? TOKEN_PROGRAM;
145
227
  const part = (list, key) => {
146
- const p = (spec[list] ?? []).find((x) => x.key === key);
228
+ const p = (spec[list] ?? []).find(x => x.key === key);
147
229
  if (!p) throw new Error(`No ${list.replace(/s$/, '')} “${key}” in this economy.`);
148
230
  return p;
149
231
  };
@@ -159,47 +241,61 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
159
241
  return a?.owner === loot.LOOT_PROGRAM ? loot.decode(name, a.data) : null;
160
242
  };
161
243
  /** 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;
244
+ const ticketCurrencyOf = (l, lottery) =>
245
+ l.ticketCurrency ?? [...tokens.keys()].find(k => mintOf(k) === lottery.terms.mint) ?? currencyKey;
163
246
  /** Farm hooks shared by all actions. Static lists resolve automatically. For dynamic hooks,
164
247
  * 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
248
+ * shares its pool the move's last one when a seat gave rewards back in it (an early exit, a watched
166
249
  * NFT gone): from the farm's rewards vault back to its pool, the project the authority.
167
250
  * Explicit extra replaces resolution and must include all token hook/memo and Core adapter accounts. */
168
251
  const farmTokens = async (mints, create, { extra, transfers = {} } = {}) => {
169
252
  const selected = [...new Set(mints)];
170
- const resolved = await Promise.all(selected.map(async (mint) => {
171
- if (extra !== undefined) {
172
- const account = await client.account(mint);
173
- if (![TOKEN_PROGRAM, TOKEN_2022_PROGRAM].includes(account?.owner)) throw new Error(`There is no supported token ${mint} on this network.`);
174
- return { mint, tokenProgram: account.owner, extraAccounts: [] };
175
- }
176
- try {
177
- return { mint, ...await resolveTransfers(client, mint, transfers[mint] ?? []) };
178
- } catch (cause) {
179
- throw new Error(`Cannot resolve farm transfers for ${mint}: ${cause.message} Supply exact transfers keyed by mint, or explicit extra hook/memo accounts.`, { cause });
180
- }
181
- }));
182
- const tokenPrograms = { ...programs, ...Object.fromEntries(resolved.map(({ mint, tokenProgram }) => [mint, tokenProgram])) };
253
+ const resolved = await Promise.all(
254
+ selected.map(async mint => {
255
+ if (extra !== undefined) {
256
+ const account = await client.account(mint);
257
+ if (![TOKEN_PROGRAM, TOKEN_2022_PROGRAM].includes(account?.owner))
258
+ throw new Error(`There is no supported token ${mint} on this network.`);
259
+ return { mint, tokenProgram: account.owner, extraAccounts: [] };
260
+ }
261
+ try {
262
+ return { mint, ...(await resolveTransfers(client, mint, transfers[mint] ?? [])) };
263
+ } catch (cause) {
264
+ throw new Error(
265
+ `Cannot resolve farm transfers for ${mint}: ${cause.message} Supply exact transfers keyed by mint, or explicit extra hook/memo accounts.`,
266
+ { cause },
267
+ );
268
+ }
269
+ }),
270
+ );
271
+ const tokenPrograms = {
272
+ ...programs,
273
+ ...Object.fromEntries(resolved.map(({ mint, tokenProgram }) => [mint, tokenProgram])),
274
+ };
183
275
  return {
184
276
  programs: tokenPrograms,
185
- extra: extra ?? resolved.flatMap((r) => r.extraAccounts),
186
- before: [...new Set(create)].map((mint) => createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, mint, tokenPrograms[mint])),
277
+ extra: extra ?? resolved.flatMap(r => r.extraAccounts),
278
+ before: [...new Set(create)].map(mint =>
279
+ createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, mint, tokenPrograms[mint]),
280
+ ),
187
281
  };
188
282
  };
189
- const farmCollection = async (asset) => {
283
+ const farmCollection = async asset => {
190
284
  const account = await client.account(asset);
191
285
  // Core tombstones and missing assets remain valid farm-cleanup paths.
192
286
  return account?.owner === MPL_CORE && account.data[0] === 1
193
- ? parseCoreAsset(account.data).collection ?? collections[0]
287
+ ? (parseCoreAsset(account.data).collection ?? collections[0])
194
288
  : collections[0];
195
289
  };
196
290
  /** The Core collection an asset is in, or null. */
197
- const collectionOf = async (asset) => {
291
+ const collectionOf = async asset => {
198
292
  const account = await client.account(asset);
199
- return account?.owner === MPL_CORE && account.data[0] === 1 ? parseCoreAsset(account.data).collection ?? null : null;
293
+ return account?.owner === MPL_CORE && account.data[0] === 1
294
+ ? (parseCoreAsset(account.data).collection ?? null)
295
+ : null;
200
296
  };
201
297
  /** A Token Metadata NFT's { programmable, ruleSet } from its metadata, or null for anything else (a Core asset). */
202
- const tokenMetadataOf = async (asset) => {
298
+ const tokenMetadataOf = async asset => {
203
299
  const account = await client.account(asset);
204
300
  if (account?.owner !== TOKEN_PROGRAM) return null;
205
301
  return loot.decodeTokenMetadata((await client.account(loot.tokenMetadata.metadata(asset)))?.data);
@@ -220,7 +316,9 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
220
316
  const fresh = loot.freshNftAccount({ payer, holder, mint: asset, index, there: await client.account(address) });
221
317
  if (fresh) return { into: fresh.address, before: fresh.instructions };
222
318
  }
223
- throw new Error(`${holder}'s accounts of the NFT ${asset} can't take it (owned by another key, or frozen), and no fresh one could be opened.`);
319
+ throw new Error(
320
+ `${holder}'s accounts of the NFT ${asset} can't take it (owned by another key, or frozen), and no fresh one could be opened.`,
321
+ );
224
322
  }
225
323
  /**
226
324
  * What a route needs of an NFT, whatever its kind: { collection, hasAuthority, tokenMetadata, compressed, before }.
@@ -237,12 +335,19 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
237
335
  const proof = await client.call('getAssetProof', { id: asset });
238
336
  const tree = await client.account(das.compression.tree);
239
337
  const compressed = loot.compressedFromDas({ asset: das, proof, treeData: tree.data });
240
- return { compressed, hasAuthority: false, before: [computeUnitLimit(1_000_000), loot.proveLeafIx({ signer, compressed })] };
338
+ return {
339
+ compressed,
340
+ hasAuthority: false,
341
+ before: [computeUnitLimit(1_000_000), loot.proveLeafIx({ signer, compressed })],
342
+ };
241
343
  }
242
344
  }
243
345
  const tokenMetadata = await tokenMetadataOf(asset);
244
346
  // A Token Metadata mint is taken only as an NFT: one token, no decimals, none more ever minted (NotAnNft).
245
- if (tokenMetadata && account && !loot.isNftMint(asset, account.data)) throw new Error(`${asset} is a semi-fungible item (several units, or a mint that can still mint): only an NFT is taken.`);
347
+ if (tokenMetadata && account && !loot.isNftMint(asset, account.data))
348
+ throw new Error(
349
+ `${asset} is a semi-fungible item (several units, or a mint that can still mint): only an NFT is taken.`,
350
+ );
246
351
  const { into, before } = tokenMetadata && to ? await nftInto(asset, to, payer) : { into: null, before: [] };
247
352
  return {
248
353
  collection: await farmCollection(asset),
@@ -255,17 +360,12 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
255
360
  * What `loot.prizeMetas` needs to give these prizes (`null`s are skipped): each pool's mint, each item (its kind, its
256
361
  * collection and its next serial; null for one never written), each NFT's kind (`nfts`, and `nft` for one), the
257
362
  * 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`).
363
+ * roll its box, decoded (`boxes`, `boxesOf`).
260
364
  */
261
365
  async function prizeInputs(prizes, signer = wallet.address, winner = wallet.address) {
262
- const out = { poolMints: {}, items: {}, nfts: {}, nft: undefined, collection: collections[0], before: [], boxes: {}, rollPools: {} };
366
+ const out = { poolMints: {}, items: {}, nfts: {}, nft: undefined, collection: collections[0], before: [] };
367
+ out.boxes = await boxesOf(prizes.filter(Boolean).flatMap(p => (p.Roll ? [p.Roll[0]] : [])));
263
368
  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
369
  const pool = prize.Tokens?.from.Pool?.[0];
270
370
  if (pool !== undefined && !(pool in out.poolMints)) {
271
371
  const account = await client.account(loot.pda.pool(project, pool));
@@ -285,39 +385,75 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
285
385
  return out;
286
386
  }
287
387
  /** The Sheet the project keeps of an NFT it has no authority over: { key: value } of what the project wrote. */
288
- const sheetOf = async (nft) => {
388
+ const sheetOf = async nft => {
289
389
  const found = await client.account(loot.pda.sheet(project, nft));
290
- return Object.fromEntries((loot.decodeSheet(found?.owner === loot.LOOT_PROGRAM ? found.data : null)?.attributes ?? []).map((a) => [a.key, a.value]));
390
+ return Object.fromEntries(
391
+ (loot.decodeSheet(found?.owner === loot.LOOT_PROGRAM ? found.data : null)?.attributes ?? []).map(a => [
392
+ a.key,
393
+ a.value,
394
+ ]),
395
+ );
291
396
  };
292
397
  let projectState = null;
293
398
  const projectNow = async () => (projectState ??= await read('Project', project));
294
399
  /** A spec auction's address: it names the seller, which xsolana.json records once it is live. */
295
- const auctionAt = (a) => {
400
+ const auctionAt = a => {
296
401
  if (!a.seller) throw new Error(`The auction “${a.key}” has no seller in xsolana.json: publish the economy again.`);
297
402
  return loot.pda.auction(project, a.seller, a.id);
298
403
  };
299
404
 
300
- const itemKeyOf = (id) => (spec.items ?? []).find((it) => it.id === Number(id))?.key ?? null;
405
+ const itemKeyOf = id => (spec.items ?? []).find(it => it.id === Number(id))?.key ?? null;
301
406
  /** Who holds a collectible now. */
302
- const ownerOf = async (asset) => parseCoreAsset((await client.account(asset)).data).owner;
407
+ const ownerOf = async asset => parseCoreAsset((await client.account(asset)).data).owner;
303
408
  /** `{ asset, farm }` for a Core asset on its seat in one of the economy's farms of these custodies, or null. */
304
409
  const seatedIn = async (asset, custodies = ['escrow', 'freeze']) => {
305
- for (const f of (spec.farms ?? []).filter((x) => x.stakes === 'collectibles' && custodies.includes(x.custody ?? 'escrow'))) {
410
+ for (const f of (spec.farms ?? []).filter(
411
+ x => x.stakes === 'collectibles' && custodies.includes(x.custody ?? 'escrow'),
412
+ )) {
306
413
  if (await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), asset))) return { asset, farm: f.id };
307
414
  }
415
+ // Or boosting the player's token seat in a farm of tokens: that seat, not one of its own (`booster`).
416
+ for (const f of (spec.farms ?? []).filter(
417
+ x => x.stakes !== 'collectibles' && custodies.includes(x.custody ?? 'escrow'),
418
+ )) {
419
+ const seat = wallet?.address
420
+ ? await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), wallet.address))
421
+ : null;
422
+ if (seat?.asset === asset) return { asset, farm: f.id, booster: true };
423
+ }
308
424
  return null;
309
425
  };
310
426
  /** The same for one the project holds: staked in escrow. */
311
- const escrowedIn = async (asset) => ((await ownerOf(asset).catch(() => null)) === project ? seatedIn(asset, ['escrow']) : null);
427
+ const escrowedIn = async asset =>
428
+ (await ownerOf(asset).catch(() => null)) === project ? seatedIn(asset, ['escrow']) : null;
429
+ /** The same for one the project locks in the player's wallet: staked in a farm that freezes (a soulbound Core asset
430
+ * carries no lock to tell it is staked, so it goes alone). */
431
+ const lockedIn = async asset => {
432
+ const seated = await seatedIn(asset, ['freeze']);
433
+ if (!seated) return null;
434
+ const account = await client.account(asset);
435
+ return account?.owner === MPL_CORE && parseCoreAsset(account.data).soulbound ? null : seated;
436
+ };
312
437
  /** The player's collectibles of this project (in any of its collections), each with the item it is (its "item_id" attribute). */
313
438
  async function owned(owner = wallet?.address) {
314
- const found = (await Promise.all(collections.map((c) => client.programAccounts(MPL_CORE, ownedAssetFilters(owner, c))))).flat();
439
+ const found = (
440
+ await Promise.all(collections.map(c => client.programAccounts(MPL_CORE, ownedAssetFilters(owner, c))))
441
+ ).flat();
315
442
  return found.map(({ pubkey, account }) => {
316
443
  const a = parseCoreAsset(account.data);
317
444
  const itemId = a.attributes?.item_id !== undefined ? Number(a.attributes.item_id) : null;
318
445
  return { asset: pubkey, ...a, itemId, item: itemId === null ? null : itemKeyOf(itemId) };
319
446
  });
320
447
  }
448
+ /** A token seat's refresh (`seat` decoded), with its booster's accounts (a compressed one proven first), to go first in
449
+ * a stake or a compound's transaction (`loot.stakeRefreshFirst`). */
450
+ async function refreshOf(f, farm, seat) {
451
+ const { before, ...nft } = seat.asset !== NOBODY ? await nftOf(seat.asset) : { before: [] };
452
+ return [
453
+ ...before,
454
+ loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by: seat.player, seat, ...nft }),
455
+ ];
456
+ }
321
457
  /**
322
458
  * 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
459
  * 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
@@ -334,14 +470,18 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
334
470
  if (gate.Staked) {
335
471
  const { farm, at_least: atLeast } = gate.Staked;
336
472
  const seat = await readMaybe('Seat', loot.pda.seat(farm, wallet.address));
337
- const f = (spec.farms ?? []).find((x) => loot.pda.farm(project, x.id) === farm);
473
+ const f = (spec.farms ?? []).find(x => loot.pda.farm(project, x.id) === farm);
338
474
  const t = f ? tokens.get(f.stakes) : null;
339
475
  const needs = t ? `${show(atLeast, decimalsOf(t.key))} ${t.symbol ?? t.key}` : `${atLeast} (in base units)`;
340
476
  const where = f ? `“${f.name}”` : 'its farm';
341
- if (!seat || BigInt(seat.amount) < BigInt(atLeast)) throw new Error(`You need at least ${needs} staked in ${where} to ${what}.`);
477
+ if (!seat || BigInt(seat.amount) < BigInt(atLeast))
478
+ throw new Error(`You need at least ${needs} staked in ${where} to ${what}.`);
342
479
  // Only a locked stake counts: its tier's lock still running.
343
480
  const lock = BigInt((await read('Farm', farm)).tiers[seat.tier]?.lock ?? 0);
344
- if (lock === 0n || BigInt(now()) >= BigInt(seat.since) + lock) throw new Error(`Your stake in ${where} counts only while it is locked: stake in a tier with a lock (staking again starts a new one) to ${what}.`);
481
+ if (lock === 0n || BigInt(now()) >= BigInt(seat.since) + lock)
482
+ throw new Error(
483
+ `Your stake in ${where} counts only while it is locked: stake in a tier with a lock (staking again starts a new one) to ${what}.`,
484
+ );
345
485
  return null;
346
486
  }
347
487
  if (gate.Wallets) {
@@ -353,30 +493,38 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
353
493
  if (!rules.length) return null;
354
494
  const rule = rules[0];
355
495
  const atLeast = rule.test?.AtLeast?.[0];
356
- const equals = rule.test?.Equals?.text;
496
+ const equals = rule.test?.OneOf?.texts?.length === 1 ? rule.test.OneOf.texts[0] : undefined;
357
497
  const item = rule.of?.Item?.[0];
358
- const named = rules.map((r) => r.of?.Collection?.[0]).find((c) => c && !ours(c));
498
+ const named = rules.map(r => r.of?.Collection?.[0]).find(c => c && !ours(c));
359
499
  const other = named ?? null;
360
500
  const passes = (r, c) => {
361
501
  if (r.of?.Item?.[0] !== undefined && c.itemId !== Number(r.of.Item[0])) return false;
362
502
  const value = c.attributes?.[r.key];
363
503
  if (r.test?.AtLeast?.[0] !== undefined) return Number(value) >= Number(r.test.AtLeast[0]);
364
- if (r.test?.Equals?.text !== undefined) return String(value) === String(r.test.Equals.text);
504
+ if (r.test?.OneOf?.texts)
505
+ return r.test.OneOf.texts.map(String).includes(String(r.key === 'item_id' ? c.itemId : value));
365
506
  return true;
366
507
  };
367
- const pass = (c) => rules.every((r) => passes(r, c));
508
+ const pass = c => rules.every(r => passes(r, c));
368
509
  if (other) {
369
510
  const found = await client.programAccounts(MPL_CORE, ownedAssetFilters(wallet.address, other));
370
511
  for (const { pubkey, account } of found) {
371
512
  const a = parseCoreAsset(account.data);
372
513
  if (pass({ attributes: { ...a.attributes, ...(await sheetOf(pubkey)) } })) return { asset: pubkey };
373
514
  }
374
- throw new Error(`You need an NFT of the collection ${other}${atLeast !== undefined ? ` with ${rule.key} ${atLeast} or more` : equals !== undefined ? ` whose ${rule.key} is ${equals}` : ''} to ${what}.`);
515
+ throw new Error(
516
+ `You need an NFT of the collection ${other}${atLeast !== undefined ? ` with ${rule.key} ${atLeast} or more` : equals !== undefined ? ` whose ${rule.key} is ${equals}` : ''} to ${what}.`,
517
+ );
375
518
  }
376
519
  const found = (await owned()).find(pass);
377
520
  if (found) return found.asset;
378
- const needs = item !== undefined ? `a ${(spec.items ?? []).find((it) => it.id === Number(item))?.name ?? 'collectible of this project'}` : 'a collectible of this project';
379
- throw new Error(`You need ${needs}${atLeast !== undefined ? ` with ${rule.key} ${atLeast} or more` : equals !== undefined ? ` whose ${rule.key} is ${equals}` : ''} to ${what}.`);
521
+ const needs =
522
+ item !== undefined
523
+ ? `a ${(spec.items ?? []).find(it => it.id === Number(item))?.name ?? 'collectible of this project'}`
524
+ : 'a collectible of this project';
525
+ throw new Error(
526
+ `You need ${needs}${atLeast !== undefined ? ` with ${rule.key} ${atLeast} or more` : equals !== undefined ? ` whose ${rule.key} is ${equals}` : ''} to ${what}.`,
527
+ );
380
528
  }
381
529
 
382
530
  /**
@@ -399,13 +547,17 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
399
547
  */
400
548
  async function unwrapAfter(instructions) {
401
549
  const wrapped = associatedTokenAddress(wallet.address, NATIVE_MINT);
402
- const opens = instructions.some((ix) => ix.programId === ASSOCIATED_TOKEN_PROGRAM && ix.keys[1]?.pubkey === wrapped);
403
- const closes = instructions.some((ix) => ix.programId === TOKEN_PROGRAM && ix.data?.[0] === 9 && ix.keys[0]?.pubkey === wrapped);
550
+ const opens = instructions.some(ix => ix.programId === ASSOCIATED_TOKEN_PROGRAM && ix.keys[1]?.pubkey === wrapped);
551
+ const closes = instructions.some(
552
+ ix => ix.programId === TOKEN_PROGRAM && ix.data?.[0] === 9 && ix.keys[0]?.pubkey === wrapped,
553
+ );
404
554
  if (closes) return instructions;
405
555
  const close = closeTokenAccount(wrapped, wallet.address, wallet.address);
406
556
  if (opens || (await client.account(wrapped))) return [...instructions, close];
407
- const touches = instructions.some((ix) => ix.keys?.some((k) => k.pubkey === wrapped));
408
- return touches ? [createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, NATIVE_MINT), ...instructions, close] : instructions;
557
+ const touches = instructions.some(ix => ix.keys?.some(k => k.pubkey === wrapped));
558
+ return touches
559
+ ? [createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, NATIVE_MINT), ...instructions, close]
560
+ : instructions;
409
561
  }
410
562
 
411
563
  /**
@@ -414,18 +566,98 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
414
566
  * Its size is checked before the wallet signs (`sendFitting`): a transaction too big for a legacy one goes as one v0
415
567
  * transaction with a lookup table the wallet makes first (one to three more approvals, and the table's rent until the
416
568
  * 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.
569
+ * go alone, after the table and before the rest. A compressed NFT's record closes in the same transaction, its prover
570
+ * signing (`loot.withLeafCloses`), so its rent comes straight back.
571
+ */
572
+ /**
573
+ * The project's standing lookup tables (made at go-live, `spec.live.tables`), each as the chain holds it now: one
574
+ * deactivated (or gone) is left out, and a transaction then makes a table of its own as before.
418
575
  */
576
+ let standingTables = null;
577
+ async function standing() {
578
+ standingTables ??= (async () => {
579
+ const addresses = spec.live?.tables ?? [];
580
+ const found = addresses.length ? await client.accounts(addresses) : [];
581
+ return addresses.flatMap((address, i) => {
582
+ const table = readTable(found[i] && { ...found[i], owner: found[i].owner ?? LOOKUP_TABLE_PROGRAM });
583
+ return table && table.deactivationSlot === null ? [{ address, addresses: table.addresses }] : [];
584
+ });
585
+ })();
586
+ return standingTables;
587
+ }
588
+
419
589
  async function send(instructions, extra = [], { keepWrapped = false } = {}) {
420
590
  if (!wallet?.address) throw new Error('Connect a wallet first.');
591
+ // A compressed NFT's record is closed in the transaction that used it, its prover signing: its rent comes back.
592
+ instructions = loot.withLeafCloses(instructions);
421
593
  if (!keepWrapped) instructions = await unwrapAfter(instructions);
422
- const sign = async (message) => {
594
+ const sign = async message => {
423
595
  const { signers } = readTransaction(wireTransaction(message));
424
- const sigs = await Promise.all(signers.map(async (s) => (extra.find((k) => k.address === s) ? extra.find((k) => k.address === s).sign(message) : new Uint8Array(64))));
596
+ const sigs = await Promise.all(
597
+ signers.map(async s =>
598
+ extra.find(k => k.address === s) ? extra.find(k => k.address === s).sign(message) : new Uint8Array(64),
599
+ ),
600
+ );
425
601
  return wallet.signTransaction(wireTransaction(message, sigs));
426
602
  };
427
603
  // 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;
604
+ return (
605
+ await sendFitting({
606
+ rpc: client,
607
+ payer: wallet.address,
608
+ instructions,
609
+ sign,
610
+ alone: isProveLeaf,
611
+ first: [project],
612
+ standing: await standing(),
613
+ }).catch(inWords)
614
+ ).signature;
615
+ }
616
+
617
+ const sendNow = (...args) => send(...args);
618
+
619
+ /**
620
+ * Several independent steps of the player's, each one transaction (`lists`: their instructions), signed in one wallet
621
+ * prompt when the wallet signs several at once (`wallet.signTransactions`), then sent in order; one by one otherwise,
622
+ * and for a step too big for one transaction. Returns each step's { signature } or { error }.
623
+ */
624
+ async function sendBatch(lists) {
625
+ const settled = p =>
626
+ p.then(
627
+ signature => ({ signature }),
628
+ e => ({ error: e.message }),
629
+ );
630
+ let signed = null;
631
+ let messages = [];
632
+ let lastValidBlockHeight = null;
633
+ if (wallet?.signTransactions && lists.length > 1) {
634
+ const latest = await client.latestBlockhash();
635
+ lastValidBlockHeight = latest.lastValidBlockHeight;
636
+ const tables = await standing();
637
+ for (const l of lists)
638
+ messages.push(
639
+ compileFitting({
640
+ payer: wallet.address,
641
+ blockhash: latest.blockhash,
642
+ instructions: await unwrapAfter(loot.withLeafCloses(l)),
643
+ tables,
644
+ }),
645
+ );
646
+ const wires = messages.filter(Boolean).map(m => wireTransaction(m));
647
+ const back = wires.length > 1 ? await wallet.signTransactions(wires).catch(() => null) : null;
648
+ if (back) signed = messages.map(m => (m ? back.shift() : null));
649
+ }
650
+ const out = [];
651
+ for (const [i, l] of lists.entries()) {
652
+ const wire = signed?.[i];
653
+ const confirmed = async () => {
654
+ const signature = await client.send(wire).catch(inWords);
655
+ await client.confirm?.(signature, { lastValidBlockHeight });
656
+ return signature;
657
+ };
658
+ out.push(await settled(wire ? confirmed() : send(l)));
659
+ }
660
+ return out;
429
661
  }
430
662
 
431
663
  /**
@@ -434,20 +666,42 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
434
666
  * logs, when it is one of loot's (Anchor's codes start at 6000; the token programs' are small).
435
667
  */
436
668
  function inWords(e) {
669
+ // The program logs an error's name only (its text is compiled out): the IDL's sentence for it.
670
+ const logged = e?.logs ? loot.lootError(e.logs) : null;
671
+ if (logged)
672
+ throw Object.assign(new Error(`${logged.msg} (${logged.name}).`, { cause: e }), {
673
+ code: logged.code,
674
+ name: logged.name,
675
+ logs: e.logs,
676
+ });
437
677
  const said = String(e?.message ?? '');
438
678
  const m = /custom program error: 0x([0-9a-f]+)/i.exec(said);
439
- const failed = e?.logs?.find((line) => / failed: custom program error: 0x/i.test(line));
679
+ const failed = e?.logs?.find(line => / failed: custom program error: 0x/i.test(line));
440
680
  const known = m && (!failed || failed.includes(loot.LOOT_PROGRAM)) ? loot.ERRORS[parseInt(m[1], 16)] : null;
441
681
  if (!known) throw e;
442
- throw Object.assign(new Error(`${known.msg} (${known.name}).`, { cause: e }), { code: parseInt(m[1], 16), name: known.name, logs: e.logs });
682
+ throw Object.assign(new Error(`${known.msg} (${known.name}).`, { cause: e }), {
683
+ code: parseInt(m[1], 16),
684
+ name: known.name,
685
+ logs: e.logs,
686
+ });
443
687
  }
444
688
 
445
689
  /**
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.
690
+ * Build, sign with `key` alone (it pays), send and confirm: a throwaway key's transaction. No wallet prompt. Legacy,
691
+ * or v0 with the project's standing tables (which cost it nothing); the key holds no rent for a table of its own.
448
692
  */
449
693
  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;
694
+ instructions = loot.withLeafCloses(instructions);
695
+ return (
696
+ await sendFitting({
697
+ rpc: client,
698
+ payer: key.address,
699
+ instructions,
700
+ sign: async message => wireTransaction(message, [await key.sign(message)]),
701
+ table: false,
702
+ standing: await standing(),
703
+ }).catch(inWords)
704
+ ).signature;
451
705
  }
452
706
 
453
707
  /**
@@ -458,30 +712,191 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
458
712
  const prize = farm.tiers[tier]?.prize ?? null;
459
713
  const given = await prizeInputs([prize]);
460
714
  // The rewards' accounts as the stake resolved them (a hook that needs each transfer named is sized without them).
461
- const token = { ...staking, before: [createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, farm.rewards_mint, staking.programs[farm.rewards_mint] ?? TOKEN_PROGRAM)] };
462
- const exit = (forfeit) => [...before, ...(forfeit ? [] : [...given.before, ...token.before]), loot.unstakeNftIx({ player: wallet.address, project, id: f.id, farm, asset, ...nft, forfeit, roll: farm.tiers[tier]?.roll ?? null, nonce: 1n, tier, projectCollection: given.collection, poolMints: given.poolMints, items: given.items, nft: given.nft, ...token })];
715
+ const token = {
716
+ ...staking,
717
+ before: [
718
+ createAssociatedTokenAccountIdempotent(
719
+ wallet.address,
720
+ wallet.address,
721
+ farm.rewards_mint,
722
+ staking.programs[farm.rewards_mint] ?? TOKEN_PROGRAM,
723
+ ),
724
+ ],
725
+ };
726
+ const exit = forfeit => [
727
+ ...before,
728
+ ...(forfeit ? [] : [...given.before, ...token.before]),
729
+ loot.unstakeNftIx({
730
+ player: wallet.address,
731
+ project,
732
+ id: f.id,
733
+ farm,
734
+ asset,
735
+ ...nft,
736
+ forfeit,
737
+ roll: farm.tiers[tier]?.roll ?? null,
738
+ nonce: 1n,
739
+ tier,
740
+ projectCollection: given.collection,
741
+ poolMints: given.poolMints,
742
+ items: given.items,
743
+ nft: given.nft,
744
+ ...token,
745
+ }),
746
+ ];
463
747
  for (const forfeit of [false, true]) {
464
- const instructions = exit(forfeit).filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM);
465
- if (!sendPlan({ payer: wallet.address, instructions: [computeUnitLimit(1_400_000), ...instructions], alone: isProveLeaf }))
466
- throw new Error(`Not staked: this NFT couldn't come back out of “${f.name ?? f.key}”. Its unstake${forfeit ? ', even giving up the rewards,' : ''} would lock more accounts than one transaction can (${transactionLocks(wallet.address, [computeUnitLimit(1_400_000), ...instructions], { asV0: true })} with its lookup table; at most 64), so it would stay in the farm.`);
748
+ const instructions = exit(forfeit).filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM);
749
+ if (
750
+ !sendPlan({
751
+ payer: wallet.address,
752
+ instructions: [computeUnitLimit(1_400_000), ...instructions],
753
+ alone: isProveLeaf,
754
+ })
755
+ )
756
+ throw new Error(
757
+ `Not staked: this NFT couldn't come back out of “${f.name ?? f.key}”. Its unstake${forfeit ? ', even giving up the rewards,' : ''} would lock more accounts than one transaction can (${transactionLocks(wallet.address, [computeUnitLimit(1_400_000), ...instructions], { asV0: true })} with its lookup table; at most 64), so it would stay in the farm.`,
758
+ );
467
759
  }
468
760
  }
469
761
 
470
762
  /** An NFT's kind as a route's `ticket` or `nft` takes it: without its asset. */
471
763
  const kindOnly = ({ asset: _, ...kind }) => kind;
472
764
 
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);
765
+ /**
766
+ * 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
767
+ * throwaway key made in memory for that one job. `keyFor(pays)` makes it, and the transfer that funds it, which goes
768
+ * in the wallet's own transaction: what its steps pay (`pays`: their fees, an ask's oracle fee, the rent of what a
769
+ * settle opens or mints, `loot.settleRent`) and the rent minimum read from the cluster, plus one spare fee so it can
770
+ * always empty itself. A key pays for an account only with lamports the wallet gave it for that, and never makes a
771
+ * proof record: then the wallet sends that step. `key`: one made already (a box's open names it before it knows how
772
+ * many settles it funds).
773
+ */
774
+ async function keyFor(pays = SIGNATURE_FEE, key = null) {
775
+ key ??= await freshKey();
776
+ return {
777
+ key,
778
+ fund: transferLamports(wallet.address, key.address, (await rentEmpty()) + BigInt(pays) + SIGNATURE_FEE),
779
+ };
780
+ }
781
+
782
+ /** 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
783
+ * a transaction's fee: a fee payer stays rent-exempt or ends empty. */
784
+ let rentMinimum = null;
785
+ const rentEmpty = async () =>
786
+ (rentMinimum ??= BigInt(
787
+ await client.call('getMinimumBalanceForRentExemption', [0]).catch(() => Number(RENT_EMPTY)),
788
+ ));
789
+
790
+ /** Whether `instructions` would land as `payer` sends them now (a dry run, with the standing tables when they need
791
+ * them), and the accounts `watch` as they would be after: { ok, accounts, error }, `error` loot's when it refused
792
+ * ({ code, name, msg }, `loot.lootError`). */
793
+ async function dryRun(payer, instructions, watch = []) {
794
+ const { blockhash } = await client.latestBlockhash();
795
+ const message = compileFitting({ payer, blockhash, instructions, tables: await standing() });
796
+ if (!message) return { ok: false, accounts: [], error: null };
797
+ let error = null;
798
+ const r = await client.simulate(wireTransaction(message), { accounts: watch, replaceBlockhash: true }).catch(e => {
799
+ error = loot.lootError(e?.logs ?? []);
800
+ return null;
801
+ });
802
+ return { ok: Boolean(r), accounts: r?.accounts ?? [], error };
803
+ }
804
+
805
+ /** A throwaway key empties itself back to the wallet: all it holds now, less its own fee. Nothing when it holds no more
806
+ * than that fee (a settle of its that failed on chain took its fee: the wallet then pays the move, one more prompt). */
807
+ async function emptyKey(key) {
808
+ const left = BigInt(await client.balance(key.address).catch(() => 0n));
809
+ if (left >= (await rentEmpty()) + SIGNATURE_FEE)
810
+ await sendAs(key, [transferLamports(key.address, wallet.address, left - SIGNATURE_FEE)]).catch(() => null);
811
+ else if (left > 0n) await send([transferLamports(key.address, wallet.address, left)], [key]).catch(() => null);
812
+ // A key kept in storage is dropped once it holds nothing.
813
+ if (key.name && !(BigInt(await client.balance(key.address).catch(() => 1n)) > 0n)) keys?.forget(key.name);
814
+ }
815
+
816
+ /** A key's job: its step (`build(signer)`), simulated as the key sends it, then sent by the key, or by the wallet when
817
+ * the simulation fails (one more prompt); then the key empties itself back to the wallet. Returns the signature. */
818
+ async function keyRun(key, build) {
819
+ const steps = await build(key.address);
820
+ const signature = (await dryRun(key.address, steps)).ok ? await sendAs(key, steps).catch(() => null) : null;
821
+ const sent = signature ?? (await send(await build(wallet.address)));
822
+ await emptyKey(key);
823
+ return sent;
824
+ }
825
+
826
+ /**
827
+ * Rolls in one prompt: a step that makes orders waiting for their roll (a craft, a buy, a mission left, a grant or a
828
+ * contest prize, a lot won) asks for each in the same transaction, and a throwaway key it funds then settles each (the
829
+ * key job rule: funded with each settle's fees and what it may open or mint, `loot.settleRent`). A dry run finds them:
830
+ * the accounts the step writes that come out as orders waiting (a limit can hold a round back, so only what it would
831
+ * make). A box with nothing in stock refuses the ask, and with it the step (`SoldOut`): nothing is sent, and the
832
+ * player is told. When the step and its asks would not land together for any other reason (a missing prize vault,
833
+ * too big a transaction), the step goes alone and its rolls wait in `claims()`. Returns { signature, rolls }.
834
+ */
835
+ async function sendRolling(instructions) {
836
+ const watch = [
837
+ ...new Set(
838
+ instructions
839
+ .filter(ix => ix.programId === loot.LOOT_PROGRAM)
840
+ .flatMap(ix => ix.keys.filter(k => k.isWritable && !k.isSigner).map(k => k.pubkey)),
841
+ ),
842
+ ];
843
+ const after = (await dryRun(wallet.address, instructions, watch)).accounts;
844
+ const waiting = watch.flatMap((address, i) => {
845
+ const a = after[i];
846
+ if (a?.owner !== loot.LOOT_PROGRAM || loot.accountKind(a.data) !== 'Order') return [];
847
+ const order = loot.decode('Order', a.data);
848
+ return order.status === 'Waiting' && order.player === wallet.address ? [[address, order]] : [];
849
+ });
850
+ if (!waiting.length) return { signature: await send(instructions), rolls: [] };
851
+ const asks = [];
852
+ const funds = [];
853
+ const burners = [];
854
+ for (const [orderAddress, order] of waiting) {
855
+ const box = await read('LootBox', order.loot_box);
856
+ // Each order its own key, named by it (it can be made again after a reload, `recover`): its settle's fee, a spare
857
+ // for its parts and its give-back, and what its settle may open or mint. The ask names it as the order's settler.
858
+ const { key, fund } = await keyFor(
859
+ SIGNATURE_FEE * 3n + loot.settleRent(box, Number(order.opens ?? 1), await itemsOf(box)),
860
+ await jobKey(orderAddress),
861
+ );
862
+ asks.push(...(await asksFor(wallet.address, orderAddress, order, key.address)));
863
+ funds.push(fund);
864
+ burners.push(key);
865
+ }
866
+ const all = [...instructions, ...asks, ...funds];
867
+ const tried = await dryRun(wallet.address, all);
868
+ if (tried.error?.name === 'SoldOut')
869
+ throw Object.assign(
870
+ new Error(
871
+ 'The box this rolls has nothing left to give right now (SoldOut): nothing was sent. Try again once it is restocked.',
872
+ ),
873
+ { code: tried.error.code, name: 'SoldOut' },
874
+ );
875
+ // Too heavy to be answered: a step sent alone would pay for a roll that can only fail.
876
+ if (tried.error?.name === 'PackTooLarge')
877
+ throw Object.assign(
878
+ new Error(
879
+ "The box this rolls is too heavy for the oracle's answer as it stands (PackTooLarge): nothing was sent.",
880
+ ),
881
+ { code: tried.error.code, name: 'PackTooLarge' },
882
+ );
883
+ if (!tried.ok) return { signature: await send(instructions), rolls: [] };
884
+ const signature = await send(all);
885
+ const rolls = [];
886
+ for (const [k, [orderAddress]] of waiting.entries())
887
+ rolls.push(await collect(orderAddress, { burner: burners[k], opened: true }));
888
+ return { signature, rolls };
889
+ }
475
890
 
476
891
  /**
477
892
  * Waits for an order's roll (the oracle answers a moment after the open), then settles it. With `burner` (the
478
893
  * 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
894
+ * 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
895
  * won that the player puts back into their stash in place of taking them (one whose collection now refuses to let it
481
896
  * move); the player signs that settle.
482
897
  */
483
898
  async function collect(orderAddress, options = {}) {
484
- only(options, ['timeoutMs', 'burner', 'putBack', 'opened', 'landed'], 'collect');
899
+ only(options, ['timeoutMs', 'burner', 'putBack', 'opened', 'landed', 'keepBurner'], 'collect');
485
900
  try {
486
901
  return await collectOnce(orderAddress, options);
487
902
  } catch (e) {
@@ -490,18 +905,17 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
490
905
  // needs no burner: the player signs that settle.
491
906
  const { burner } = options;
492
907
  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);
499
- if (!(await client.account(orderAddress))) return { order: orderAddress, settled: 'elsewhere', says: 'The order was settled already.' };
908
+ await emptyKey(burner);
909
+ if (!(await client.account(orderAddress)))
910
+ return { order: orderAddress, settled: 'elsewhere', says: 'The order was settled already.' };
500
911
  throw e;
501
912
  }
502
913
  }
503
914
 
504
- async function collectOnce(orderAddress, { timeoutMs = 60_000, burner = null, putBack = [], opened = false, landed = null } = {}) {
915
+ async function collectOnce(
916
+ orderAddress,
917
+ { timeoutMs = 60_000, burner = null, putBack = [], opened = false, landed = null, keepBurner = false } = {},
918
+ ) {
505
919
  const started = Date.now();
506
920
  // `landed`: the slot one's own open landed in; the order is read from a node that has seen it.
507
921
  const current = async () => {
@@ -516,8 +930,8 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
516
930
  order = await current();
517
931
  }
518
932
  // 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())) {
933
+ // its player or the project's keys ask, here the player.
934
+ if (order && loot.rollAskable(order, await slotNow(), await chainNow())) {
521
935
  await askRoll(orderAddress);
522
936
  order = await current();
523
937
  }
@@ -525,14 +939,31 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
525
939
  await sleep(1500);
526
940
  order = await current();
527
941
  }
942
+ // A free roll whose answer found nothing at all in stock waits again: for a restock, or for its player to end it.
943
+ if (order?.status === 'Waiting') {
944
+ throw new Error(
945
+ "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.",
946
+ );
947
+ }
528
948
  if (!order) {
529
- if (burner) await sendAs(burner, [giveBack(burner)]);
949
+ if (burner && !keepBurner) await emptyKey(burner);
530
950
  // One's own open still not seen after that: say so plainly, never that it was settled (claims() lists it).
531
- 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).' };
951
+ if (opened)
952
+ return {
953
+ order: orderAddress,
954
+ settled: null,
955
+ says: 'The network doesn’t show this order yet: collect it again in a moment (claims() lists it).',
956
+ };
532
957
  return { order: orderAddress, settled: 'elsewhere', says: 'The order was settled already.' };
533
958
  }
534
- if (order.status === 'Pending') throw new Error('The oracle has not rolled yet: try collecting again in a minute (an order never rolled refunds after about 10 minutes).');
535
- if (order.status === 'Asked') throw new Error('The oracle has not rolled yet: try collecting again in a minute (a roll never answered is asked again after about 10 minutes).');
959
+ if (order.status === 'Pending')
960
+ throw new Error(
961
+ 'The oracle has not rolled yet: try collecting again in a minute (an order never rolled refunds after about 10 minutes).',
962
+ );
963
+ if (order.status === 'Asked')
964
+ throw new Error(
965
+ 'The oracle has not rolled yet: try collecting again in a minute (a roll never answered is asked again after about 10 minutes).',
966
+ );
536
967
  if (order.status === 'Failed') throw new Error('The roll failed (the box changed or ran out): refund the order.');
537
968
  // An order closes only in a later slot than the one it was opened in.
538
969
  for (let i = 0; i < 20 && client.call; i++) {
@@ -544,64 +975,157 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
544
975
  // What the roll set aside: each item it gave (its kind and collection) and each NFT's kind (a compressed one
545
976
  // proven first).
546
977
  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));
978
+ for (const won of order.won) {
979
+ const id = won.Item?.item ?? won.Compressed?.item;
980
+ if (id !== undefined && !(id in items)) items[id] = await read('Item', loot.pda.item(project, id));
981
+ }
982
+ // A compressed prize is minted into the tree its box names (any of the project's with room), the next NFT it mints.
983
+ const treeOf = async () => {
984
+ if (!order.won.some(w => w.Compressed)) return {};
985
+ const box = await read('LootBox', order.loot_box);
986
+ const tree = box.entries.flatMap(e => e.prizes).find(p => p.Compressed)?.Compressed.tree;
987
+ const config = (await client.account(loot.pda.treeConfig(tree)))?.data;
988
+ // An item's collection without Bubblegum's plugin mints nothing: the prizes after it take its place in the tree.
989
+ const unminted = [];
990
+ for (const c of new Set(
991
+ order.won
992
+ .filter(w => w.Compressed)
993
+ .map(w => items[w.Compressed.item]?.collection)
994
+ .filter(Boolean),
995
+ )) {
996
+ const data = (await client.account(c))?.data;
997
+ if (!data || !parseCoreCollection(data).bubblegum) unminted.push(c);
998
+ }
999
+ return {
1000
+ tree,
1001
+ unminted,
1002
+ treeMinted: config ? config.subarray(80, 88).reduce((n, b, i) => n | (BigInt(b) << BigInt(8 * i)), 0n) : 0n,
1003
+ };
1004
+ };
548
1005
  const holders = await holdersOf(order.shares);
549
1006
  // Given by its fate to a wallet that is an account of the loot program, the NFT goes back to the player.
550
1007
  const gives = order.held ? order.fate?.then?.Give?.[0] : undefined;
551
1008
  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) => {
1009
+ /** The settle's part up to `upto` (`loot.SETTLE_LAST`: all that is left; 0: the split alone) as `signer` sends it,
1010
+ * and what goes before it: each compressed NFT's proof, a fresh token account. */
1011
+ const settleBy = async (signer, upto = loot.SETTLE_LAST) => {
554
1012
  const nfts = {};
555
1013
  const proving = [];
556
- for (const won of order.won) {
1014
+ const end = Math.min(order.won.length, upto);
1015
+ for (const won of order.won.slice(Number(order.settled ?? 0), end)) {
557
1016
  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.
559
- const { before, hasAuthority: _, ...kind } = await nftOf(won.Nft.nft, signer, { to: order.player, payer: wallet.address });
1017
+ // A fresh token account a Token Metadata NFT needs (its holder's own can't take it) is the wallet's to open: a
1018
+ // settle with anything before it is the wallet's to send.
1019
+ const {
1020
+ before,
1021
+ hasAuthority: _,
1022
+ ...kind
1023
+ } = await nftOf(won.Nft.nft, signer, { to: order.player, payer: wallet.address });
560
1024
  nfts[won.Nft.nft] = kind;
561
1025
  proving.push(...before);
562
1026
  }
563
1027
  }
564
1028
  // 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 });
567
- return { ix, nfts, before: [...subjectProof, ...proving].filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM) };
1029
+ const { before: subjectProof, ...subject } =
1030
+ order.subject && order.subject !== NOBODY ? await nftOf(order.subject, signer) : { before: [] };
1031
+ const ix = loot.settleIx({
1032
+ signer,
1033
+ orderAddress,
1034
+ order,
1035
+ projectState: g,
1036
+ items,
1037
+ nfts,
1038
+ putBack,
1039
+ subject,
1040
+ handBack,
1041
+ holders,
1042
+ programs,
1043
+ upto,
1044
+ ...(await treeOf()),
1045
+ });
1046
+ return { ix, nfts, before: [...subjectProof, ...proving].filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM) };
568
1047
  };
1048
+ // An order whose settle doesn't fit one transaction (the instruction trace, 64 accounts, 1,232 bytes) settles in
1049
+ // parts, each its own transaction, from where the last stopped. The first splits the payment, its shares' accounts
1050
+ // with it (alone, `upto` 0, when not even one prize fits beside them); parts that hand prizes over (`upto` where
1051
+ // each stops), as many as the trace holds (`settlePartEnd`) and fewer until they fit; then a last part
1052
+ // (`SETTLE_LAST`) with the NFT rolled on. The burner sends each, paying what each mints with what the open funded it
1053
+ // with; the player's wallet, a part it can't.
1054
+ const signatures = [];
1055
+ // A key the order doesn't name can't settle it (`Order::settler`): then the player signs.
1056
+ const by = burner && !putBack.length && order.settler === burner.address ? burner.address : wallet.address;
1057
+ const fits = async instructions =>
1058
+ Boolean(compileFitting({ payer: by, blockhash: NOBODY, instructions, tables: await standing() })) &&
1059
+ transactionLocks(by, instructions) <= 64;
1060
+ const part = async end => [computeUnitLimit(loot.settleComputeUnits(order)), (await settleBy(by, end)).ix];
1061
+ const sendPart = async end =>
1062
+ signatures.push(by === wallet.address ? await send(await part(end)) : await sendAs(burner, await part(end)));
1063
+ for (;;) {
1064
+ const from = Number(order.settled ?? 0);
1065
+ if (from >= order.won.length) break;
1066
+ let end = loot.settlePartEnd(order, from, items);
1067
+ if (end >= order.won.length && (await fits(await part(loot.SETTLE_LAST)))) break;
1068
+ while (end > from + 1 && !(await fits(await part(end)))) end--;
1069
+ await sendPart(Number(order.drew ?? 0) > 0 && !(await fits(await part(end))) ? 0 : end);
1070
+ order = await current();
1071
+ if (!order) return { order: orderAddress, settled: 'elsewhere', says: 'The order was settled already.' };
1072
+ }
569
1073
  // Room for every move the shape makes (`loot.settleComputeUnits`), and for a settle of several NFTs the bigger heap
570
1074
  // the program's allocator uses (`loot.settleHeapFrame`).
571
1075
  const heap = loot.settleHeapFrame(order);
572
1076
  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 {
1077
+ // The settle opens or mints what the order won (a collectible, a token account, a Sheet, the fate's writes), paid by
1078
+ // whoever signs it: the burner, with what the open funded it with (`loot.settleRent`). It settles alone unless the
1079
+ // settle needs something first (a proof, a fresh token account) or doesn't fit a legacy transaction (a lookup
1080
+ // 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
1081
+ // wallet settles (one more prompt). An NFT put back into its stash (one its collection no longer lets move) is the
1082
+ // player's own choice: they sign.
1083
+ let signature = null;
1084
+ let emptied = false;
1085
+ const byBurner =
1086
+ burner && !putBack.length && order.settler === burner.address ? await settleBy(burner.address) : null;
1087
+ const alone = byBurner && !byBurner.before.length ? [...budget, byBurner.ix] : null;
1088
+ const legacy = instructions => Boolean(compileFitting({ payer: burner.address, blockhash: NOBODY, instructions }));
1089
+ const tried = alone && legacy(alone) ? await dryRun(burner.address, alone, [burner.address]) : null;
1090
+ if (tried?.ok) {
1091
+ // Its last settle gives back all the burner holds after it, in the same transaction, as the dry run says it will
1092
+ // hold (its fee and what the settle opened taken); when that doesn't land as one, the burner empties itself after.
1093
+ const left = BigInt(tried.accounts[0]?.lamports ?? 0);
1094
+ const back = !keepBurner && left > 0n ? [...alone, transferLamports(burner.address, wallet.address, left)] : null;
1095
+ if (back && legacy(back) && (await dryRun(burner.address, back)).ok)
1096
+ signature = await sendAs(burner, back).catch(() => null);
1097
+ emptied = Boolean(signature);
1098
+ signature ??= await sendAs(burner, alone).catch(() => null);
1099
+ }
1100
+ if (!signature) {
587
1101
  // The wallet's settle: as a v0 transaction with a lookup table when it doesn't fit a legacy one (`send`).
588
1102
  const { ix, before } = await settleBy(wallet.address);
589
1103
  signature = await send([...budget, ...before, ix]);
590
- if (burner) await sendAs(burner, [giveBack(burner)]);
591
1104
  }
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) } : {}) };
1105
+ // The burner gives back what it holds after its last settle (a purchase of several orders: after the last order's).
1106
+ if (burner && !keepBurner && !emptied) await emptyKey(burner);
1107
+ else if (burner && emptied) keys?.forget(burner.name);
1108
+ const assets = wonAssets(order).map(a => (putBack.includes(a) ? null : a));
1109
+ return {
1110
+ order: orderAddress,
1111
+ won: order.won,
1112
+ assets,
1113
+ settled: signature,
1114
+ ...(signatures.length ? { parts: [...signatures, signature] } : {}),
1115
+ ...(assets.find(Boolean) ? { asset: assets.find(Boolean) } : {}),
1116
+ };
594
1117
  }
595
1118
 
596
1119
  /**
597
1120
  * 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.
1121
+ * over; null for tokens. A normal item is the project's backend's to deliver.
599
1122
  */
600
- function wonAssets(orderAddress, order) {
601
- return order.won.map((won, k) => {
1123
+ function wonAssets(order) {
1124
+ return order.won.map(won => {
602
1125
  if (won.Item) return loot.pda.itemAsset(loot.pda.item(project, won.Item.item), won.Item.serial);
603
1126
  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));
1127
+ // A compressed NFT's asset id is the tree's count when it was minted: read from its settle's receipt or the
1128
+ // player's assets (`claims`), not from the order.
605
1129
  return null;
606
1130
  });
607
1131
  }
@@ -609,19 +1133,69 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
609
1133
  /** A nonce for an order no other order of the player's is at. */
610
1134
  const freshNonce = () => BigInt(Date.now()) * 1000n + BigInt(Math.floor(Math.random() * 1000));
611
1135
 
1136
+ /** Each box of `ids`, decoded (null for one never written), by id. */
1137
+ async function boxesOf(ids) {
1138
+ const out = {};
1139
+ for (const id of new Set(ids)) out[id] = await readMaybe('LootBox', loot.pda.box(project, id));
1140
+ return out;
1141
+ }
1142
+
1143
+ /** Each item a box's prizes name, decoded (null for one never written), by id: a fresh one's tables go with its roll. */
1144
+ async function itemsOf(box) {
1145
+ const out = {};
1146
+ for (const p of box.entries.flatMap(e => e.prizes)) {
1147
+ const id = p.Item?.[0] ?? p.Compressed?.item;
1148
+ if (id !== undefined && !(id in out)) out[id] = await readMaybe('Item', loot.pda.item(project, id));
1149
+ }
1150
+ return out;
1151
+ }
1152
+
612
1153
  /** The mint of each pool a box's tokens come from (null for a pool not opened). */
613
1154
  async function poolMintsOf(box) {
614
- const prizePools = [...new Set(box.entries.flatMap((e) => e.prizes).map((p) => p.Tokens?.from.Pool?.[0]).filter((p) => p !== undefined))];
615
- const pools = prizePools.length ? await client.accounts(prizePools.map((p) => loot.pda.pool(project, p))) : [];
616
- return Object.fromEntries(prizePools.map((p, i) => [p, pools[i] ? encodeBase58(pools[i].data.subarray(0, 32)) : null]));
1155
+ const prizePools = [
1156
+ ...new Set(
1157
+ box.entries
1158
+ .flatMap(e => e.prizes)
1159
+ .map(p => p.Tokens?.from.Pool?.[0])
1160
+ .filter(p => p !== undefined),
1161
+ ),
1162
+ ];
1163
+ const pools = prizePools.length ? await client.accounts(prizePools.map(p => loot.pda.pool(project, p))) : [];
1164
+ return Object.fromEntries(
1165
+ prizePools.map((p, i) => [p, pools[i] ? encodeBase58(pools[i].data.subarray(0, 32)) : null]),
1166
+ );
617
1167
  }
618
1168
 
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);
1169
+ /** The instructions that ask the oracle for the roll of `order` (at `orderAddress`), `payer` paying it. Each prize
1170
+ * mint's token program is read from the mint when the app wasn't given it (`programs`): the ask opens the project's
1171
+ * vault of it. */
1172
+ async function asksFor(payer, orderAddress, order, settler) {
622
1173
  const box = await read('LootBox', order.loot_box);
623
1174
  const poolMints = await poolMintsOf(box);
624
- return send([computeUnitLimit(400_000), ...loot.askRollIx({ payer: wallet.address, orderAddress, order, box, poolMints, programs })]);
1175
+ const mints = [
1176
+ ...Object.values(poolMints),
1177
+ ...box.entries.flatMap(e => e.prizes.map(p => p.Tokens?.from.Mint?.[0])),
1178
+ ].filter(x => x && !programs[x]);
1179
+ const owners = mints.length ? await client.accounts(mints) : [];
1180
+ const known = { ...programs, ...Object.fromEntries(mints.map((x, i) => [x, owners[i]?.owner ?? TOKEN_PROGRAM])) };
1181
+ return loot.askRollIx({
1182
+ payer,
1183
+ orderAddress,
1184
+ order,
1185
+ box,
1186
+ settler,
1187
+ poolMints,
1188
+ items: await itemsOf(box),
1189
+ programs: known,
1190
+ });
1191
+ }
1192
+
1193
+ /** Asks the oracle for a roll a mission, a recipe or a prize made (`Waiting`): the player pays the oracle. */
1194
+ async function askRoll(orderAddress) {
1195
+ return send([
1196
+ computeUnitLimit(400_000),
1197
+ ...(await asksFor(wallet.address, orderAddress, await read('Order', orderAddress))),
1198
+ ]);
625
1199
  }
626
1200
 
627
1201
  /**
@@ -631,11 +1205,18 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
631
1205
  * the wallet is asked.
632
1206
  */
633
1207
  function boundOf(box, name, { maxPrice, listing } = {}, key) {
634
- if (listing && key !== undefined && listing.key !== undefined && listing.key !== key) throw new Error(`That listing is “${listing.key}”, not “${key}”.`);
1208
+ if (listing && key !== undefined && listing.key !== undefined && listing.key !== key)
1209
+ throw new Error(`That listing is “${listing.key}”, not “${key}”.`);
635
1210
  const given = maxPrice ?? listing?.priceUnits;
636
- if (given === undefined || given === null) throw new Error(`Say the most the player agreed to pay for “${name}”: pass the listing shop() or boxes() showed (listing), or maxPrice in base units. The price read now is never the bound.`);
1211
+ if (given === undefined || given === null)
1212
+ throw new Error(
1213
+ `Say the most the player agreed to pay for “${name}”: pass the listing shop() or boxes() showed (listing), or maxPrice in base units. The price read now is never the bound.`,
1214
+ );
637
1215
  const bound = BigInt(given);
638
- if (BigInt(box.price) > bound) throw new Error(`The price of “${name}” is now ${show(box.price, decimalsOf(box.currencyKey))}, more than the ${show(bound, decimalsOf(box.currencyKey))} the player was shown: show them the new price first.`);
1216
+ if (BigInt(box.price) > bound)
1217
+ throw new Error(
1218
+ `The price of “${name}” is now ${show(box.price, decimalsOf(box.currencyKey))}, more than the ${show(bound, decimalsOf(box.currencyKey))} the player was shown: show them the new price first.`,
1219
+ );
639
1220
  return bound;
640
1221
  }
641
1222
 
@@ -645,48 +1226,100 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
645
1226
  * was given, or what is left of it once the options it takes are read; `takes`: those it takes, said in the error.
646
1227
  */
647
1228
  function only(options, takes, method) {
648
- const unknown = Object.keys(options ?? {}).filter((k) => !takes.includes(k) && options[k] !== undefined);
1229
+ const unknown = Object.keys(options ?? {}).filter(k => !takes.includes(k) && options[k] !== undefined);
649
1230
  if (!unknown.length) return;
650
- const hint = unknown.includes('choice') && takes.includes('chosenRecipient') ? ' (a buyer’s choice is `chosenRecipient`)' : '';
651
- throw new Error(`${method} doesn’t take ${unknown.map((k) => `“${k}”`).join(', ')}${hint}. It takes: ${takes.join(', ')}.`);
1231
+ const hint =
1232
+ unknown.includes('choice') && takes.includes('chosenRecipient') ? ' (a buyer’s choice is `chosenRecipient`)' : '';
1233
+ throw new Error(
1234
+ `${method} doesn’t take ${unknown.map(k => `“${k}”`).join(', ')}${hint}. It takes: ${takes.join(', ')}.`,
1235
+ );
652
1236
  }
653
1237
  /** 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'];
1238
+ const BOX_OPTIONS = ['shown', 'listProof', 'maxPrice', 'listing', 'chosenRecipient', 'on', 'oneSignature', 'count'];
655
1239
 
656
1240
  /**
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.
1241
+ * Buys `count` of a box that needs no roll (a shop item, a bundle, a fixed pack: one entry, nothing random, no NFT
1242
+ * rolled on) in one transaction: the price is split and every prize given at once, once for each draw of each, with
1243
+ * no order to settle. A roll the box gives (`Prize::Roll`) is asked for in the same transaction and settled by a
1244
+ * throwaway key after (`sendRolling`: still one prompt). `shown` (an NFT for its gate), `listProof` (for a list gate),
1245
+ * `chosenRecipient` (the wallet, or collectible, a share the box lets the buyer name goes to). Returns { signature,
1246
+ * assets }: for each prize given, the collectible minted or the NFT given (null for tokens or a normal item), and for
1247
+ * a roll the order it is made in; with `rolls`, what each roll gave. `orNull`: a pack (more than one draw in all)
1248
+ * whose purchase doesn't fit one legacy transaction, or the instruction trace, returns null, sending nothing.
662
1249
  */
663
- async function buyBox(boxId, part, { shown = null, listProof, maxPrice, listing, chosenRecipient = null } = {}) {
1250
+ async function buyBox(
1251
+ boxId,
1252
+ part,
1253
+ { shown = null, listProof, maxPrice, listing, chosenRecipient = null, count = 1, orNull = false } = {},
1254
+ ) {
664
1255
  const g = await projectNow();
665
1256
  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);
1257
+ if (!Number.isInteger(count) || count < 1) throw new Error('Buy a whole number, one at least (count).');
1258
+ const bound =
1259
+ boundOf({ ...box, currencyKey: part?.currency }, part?.name ?? `box ${boxId}`, { maxPrice, listing }, part?.key) *
1260
+ BigInt(count);
667
1261
  shown ??= await passing(box.gate, 'buy this');
668
1262
  listProof = listProofOf(box.gate, part?.gate?.list, listProof);
669
1263
  const mint = box.currency;
670
1264
  const [entry] = box.entries;
671
1265
  const given = await prizeInputs(entry.prizes);
672
1266
  const nonce = freshNonce();
673
- const choosesHolder = box.shares.some((s) => s.to.Chosen && s.to.Chosen.item != null);
1267
+ const choosesHolder = box.shares.some(s => s.to.Chosen && s.to.Chosen.item != null);
674
1268
  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 });
1269
+ const ix = loot.buyIx({
1270
+ player: wallet.address,
1271
+ project,
1272
+ box,
1273
+ maxPrice: bound,
1274
+ count,
1275
+ treasury: g.treasury,
1276
+ chosenRecipient,
1277
+ chosen: choosesHolder ? chosenRecipient : null,
1278
+ nonce,
1279
+ shown,
1280
+ listProof,
1281
+ items: given.items,
1282
+ poolMints: given.poolMints,
1283
+ nfts: given.nfts,
1284
+ holders,
1285
+ programs,
1286
+ });
676
1287
  const made = {};
677
- const assets = entry.prizes.map((prize, k) => {
1288
+ const gives = loot.buyGives(box, count);
1289
+ const assets = gives.map(({ prize, k }) => {
678
1290
  if (prize.Item) {
679
1291
  const id = prize.Item[0];
680
1292
  const item = given.items[id];
681
1293
  made[id] = (made[id] ?? 0) + 1;
682
- return item?.kind === 'Collectible' ? loot.pda.itemAsset(loot.pda.item(project, id), Number(item.issued) + made[id]) : null;
1294
+ return item?.kind === 'Collectible'
1295
+ ? loot.pda.itemAsset(loot.pda.item(project, id), Number(item.issued) + made[id])
1296
+ : null;
683
1297
  }
684
1298
  if (prize.Nft) return prize.Nft.nft;
685
1299
  if (prize.Roll) return loot.pda.order(project, wallet.address, loot.prizeRollNonce(nonce + BigInt(k)));
686
1300
  return null;
687
1301
  });
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) } : {}) };
1302
+ const rollsMade = gives.filter(x => x.prize.Roll).length;
1303
+ const instructions = withSolWrap(wallet.address, mint, bound, [
1304
+ computeUnitLimit(rollsMade || gives.length > 2 ? 1_400_000 : 600_000),
1305
+ ...given.before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
1306
+ ix,
1307
+ ]);
1308
+ if (orNull && gives.length > 1) {
1309
+ const fits =
1310
+ compileFitting({ payer: wallet.address, blockhash: NOBODY, instructions }) &&
1311
+ transactionLocks(wallet.address, instructions) <= 64;
1312
+ if (!fits || !(await dryRun(wallet.address, instructions)).ok) return null;
1313
+ }
1314
+ const { signature, rolls } = rollsMade
1315
+ ? await sendRolling(instructions)
1316
+ : { signature: await send(instructions), rolls: [] };
1317
+ return {
1318
+ signature,
1319
+ assets,
1320
+ ...(assets.find(Boolean) ? { asset: assets.find(Boolean) } : {}),
1321
+ ...(rolls.length ? { rolls } : {}),
1322
+ };
690
1323
  }
691
1324
 
692
1325
  /**
@@ -696,15 +1329,36 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
696
1329
  * referrer, a plot). `on`: the NFT a box that rolls on one takes (an NFT whose price it is, a sword to enchant).
697
1330
  * `listProof`: for a list gate.
698
1331
  */
699
- async function open(boxId, part, { shown = null, on = null, maxPrice, listing, oneSignature = true, chosenRecipient = null, listProof } = {}) {
1332
+ async function open(
1333
+ boxId,
1334
+ part,
1335
+ {
1336
+ shown = null,
1337
+ on = null,
1338
+ maxPrice,
1339
+ listing,
1340
+ oneSignature = true,
1341
+ chosenRecipient = null,
1342
+ listProof,
1343
+ count = 1,
1344
+ } = {},
1345
+ ) {
700
1346
  const g = await projectNow();
701
1347
  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);
1348
+ // `count` packs in one purchase: one wallet prompt, one oracle answer, each at the price the one before it left.
1349
+ if (!Number.isInteger(count) || count < 1) throw new Error('Open a whole number of packs, one at least (count).');
1350
+ const bound =
1351
+ boundOf({ ...box, currencyKey: part?.currency }, part?.name ?? `box ${boxId}`, { maxPrice, listing }, part?.key) *
1352
+ BigInt(count);
1353
+ // Each item a prize names: a fresh one's tables go with the purchase.
1354
+ const items = await itemsOf(box);
703
1355
  // A box that rolls on an NFT takes the one the player brings (`on`), whatever its kind.
704
1356
  if (box.subject && !on) throw new Error('This box rolls on an NFT: say which (on).');
705
1357
  const brought = box.subject ? await nftOf(on) : { before: [] };
706
1358
  const { before: proving, hasAuthority, ...kind } = brought;
707
- const subject = box.subject ? { asset: on, hasAuthority, ...kind, collection: kind.collection ?? (await collectionOf(on)) ?? MPL_CORE } : undefined;
1359
+ const subject = box.subject
1360
+ ? { asset: on, hasAuthority, ...kind, collection: kind.collection ?? (await collectionOf(on)) ?? MPL_CORE }
1361
+ : undefined;
708
1362
  if (!box.subject) shown ??= await passing(box.gate, 'buy this');
709
1363
  listProof = listProofOf(box.gate, part?.gate?.list, listProof);
710
1364
  const mint = box.currency;
@@ -712,57 +1366,123 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
712
1366
  const nonce = box.subject && loot.boxHolds(box) ? loot.nonceOn(on) : freshNonce();
713
1367
  // Each pool's mint, and each mint tokens are minted from: what is won waits in the project's vault for its mint.
714
1368
  const poolMints = await poolMintsOf(box);
715
- const minted = box.entries.flatMap((e) => e.prizes).map((p) => p.Tokens?.from.Mint?.[0]).filter(Boolean);
1369
+ const minted = box.entries
1370
+ .flatMap(e => e.prizes)
1371
+ .map(p => p.Tokens?.from.Mint?.[0])
1372
+ .filter(Boolean);
716
1373
  const prizeMints = [...new Set([...Object.values(poolMints).filter(Boolean), ...minted])];
717
- const choosesHolder = box.shares.some((s) => s.to.Chosen && s.to.Chosen.item != null);
1374
+ const choosesHolder = box.shares.some(s => s.to.Chosen && s.to.Chosen.item != null);
718
1375
  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 });
720
- const before = [...proving, createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, mint, programOf(mint))];
1376
+ // A purchase of more opens than one order takes (`loot.opensAnOrder`: what one oracle answer draws, and an order's
1377
+ // rent and fee within 0.01 SOL) is several orders, each its own answer, all in this one transaction: one prompt.
1378
+ const each = loot.opensAnOrder(box, { items });
1379
+ const orders = [];
1380
+ for (let left = count, k = 0n; left > 0; left -= each, k++) {
1381
+ const n = Math.min(each, left);
1382
+ // Its key, named by the order as its settler (made again after a reload: `recover`), when the settle needs no prompt.
1383
+ const settlerKey = oneSignature
1384
+ ? ((await jobKey(loot.pda.order(project, wallet.address, nonce + k))) ?? (await freshKey()))
1385
+ : null;
1386
+ orders.push({
1387
+ nonce: nonce + k,
1388
+ n,
1389
+ settlerKey,
1390
+ ix: loot.openBoxIx({
1391
+ settler: settlerKey?.address,
1392
+ player: wallet.address,
1393
+ project,
1394
+ box,
1395
+ nonce: nonce + k,
1396
+ maxPrice: (bound / BigInt(count)) * BigInt(n),
1397
+ count: n,
1398
+ items,
1399
+ shown,
1400
+ listProof,
1401
+ subject,
1402
+ chosenRecipient,
1403
+ chosen,
1404
+ poolMints,
1405
+ programs,
1406
+ }),
1407
+ });
1408
+ }
1409
+ const before = [
1410
+ ...proving,
1411
+ createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, mint, programOf(mint)),
1412
+ ];
721
1413
  // (A compressed NFT's proof brings its own compute limit, the transaction's only one.)
722
- let burner = null;
723
1414
  let solPrize = false;
724
1415
  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();
1416
+ // The player opens here every account the settle will pay into (their prize's, and each wallet a share of the
1417
+ // sale pays: a referrer, a plot's holder, the treasury), so the burner's settle opens none of them.
728
1418
  for (const prizeMint of prizeMints) {
729
1419
  solPrize ||= prizeMint === NATIVE_MINT;
730
- before.push(createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, prizeMint, programOf(prizeMint)));
1420
+ before.push(
1421
+ createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, prizeMint, programOf(prizeMint)),
1422
+ );
731
1423
  }
732
1424
  const shares = loot.chosenShares(box.shares, chosenRecipient);
733
1425
  const holders = await holdersOf(shares);
734
- const payees = shares.flatMap((s) => (s.to === 'Treasury' ? [g.treasury] : s.to.Wallet ? [s.to.Wallet[0]] : s.to.Holder && holders[s.to.Holder[0]] ? [holders[s.to.Holder[0]]] : []));
1426
+ const payees = shares.flatMap(s =>
1427
+ s.to === 'Treasury'
1428
+ ? [g.treasury]
1429
+ : s.to.Wallet
1430
+ ? [s.to.Wallet[0]]
1431
+ : s.to.Holder && holders[s.to.Holder[0]]
1432
+ ? [holders[s.to.Holder[0]]]
1433
+ : [],
1434
+ );
735
1435
  const paid = new Set([g.treasury, ...payees]);
736
- for (const payee of paid) before.push(createAssociatedTokenAccountIdempotent(wallet.address, payee, mint, programOf(mint)));
1436
+ for (const payee of paid)
1437
+ before.push(createAssociatedTokenAccountIdempotent(wallet.address, payee, mint, programOf(mint)));
737
1438
  // A sale that pays the player themselves (the owner buying from their own shop) pays into their wrapped-SOL
738
1439
  // account: it stays open for the settle too.
739
1440
  solPrize ||= mint === NATIVE_MINT && paid.has(wallet.address);
740
1441
  }
741
- const after = burner ? [transferLamports(wallet.address, burner.address, BURNER_LAMPORTS)] : [];
1442
+ // Each order's burner, named by the order (made again after a reload: `recover`), holds the fee of each settle part
1443
+ // its order may take (`settleParts`) and of its give-back, and what its settle may mint or open (`settleRent`: the
1444
+ // order lends nothing); it gives back what it doesn't use.
1445
+ const addresses = orders.map(o => loot.pda.order(project, wallet.address, o.nonce));
1446
+ const burners = [];
1447
+ const after = [];
1448
+ if (oneSignature) {
1449
+ for (const o of orders) {
1450
+ const { key, fund } = await keyFor(
1451
+ SIGNATURE_FEE * BigInt(loot.settleParts(box, o.n) + 1) + loot.settleRent(box, o.n, items),
1452
+ o.settlerKey,
1453
+ );
1454
+ burners.push(key);
1455
+ after.push(fund);
1456
+ }
1457
+ }
742
1458
  // A SOL prize, or a share of the sale, lands in the player's wrapped-SOL account, which the prompt-less settle
743
1459
  // 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]);
745
- if (solPrize) instructions = instructions.filter((x) => !(x.programId === TOKEN_PROGRAM && x.data?.[0] === 9 && x.keys[0]?.pubkey === associatedTokenAddress(wallet.address, NATIVE_MINT)));
1460
+ let instructions = withSolWrap(wallet.address, mint, BigInt(box.price) * BigInt(count), [
1461
+ ...before,
1462
+ ...orders.map(o => o.ix),
1463
+ ...after,
1464
+ ]);
1465
+ if (solPrize)
1466
+ instructions = instructions.filter(
1467
+ x =>
1468
+ !(
1469
+ x.programId === TOKEN_PROGRAM &&
1470
+ x.data?.[0] === 9 &&
1471
+ x.keys[0]?.pubkey === associatedTokenAddress(wallet.address, NATIVE_MINT)
1472
+ ),
1473
+ );
746
1474
  const signature = await send(instructions, [], { keepWrapped: solPrize });
747
1475
  // The slot it landed in: the order is read from a node that has seen it (a load-balanced RPC's node lagging
748
1476
  // behind answers nothing for an order just opened).
749
1477
  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
- }
1478
+ return {
1479
+ signature,
1480
+ orderAddress: addresses[0],
1481
+ orderAddresses: addresses,
1482
+ burner: burners[0] ?? null,
1483
+ burners,
1484
+ landed,
1485
+ };
766
1486
  }
767
1487
 
768
1488
  /**
@@ -771,27 +1491,51 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
771
1491
  * program would refuse them too: TermsChanged).
772
1492
  */
773
1493
  async function shownListing(asset, listing, kind) {
774
- if (!listing) throw new Error('Say the terms the player was shown: pass the listing listing() or listings() showed (listing). The listing read now is never signed in its place.');
1494
+ if (!listing)
1495
+ throw new Error(
1496
+ 'Say the terms the player was shown: pass the listing listing() or listings() showed (listing). The listing read now is never signed in its place.',
1497
+ );
775
1498
  if (listing.asset !== asset) throw new Error(`That listing is of ${listing.asset}, not ${asset}.`);
776
- if (!listing.deal?.[kind]) throw new Error(kind === 'Sell' ? 'That listing is for rent, not for sale.' : 'That listing is for sale, not for rent.');
1499
+ if (!listing.deal?.[kind])
1500
+ throw new Error(
1501
+ kind === 'Sell' ? 'That listing is for rent, not for sale.' : 'That listing is for sale, not for rent.',
1502
+ );
777
1503
  const listed = await readMaybe('Listing', loot.pda.listing(project, asset));
778
1504
  if (!listed) throw new Error(`${asset} is not listed any more.`);
779
1505
  const [was, is] = [loot.listingTerms(listing), loot.listingTerms(listed)];
780
- 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.');
1506
+ if (was.some((b, i) => b !== is[i]))
1507
+ throw new Error(
1508
+ 'This listing has changed since the player was shown it: show them its terms as they are now first.',
1509
+ );
781
1510
  }
782
1511
 
783
- /** A box bought at once when it needs no roll (`buyBox`), or opened and collected. */
1512
+ /** A box bought at once when it needs no roll (`buyBox`: a shop item, a bundle, a fixed pack of any count), or
1513
+ * opened and collected: one that rolls, gives a fresh item (its traits are rolled), or rolls on an NFT. A fixed pack
1514
+ * too big for one transaction is opened instead (the oracle answers it like any box). */
784
1515
  async function buyOrOpen(boxId, part, options) {
785
1516
  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);
1517
+ if (!loot.boxRolls(box.entries) && !box.subject && !loot.boxFresh(box.entries, await itemsOf(box))) {
1518
+ const { on: _on, oneSignature: _one, ...buying } = options ?? {};
1519
+ const bought = await buyBox(boxId, part, { ...buying, orNull: true });
1520
+ if (bought) return bought;
789
1521
  }
790
- const { orderAddress, burner, landed } = await open(boxId, part, options);
791
- return collect(orderAddress, { burner, opened: true, landed });
1522
+ const { orderAddresses, burners, landed } = await open(boxId, part, options);
1523
+ if (orderAddresses.length === 1)
1524
+ return collect(orderAddresses[0], { burner: burners[0] ?? null, opened: true, landed });
1525
+ // Several orders of one purchase: each collected in turn by its own burner.
1526
+ const got = [];
1527
+ for (const [k, address] of orderAddresses.entries())
1528
+ got.push(await collect(address, { burner: burners[k] ?? null, opened: true, landed }));
1529
+ return {
1530
+ ...got.at(-1),
1531
+ orders: got,
1532
+ won: got.flatMap(g => g.won ?? []),
1533
+ assets: got.flatMap(g => g.assets ?? []),
1534
+ parts: got.flatMap(g => g.parts ?? [g.settled]),
1535
+ };
792
1536
  }
793
1537
 
794
- /** The current slot (an order's deadlines count in slots). */
1538
+ /** 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
1539
  const slotNow = async () => BigInt(await client.call('getSlot', [{ commitment: 'confirmed' }]));
796
1540
  /** The slots the SlotHashes sysvar holds now (a lookup table closes once its deactivation slot has left them). */
797
1541
  const recentSlots = async () => slotHashes((await client.account(SYSVAR_SLOT_HASHES))?.data);
@@ -803,6 +1547,34 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
803
1547
  return v >= 1n << 63n ? v - (1n << 64n) : v;
804
1548
  };
805
1549
 
1550
+ /** How many ends farm `f` has passed and not walked yet (`loot.endsToWalk`), and its decoded record: 0 for one that keeps none. */
1551
+ async function endsOf(f, farm) {
1552
+ if (!farm.keeps_ends) return { left: 0, ends: [] };
1553
+ const data = (await client.account(loot.pda.ends(loot.pda.farm(project, f.id))))?.data;
1554
+ const ends = data ? loot.decodeEnds(data) : [];
1555
+ return { left: loot.endsToWalk(farm, ends, await chainNow()), ends };
1556
+ }
1557
+
1558
+ /**
1559
+ * 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
1560
+ * ends to walk asks for all a transaction has, and one with more than that holds is refused in words, nothing signed.
1561
+ */
1562
+ async function walking(f, farm, instructions) {
1563
+ const { left } = await endsOf(f, farm);
1564
+ if (!left) return instructions;
1565
+ if (left > loot.MOVE_WALK_ROOM)
1566
+ throw Object.assign(
1567
+ new Error(
1568
+ `“${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).`,
1569
+ ),
1570
+ { name: 'FarmNeedsWalk' },
1571
+ );
1572
+ return [
1573
+ computeUnitLimit(loot.WALK_COMPUTE_UNITS),
1574
+ ...instructions.filter(x => !(x.programId === COMPUTE_BUDGET_PROGRAM && x.data[0] === 2)),
1575
+ ];
1576
+ }
1577
+
806
1578
  /**
807
1579
  * Refuses a stake a farm would refuse (`FarmOver`) before anything is signed: a farm with an end takes none at its end
808
1580
  * or after it, nor one at a tier whose lock would run past it. `f` is the spec's farm, `farm` the decoded one.
@@ -811,8 +1583,13 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
811
1583
  const at = await chainNow();
812
1584
  if (loot.farmOpenFor(farm, tier, at)) return;
813
1585
  const ends = new Date(Number(farm.ends_at) * 1000).toISOString();
814
- if (at >= BigInt(farm.ends_at)) throw new Error(`“${f.name ?? f.key}” ended at ${ends}: it takes no stake any more. Stakes in it can still leave and harvest.`);
815
- throw new Error(`“${f.name ?? f.key}” ends at ${ends}, and lock tier ${tier} would run past it: stake at a tier with a shorter lock.`);
1586
+ if (at >= BigInt(farm.ends_at))
1587
+ throw new Error(
1588
+ `“${f.name ?? f.key}” ended at ${ends}: it takes no stake any more. Stakes in it can still leave and harvest.`,
1589
+ );
1590
+ throw new Error(
1591
+ `“${f.name ?? f.key}” ends at ${ends}, and lock tier ${tier} would run past it: stake at a tier with a shorter lock.`,
1592
+ );
816
1593
  }
817
1594
 
818
1595
  /** Who holds each collectible a split's holder shares name now: { asset: holder }. */
@@ -823,19 +1600,246 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
823
1600
  }
824
1601
 
825
1602
  /**
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 }]).
1603
+ * A lottery and its rounds that have a record: the current one (null when nobody bought into it yet), and the two
1604
+ * before it while they wait for their draw or still pay out (the ask moves the lottery on, so a round drawing or
1605
+ * owing parts is an older one). Each { address, round } (`loot.decodeRound`).
829
1606
  */
830
1607
  async function roundOf(l) {
831
1608
  const address = loot.pda.lottery(project, l.id);
832
1609
  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 };
1610
+ const back = [1, 2]
1611
+ .filter(k => lottery.round >= k)
1612
+ .map(k => loot.pda.round(address, lottery.incarnation, lottery.round - k));
1613
+ const keys = [loot.currentRound(address, lottery), ...back];
1614
+ const found = await client.accounts(keys);
1615
+ const rounds = keys.map((key, i) => ({
1616
+ address: key,
1617
+ round: found[i]?.owner === loot.LOOT_PROGRAM ? loot.decodeRound(found[i].data) : null,
1618
+ }));
1619
+ return { address, lottery, current: rounds[0].round, rounds: rounds.filter(r => r.round) };
1620
+ }
1621
+
1622
+ /**
1623
+ * The tail a round's transfers need: each mint's transfer-hook accounts (its extra-account metas, read as every
1624
+ * hooked transfer of the client reads them), once each; none for a mint with no hook. A hook whose list names each
1625
+ * transfer can't be resolved ahead and is left out (the call then fails on it).
1626
+ */
1627
+ async function hookTail(mints) {
1628
+ const out = new Map();
1629
+ for (const mint of new Set(mints.filter(x => x && x !== NOBODY))) {
1630
+ const resolved = await resolveTransfers(client, mint).catch(() => null);
1631
+ for (const m of resolved?.extraAccounts ?? []) {
1632
+ const had = out.get(m.pubkey);
1633
+ out.set(m.pubkey, had ? { ...had, isWritable: had.isWritable || m.isWritable } : m);
1634
+ }
1635
+ }
1636
+ return [...out.values()];
1637
+ }
1638
+ /** The mints a round's ask and pulls move: its pot's, its payment's and each token run's. */
1639
+ const roundMints = (r, poolMints = {}) => [
1640
+ r.terms.mint,
1641
+ loot.paymentMint(r.terms),
1642
+ ...r.terms.places
1643
+ .map(p => p.prize?.Tokens?.from)
1644
+ .filter(Boolean)
1645
+ .map(from => (from.Pool ? poolMints[from.Pool[0]] : (from.Mint?.[0] ?? from.Vest?.[0]))),
1646
+ ];
1647
+
1648
+ /** Each pool a round's places give tokens from, mapped to its mint (one never opened: the economy's currency). */
1649
+ async function roundPoolMints(terms) {
1650
+ const out = {};
1651
+ for (const p of terms.places) {
1652
+ const pool = p.prize?.Tokens?.from.Pool?.[0];
1653
+ if (pool === undefined || pool in out) continue;
1654
+ const account = await client.account(loot.pda.pool(project, pool));
1655
+ out[pool] = account ? encodeBase58(account.data.subarray(0, 32)) : mintOf();
1656
+ }
1657
+ return out;
1658
+ }
1659
+
1660
+ /** The ask for a round's draw (`round` decoded, `key` its address; null for the current round with no record). A
1661
+ * round paid from a pot first moves in what the round before left it (`lottery.owing`): that round's record says
1662
+ * which pot (gone, any will do). */
1663
+ async function drawStep(l, lottery, key, round, signer) {
1664
+ const terms = round?.terms ?? lottery.terms;
1665
+ const poolMints = await roundPoolMints(terms);
1666
+ const extra = await hookTail(roundMints({ terms }, poolMints));
1667
+ const first = !round || round.status === 'Open';
1668
+ const owingAt =
1669
+ first && lottery?.owing && lottery.owing !== NOBODY && loot.potIdOf(terms) != null
1670
+ ? await client.account(lottery.owing)
1671
+ : null;
1672
+ const owing = owingAt?.owner === loot.LOOT_PROGRAM ? loot.decodeRound(owingAt.data) : null;
1673
+ // That round's answer says what it left the pot: until it lands (or the round is cancelled), this draw waits.
1674
+ if (owing?.status === 'Drawing')
1675
+ throw new Error(
1676
+ `The round of “${l.name ?? l.key}” before this one still waits for the oracle’s answer, and this round is paid from the same pot: its draw waits until that answer lands, or that round is cancelled (anyone may ask again after ${loot.STALE_SECONDS} seconds).`,
1677
+ );
1678
+ return [
1679
+ computeUnitLimit(600_000),
1680
+ loot.drawLotteryIx({
1681
+ payer: signer,
1682
+ project,
1683
+ id: l.id,
1684
+ lottery,
1685
+ round,
1686
+ roundAddress: key,
1687
+ owing,
1688
+ poolMints,
1689
+ extra,
1690
+ }),
1691
+ ];
1692
+ }
1693
+
1694
+ /**
1695
+ * When a draw of round `r` (at `key`) would cancel it (an open round short of its least tickets; a waiting one past
1696
+ * the timeout), the pulls of `who`'s own parts as the cancelled round owes them (a few entries' worth), to go right
1697
+ * after the draw in its transaction; none otherwise. The round's close rides on them when they are all it owes.
1698
+ */
1699
+ async function cancelPulls(key, r, who) {
1700
+ const short =
1701
+ r.status === 'Open' &&
1702
+ BigInt(r.sold) < BigInt(r.terms.min_sold ?? 0) &&
1703
+ BigInt(now()) >= BigInt(r.terms.draws_at);
1704
+ if (!short && !loot.lotteryCancellable(r, await chainNow())) return [];
1705
+ // As the program cancels it: every entry's own part owed, and what the ask moved in owed back.
1706
+ const potOwed = BigInt(r.pot_arrived) > BigInt(r.to_pot) ? BigInt(r.pot_arrived) - BigInt(r.to_pot) : 0n;
1707
+ const pools = r.prizes.filter(p => !p.minted && BigInt(p.arrived) > 0n).length;
1708
+ const cancelled = {
1709
+ ...r,
1710
+ status: 'Cancelled',
1711
+ pot_owed: potOwed,
1712
+ owed: Number(r.owns) + (potOwed > 0n ? 1 : 0) + pools,
1713
+ };
1714
+ const mine = r.entries
1715
+ .flatMap((e, i) =>
1716
+ e.buyer === who && loot.partsOwed(cancelled, i).length
1717
+ ? [{ entry: i, parts: loot.partsOwed(cancelled, i) }]
1718
+ : [],
1719
+ )
1720
+ .slice(0, 4);
1721
+ const settles = mine.reduce(
1722
+ (n, { entry, parts }) => n + parts.reduce((m, p) => m + pullSettles(cancelled, entry, p), 0),
1723
+ 0,
1724
+ );
1725
+ const out = [];
1726
+ for (const [k, { entry, parts }] of mine.entries())
1727
+ out.push(
1728
+ ...(
1729
+ await pulls(key, cancelled, entry, parts, who, { closes: settles >= cancelled.owed && k === mine.length - 1 })
1730
+ ).instructions,
1731
+ );
1732
+ return out;
1733
+ }
1734
+
1735
+ /** The nonce a pull of entry `i` of the round at `key` names: a place's roll is made at `prizeRollNonce(nonce + place)`. */
1736
+ const pullNonce = (key, i) => loot.prizeNonce(key, BigInt(i));
1737
+
1738
+ /** How many of a round's owed parts a pull of `part` (of entry `i`) settles. */
1739
+ const pullSettles = (r, i, part) => {
1740
+ if (part === 'Own' || part.Common) return 1;
1741
+ const run = Number((part.Pot ?? part.Prize)[0]);
1742
+ return part.Pot || loot.tokenRun(r, run)
1743
+ ? loot.placesPulled(r, i, run, Boolean(part.Prize))
1744
+ : loot.prizePlacesOwed(r, i, run).length;
1745
+ };
1746
+
1747
+ /**
1748
+ * The pulls of `parts` of entry `i` of a round (`partsOwed`; `i` null for parts owed in common, `commonOwed`), signed by
1749
+ * `signer`, and the rolls they make ([{ order, box }], `loot.pullRolls`). With `closes`, the last pull carries the
1750
+ * round's close (when the parts it pays are the last owed).
1751
+ */
1752
+ async function pulls(key, r, i, parts, signer, { closes = false } = {}) {
1753
+ const e = i == null ? null : r.entries[i];
1754
+ const out = [];
1755
+ const rolls = [];
1756
+ const g = await projectNow();
1757
+ // A run's token prize is in its own mint: the prizes the round recorded name each.
1758
+ const extra = await hookTail([...roundMints(r), ...r.prizes.map(p => p.mint)]);
1759
+ for (const [k, part] of parts.entries()) {
1760
+ const more = {};
1761
+ if (part === 'Own' && e.nft !== NOBODY) {
1762
+ const then = r.status === 'Cancelled' || r.terms.returns ? null : r.terms.nft_ticket?.then;
1763
+ more.handBack = Boolean(then?.Give) && loot.handsBack(await client.account(then.Give[0]));
1764
+ const to = !then || more.handBack ? e.buyer : then.Give ? then.Give[0] : undefined;
1765
+ const { before = [], hasAuthority: _, ...kind } = await nftOf(e.nft, signer, { to });
1766
+ out.push(...before);
1767
+ more.ticket = kind;
1768
+ } else if (part.Prize && !loot.tokenRun(r, Number(part.Prize[0]))) {
1769
+ // Each of the entry's places in the run: an NFT of the lottery's own stash (which one, as the pull takes them
1770
+ // out), a project stash's NFT, an item, or a roll (asked in the same transaction).
1771
+ const run = Number(part.Prize[0]);
1772
+ const given = r.terms.places[run].prize;
1773
+ const inputs = await prizeInputs([given], signer, e.buyer);
1774
+ out.push(...inputs.before);
1775
+ const stash = loot.ownStash(r.terms.places[run])
1776
+ ? loot.decodeStash((await client.account(loot.pda.lotteryStash(r.lottery)))?.data ?? null)
1777
+ : null;
1778
+ if (stash) {
1779
+ const predicted = loot.stashNfts(r, stash);
1780
+ for (const place of loot.prizePlacesOwed(r, i, run)) {
1781
+ const nft = predicted[loot.nftSlot(r, place)];
1782
+ if (!nft || inputs.nfts[nft]) continue;
1783
+ const { before, hasAuthority: _, ...kind } = await nftOf(nft, signer, { to: e.buyer });
1784
+ inputs.nfts[nft] = kind;
1785
+ out.push(...before);
1786
+ }
1787
+ }
1788
+ const stashes = given.Nft
1789
+ ? {
1790
+ [given.Nft.stash]: loot.decodeStash(
1791
+ (await client.account(loot.pda.stash(project, given.Nft.stash)))?.data ?? null,
1792
+ ) ?? { nfts: [] },
1793
+ }
1794
+ : {};
1795
+ more.prize = {
1796
+ stash,
1797
+ stashes,
1798
+ nfts: inputs.nfts,
1799
+ collection: inputs.collection,
1800
+ poolMints: inputs.poolMints,
1801
+ items: inputs.items,
1802
+ projectMint: mintOf(),
1803
+ };
1804
+ more.nonce = pullNonce(key, i);
1805
+ rolls.push(...loot.pullRolls({ project, round: r, entry: i, part, nonce: more.nonce }));
1806
+ } else if (part.Common) {
1807
+ // Where a part owed in common goes: a share's payee (a collectible's holder now), what the places leave (the
1808
+ // pot, or the `rest` a round paid from its own money names), a pool; one that can't take it (a pool never
1809
+ // opened, the pot closed since, an account gone) sends it to the treasury.
1810
+ const c = Number(part.Common[0]);
1811
+ const n = r.terms.shares.length;
1812
+ const to =
1813
+ c < n
1814
+ ? r.terms.shares[c].to
1815
+ : c === n
1816
+ ? loot.leftoverTo(r.lottery, r.terms)
1817
+ : r.terms.places[Number(r.prizes[c - n - 1].run)].prize.Tokens.from;
1818
+ if (to.Holder) more.holders = await holdersOf([{ to }]);
1819
+ const target = to.Pool ? loot.pda.pool(project, to.Pool[0]) : to.Account?.[0];
1820
+ // A pot or pool made again in another mint can't take it either: the program sends it to the treasury.
1821
+ const mint = c < n ? loot.paymentMint(r.terms) : c === n ? r.terms.mint : r.prizes[c - n - 1].mint;
1822
+ const held = target ? await client.account(target) : null;
1823
+ if (target && (!held || encodeBase58(held.data.subarray(0, 32)) !== mint)) more.toTreasury = true;
1824
+ }
1825
+ const last = k === parts.length - 1;
1826
+ const round = closes && last ? { ...r, owed: pullSettles(r, i, part) } : { ...r, owed: Number.MAX_SAFE_INTEGER };
1827
+ out.push(
1828
+ loot.pullIx({
1829
+ payer: signer,
1830
+ project,
1831
+ roundAddress: key,
1832
+ round,
1833
+ entry: i ?? 0,
1834
+ part,
1835
+ projectState: g,
1836
+ programs,
1837
+ extra,
1838
+ ...more,
1839
+ }),
1840
+ );
1841
+ }
1842
+ return { instructions: out, rolls };
839
1843
  }
840
1844
 
841
1845
  return {
@@ -846,24 +1850,47 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
846
1850
 
847
1851
  /** The shop: every item with a price (or free), its price now and what is left. */
848
1852
  async shop() {
849
- const items = (spec.items ?? []).filter((it) => Number.isInteger(it.shopBox));
850
- const accounts = await client.accounts(items.flatMap((it) => [loot.pda.box(project, it.shopBox), loot.pda.item(project, it.id)]));
1853
+ const items = (spec.items ?? []).filter(it => Number.isInteger(it.shopBox));
1854
+ const accounts = await client.accounts(
1855
+ items.flatMap(it => [loot.pda.box(project, it.shopBox), loot.pda.item(project, it.id)]),
1856
+ );
851
1857
  return items.map((it, i) => {
852
1858
  const box = accounts[2 * i] && loot.decode('LootBox', accounts[2 * i].data);
853
1859
  const item = accounts[2 * i + 1] && loot.decode('Item', accounts[2 * i + 1].data);
854
1860
  const dec = decimalsOf(currencyKey);
855
- return { key: it.key, name: it.name, price: box ? show(box.price, dec) : null, priceUnits: box?.price ?? null, currency: tokens.get(currencyKey)?.symbol ?? currencyKey, active: Boolean(box?.active && item?.active), left: item?.max_supply ? item.max_supply - item.issued : null };
1861
+ return {
1862
+ key: it.key,
1863
+ name: it.name,
1864
+ price: box ? show(box.price, dec) : null,
1865
+ priceUnits: box?.price ?? null,
1866
+ currency: tokens.get(currencyKey)?.symbol ?? currencyKey,
1867
+ active: Boolean(box?.active && item?.active),
1868
+ left: item?.max_supply ? item.max_supply - item.issued : null,
1869
+ };
856
1870
  });
857
1871
  },
858
1872
 
859
- /** The boxes: price now and each outcome's chance. */
1873
+ /** The boxes: price now, each outcome's chance as written, and what the box does once an outcome runs out
1874
+ * (`whenOut`: 'stopSales', it isn't sold until restocked; 'drawLeft', it draws among what is left). */
860
1875
  async boxes() {
861
1876
  const list = spec.boxes ?? [];
862
- const accounts = await client.accounts(list.map((b) => loot.pda.box(project, b.id)));
1877
+ const accounts = await client.accounts(list.map(b => loot.pda.box(project, b.id)));
863
1878
  return list.map((b, i) => {
864
1879
  const box = accounts[i] && loot.decode('LootBox', accounts[i].data);
865
1880
  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 })) };
1881
+ return {
1882
+ key: b.key,
1883
+ name: b.name,
1884
+ price: box ? show(box.price, decimalsOf(b.currency)) : null,
1885
+ priceUnits: box?.price ?? null,
1886
+ currency: tokens.get(b.currency ?? currencyKey)?.symbol,
1887
+ active: Boolean(box?.active),
1888
+ whenOut: box?.when_out === 'DrawLeft' ? 'drawLeft' : 'stopSales',
1889
+ outcomes: (b.outcomes ?? []).map((o, j) => ({
1890
+ ...o,
1891
+ chancePercent: box ? (100 * box.entries[j].weight) / total : o.chance,
1892
+ })),
1893
+ };
867
1894
  });
868
1895
  },
869
1896
 
@@ -881,7 +1908,8 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
881
1908
 
882
1909
  /**
883
1910
  * 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.
1911
+ * (`buy`); one that rolls waits for the roll and collects it, settling a pack in as many parts as it needs. `count`
1912
+ * opens several packs in one purchase: one wallet prompt and one oracle answer (`maxPrice` is a pack's).
885
1913
  */
886
1914
  async openBox(boxKey, options = {}) {
887
1915
  only(options, BOX_OPTIONS, 'openBox');
@@ -892,16 +1920,78 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
892
1920
  /** Collects an order opened earlier (its roll arrived after the app stopped waiting). */
893
1921
  collect,
894
1922
 
1923
+ /**
1924
+ * Finds the settle keys a page that closed left holding the player's SOL, and finishes their jobs: a key whose order
1925
+ * has rolled settles it (no prompt); one whose order is gone, failed or due its refund sends its SOL back; one whose
1926
+ * order still waits for its roll keeps it for later. Each key is made again from the wallet's signature (asked once
1927
+ * a session, `walletStandardSigner`'s `signMessage`), or read from the page's storage (`keyStore`) when the wallet
1928
+ * signs no messages. Returns [{ name, key, settled } | { name, key, swept } | { name, key, waiting }].
1929
+ */
1930
+ async recover() {
1931
+ if (!keys) throw new Error('Connect a wallet first.');
1932
+ const me = wallet.address;
1933
+ const tag = t => ({ memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS[t])) } });
1934
+ const at = (offset, bytes) => ({ memcmp: { offset, bytes } });
1935
+ const orders = new Map();
1936
+ for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [
1937
+ tag('Order'),
1938
+ at(1, project),
1939
+ at(33, me),
1940
+ ]))
1941
+ orders.set(pubkey, loot.decode('Order', account.data));
1942
+ const holds = (await client.programAccounts(loot.LOOT_PROGRAM, [tag('Hold'), at(1, project), at(65, me)])).map(
1943
+ h => h.pubkey,
1944
+ );
1945
+ // Keys are named by an order of the wallet's, a hold (a loan's release), or an auction (the releases after its end).
1946
+ const names = [
1947
+ ...new Set([...orders.keys(), ...holds, ...(spec.auctions ?? []).map(a => auctionAt(a)), ...keys.stored()]),
1948
+ ];
1949
+ if (!names.length) return [];
1950
+ const made = (await Promise.all(names.map(name => keys.again(name)))).filter(Boolean);
1951
+ const held = made.length ? await client.accounts(made.map(k => k.address)) : [];
1952
+ const [slot, time] = [await slotNow(), await chainNow()];
1953
+ const out = [];
1954
+ for (const [i, key] of made.entries()) {
1955
+ const lamports = BigInt(held[i]?.lamports ?? 0);
1956
+ if (lamports === 0n) {
1957
+ keys.forget(key.name);
1958
+ continue;
1959
+ }
1960
+ const order = orders.get(key.name);
1961
+ const due = order ? loot.orderRefundable(order, slot, time) : false;
1962
+ if (order?.status === 'Rolled' && slot > BigInt(order.requested_slot) && !due) {
1963
+ const got = await collect(key.name, { burner: key }).catch(e => ({ error: e.message }));
1964
+ out.push({
1965
+ name: key.name,
1966
+ key: key.address,
1967
+ ...(got.error ? { error: got.error } : { settled: got.settled, won: got.won }),
1968
+ });
1969
+ } else if (order && ['Pending', 'Asked', 'Waiting'].includes(order.status) && !due) {
1970
+ out.push({ name: key.name, key: key.address, waiting: order.status });
1971
+ } else {
1972
+ await emptyKey(key);
1973
+ out.push({ name: key.name, key: key.address, swept: lamports });
1974
+ }
1975
+ }
1976
+ return out;
1977
+ },
1978
+
895
1979
  /** Gives an order's payment back once its time has come (a roll that failed or never came). */
896
1980
  async refund(orderAddress) {
897
1981
  const order = await read('Order', orderAddress);
898
1982
  // An NFT the project held for the roll goes back.
899
- const { before = [], hasAuthority: _, ...subject } = order.held ? await nftOf(order.subject, wallet.address, { to: order.player }) : {};
1983
+ const {
1984
+ before = [],
1985
+ hasAuthority: _,
1986
+ ...subject
1987
+ } = order.held ? await nftOf(order.subject, wallet.address, { to: order.player }) : {};
900
1988
  return send([...before, loot.refundIx({ signer: wallet.address, orderAddress, order, subject, programs })]);
901
1989
  },
902
1990
 
903
- /** Asks the oracle for a roll a mission, a recipe or a prize made (anyone may; the asker pays the oracle). */
904
- askRoll: (orderAddress) => askRoll(orderAddress),
1991
+ /** Asks the oracle for a roll a mission, a recipe or a prize made (anyone may; the asker pays the oracle). On a box
1992
+ * with nothing in stock only the order's player may ask, a slot after it was made or last asked, and that ends the
1993
+ * roll there with nothing, no oracle (a lot its winner paid for fails, for its refund). */
1994
+ askRoll: orderAddress => askRoll(orderAddress),
905
1995
 
906
1996
  /**
907
1997
  * Pays a payment of the economy (an entry, an attempt, a tip). `amount`: what the player chose, in whole units, for
@@ -918,21 +2008,54 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
918
2008
  if (chosen !== undefined && !p.any) throw new Error(`“${p.name}” has a set amount: ${p.amount}.`);
919
2009
  const amount = chosen === undefined ? least : units(chosen, decimalsOf(p.currency));
920
2010
  if (amount < least || amount <= 0n) throw new Error(`“${p.name}” takes at least ${p.amount}.`);
921
- const pools = new Map((spec.pools ?? []).map((x) => [x.key, x.index]));
2011
+ const pools = new Map((spec.pools ?? []).map(x => [x.key, x.index]));
922
2012
  // `pay` takes its shares from the payer each time: a "chosen" share pays the wallet they name, and with none named
923
2013
  // it is left out, so what it would pay goes to the treasury with the rest (as a purchase's does).
924
- if (chosenRecipient && chosenRecipient === wallet.address) throw new Error('A payer can’t name their own wallet for a share they choose.');
925
- const shares = (p.split ?? []).flatMap((s) => {
2014
+ if (chosenRecipient && chosenRecipient === wallet.address)
2015
+ throw new Error('A payer can’t name their own wallet for a share they choose.');
2016
+ const shares = (p.split ?? []).flatMap(s => {
926
2017
  const [kind, key] = s.to.split(':');
927
2018
  const bps = Math.round(s.percent * 100);
928
- if (kind === 'chosen' && key) throw new Error(`“${p.name}” pays the holder of a “${key}” the payer names: a payment can’t check the collectible, so its split takes a chosen wallet only.`);
2019
+ if (kind === 'chosen' && key)
2020
+ throw new Error(
2021
+ `“${p.name}” pays the holder of a “${key}” the payer names: a payment can’t check the collectible, so its split takes a chosen wallet only.`,
2022
+ );
929
2023
  if (kind === 'chosen') return chosenRecipient ? [{ to: { Wallet: [chosenRecipient] }, bps }] : [];
930
- const pot = (lotteryKey) => loot.pda.pot(loot.pda.lottery(project, part('lotteries', lotteryKey).id));
931
- const to = kind === 'burn' ? 'Burn' : kind === 'pool' ? { Pool: [pools.get(key)] } : kind === 'account' ? { Account: [key] } : kind === 'wallet' ? { Wallet: [key] } : kind === 'treasury' ? 'Treasury' : kind === 'lottery' ? { Account: [pot(key)] } : { Holder: [key] };
2024
+ const pot = lotteryKey => {
2025
+ const l = part('lotteries', lotteryKey);
2026
+ if (!Number.isInteger(l.pot))
2027
+ throw new Error(
2028
+ `“${l.name ?? lotteryKey}” pays each round from its own ticket money: it keeps no pot to pay into.`,
2029
+ );
2030
+ return loot.pda.lotteryPot(loot.pda.lottery(project, l.id), l.pot);
2031
+ };
2032
+ const to =
2033
+ kind === 'burn'
2034
+ ? 'Burn'
2035
+ : kind === 'pool'
2036
+ ? { Pool: [pools.get(key)] }
2037
+ : kind === 'account'
2038
+ ? { Account: [key] }
2039
+ : kind === 'wallet'
2040
+ ? { Wallet: [key] }
2041
+ : kind === 'treasury'
2042
+ ? 'Treasury'
2043
+ : kind === 'lottery'
2044
+ ? { Account: [pot(key)] }
2045
+ : { Holder: [key] };
932
2046
  return [{ to, bps }];
933
2047
  });
934
- holders ??= shares.some((x) => x.to.Holder) ? await holdersOf(shares) : undefined;
935
- const ix = loot.payIx({ player: wallet.address, project, mint, treasury: g.treasury, amount, shares, holders, programs });
2048
+ holders ??= shares.some(x => x.to.Holder) ? await holdersOf(shares) : undefined;
2049
+ const ix = loot.payIx({
2050
+ player: wallet.address,
2051
+ project,
2052
+ mint,
2053
+ treasury: g.treasury,
2054
+ amount,
2055
+ shares,
2056
+ holders,
2057
+ programs,
2058
+ });
936
2059
  return send(withSolWrap(wallet.address, mint, amount, [ix]));
937
2060
  },
938
2061
 
@@ -944,7 +2067,8 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
944
2067
  async payNft(asset, { to = null, reference, ...others } = NO_OPTIONS) {
945
2068
  only(others, ['to', 'reference'], 'payNft');
946
2069
  // An NFT paid goes to a wallet: never the project's address or an account of the program (ToTheProject).
947
- if (to && (to === project || (await client.account(to))?.owner === loot.LOOT_PROGRAM)) throw new Error('An NFT paid can’t go to the project or an account of the program: name a wallet, or burn it.');
2070
+ if (to && (to === project || (await client.account(to))?.owner === loot.LOOT_PROGRAM))
2071
+ throw new Error('An NFT paid can’t go to the project or an account of the program: name a wallet, or burn it.');
948
2072
  const { before, ...nft } = await nftOf(asset);
949
2073
  return send([...before, loot.payNftIx({ player: wallet.address, project, asset, to, ...nft, reference })]);
950
2074
  },
@@ -956,16 +2080,32 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
956
2080
  */
957
2081
  async lotteries() {
958
2082
  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;
962
- 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
- });
2083
+ const out = [];
2084
+ for (const l of list) {
2085
+ const there = await client.account(loot.pda.lottery(project, l.id));
2086
+ // The terms on sale are the round's own copy once someone bought into it, the lottery's before.
2087
+ const { lottery, current } =
2088
+ there?.owner === loot.LOOT_PROGRAM ? await roundOf(l) : { lottery: null, current: null };
2089
+ const copy = current ?? lottery;
2090
+ const terms = copy?.terms;
2091
+ const currency = lottery ? ticketCurrencyOf(l, lottery) : (l.ticketCurrency ?? currencyKey);
2092
+ const ended = lottery && Number(lottery.every) === 0 && lottery.round > 0;
2093
+ out.push({
2094
+ key: l.key,
2095
+ name: l.name,
2096
+ price: terms ? show(terms.price, decimalsOf(currency)) : null,
2097
+ priceUnits: terms?.price ?? null,
2098
+ currency: tokens.get(currency)?.symbol ?? currency,
2099
+ status: !lottery ? null : ended ? 'Ended' : (current?.status ?? 'Open'),
2100
+ round: lottery?.round ?? null,
2101
+ sold: lottery ? (current?.sold ?? 0n) : null,
2102
+ drawsAt: terms ? Number(terms.draws_at) : null,
2103
+ nftTicket: Boolean(lottery?.terms.nft_ticket),
2104
+ incarnation: lottery?.incarnation ?? null,
2105
+ revision: copy?.revision ?? null,
2106
+ });
2107
+ }
2108
+ return out;
969
2109
  },
970
2110
 
971
2111
  /**
@@ -977,53 +2117,135 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
977
2117
  * passes its rule: `nft`, its address (held by the project until the draw, then burned, given on or stashed; back to
978
2118
  * the player if the round is cancelled). `listProof`: for a list gate.
979
2119
  */
980
- async buyTickets(lotteryKey, count = 1, { shown, chosenRecipient = null, listProof, nft: paidWith, listing, maxPrice, ...others } = NO_OPTIONS) {
2120
+ async buyTickets(
2121
+ lotteryKey,
2122
+ count = 1,
2123
+ { shown, chosenRecipient = null, listProof, nft: paidWith, listing, maxPrice, ...others } = NO_OPTIONS,
2124
+ ) {
981
2125
  only(others, ['shown', 'chosenRecipient', 'listProof', 'nft', 'listing', 'maxPrice'], 'buyTickets');
982
2126
  const l = part('lotteries', lotteryKey);
983
2127
  const name = l.name ?? lotteryKey;
984
- if (listing && listing.key !== undefined && listing.key !== l.key) throw new Error(`That listing is “${listing.key}”, not “${l.key}”.`);
2128
+ if (listing && listing.key !== undefined && listing.key !== l.key)
2129
+ throw new Error(`That listing is “${listing.key}”, not “${l.key}”.`);
985
2130
  const given = maxPrice ?? listing?.priceUnits;
986
- 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.
990
- 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;
2131
+ if (given === undefined || given === null)
2132
+ throw new Error(
2133
+ `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.`,
2134
+ );
2135
+ const { lottery: onChain, current: openRound } = await roundOf(l);
2136
+ // A round whose draw is due takes no more tickets: its draw goes first, in this same transaction (the buyer pays
2137
+ // the oracle's fee for it), and moves the lottery on, so this purchase is the next round's first. A lottery that
2138
+ // draws once has no next round.
2139
+ const due = openRound?.status === 'Open' && BigInt(now()) >= BigInt(openRound.terms.draws_at) ? openRound : null;
2140
+ if (due && Number(onChain.every) === 0)
2141
+ throw new Error(`The one draw of “${l.name}” is due: it takes no more tickets. Reveal it (claims() lists it).`);
2142
+ const draw = due
2143
+ ? (await drawStep(l, onChain, null, due, wallet.address)).filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM)
2144
+ : [];
2145
+ const current = due ? { ...onChain, round: onChain.round + 1 } : onChain;
2146
+ const round = due ? null : openRound;
2147
+ // The terms signed are those shown: the round's own copy once someone bought into it, the lottery's before. A
2148
+ // lottery written again or changed since is refused here, and by the program (TermsChanged) if it changes after
2149
+ // this read.
2150
+ const { terms, revision } = round ?? current;
2151
+ if (listing && (listing.incarnation == null || listing.revision == null))
2152
+ throw new Error(
2153
+ `That listing of “${name}” names no terms (incarnation, revision): pass the lottery lotteries() showed.`,
2154
+ );
2155
+ if (
2156
+ listing &&
2157
+ (BigInt(listing.incarnation) !== BigInt(current.incarnation) || Number(listing.revision) !== Number(revision))
2158
+ )
2159
+ throw new Error(
2160
+ `“${name}” has changed since the player was shown it: show them the lottery as it is now first.`,
2161
+ );
2162
+ const lottery = current;
993
2163
  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.`);
2164
+ if (BigInt(terms.price) > BigInt(given))
2165
+ throw new Error(
2166
+ `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.`,
2167
+ );
2168
+ if (Number(lottery.every) === 0 && lottery.round > 0) throw new Error(`“${l.name}” has drawn and ended.`);
996
2169
  shown ??= await passing(lottery.gate, 'buy a ticket');
997
2170
  listProof = listProofOf(lottery.gate, l.gate?.list, listProof);
998
2171
  let nft;
999
2172
  const before = [];
1000
- if (lottery.nft_ticket) {
2173
+ if (terms.nft_ticket) {
1001
2174
  if (!paidWith) throw new Error(`A ticket of “${l.name}” costs an NFT: say which (nft).`);
1002
2175
  if (BigInt(count) !== 1n) throw new Error('An NFT pays for one ticket at a time.');
1003
2176
  const { before: proof, ...kind } = await nftOf(paidWith);
1004
2177
  nft = { asset: paidWith, ...kind };
1005
2178
  before.push(...proof);
1006
2179
  }
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.
2180
+ const mint = loot.paymentMint(terms);
2181
+ const { shares } = terms;
2182
+ const choosesHolder = shares.some(s => s.to.Chosen && s.to.Chosen.item != null);
2183
+ // A share the buyer chooses is paid now, from their own account: to the holder of the collectible they chose (read
2184
+ // now), the wallet they named, or with no choice the treasury.
2185
+ const holders = choosesHolder && chosenRecipient ? await holdersOf([{ to: { Holder: [chosenRecipient] } }]) : {};
2186
+ // A payment mint with a transfer hook: its accounts ride along.
2187
+ const extra = await hookTail([mint]);
2188
+ const ix = loot.buyTicketsIx({
2189
+ player: wallet.address,
2190
+ project,
2191
+ id: l.id,
2192
+ lottery,
2193
+ round,
2194
+ mint,
2195
+ count: BigInt(count),
2196
+ shown,
2197
+ listProof,
2198
+ chosenRecipient,
2199
+ chosen: choosesHolder ? chosenRecipient : null,
2200
+ nft,
2201
+ holders,
2202
+ treasury: (await projectNow()).treasury,
2203
+ programs,
2204
+ extra,
2205
+ });
2206
+ // An overdue round nobody bought into moves to its next date by itself when a ticket is bought.
2207
+ // 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
2208
+ // buyer when it is cancelled, each sized before the NFT goes in.
1016
2209
  if (nft) {
1017
2210
  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);
2211
+ const entry = { buyer: wallet.address, nft: paidWith, start: 0n, end: 1n, amount: 0n, paid: 0n };
1020
2212
  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 })];
2213
+ const r = {
2214
+ lottery: loot.pda.lottery(project, l.id),
2215
+ status,
2216
+ terms,
2217
+ accounts: [],
2218
+ owed: 2,
2219
+ opener: wallet.address,
2220
+ entries: [entry],
2221
+ };
2222
+ const exit = [
2223
+ computeUnitLimit(1_400_000),
2224
+ ...before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
2225
+ loot.pullIx({
2226
+ payer: wallet.address,
2227
+ project,
2228
+ roundAddress: loot.currentRound(r.lottery, lottery),
2229
+ round: r,
2230
+ entry: 0,
2231
+ part: 'Own',
2232
+ projectState: g,
2233
+ ticket: kindOnly(nft),
2234
+ programs,
2235
+ }),
2236
+ ];
1022
2237
  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.`);
2238
+ throw new Error(
2239
+ `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.`,
2240
+ );
1024
2241
  }
1025
2242
  }
1026
- return send([...before, ...withSolWrap(wallet.address, mint, lottery.price * BigInt(count), [ix])]);
2243
+ const buy = [...before, ...withSolWrap(wallet.address, mint, BigInt(terms.price) * BigInt(count), [ix])];
2244
+ return send(
2245
+ draw.length
2246
+ ? [computeUnitLimit(800_000), ...draw, ...buy.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM)]
2247
+ : buy,
2248
+ );
1027
2249
  },
1028
2250
 
1029
2251
  /**
@@ -1048,33 +2270,39 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1048
2270
  shown ??= await passing(farm.gates, 'stake here');
1049
2271
  const mint = farm.farmed.Tokens.mint;
1050
2272
  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.
2273
+ // While its lock runs, a seat moves only to a tier with no early exit, or one whose early exit costs at least as
2274
+ // much as its own (`ExitWhileLocked`): never to a way out it didn't have, or a cheaper one.
1053
2275
  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);
2276
+ if (mine && BigInt(mine.amount) > 0n && Number(mine.tier) !== Number(tier)) {
2277
+ const [from, to] = [Number(farm.tiers[mine.tier].exit_bps), Number(farm.tiers[tier].exit_bps)];
2278
+ const locked = (await chainNow()) < BigInt(mine.since) + BigInt(farm.tiers[mine.tier].lock);
2279
+ if (locked && !(to === 0 || (from > 0 && to >= from)))
2280
+ throw new Error(
2281
+ '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.',
2282
+ );
1060
2283
  }
1061
- const token = await farmTokens([mint, farm.rewards_mint], harvestFirst ? [farm.rewards_mint] : [], { extra, transfers });
1062
- 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])]);
2284
+ const token = await farmTokens([mint], [], { extra, transfers });
2285
+ const ix = loot.stakeTokensIx({
2286
+ player: wallet.address,
2287
+ project,
2288
+ id: f.id,
2289
+ farm,
2290
+ amount: value,
2291
+ tier,
2292
+ shown,
2293
+ ...token,
2294
+ });
2295
+ // A seat with a booster on (its boost is read again), or one whose growth the stake changes, is refreshed first, in
2296
+ // the same transaction.
2297
+ const before = loot.stakeRefreshFirst(farm, mine, { tier, now: await chainNow() })
2298
+ ? await refreshOf(f, farm, mine)
2299
+ : [];
2300
+ return send(await walking(f, farm, [...before, ...withSolWrap(wallet.address, mint, value, [ix])]));
1072
2301
  },
1073
2302
 
1074
2303
  /**
1075
2304
  * 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).
2305
+ * lock, a tier with an early exit lets it leave for its fee, keeping what the seat was credited.
1078
2306
  */
1079
2307
  async unstake(farmKey, amount = 0, { forfeit = false, extra, transfers, ...others } = NO_OPTIONS) {
1080
2308
  only(others, ['forfeit', 'extra', 'transfers'], 'unstake');
@@ -1083,7 +2311,24 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1083
2311
  const value = units(amount, decimalsOf(f.stakes));
1084
2312
  const mints = [...(value > 0n ? [farm.farmed.Tokens.mint] : []), ...(!forfeit ? [farm.rewards_mint] : [])];
1085
2313
  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 })]);
2314
+ // An exit that would lose what the seat grew since its last refresh writes it first, in the same transaction.
2315
+ const by = wallet.address;
2316
+ const seat = await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), by));
2317
+ const first = [];
2318
+ if (seat && loot.refreshFirst(farm, seat, value, forfeit)) {
2319
+ const { before, ...nft } = seat.asset !== NOBODY ? await nftOf(seat.asset) : { before: [] };
2320
+ first.push(
2321
+ ...before,
2322
+ loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by, seat, ...nft, extra: token.extra }),
2323
+ );
2324
+ }
2325
+ return send(
2326
+ await walking(f, farm, [
2327
+ ...token.before,
2328
+ ...first,
2329
+ loot.unstakeTokensIx({ player: wallet.address, project, id: f.id, farm, amount: value, forfeit, ...token }),
2330
+ ]),
2331
+ );
1087
2332
  },
1088
2333
 
1089
2334
  /**
@@ -1098,25 +2343,30 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1098
2343
  await openFor(f, farm, tier);
1099
2344
  const token = await farmTokens([farm.rewards_mint], [], { extra, transfers });
1100
2345
  const { before, ...nft } = await nftOf(asset);
1101
- const seat = farm.custody === 'Watch' && farm.farmed === 'Collectibles' ? await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), asset)) : null;
2346
+ const seat =
2347
+ farm.custody === 'Watch' && farm.farmed === 'Collectibles'
2348
+ ? await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), asset))
2349
+ : null;
1102
2350
  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 = [];
2351
+ // A booster boosts a stake: a farm of tokens takes one only on a seat with tokens staked (NotFarmed). In a farm
2352
+ // whose growth writes the profile, the seat is refreshed first, in the same transaction (`boosterRefreshFirst`).
2353
+ const refresh = [];
1107
2354
  if (farm.farmed.Tokens) {
1108
2355
  const mine = await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), wallet.address));
1109
- 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
- }
2356
+ if (!mine || BigInt(mine.amount) === 0n)
2357
+ throw new Error(`Stake some ${f.stakes} in “${f.name ?? f.key}” first: a booster boosts a stake.`);
2358
+ if (loot.boosterRefreshFirst(farm)) refresh.push(...(await refreshOf(f, farm, mine)));
1115
2359
  }
1116
2360
  // Its way out must land too: its unstake from this tier, with the tier's
1117
2361
  // prize and as a forfeit, is sized before it goes in, and an NFT that could never come back out isn't staked.
1118
2362
  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 })]);
2363
+ return send(
2364
+ await walking(f, farm, [
2365
+ ...refresh,
2366
+ ...before,
2367
+ loot.stakeNftIx({ player: wallet.address, project, id: f.id, farm, asset, ...nft, previous, tier, ...token }),
2368
+ ]),
2369
+ );
1120
2370
  },
1121
2371
 
1122
2372
  /**
@@ -1129,9 +2379,15 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1129
2379
  only(others, ['forfeit', 'extra', 'transfers'], 'unstakeNft');
1130
2380
  const f = part('farms', farmKey);
1131
2381
  const farm = await read('Farm', loot.pda.farm(project, f.id));
1132
- const token = await farmTokens(forfeit ? [] : [farm.rewards_mint], forfeit || farm.farmed.Tokens ? [] : [farm.rewards_mint], { extra, transfers });
2382
+ const token = await farmTokens(
2383
+ forfeit ? [] : [farm.rewards_mint],
2384
+ forfeit || farm.farmed.Tokens ? [] : [farm.rewards_mint],
2385
+ { extra, transfers },
2386
+ );
1133
2387
  // Held in escrow, it comes back into the player's account (frozen in place, it never left it).
1134
- const { before, ...nft } = await nftOf(asset, wallet.address, { to: farm.custody === 'Escrow' ? wallet.address : undefined });
2388
+ const { before, ...nft } = await nftOf(asset, wallet.address, {
2389
+ to: farm.custody === 'Escrow' ? wallet.address : undefined,
2390
+ });
1135
2391
  // Leaving a mission that rolls: its roll is made in an order of the player's, which `collect` then finishes; its
1136
2392
  // prize's roll (a chest) at the next nonce.
1137
2393
  const seat = await read('Seat', loot.pda.seat(loot.pda.farm(project, f.id), asset));
@@ -1139,12 +2395,78 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1139
2395
  const prize = farm.tiers[seat.tier]?.prize ?? null;
1140
2396
  // A forfeit takes no prize: none of its accounts (`unstakeNftIx`).
1141
2397
  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.
2398
+ // 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
2399
+ // `prizeRollNonce` of the one named (`namedRollNonce`).
1143
2400
  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;
2401
+ const boxes = roll != null ? { [roll]: await read('LootBox', loot.pda.box(project, roll)) } : {};
2402
+ // A roll still at the NFT's order address blocks the leave, unless it was asked and came back with nothing to give
2403
+ // (Waiting, asked once): that one stands in for this. Any other is asked (its player or the project's keys), answered and settled first.
2404
+ if (roll != null) {
2405
+ const at = loot.pda.order(project, wallet.address, loot.namedRollNonce(boxes[roll], asset, nonce));
2406
+ const there = await readMaybe('Order', at);
2407
+ if (there && !(there.status === 'Waiting' && BigInt(there.asked_first) !== 0n))
2408
+ throw new Error(
2409
+ `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.`,
2410
+ );
2411
+ }
2412
+ const ix = loot.unstakeNftIx({
2413
+ player: wallet.address,
2414
+ project,
2415
+ id: f.id,
2416
+ farm,
2417
+ asset,
2418
+ ...nft,
2419
+ forfeit,
2420
+ roll,
2421
+ nonce,
2422
+ tier: seat.tier,
2423
+ projectCollection: given.collection,
2424
+ poolMints: given.poolMints,
2425
+ items: given.items,
2426
+ nft: given.nft,
2427
+ boxes,
2428
+ ...token,
2429
+ });
2430
+ const { signature, rolls } = await sendRolling(
2431
+ await walking(f, farm, [...before, ...given.before, ...token.before, ix]),
2432
+ );
2433
+ const rolled = rolls.length ? { rolled: rolls[0] } : {};
2434
+ if (roll != null)
2435
+ return {
2436
+ signature,
2437
+ roll: loot.pda.order(project, wallet.address, loot.namedRollNonce(boxes[roll], asset, nonce)),
2438
+ ...rolled,
2439
+ };
2440
+ return prize?.Roll && !forfeit
2441
+ ? { signature, roll: loot.pda.order(project, wallet.address, loot.prizeRollNonce(nonce + 1n)), ...rolled }
2442
+ : signature;
2443
+ },
2444
+
2445
+ /**
2446
+ * What farm `farmKey` has to walk: `{ ends, needsWalk, parts }`, the times its seats stop earning it has passed and not
2447
+ * yet credited up to, whether a move can no longer do them in its own transaction, and how many transactions
2448
+ * `walkFarm` would take. Every move walks them first; nothing is lost however long they wait.
2449
+ */
2450
+ async farmWalk(farmKey) {
2451
+ const f = part('farms', farmKey);
2452
+ const { left } = await endsOf(f, await read('Farm', loot.pda.farm(project, f.id)));
2453
+ return { ends: left, needsWalk: left > loot.MOVE_WALK_ROOM, parts: Math.ceil(left / loot.WALK_PER_TX) };
2454
+ },
2455
+
2456
+ /**
2457
+ * Walks a farm up to now in parts (`walk_farm`), for one that has passed more ends than a move can walk: one transaction
2458
+ * for every `loot.WALK_PER_TX` ends, oldest first. Anyone may send it, and it costs only the network fee. Returns their
2459
+ * signatures, none when there is nothing to walk.
2460
+ */
2461
+ async walkFarm(farmKey, { perTx = loot.WALK_PER_TX, ...others } = NO_OPTIONS) {
2462
+ only(others, ['perTx'], 'walkFarm');
2463
+ const f = part('farms', farmKey);
2464
+ const farm = await read('Farm', loot.pda.farm(project, f.id));
2465
+ const { ends } = await endsOf(f, farm);
2466
+ const out = [];
2467
+ for (const ix of loot.walkFarmParts({ project, id: f.id, farm, ends, now: await chainNow(), perTx }))
2468
+ out.push(await send([computeUnitLimit(loot.WALK_COMPUTE_UNITS), ix]));
2469
+ return out;
1148
2470
  },
1149
2471
 
1150
2472
  /**
@@ -1152,16 +2474,21 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1152
2474
  * and reads its boosts again. Anyone may send it; the player pays for what the write adds. `by`: the collectible,
1153
2475
  * or the player for their token seat's booster.
1154
2476
  */
1155
- async refresh(farmKey, by, { extra, transfers, ...others } = NO_OPTIONS) {
2477
+ async refresh(farmKey, by, { extra, ...others } = NO_OPTIONS) {
2478
+ // `transfers` (a token's transfer plan) is taken and needs nothing: a refresh moves no tokens.
1156
2479
  only(others, ['extra', 'transfers'], 'refresh');
1157
2480
  const f = part('farms', farmKey);
1158
2481
  const farm = await read('Farm', loot.pda.farm(project, f.id));
1159
2482
  const seat = await read('Seat', loot.pda.seat(loot.pda.farm(project, f.id), by));
1160
2483
  const held = seat.asset !== NOBODY;
1161
2484
  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 })]);
2485
+ // A refresh moves no tokens (a farm's rewards are numbers until a seat is paid): only a collectible's own tail.
2486
+ return send(
2487
+ await walking(f, farm, [
2488
+ ...before,
2489
+ loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by, seat, ...nft, extra: extra ?? [] }),
2490
+ ]),
2491
+ );
1165
2492
  },
1166
2493
 
1167
2494
  /**
@@ -1172,13 +2499,6 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1172
2499
  only(others, ['compound', 'extra', 'transfers'], 'harvest');
1173
2500
  const f = part('farms', farmKey);
1174
2501
  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
2502
  const token = await farmTokens([farm.rewards_mint], [farm.rewards_mint], { extra, transfers });
1183
2503
  // In a farm that watches: what shows the seat's NFT is still the player's (a compressed one proven first).
1184
2504
  let [held, tree, before] = [null, null, []];
@@ -1191,31 +2511,35 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1191
2511
  before = nft.compressed ? nft.before : [];
1192
2512
  }
1193
2513
  }
1194
- return send([...before, ...token.before, loot.harvestIx({ player: wallet.address, project, id: f.id, farm, by, held, tree, compound, ...token })]);
2514
+ // A compound stakes more: a seat with a booster on, or whose growth reads the amount, is refreshed first.
2515
+ const seat = compound ? await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), by)) : null;
2516
+ const first = loot.stakeRefreshFirst(farm, seat, { compound: true }) ? await refreshOf(f, farm, seat) : [];
2517
+ return send(
2518
+ await walking(f, farm, [
2519
+ ...before,
2520
+ ...token.before,
2521
+ ...first,
2522
+ loot.harvestIx({ player: wallet.address, project, id: f.id, farm, by, held, tree, compound, ...token }),
2523
+ ]),
2524
+ );
1195
2525
  },
1196
2526
 
1197
2527
  /**
1198
2528
  * 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.
2529
+ * staked token).
1200
2530
  */
1201
2531
  async compound(farmKey, opts = {}) {
1202
2532
  only(opts, ['extra', 'transfers'], 'compound');
1203
2533
  return this.harvest(farmKey, wallet.address, { ...opts, compound: true });
1204
2534
  },
1205
2535
 
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
2536
  /**
1213
2537
  * 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
2538
  * no booster), what it holds staked (`amount`, in whole units of the staked token, a number for display that can
1215
2539
  * round past 2^53 base units; `amountUnits`, in base units exactly, a bigint; 1 for an NFT), its tier, when it
1216
2540
  * 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
2541
+ * boost in percent, until when it earns (`earnsUntil`, unix seconds, null for no end: a tier's term, an upkeep), and
2542
+ * 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
2543
  * network (nothing is signed or sent): what the farm released or its rate paid since its last move is in it, by the
1220
2544
  * farm's rounding. When the dry run fails, it is what the seat was last credited, and `counted` is false.
1221
2545
  * One search of the chain, and one dry run per seat: read it when the page opens and after a stake or a harvest.
@@ -1225,20 +2549,25 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1225
2549
  const f = part('farms', farmKey);
1226
2550
  const farmAddress = loot.pda.farm(project, f.id);
1227
2551
  const farm = await read('Farm', farmAddress);
1228
- const rewards = [...tokens.values()].find((t) => mintOf(t.key) === farm.rewards_mint);
2552
+ const rewards = [...tokens.values()].find(t => mintOf(t.key) === farm.rewards_mint);
1229
2553
  const nfts = f.stakes === 'collectibles';
1230
- const found = await client.programAccounts(loot.LOOT_PROGRAM, [
1231
- { 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: [] }));
2554
+ // A seat names its player (offset 1), not its farm: a seat of this farm is the one at its own address here.
2555
+ const found = (
2556
+ await client.programAccounts(loot.LOOT_PROGRAM, [
2557
+ { memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS.Seat)) } },
2558
+ { memcmp: { offset: 1, bytes: player } },
2559
+ ])
2560
+ ).filter(({ pubkey, account }) => {
2561
+ const seat = loot.decode('Seat', account.data);
2562
+ return pubkey === loot.pda.seat(farmAddress, nfts ? seat.asset : player);
2563
+ });
1236
2564
  const { blockhash } = await client.latestBlockhash();
1237
2565
  // Who the dry run names as the refresh's signer and fee payer: the network wants one that holds SOL, whoever it is
1238
2566
  // (nothing is signed): the player, else the wallet looking, else the project's authority.
1239
2567
  let payer;
1240
2568
  const funded = async () => {
1241
- for (const a of [player, wallet?.address, live.authority].filter(Boolean)) if ((await client.balance(a).catch(() => 0n)) > 0n) return a;
2569
+ for (const a of [player, wallet?.address, live.authority].filter(Boolean))
2570
+ if ((await client.balance(a).catch(() => 0n)) > 0n) return a;
1242
2571
  return player;
1243
2572
  };
1244
2573
  const out = [];
@@ -1250,9 +2579,11 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1250
2579
  payer ??= await funded();
1251
2580
  const { before, ...nft } = held ? await nftOf(seat.asset, payer) : { before: [] };
1252
2581
  const by = nfts ? seat.asset : player;
1253
- const ix = loot.refreshSeatIx({ signer: payer, project, id: f.id, farm, by, seat, ...nft, extra: token.extra });
2582
+ const ix = loot.refreshSeatIx({ signer: payer, project, id: f.id, farm, by, seat, ...nft });
1254
2583
  const message = compileMessage({ payer, blockhash, instructions: [...before, ix] });
1255
- const after = (await client.simulate(wireTransaction(message), { accounts: [pubkey], replaceBlockhash: true })).accounts[0];
2584
+ const after = (
2585
+ await client.simulate(wireTransaction(message), { accounts: [pubkey], replaceBlockhash: true })
2586
+ ).accounts[0];
1256
2587
  if (after?.data) [seat, counted] = [loot.decode('Seat', after.data), true];
1257
2588
  } catch {
1258
2589
  // The seat as it was last credited.
@@ -1270,6 +2601,8 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1270
2601
  rewards: rewards?.key ?? null,
1271
2602
  waiting: show(seat.owed, rewards ? decimalsOf(rewards.key) : 0),
1272
2603
  owedUnits: BigInt(seat.owed),
2604
+ // Until when it earns and grows (unix seconds): its tier's term or its fuel, the sooner; null for no end.
2605
+ earnsUntil: BigInt(seat.earns_until) >= loot.NO_END ? null : Number(seat.earns_until),
1273
2606
  counted,
1274
2607
  });
1275
2608
  }
@@ -1279,7 +2612,7 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1279
2612
  /** The recipes: whether each is on, and its version now (what `craft` takes as the terms the player was shown). */
1280
2613
  async recipes() {
1281
2614
  const list = spec.recipes ?? [];
1282
- const accounts = await client.accounts(list.map((r) => loot.pda.recipe(project, r.id)));
2615
+ const accounts = await client.accounts(list.map(r => loot.pda.recipe(project, r.id)));
1283
2616
  return list.map((r, i) => {
1284
2617
  const recipe = accounts[i] && loot.decode('Recipe', accounts[i].data);
1285
2618
  return { key: r.key, name: r.name, active: Boolean(recipe?.active), version: recipe ? recipe.version : null };
@@ -1290,59 +2623,79 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1290
2623
  * Crafts a recipe, handing in `assets` (the collectibles it burns or uses, in the recipe's order). `version`: the
1291
2624
  * recipe's version the page showed the player (`recipes()`, or the listing itself as `listing`), so a recipe the
1292
2625
  * 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.
2626
+ * read now is never signed in its place. `extra`: the tail a Core collection's plugins need when the craft moves or
2627
+ * rolls on a Core NFT (sent after the Memo program's account, as on every other Core move).
1294
2628
  */
1295
- async craft(recipeKey, assets = [], times = 1, { version, listing, ...others } = NO_OPTIONS) {
1296
- only(others, ['version', 'listing'], 'craft');
2629
+ async craft(recipeKey, assets = [], times = 1, { version, listing, extra = [], ...others } = NO_OPTIONS) {
2630
+ only(others, ['version', 'listing', 'extra'], 'craft');
1297
2631
  const r = part('recipes', recipeKey);
1298
2632
  version ??= listing?.version ?? undefined;
1299
- 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.`);
2633
+ if (version === undefined || version === null)
2634
+ throw new Error(
2635
+ `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.`,
2636
+ );
1300
2637
  const current = await read('Recipe', loot.pda.recipe(project, r.id));
1301
2638
  const recipe = { ...current, version };
1302
- await poolShareGiven(recipe.outputs.filter((o) => o.Prize).map((o) => o.Prize[0] ?? o.Prize));
1303
- const pays = recipe.inputs.filter((i) => i.Pay).map((i) => i.Pay);
1304
- const holders = await holdersOf(pays.map((p) => ({ to: p.to })));
2639
+ const pays = recipe.inputs.filter(i => i.Pay).map(i => i.Pay);
2640
+ const holders = await holdersOf(pays.map(p => ({ to: p.to })));
1305
2641
  const treasury = (await projectNow()).treasury;
1306
2642
  // An NFT a `show` line takes, by its address: as the project's, a Core asset of another collection, or a Token
1307
2643
  // Metadata mint, which the craft reads differently.
1308
2644
  // 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);
2645
+ const at = i =>
2646
+ recipe.inputs
2647
+ .slice(0, i)
2648
+ .reduce((n, x) => n + (x.Burn?.count ?? (x.Use || x.Show || x.Staked || x.Trade ? 1 : 0)), 0);
1310
2649
  const shownAt = new Set(recipe.inputs.flatMap((input, i) => (input.Show ? [at(i)] : [])));
1311
2650
  // An NFT a `trade` line takes in, any kind: read for its kind (a compressed one proven first).
1312
2651
  const tradedAt = new Set(recipe.inputs.flatMap((input, i) => (input.Trade ? [at(i)] : [])));
1313
2652
  const proving = [];
1314
2653
  // 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)] : [])));
2654
+ const keptAt = new Set(recipe.inputs.flatMap((input, i) => (input.Use ? [at(i)] : [])));
1316
2655
  // One a `staked` line takes: on its seat in the farm the line names, escrowed or frozen there.
1317
2656
  const stakedAt = new Map(recipe.inputs.flatMap((input, i) => (input.Staked ? [[at(i), input.Staked.farm]] : [])));
1318
- const handed = await Promise.all(assets.map(async (nft, i) => {
1319
- if (stakedAt.has(i) && typeof nft === 'string') {
1320
- const farm = stakedAt.get(i);
1321
- if (!(await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, farm), nft)))) {
1322
- const name = (spec.farms ?? []).find((f) => f.id === farm)?.name ?? `farm ${farm}`;
1323
- throw new Error(`${nft} is not staked in “${name}”, where this recipe takes it.`);
2657
+ // The places a raise lowers a number on.
2658
+ const lowered = new Set(
2659
+ (recipe.raises ?? []).filter(raise => BigInt(raise.shift.by) < 0n).map(raise => Number(raise.place)),
2660
+ );
2661
+ const handed = await Promise.all(
2662
+ assets.map(async (nft, i) => {
2663
+ if (stakedAt.has(i) && typeof nft === 'string') {
2664
+ const farm = stakedAt.get(i);
2665
+ if (!(await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, farm), nft)))) {
2666
+ const name = (spec.farms ?? []).find(f => f.id === farm)?.name ?? `farm ${farm}`;
2667
+ throw new Error(`${nft} is not staked in “${name}”, where this recipe takes it.`);
2668
+ }
2669
+ return { asset: nft, farm };
1324
2670
  }
1325
- return { asset: nft, farm };
1326
- }
1327
- if (keptAt.has(i) && typeof nft === 'string') return (await escrowedIn(nft)) ?? nft;
1328
- if (tradedAt.has(i) && typeof nft === 'string') {
1329
- const { before, ...kind } = await nftOf(nft);
1330
- proving.push(...before);
1331
- return { asset: nft, ...kind };
1332
- }
1333
- if (!shownAt.has(i) || typeof nft !== 'string') return nft;
1334
- const account = await client.account(nft);
1335
- if (account?.owner !== MPL_CORE) return { mint: nft, owner: wallet.address };
1336
- const theirs = await collectionOf(nft);
1337
- return ours(theirs) ? nft : { asset: nft, collection: theirs };
1338
- }));
2671
+ if (keptAt.has(i) && typeof nft === 'string') return (await escrowedIn(nft)) ?? (await lockedIn(nft)) ?? nft;
2672
+ if (tradedAt.has(i) && typeof nft === 'string') {
2673
+ const { before, ...kind } = await nftOf(nft);
2674
+ proving.push(...before);
2675
+ return { asset: nft, ...kind };
2676
+ }
2677
+ if (!shownAt.has(i) || typeof nft !== 'string') return nft;
2678
+ // Shown on a seat of a farm that freezes, with a raise lowering a number on it: its seat and farm go with it.
2679
+ const seated = lowered.has(i) ? await lockedIn(nft) : null;
2680
+ const on = seated ? { farm: seated.farm } : {};
2681
+ const account = await client.account(nft);
2682
+ if (account?.owner !== MPL_CORE) return { mint: nft, owner: wallet.address, ...on };
2683
+ const theirs = await collectionOf(nft);
2684
+ return ours(theirs) ? (seated ? { asset: nft, ...on } : nft) : { asset: nft, collection: theirs, ...on };
2685
+ }),
2686
+ );
1339
2687
  // A recipe that rolls makes its roll in an order of the player's, which `collect` then finishes.
1340
- 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.
1342
- const rollOn = recipe.outputs.find((o) => o.RollOn)?.RollOn;
1343
- const nonce = rollOn ? loot.nonceOn(typeof assets[rollOn.place] === 'string' ? assets[rollOn.place] : assets[rollOn.place].asset) : freshNonce();
2688
+ const rolls = recipe.outputs.some(o => o.RollOn || o.Prize?.[0]?.Roll);
2689
+ // A roll on a collectible kept is named by that collectible's nonce (`nonceOn`): on a box that holds it, its
2690
+ // order is there, one at a time on it (`namedRollNonce`).
2691
+ const rollOn = recipe.outputs.find(o => o.RollOn)?.RollOn;
2692
+ const nonce = rollOn
2693
+ ? loot.nonceOn(typeof assets[rollOn.place] === 'string' ? assets[rollOn.place] : assets[rollOn.place].asset)
2694
+ : freshNonce();
1344
2695
  // A roll on a kept collectible (`RollOn`) passes its box's prize mints as a roll given does.
1345
- const given = await prizeInputs(recipe.outputs.map((o) => o.Prize?.[0] ?? (o.RollOn ? { Roll: [o.RollOn.box_id] } : null)));
2696
+ const given = await prizeInputs(
2697
+ recipe.outputs.map(o => o.Prize?.[0] ?? (o.RollOn ? { Roll: [o.RollOn.box_id] } : null)),
2698
+ );
1346
2699
  // Each item a line takes passes its collection.
1347
2700
  const items = { ...given.items };
1348
2701
  for (const input of recipe.inputs) {
@@ -1351,30 +2704,64 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1351
2704
  }
1352
2705
  // The collectibles it makes, each at its item's next serial.
1353
2706
  const counts = {};
1354
- const made = recipe.outputs.flatMap((o) => {
2707
+ const made = recipe.outputs.flatMap(o => {
1355
2708
  const id = o.Prize?.[0]?.Item?.[0];
1356
2709
  if (id === undefined || items[id]?.kind !== 'Collectible') return [];
1357
2710
  counts[id] = (counts[id] ?? 0) + 1;
1358
2711
  return [loot.pda.itemAsset(loot.pda.item(project, id), Number(items[id].issued) + counts[id])];
1359
2712
  });
1360
- 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 })];
2713
+ const before = [...proving, ...given.before].filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM);
2714
+ let instructions = [
2715
+ computeUnitLimit(rolls ? 1_400_000 : 1_000_000),
2716
+ ...before,
2717
+ loot.craftIx({
2718
+ player: wallet.address,
2719
+ project,
2720
+ collection: collections[0],
2721
+ id: r.id,
2722
+ recipe,
2723
+ assets: handed,
2724
+ times,
2725
+ nonce,
2726
+ holders,
2727
+ programs,
2728
+ treasury,
2729
+ poolMints: given.poolMints,
2730
+ items,
2731
+ nfts: given.nfts,
2732
+ boxes: given.boxes,
2733
+ extra,
2734
+ }),
2735
+ ];
1362
2736
  // Fuel bought for a staked collectible counts once its seat is refreshed: in the same transaction.
1363
2737
  for (const raise of recipe.raises ?? []) {
1364
2738
  const asset = assets[raise.place];
1365
2739
  if (typeof asset !== 'string') continue;
1366
- for (const f of (spec.farms ?? []).filter((x) => x.upkeepAttribute === raise.key)) {
2740
+ for (const f of (spec.farms ?? []).filter(x => x.upkeepAttribute === raise.shift.key)) {
1367
2741
  const seat = await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), asset));
1368
2742
  if (!seat) continue;
1369
2743
  const farm = await read('Farm', loot.pda.farm(project, f.id));
1370
2744
  const { before, ...nft } = await nftOf(asset);
1371
- instructions.push(...before, loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by: asset, seat, ...nft }));
2745
+ instructions.push(
2746
+ ...before,
2747
+ loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by: asset, seat, ...nft }),
2748
+ );
1372
2749
  }
1373
2750
  }
1374
2751
  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)) } : {}) };
2752
+ // A roll on a collectible kept is at its nonce; a prize's roll at `prizeRollNonce` of the craft's. Asked for in the
2753
+ // craft's own transaction and settled by a key after: one prompt.
2754
+ const on =
2755
+ rollOn && (typeof assets[rollOn.place] === 'string' ? assets[rollOn.place] : assets[rollOn.place].asset);
2756
+ const roll = rolls
2757
+ ? loot.pda.order(
2758
+ project,
2759
+ wallet.address,
2760
+ rollOn ? loot.namedRollNonce(given.boxes[rollOn.box_id], on, nonce) : loot.prizeRollNonce(nonce),
2761
+ )
2762
+ : null;
2763
+ const { signature, rolls: rolled } = await sendRolling(instructions);
2764
+ return { signature, made, ...(roll ? { roll } : {}), ...(rolled.length ? { rolled: rolled[0] } : {}) };
1378
2765
  },
1379
2766
 
1380
2767
  /**
@@ -1384,7 +2771,17 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1384
2771
  async hold(asset, seconds) {
1385
2772
  const freeAt = BigInt(typeof seconds === 'object' ? seconds.until : now() + Number(seconds));
1386
2773
  const { before, hasAuthority: _, ...nft } = await nftOf(asset);
1387
- const signature = await send([...before, loot.holdIx({ player: wallet.address, project, asset, ...nft, collection: nft.collection ?? (await collectionOf(asset)) ?? MPL_CORE, freeAt })]);
2774
+ const signature = await send([
2775
+ ...before,
2776
+ loot.holdIx({
2777
+ player: wallet.address,
2778
+ project,
2779
+ asset,
2780
+ ...nft,
2781
+ collection: nft.collection ?? (await collectionOf(asset)) ?? MPL_CORE,
2782
+ freeAt,
2783
+ }),
2784
+ ]);
1388
2785
  return { signature, freeAt };
1389
2786
  },
1390
2787
 
@@ -1401,12 +2798,30 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1401
2798
  if (!offer.deal.Lend) throw new Error(`The offer ${address} buys NFTs; it lends nothing.`);
1402
2799
  const account = await client.account(offer.mint);
1403
2800
  if (!account) throw new Error(`The loan's token ${offer.mint} is not on this cluster.`);
1404
- const lender = (who) => (who === maker ? ' That is the lender: they could keep you from repaying and take your NFT when the loan is due.' : '');
1405
- const risks = mintPowers(account).map((p) => {
1406
- if (p.power === 'freeze') return { ...p, says: `${p.holder} can freeze accounts of the loan's token, which makes repaying fail.${lender(p.holder)}` };
1407
- if (p.power === 'pause') return { ...p, says: `${p.holder ?? 'Its pause authority'} can pause every transfer of the loan's token, which makes repaying fail${p.paused ? ' (it is paused now)' : ''}.${lender(p.holder)}` };
1408
- if (p.power === 'hook') return { ...p, says: `Every transfer of the loan's token runs the program ${p.holder}${p.authority ? `, which ${p.authority} can change` : ''}; it can refuse a repayment.${lender(p.authority)}` };
1409
- return { ...p, says: `${p.holder} can move anyone's tokens of the loan's token, the ones you would repay with too.${lender(p.holder)}` };
2801
+ const lender = who =>
2802
+ who === maker
2803
+ ? ' That is the lender: they could keep you from repaying and take your NFT when the loan is due.'
2804
+ : '';
2805
+ const risks = mintPowers(account).map(p => {
2806
+ if (p.power === 'freeze')
2807
+ return {
2808
+ ...p,
2809
+ says: `${p.holder} can freeze accounts of the loan's token, which makes repaying fail.${lender(p.holder)}`,
2810
+ };
2811
+ if (p.power === 'pause')
2812
+ return {
2813
+ ...p,
2814
+ says: `${p.holder ?? 'Its pause authority'} can pause every transfer of the loan's token, which makes repaying fail${p.paused ? ' (it is paused now)' : ''}.${lender(p.holder)}`,
2815
+ };
2816
+ if (p.power === 'hook')
2817
+ return {
2818
+ ...p,
2819
+ says: `Every transfer of the loan's token runs the program ${p.holder}${p.authority ? `, which ${p.authority} can change` : ''}; it can refuse a repayment.${lender(p.authority)}`,
2820
+ };
2821
+ return {
2822
+ ...p,
2823
+ says: `${p.holder} can move anyone's tokens of the loan's token, the ones you would repay with too.${lender(p.holder)}`,
2824
+ };
1410
2825
  });
1411
2826
  const { amount, repay, lasts } = offer.deal.Lend;
1412
2827
  return { address, offer, mint: offer.mint, program: account.owner, amount, repay, lasts, risks };
@@ -1421,17 +2836,111 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1421
2836
  async borrow(maker, id, asset, { due, acceptRisks = false, extra = [], ...others } = NO_OPTIONS) {
1422
2837
  only(others, ['due', 'acceptRisks', 'extra'], 'borrow');
1423
2838
  const loan = await this.loanOffer(maker, id);
1424
- if (loan.risks.length && acceptRisks !== true) throw new Error(`Repaying this loan can be blocked. ${loan.risks.map((r) => r.says).join(' ')} Borrow only if the player accepts this: pass acceptRisks: true.`);
2839
+ if (loan.risks.length && acceptRisks !== true)
2840
+ throw new Error(
2841
+ `Repaying this loan can be blocked. ${loan.risks.map(r => r.says).join(' ')} Borrow only if the player accepts this: pass acceptRisks: true.`,
2842
+ );
1425
2843
  const lasts = BigInt(loan.lasts);
1426
2844
  due = BigInt(due ?? BigInt(now()) + lasts - (lasts > 120n ? 60n : 0n));
1427
2845
  const { before, ...nft } = await nftOf(asset);
1428
2846
  const collection = nft.collection ?? (await collectionOf(asset)) ?? MPL_CORE;
1429
2847
  const loanPrograms = { ...programs, [loan.mint]: loan.program };
1430
- const ix = loot.borrowIx({ borrower: wallet.address, project, id, offer: loan.offer, due, asset, ...nft, collection, programs: loanPrograms, extra });
1431
- const signature = await send([...before, createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, loan.mint, loan.program), ix]);
2848
+ const ix = loot.borrowIx({
2849
+ borrower: wallet.address,
2850
+ project,
2851
+ id,
2852
+ offer: loan.offer,
2853
+ due,
2854
+ asset,
2855
+ ...nft,
2856
+ collection,
2857
+ programs: loanPrograms,
2858
+ extra,
2859
+ });
2860
+ const signature = await send([
2861
+ ...before,
2862
+ createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, loan.mint, loan.program),
2863
+ ix,
2864
+ ]);
1432
2865
  return { signature, due, hold: loot.pda.hold(asset, due) };
1433
2866
  },
1434
2867
 
2868
+ /**
2869
+ * The loan on `asset` (held until `due`), as a page shows it before anyone repays: { lender, mint, repay (base units),
2870
+ * due, paid }. `repay` is what `repay` then takes as the amount shown. Null when it holds no loan.
2871
+ */
2872
+ async loan(asset, due) {
2873
+ const hold = await readMaybe('Hold', loot.pda.hold(asset, due));
2874
+ if (!hold?.debt) return null;
2875
+ return {
2876
+ lender: hold.debt.lender,
2877
+ mint: hold.debt.mint,
2878
+ repay: BigInt(hold.debt.repay),
2879
+ due: Number(hold.free_at),
2880
+ borrower: hold.player,
2881
+ };
2882
+ },
2883
+
2884
+ /**
2885
+ * Repays the loan on `asset` (held until `due`), the player's own or a friend's, then sends the NFT home to its
2886
+ * borrower. `repay`: the debt the payer was shown (`loan()`'s, in base units); the program refuses any other
2887
+ * (`TermsChanged`). The repay goes in the wallet's transaction alone, with what it opens (the borrower's Token
2888
+ * Metadata account), so a release that fails never undoes a repay that must land before the loan is due; the same
2889
+ * transaction funds a throwaway key that sends the release (anyone may), a pNFT's or a compressed NFT's too: the key
2890
+ * is funded for what that release opens (a pNFT's token account and record, a compressed NFT's proof record, which
2891
+ * comes back in the same transaction). Returns { signature, released }.
2892
+ */
2893
+ async repay(asset, due, { repay, extra = [], ...others } = NO_OPTIONS) {
2894
+ only(others, ['repay', 'extra'], 'repay');
2895
+ const hold = await read('Hold', loot.pda.hold(asset, due));
2896
+ if (!hold.debt) throw new Error('This NFT is held for no loan left to repay.');
2897
+ if (repay === undefined || repay === null)
2898
+ throw new Error(
2899
+ 'Say the debt the payer was shown (repay, from loan()): the amount read now is never paid in its place.',
2900
+ );
2901
+ if (BigInt(repay) !== BigInt(hold.debt.repay))
2902
+ throw new Error(
2903
+ `This loan now asks ${hold.debt.repay}, not the ${repay} shown: show the payer the loan as it is now first.`,
2904
+ );
2905
+ const { before, hasAuthority: _, ...nft } = await nftOf(asset, wallet.address, { to: hold.player });
2906
+ const collection = nft.collection ?? (await collectionOf(asset)) ?? MPL_CORE;
2907
+ // A pNFT's or a compressed NFT's release opens something (a token account and record, a proof record): its key
2908
+ // is funded for it, and makes its own proof and accounts as it sends the release.
2909
+ const heavy = before.some(isProveLeaf) || Boolean(nft.tokenMetadata?.programmable);
2910
+ const opens = heavy ? [] : before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM);
2911
+ const at = loot.pda.hold(asset, due);
2912
+ const { key, fund } = await keyFor(
2913
+ heavy ? loot.SETTLE_RENT_UNIT + SIGNATURE_FEE : SIGNATURE_FEE,
2914
+ await jobKey(at),
2915
+ );
2916
+ const repaid = loot.repayIx({ payer: wallet.address, project, hold, repay, programs, extra });
2917
+ const signature = await send(
2918
+ withSolWrap(wallet.address, hold.debt.mint, BigInt(hold.debt.repay), [...opens, repaid, fund]),
2919
+ );
2920
+ // Repaid, the hold is a plain one that is over (no debt, `free_at` 0) at the address its loan named.
2921
+ const release = async signer => {
2922
+ const {
2923
+ before: first,
2924
+ hasAuthority: _h,
2925
+ ...kind
2926
+ } = heavy ? await nftOf(asset, signer, { to: hold.player }) : { before: [], ...nft };
2927
+ return [
2928
+ ...first,
2929
+ loot.releaseIx({
2930
+ signer,
2931
+ project,
2932
+ asset,
2933
+ ...kind,
2934
+ collection,
2935
+ hold: { ...hold, debt: null, free_at: 0 },
2936
+ address: at,
2937
+ }),
2938
+ ];
2939
+ };
2940
+ const released = await keyRun(key, release);
2941
+ return { signature, released };
2942
+ },
2943
+
1435
2944
  /**
1436
2945
  * Backs `side` of contest number `id` (the authority opens contests while the project runs) with `amount`; a contest
1437
2946
  * of NFTs takes one of the player's NFTs that passes its rule instead (`nft`, its address), held until it is
@@ -1445,11 +2954,39 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1445
2954
  if (contest.nft) {
1446
2955
  if (!staked) throw new Error('This contest takes an NFT: say which (nft).');
1447
2956
  const { before, ...kind } = await nftOf(staked);
1448
- return send([...before, loot.enterIx({ player: wallet.address, project, id, contest, side, amount: 1n, shown, listProof, nft: { asset: staked, ...kind }, programs })]);
2957
+ return send([
2958
+ ...before,
2959
+ loot.enterIx({
2960
+ player: wallet.address,
2961
+ project,
2962
+ id,
2963
+ contest,
2964
+ side,
2965
+ amount: 1n,
2966
+ shown,
2967
+ listProof,
2968
+ nft: { asset: staked, ...kind },
2969
+ programs,
2970
+ }),
2971
+ ]);
1449
2972
  }
1450
- const t = [...tokens.values()].find((x) => mintOf(x.key) === contest.mint);
1451
- const value = units(amount, t?.source === 'sol' ? 9 : t?.decimals ?? 0);
1452
- return send(withSolWrap(wallet.address, contest.mint, value, [loot.enterIx({ player: wallet.address, project, id, contest, side, amount: value, shown, listProof, programs })]));
2973
+ const t = [...tokens.values()].find(x => mintOf(x.key) === contest.mint);
2974
+ const value = units(amount, t?.source === 'sol' ? 9 : (t?.decimals ?? 0));
2975
+ return send(
2976
+ withSolWrap(wallet.address, contest.mint, value, [
2977
+ loot.enterIx({
2978
+ player: wallet.address,
2979
+ project,
2980
+ id,
2981
+ contest,
2982
+ side,
2983
+ amount: value,
2984
+ shown,
2985
+ listProof,
2986
+ programs,
2987
+ }),
2988
+ ]),
2989
+ );
1453
2990
  },
1454
2991
 
1455
2992
  /**
@@ -1459,13 +2996,22 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1459
2996
  */
1460
2997
  async auctions() {
1461
2998
  const list = spec.auctions ?? [];
1462
- const accounts = await client.accounts(list.map((a) => auctionAt(a)));
2999
+ const accounts = await client.accounts(list.map(a => auctionAt(a)));
1463
3000
  const at = BigInt(now());
1464
- const wrapped = wallet?.address ? await client.account(associatedTokenAddress(wallet.address, NATIVE_MINT)) : null;
1465
- const waiting = wrapped ? show(new DataView(wrapped.data.buffer, wrapped.data.byteOffset).getBigUint64(64, true), 9) : '0';
3001
+ const wrapped = wallet?.address
3002
+ ? await client.account(associatedTokenAddress(wallet.address, NATIVE_MINT))
3003
+ : null;
3004
+ const waiting = wrapped
3005
+ ? show(new DataView(wrapped.data.buffer, wrapped.data.byteOffset).getBigUint64(64, true), 9)
3006
+ : '0';
1466
3007
  return list.map((a, i) => {
1467
3008
  const x = accounts[i] && loot.decode('Auction', accounts[i].data);
1468
- const base = { key: a.key, name: a.name, lot: a.lot, currency: tokens.get(a.currency ?? currencyKey)?.symbol ?? a.currency ?? currencyKey };
3009
+ const base = {
3010
+ key: a.key,
3011
+ name: a.name,
3012
+ lot: a.lot,
3013
+ currency: tokens.get(a.currency ?? currencyKey)?.symbol ?? a.currency ?? currencyKey,
3014
+ };
1469
3015
  if (!x) return { ...base, ended: true };
1470
3016
  // Bids of NFTs count NFTs.
1471
3017
  const dec = x.nft_bids ? 0 : decimalsOf(a.currency);
@@ -1486,7 +3032,10 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1486
3032
  secondsLeft: Number(ends > at ? ends - at : 0n),
1487
3033
  open: nextBid !== null && at >= x.starts_at && at < ends,
1488
3034
  over: at >= ends,
1489
- ...(x.nft_bids ? { nftBids: true, held: x.held } : {}),
3035
+ // 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.
3036
+ ...(BigInt(x.proceeds_owed) > 0n ? { delivered: true, proceedsOwed: show(x.proceeds_owed, dec) } : {}),
3037
+ // A bid of NFTs: a picker of 1 to `maxNfts` (and at least `nextBid`) of the player's NFTs.
3038
+ ...(x.nft_bids ? { nftBids: true, held: x.held, maxNfts: loot.MAX_BID_NFTS } : {}),
1490
3039
  ...(tokens.get(a.currency ?? currencyKey)?.source === 'sol' ? { waiting } : {}),
1491
3040
  };
1492
3041
  });
@@ -1509,6 +3058,9 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1509
3058
  if (auction.nft_bids) {
1510
3059
  const bundle = nfts ?? (Array.isArray(amount) ? amount : null);
1511
3060
  if (!bundle?.length) throw new Error('This auction takes NFTs as bids: say which (nfts).');
3061
+ if (bundle.length > loot.MAX_BID_NFTS) throw new Error(`A bid holds at most ${loot.MAX_BID_NFTS} NFTs.`);
3062
+ if (auction.held.length >= loot.MAX_BID_NFTS)
3063
+ throw new Error(`The high bid holds ${loot.MAX_BID_NFTS} NFTs, the most a bid holds: it can't be topped.`);
1512
3064
  const before = [];
1513
3065
  const kindOf = async (asset, to) => {
1514
3066
  const { before: proof, ...kind } = await nftOf(asset, wallet.address, { to });
@@ -1520,8 +3072,18 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1520
3072
  // The bid it tops goes back to its bidder: into a fresh account of theirs when their own can't take it.
1521
3073
  const held = [];
1522
3074
  for (const asset of auction.held) held.push(await kindOf(asset, auction.high_bidder));
1523
- const ix = loot.bidIx({ bidder: wallet.address, project, id: a.id, auction, shown, listProof, nfts: bid, held, programs });
1524
- return send([computeUnitLimit(800_000), ...before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), ix]);
3075
+ const ix = loot.bidIx({
3076
+ bidder: wallet.address,
3077
+ project,
3078
+ id: a.id,
3079
+ auction,
3080
+ shown,
3081
+ listProof,
3082
+ nfts: bid,
3083
+ held,
3084
+ programs,
3085
+ });
3086
+ return send([computeUnitLimit(800_000), ...before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM), ix]);
1525
3087
  }
1526
3088
  const mint = auction.mint;
1527
3089
  const value = units(amount, decimalsOf(a.currency));
@@ -1531,34 +3093,81 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1531
3093
  const have = await client.account(wrapped);
1532
3094
  const held = have ? new DataView(have.data.buffer, have.data.byteOffset).getBigUint64(64, true) : 0n;
1533
3095
  instructions.push(createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, NATIVE_MINT));
1534
- if (value > held) instructions.push(transferLamports(wallet.address, wrapped, value - held), syncNative(wrapped));
3096
+ if (value > held)
3097
+ instructions.push(transferLamports(wallet.address, wrapped, value - held), syncNative(wrapped));
1535
3098
  }
1536
3099
  // A Token-2022 account can ask for a memo on every transfer into it: the Memo program rides along.
1537
- const memo = programOf(mint) === TOKEN_2022_PROGRAM ? [{ pubkey: MEMO_PROGRAM, isSigner: false, isWritable: false }] : [];
1538
- instructions.push(loot.bidIx({ bidder: wallet.address, project, id: a.id, auction, amount: value, shown, listProof, programs, extra: memo }));
3100
+ const memo =
3101
+ programOf(mint) === TOKEN_2022_PROGRAM ? [{ pubkey: MEMO_PROGRAM, isSigner: false, isWritable: false }] : [];
3102
+ instructions.push(
3103
+ loot.bidIx({
3104
+ bidder: wallet.address,
3105
+ project,
3106
+ id: a.id,
3107
+ auction,
3108
+ amount: value,
3109
+ shown,
3110
+ listProof,
3111
+ programs,
3112
+ extra: memo,
3113
+ }),
3114
+ );
1539
3115
  return send(instructions, [], { keepWrapped: true });
1540
3116
  },
1541
3117
 
1542
3118
  /**
1543
3119
  * Ends an auction that is over (anyone can; the winner usually does): the lot to the highest bidder, a new
1544
3120
  * 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.
3121
+ * roll lot makes the winner's roll of its box (`order`), which `collect` asks for and settles. An end too big for one
3122
+ * transaction makes each NFT a hold and sends each home with a release after it (`releases`).
1546
3123
  */
1547
3124
  async endAuction(auctionKey, { holders, ...others } = NO_OPTIONS) {
1548
3125
  only(others, ['holders'], 'endAuction');
1549
3126
  const a = part('auctions', auctionKey);
1550
3127
  const auction = await read('Auction', auctionAt(a));
1551
3128
  const g = await projectNow();
3129
+ // The lot went at an earlier end: this one pays what its seller is owed and closes the auction.
3130
+ if (BigInt(auction.proceeds_owed) > 0n) {
3131
+ const memo =
3132
+ programOf(auction.mint) === TOKEN_2022_PROGRAM
3133
+ ? [{ pubkey: MEMO_PROGRAM, isSigner: false, isWritable: false }]
3134
+ : [];
3135
+ return {
3136
+ signature: await send([
3137
+ loot.endAuctionIx({
3138
+ signer: wallet.address,
3139
+ project,
3140
+ projectState: g,
3141
+ id: a.id,
3142
+ auction,
3143
+ programs,
3144
+ extra: memo,
3145
+ }),
3146
+ ]),
3147
+ owedPaid: auction.proceeds_owed,
3148
+ };
3149
+ }
1552
3150
  const sold = auction.high > 0n;
1553
3151
  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;
1555
- const memo = programOf(auction.mint) === TOKEN_2022_PROGRAM ? [{ pubkey: MEMO_PROGRAM, isSigner: false, isWritable: false }] : [];
3152
+ const made =
3153
+ sold && item?.kind === 'Collectible'
3154
+ ? loot.pda.itemAsset(loot.pda.item(project, auction.lot.Item[0]), auction.pin)
3155
+ : null;
3156
+ const memo =
3157
+ programOf(auction.mint) === TOKEN_2022_PROGRAM
3158
+ ? [{ pubkey: MEMO_PROGRAM, isSigner: false, isWritable: false }]
3159
+ : [];
1556
3160
  holders ??= sold ? await holdersOf(auction.shares) : {};
1557
3161
  const lot = auction.lot.Asset?.[0];
1558
3162
  // The lot goes to its winner (back to its seller, unsold); a winning bid of NFTs to whoever the rest of a bid in
1559
3163
  // tokens would go to: the seller of an NFT, the treasury for a new item.
1560
- const { before, hasAuthority: _, ...nft } = lot ? await nftOf(lot, wallet.address, { to: sold ? auction.high_bidder : auction.seller }) : { before: [] };
1561
- const collection = lot && !nft.compressed && !nft.tokenMetadata ? (await collectionOf(lot)) ?? MPL_CORE : undefined;
3164
+ const {
3165
+ before,
3166
+ hasAuthority: _,
3167
+ ...nft
3168
+ } = lot ? await nftOf(lot, wallet.address, { to: sold ? auction.high_bidder : auction.seller }) : { before: [] };
3169
+ const collection =
3170
+ lot && !nft.compressed && !nft.tokenMetadata ? ((await collectionOf(lot)) ?? MPL_CORE) : undefined;
1562
3171
  const proceeds = auction.lot.Asset ? auction.seller : g.treasury;
1563
3172
  const held = [];
1564
3173
  if (sold && auction.nft_bids) {
@@ -1568,19 +3177,115 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1568
3177
  held.push(kind);
1569
3178
  }
1570
3179
  }
1571
- 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`).
1574
- 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 } : {}) };
3180
+ const ix = loot.endAuctionIx({
3181
+ signer: wallet.address,
3182
+ project,
3183
+ projectState: g,
3184
+ id: a.id,
3185
+ auction,
3186
+ item,
3187
+ ...nft,
3188
+ collection,
3189
+ held,
3190
+ holders,
3191
+ programs,
3192
+ extra: memo,
3193
+ });
3194
+ let all = [computeUnitLimit(800_000), ...before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM), ix];
3195
+ // An end that can't fit one transaction (a compressed lot's long proof, a hooked mint, many shares) makes each NFT
3196
+ // a hold for whoever it goes to, then sends each home with its own release: anyone may, and the rent comes back.
3197
+ const holds =
3198
+ lot || (sold && auction.nft_bids)
3199
+ ? !sendPlan({ payer: wallet.address, instructions: all, alone: isProveLeaf })
3200
+ : false;
3201
+ if (holds) {
3202
+ // The end's own transaction funds a throwaway key that sends each release after it (anyone may): each one's
3203
+ // fees, and what it may open (a pNFT's token account and record, a compressed NFT's proof record). One the key
3204
+ // can't send, the wallet does.
3205
+ const outs = [
3206
+ ...(lot ? [[lot, sold ? auction.high_bidder : auction.seller]] : []),
3207
+ ...(sold && auction.nft_bids ? auction.held.map(x => [x, proceeds]) : []),
3208
+ ];
3209
+ const { key, fund } = await keyFor(
3210
+ BigInt(outs.length) * (2n * SIGNATURE_FEE + loot.SETTLE_RENT_UNIT),
3211
+ await jobKey(auctionAt(a)),
3212
+ );
3213
+ all = [
3214
+ computeUnitLimit(800_000),
3215
+ loot.endAuctionIx({
3216
+ signer: wallet.address,
3217
+ project,
3218
+ projectState: g,
3219
+ id: a.id,
3220
+ auction,
3221
+ item,
3222
+ ...nft,
3223
+ collection,
3224
+ held,
3225
+ hold: true,
3226
+ holders,
3227
+ programs,
3228
+ extra: memo,
3229
+ }),
3230
+ fund,
3231
+ ];
3232
+ const signature = await send(all);
3233
+ const releases = [];
3234
+ for (const [asset, to] of outs) {
3235
+ const release = async signer => {
3236
+ const hold = await read('Hold', loot.pda.hold(asset, 0));
3237
+ const { before: proof, hasAuthority: _h, ...kind } = await nftOf(asset, signer, { to });
3238
+ return [
3239
+ ...proof,
3240
+ loot.releaseIx({
3241
+ signer,
3242
+ project,
3243
+ asset,
3244
+ ...kind,
3245
+ collection:
3246
+ kind.compressed || kind.tokenMetadata ? undefined : ((await collectionOf(asset)) ?? MPL_CORE),
3247
+ hold,
3248
+ to,
3249
+ }),
3250
+ ];
3251
+ };
3252
+ const steps = await release(key.address);
3253
+ const byKey = (await dryRun(key.address, steps)).ok ? await sendAs(key, steps).catch(() => null) : null;
3254
+ releases.push(byKey ?? (await send(await release(wallet.address))));
3255
+ }
3256
+ await emptyKey(key);
3257
+ return { signature, releases, ...(sold ? { winner: auction.high_bidder } : {}) };
3258
+ }
3259
+ // 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
3260
+ // ending it asks in the same transaction and a key settles it.
3261
+ const roll =
3262
+ sold && auction.lot.Roll
3263
+ ? loot.pda.order(
3264
+ project,
3265
+ auction.high_bidder,
3266
+ loot.prizeRollNonce(loot.prizeNonce(auctionAt(a), BigInt(auction.last_bid_at))),
3267
+ )
3268
+ : null;
3269
+ const { signature, rolls } =
3270
+ roll && auction.high_bidder === wallet.address
3271
+ ? await sendRolling(all)
3272
+ : { signature: await send(all), rolls: [] };
3273
+ return {
3274
+ signature,
3275
+ ...(sold ? { winner: auction.high_bidder } : {}),
3276
+ ...(made ? { asset: made } : {}),
3277
+ ...(roll ? { order: roll } : {}),
3278
+ ...(rolls.length ? { rolled: rolls[0] } : {}),
3279
+ };
1576
3280
  },
1577
3281
 
1578
3282
  /**
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.
3283
+ * 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
3284
+ * pays, may (a sealed burn: anyone), and nothing is paid to whoever sends it but the network fee. A run missed is missed:
3285
+ * 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
3286
  * Returns null when it isn't due yet.
1582
3287
  */
1583
- async runSchedule(scheduleKey, { holders, most = 20, ...others } = NO_OPTIONS) {
3288
+ async runSchedule(scheduleKey, { holders, most, ...others } = NO_OPTIONS) {
1584
3289
  only(others, ['holders', 'most'], 'runSchedule');
1585
3290
  const sc = part('schedules', scheduleKey);
1586
3291
  const address = loot.pda.schedule(project, sc.id);
@@ -1589,13 +3294,30 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1589
3294
  // A sealed schedule's vault: past its last run, whatever arrived since is due once more.
1590
3295
  const vault = from.Vest ? await client.account(loot.pda.vest(address)) : null;
1591
3296
  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);
3297
+ // 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
3298
+ // run; a long backlog of those takes a few calls).
3299
+ const due = Math.min(loot.runsDue(schedule, await chainNow(), held), most ?? Infinity);
1594
3300
  if (!due) return null;
1595
- const poolMint = from.Pool ? encodeBase58((await client.account(loot.pda.pool(project, from.Pool[0]))).data.subarray(0, 32)) : null;
3301
+ const poolMint = from.Pool
3302
+ ? encodeBase58((await client.account(loot.pda.pool(project, from.Pool[0]))).data.subarray(0, 32))
3303
+ : null;
1596
3304
  holders ??= await holdersOf([{ to: schedule.action.to }]);
1597
3305
  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 })));
3306
+ // A sealed fixed amount's backlog is a step for each halving: room for the longest the program takes a call.
3307
+ return send([
3308
+ computeUnitLimit(1_400_000),
3309
+ loot.runScheduleIx({
3310
+ payer: wallet.address,
3311
+ project,
3312
+ id: sc.id,
3313
+ schedule,
3314
+ poolMint,
3315
+ holders,
3316
+ programs,
3317
+ treasury,
3318
+ upto: Number(schedule.runs) + due,
3319
+ }),
3320
+ ]);
1599
3321
  },
1600
3322
 
1601
3323
  // ---------------------------------------------------------------- listings
@@ -1608,7 +3330,18 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1608
3330
  only(others, ['price', 'currency', 'shares'], 'listForSale');
1609
3331
  const mint = mintOf(currency);
1610
3332
  const { before, ...nft } = await nftOf(asset);
1611
- return send([...before, loot.openListingIx({ maker: wallet.address, project, asset, mint, deal: { Sell: { price: units(price, decimalsOf(currency)) } }, shares, ...nft })]);
3333
+ return send([
3334
+ ...before,
3335
+ loot.openListingIx({
3336
+ maker: wallet.address,
3337
+ project,
3338
+ asset,
3339
+ mint,
3340
+ deal: { Sell: { price: units(price, decimalsOf(currency)) } },
3341
+ shares,
3342
+ ...nft,
3343
+ }),
3344
+ ]);
1612
3345
  },
1613
3346
 
1614
3347
  /**
@@ -1619,14 +3352,29 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1619
3352
  only(others, ['price', 'lasts', 'maxPeriods', 'currency', 'shares'], 'listForRent');
1620
3353
  const mint = mintOf(currency);
1621
3354
  const { before, ...nft } = await nftOf(asset);
1622
- return send([...before, loot.openListingIx({ maker: wallet.address, project, asset, mint, deal: { Rent: { price: units(price, decimalsOf(currency)), lasts, max_periods: maxPeriods } }, shares, ...nft })]);
3355
+ return send([
3356
+ ...before,
3357
+ loot.openListingIx({
3358
+ maker: wallet.address,
3359
+ project,
3360
+ asset,
3361
+ mint,
3362
+ deal: { Rent: { price: units(price, decimalsOf(currency)), lasts, max_periods: maxPeriods } },
3363
+ shares,
3364
+ ...nft,
3365
+ }),
3366
+ ]);
1623
3367
  },
1624
3368
 
1625
3369
  /** The listing of `asset` (one account read), or null: { listing, asset, maker, mint, deal, renter, until }. */
1626
3370
  async listing(asset) {
1627
3371
  const listing = await readMaybe('Listing', loot.pda.listing(project, asset));
1628
3372
  if (!listing) return null;
1629
- return { listing: loot.pda.listing(project, asset), ...listing, renter: loot.renterOf(listing, await chainNow()) };
3373
+ return {
3374
+ listing: loot.pda.listing(project, asset),
3375
+ ...listing,
3376
+ renter: loot.renterOf(listing, await chainNow()),
3377
+ };
1630
3378
  },
1631
3379
 
1632
3380
  /** Who rents `asset` now (one account read, then a check that the project still holds it), or null. */
@@ -1642,7 +3390,11 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1642
3390
  /** The project's listings, a maker's (`maker`) or all: one search of the program's accounts. */
1643
3391
  async listings({ maker, ...others } = NO_OPTIONS) {
1644
3392
  only(others, ['maker'], 'listings');
1645
- const filters = [{ memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS.Listing)) } }, { memcmp: { offset: 1, bytes: project } }, ...(maker ? [{ memcmp: { offset: 33, bytes: maker } }] : [])];
3393
+ const filters = [
3394
+ { memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS.Listing)) } },
3395
+ { memcmp: { offset: 1, bytes: project } },
3396
+ ...(maker ? [{ memcmp: { offset: 33, bytes: maker } }] : []),
3397
+ ];
1646
3398
  const time = await chainNow();
1647
3399
  return (await client.programAccounts(loot.LOOT_PROGRAM, filters)).map(({ pubkey, account }) => {
1648
3400
  const listing = loot.decode('Listing', account.data);
@@ -1659,7 +3411,20 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1659
3411
  await shownListing(asset, listing, 'Sell');
1660
3412
  const { before, ...nft } = await nftOf(asset, wallet.address, { to: wallet.address });
1661
3413
  const holders = await holdersOf(listing.shares ?? []);
1662
- return send([...before, ...withSolWrap(wallet.address, listing.mint, listing.deal.Sell.price, [loot.takeListingIx({ taker: wallet.address, project, projectState: await projectNow(), listing, holders, programs, ...nft })])]);
3414
+ return send([
3415
+ ...before,
3416
+ ...withSolWrap(wallet.address, listing.mint, listing.deal.Sell.price, [
3417
+ loot.takeListingIx({
3418
+ taker: wallet.address,
3419
+ project,
3420
+ projectState: await projectNow(),
3421
+ listing,
3422
+ holders,
3423
+ programs,
3424
+ ...nft,
3425
+ }),
3426
+ ]),
3427
+ ]);
1663
3428
  },
1664
3429
 
1665
3430
  /**
@@ -1673,7 +3438,21 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1673
3438
  const total = BigInt(listing.deal.Rent.price) * BigInt(periods);
1674
3439
  // A compressed NFT is read by its record, proven first in the same transaction.
1675
3440
  const { before, compressed } = await nftOf(asset);
1676
- return send([...(compressed ? before : []), ...withSolWrap(wallet.address, listing.mint, total, [loot.takeListingIx({ taker: wallet.address, project, projectState: await projectNow(), listing, periods, compressed, holders, programs })])]);
3441
+ return send([
3442
+ ...(compressed ? before : []),
3443
+ ...withSolWrap(wallet.address, listing.mint, total, [
3444
+ loot.takeListingIx({
3445
+ taker: wallet.address,
3446
+ project,
3447
+ projectState: await projectNow(),
3448
+ listing,
3449
+ periods,
3450
+ compressed,
3451
+ holders,
3452
+ programs,
3453
+ }),
3454
+ ]),
3455
+ ]);
1677
3456
  },
1678
3457
 
1679
3458
  /** Takes a listed NFT home (its maker). While a rental runs it only stops the listing; call again once it is over. */
@@ -1690,24 +3469,63 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1690
3469
  * nothing but the lottery can take it while a place names it; the winner gets it before any ticket money moves.
1691
3470
  * `price` (whole units of `currency`) a ticket, `drawsAt` (Unix seconds) its one draw, `minSold` the least tickets
1692
3471
  * (fewer: the round is cancelled and refunded), `shares` where the ticket money goes (the seller's own wallet by
1693
- * default: the treasury). A seller runs it in a project of their own, whose owner they are.
3472
+ * default: the treasury). Its one place takes no money, so it keeps no pot: the round pays from its own ticket
3473
+ * money, and what the shares leave goes to the treasury too. A seller runs it in a project of their own, whose
3474
+ * owner they are.
1694
3475
  */
1695
- async raffle(id, nft, { price, currency, drawsAt, minSold = 0, perWallet = 0, shares = [{ to: 'Treasury', bps: 10_000 }], ...others } = NO_OPTIONS) {
3476
+ async raffle(
3477
+ id,
3478
+ nft,
3479
+ {
3480
+ price,
3481
+ currency,
3482
+ drawsAt,
3483
+ minSold = 0,
3484
+ perWallet = 0,
3485
+ shares = [{ to: 'Treasury', bps: 10_000 }],
3486
+ ...others
3487
+ } = NO_OPTIONS,
3488
+ ) {
1696
3489
  only(others, ['price', 'currency', 'drawsAt', 'minSold', 'perWallet', 'shares'], 'raffle');
1697
3490
  const kind = await nftOf(nft);
1698
3491
  const { before, hasAuthority: _, ...piece } = kind;
1699
- await send([...before, loot.stashIx({ signer: wallet.address, project, lottery: id, asset: nft, ...piece })]);
3492
+ const lotteryState = await readMaybe('Lottery', loot.pda.lottery(project, id));
3493
+ await send([
3494
+ ...before,
3495
+ loot.stashIx({ signer: wallet.address, project, lottery: id, lotteryState, asset: nft, ...piece }),
3496
+ ]);
1700
3497
  const args = {
1701
- price: units(price, decimalsOf(currency)), mint: mintOf(currency), shares, places: [{ bps: 0, prize: { Held: [nft] } }],
1702
- ticket_mint: NOBODY, every: 0n, draws_at: BigInt(drawsAt), gate: null, per_wallet: perWallet, min_sold: BigInt(minSold),
3498
+ price: units(price, decimalsOf(currency)),
3499
+ mint: mintOf(currency),
3500
+ pays_from: { Round: { rest: 'Treasury' } },
3501
+ shares,
3502
+ places: [{ bps: 0, prize: { Held: [nft] } }],
3503
+ ticket_mint: NOBODY,
3504
+ every: 0n,
3505
+ draws_at: BigInt(drawsAt),
3506
+ gate: null,
3507
+ per_wallet: perWallet,
3508
+ min_sold: BigInt(minSold),
1703
3509
  };
1704
- return send([loot.upsertIx({ owner: wallet.address, project, def: { Lottery: { id, args } } })]);
3510
+ return send([loot.upsertIx({ owner: wallet.address, project, def: { Lottery: { id, args } }, lotteryState })]);
1705
3511
  },
1706
3512
 
1707
3513
  /** 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
3514
  async unraffle(id, nft) {
1709
3515
  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 })]);
3516
+ const lotteryState = await readMaybe('Lottery', loot.pda.lottery(project, id));
3517
+ return send([
3518
+ ...before,
3519
+ loot.stashIx({
3520
+ signer: wallet.address,
3521
+ project,
3522
+ lottery: id,
3523
+ lotteryState,
3524
+ asset: nft,
3525
+ out: wallet.address,
3526
+ ...piece,
3527
+ }),
3528
+ ]);
1711
3529
  },
1712
3530
 
1713
3531
  /** Turns what waits in the player's wrapped-SOL account (a bid that was topped) into plain SOL. */
@@ -1720,7 +3538,7 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1720
3538
  /**
1721
3539
  * Claims a grant for the connected wallet: from the approved list (the proof comes from xsolana.json, or is given:
1722
3540
  * `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
3541
+ * a server signed ({ publicKey, signature, incarnation, round and amount, or rounds: [{ id, amount }], expires, personHash }). A list in xsolana.json names
1724
3542
  * no round (zeros). `amount` (a voucher's, or `options.amount` for a list's leaf): the tokens this claim gives, in
1725
3543
  * 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
3544
  * the grant's prize as it is. A grant that gives a roll of a box makes the wallet's roll, which `claims()` then
@@ -1728,14 +3546,28 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1728
3546
  */
1729
3547
  async claimGrant(grantKey, options = {}) {
1730
3548
  only(options, ['voucher', 'proof', 'personHash', 'round', 'amount', 'shown', 'listProof'], 'claimGrant');
1731
- let { voucher = null, proof: given = null, personHash: listPerson = null, round: listRound = null, amount: listAmount = 0n, shown } = options;
3549
+ let {
3550
+ voucher = null,
3551
+ proof: given = null,
3552
+ personHash: listPerson = null,
3553
+ round: listRound = null,
3554
+ amount: listAmount = 0n,
3555
+ shown,
3556
+ } = options;
1732
3557
  const gspec = part('grants', grantKey);
1733
3558
  const address = loot.pda.grant(project, gspec.id);
1734
3559
  const grant = await read('Grant', address);
1735
3560
  shown ??= await passing(grant.gate, 'claim this');
1736
3561
  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;
3562
+ // What the grant has paid this wallet: its count of claims (an open claim's roll is named by it), and the rounds
3563
+ // paid (one paid already is skipped).
3564
+ const bits = grant.shard_bits;
3565
+ const [claimsShard, roundsShard] = await client.accounts([
3566
+ loot.pda.claims(address, grant.incarnation, loot.shardOf(loot.limitKey('wallet', wallet.address), bits)),
3567
+ loot.pda.rounds(address, grant.incarnation, loot.walletShard(grant, wallet.address)),
3568
+ ]);
3569
+ const count = loot.readLimit(loot.readListAccount(claimsShard), loot.limitKey('wallet', wallet.address)).count;
3570
+ const paidRounds = loot.readListAccount(roundsShard);
1739
3571
  // What its prize needs: a pool's mint, the item, an NFT's kind (a compressed one proven first).
1740
3572
  const prized = await prizeInputs([grant.gives]);
1741
3573
  const pool = grant.gives.Tokens?.from.Pool?.[0];
@@ -1745,23 +3577,50 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1745
3577
  let proof = [];
1746
3578
  const keys = gspec.keys ?? [];
1747
3579
  if (voucher) {
1748
- 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) });
3580
+ if (voucher.incarnation == null || BigInt(voucher.incarnation) !== grant.incarnation)
3581
+ throw new Error('This voucher belongs to a different grant incarnation.');
3582
+ const message = loot.voucherBytes({
3583
+ grant: address,
3584
+ incarnation: grant.incarnation,
3585
+ wallet: wallet.address,
3586
+ personHash: voucher.personHash,
3587
+ rounds: voucher.rounds,
3588
+ round: voucher.round,
3589
+ expires: voucher.expires,
3590
+ amount: BigInt(voucher.amount ?? 0),
3591
+ });
1750
3592
  const signature = typeof voucher.signature === 'string' ? decodeBase58(voucher.signature) : voucher.signature;
1751
3593
  instructions.push(loot.ed25519Ix({ publicKey: voucher.publicKey, signature, message }));
1752
3594
  // A key under the grant's key root (a promo code's) proves it is one of them.
1753
3595
  const at = keys.indexOf(voucher.publicKey);
1754
- proof = given ?? (at >= 0 ? loot.merkleProof(keys.map((k) => loot.keyLeaf(k)), at) : []);
3596
+ proof =
3597
+ given ??
3598
+ (at >= 0
3599
+ ? loot.merkleProof(
3600
+ keys.map(k => loot.keyLeaf(k)),
3601
+ at,
3602
+ )
3603
+ : []);
1755
3604
  } else if (given) {
1756
3605
  proof = given;
1757
3606
  } else if ((gspec.list ?? []).length) {
1758
3607
  // An entry is an address (the grant's amount, round 0), or { wallet, amount, round } (whole units of the grant's
1759
3608
  // token): a wallet listed under several rounds claims each once (`options.round`, a number; the first by default).
1760
- const list = gspec.list.map((e) => (typeof e === 'string' ? { wallet: e } : e));
3609
+ const list = gspec.list.map(e => (typeof e === 'string' ? { wallet: e } : e));
1761
3610
  const mint = grant.gives.Tokens?.from.Mint?.[0] ?? poolMint;
1762
- const decimals = mint && list.some((e) => e.amount !== undefined) ? (await client.account(mint))?.data?.[44] ?? 0 : 0;
1763
- const leaves = list.map((e) => loot.listLeaf(e.wallet, null, loot.listRound(e.round ?? 0), e.amount !== undefined ? units(e.amount, decimals) : 0n));
1764
- const at = list.findIndex((e) => e.wallet === wallet.address && (typeof listRound !== 'number' || (e.round ?? 0) === listRound));
3611
+ const decimals =
3612
+ mint && list.some(e => e.amount !== undefined) ? ((await client.account(mint))?.data?.[44] ?? 0) : 0;
3613
+ const leaves = list.map(e =>
3614
+ loot.listLeaf(
3615
+ e.wallet,
3616
+ null,
3617
+ loot.listRound(e.round ?? 0),
3618
+ e.amount !== undefined ? units(e.amount, decimals) : 0n,
3619
+ ),
3620
+ );
3621
+ const at = list.findIndex(
3622
+ e => e.wallet === wallet.address && (typeof listRound !== 'number' || (e.round ?? 0) === listRound),
3623
+ );
1765
3624
  if (at < 0) throw new Error('This wallet is not on the list for this grant.');
1766
3625
  proof = loot.merkleProof(leaves, at);
1767
3626
  listRound = loot.listRound(list[at].round ?? 0);
@@ -1769,18 +3628,46 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1769
3628
  } else if ((gspec.vouchers ?? []).length || keys.length) {
1770
3629
  throw new Error('This grant pays a wallet a voucher key signs for: pass the voucher.');
1771
3630
  }
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
3631
  // What this claim gives: the amount the voucher or the list's leaf names (0: the grant's prize as it is).
1779
3632
  const amount = BigInt((voucher ? voucher.amount : listAmount) ?? 0);
1780
- const most = grant.gives.Tokens?.amount.Fixed?.[0];
1781
- 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);
3633
+ const most = grant.gives.Tokens?.amount;
3634
+ if (amount > 0n && (most === undefined || amount > BigInt(most)))
3635
+ throw new Error(
3636
+ `This claim names ${amount} tokens, more than this grant gives${most === undefined ? ' (it gives no fixed amount of tokens)' : ` (${most})`}.`,
3637
+ );
3638
+ instructions.push(
3639
+ loot.claimGrantIx({
3640
+ project,
3641
+ id: gspec.id,
3642
+ grant,
3643
+ wallet: wallet.address,
3644
+ signer: wallet.address,
3645
+ count,
3646
+ rounds: voucher?.rounds,
3647
+ round: voucher?.round ?? listRound,
3648
+ expires: voucher?.expires,
3649
+ personHash: voucher?.personHash ?? listPerson,
3650
+ amount,
3651
+ proof,
3652
+ paidRounds,
3653
+ poolMint,
3654
+ item,
3655
+ nft: prized.nft,
3656
+ shown,
3657
+ listProof: gateProof,
3658
+ programs,
3659
+ voucherKey: voucher?.publicKey,
3660
+ }),
3661
+ );
3662
+ // A claim's records and prizes can pass the 200,000 compute units a transaction has by default.
3663
+ const all = [
3664
+ computeUnitLimit(grant.gives.Roll ? 1_400_000 : loot.CLAIM_COMPUTE_UNITS),
3665
+ ...instructions.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
3666
+ ];
3667
+ if (!grant.gives.Roll) return send(all);
3668
+ // A roll's orders are asked for in the claim's own transaction and settled by a key after: one prompt.
3669
+ const { signature, rolls } = await sendRolling(all);
3670
+ return rolls.length ? { signature, rolls } : signature;
1784
3671
  },
1785
3672
 
1786
3673
  /**
@@ -1801,49 +3688,69 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1801
3688
  const r = await fetchImpl(path.startsWith('http') ? path : `${base}${path}`, init);
1802
3689
  // The status first: a proxy's error page is no JSON, and says nothing a player can act on but its status.
1803
3690
  const body = await r.json().catch(() => null);
1804
- if (!r.ok) throw Object.assign(new Error(body?.error?.why ?? `The results server answered ${r.status}.`), { code: body?.error?.code, status: r.status });
1805
- if (body === null || typeof body !== 'object') throw new Error(`The results server answered ${r.status} with no JSON.`);
3691
+ if (!r.ok)
3692
+ throw Object.assign(new Error(body?.error?.why ?? `The results server answered ${r.status}.`), {
3693
+ code: body?.error?.code,
3694
+ status: r.status,
3695
+ });
3696
+ if (body === null || typeof body !== 'object')
3697
+ throw new Error(`The results server answered ${r.status} with no JSON.`);
1806
3698
  return body;
1807
3699
  };
1808
- const get = (path) => ask(path);
3700
+ const get = path => ask(path);
1809
3701
  // Where a list's proof is, as the server named it: a path on the server itself, or a whole URL on the server's own
1810
3702
  // origin. Any other place is refused before anything is fetched, so a server's answer can't send the page to
1811
3703
  // another host. A server given as a path (such as "/results") is on the page's own origin.
1812
- const proofAt = (proof) => {
3704
+ const proofAt = proof => {
1813
3705
  const said = String(proof ?? '');
1814
3706
  let origin = null;
1815
3707
  let url = null;
1816
3708
  try {
1817
3709
  const home = new URL(`${base}/`, globalThis.location?.href);
1818
3710
  origin = home.origin;
1819
- url = new URL(said.startsWith('/') && !said.startsWith('//') ? `${home.href.replace(/\/$/, '')}${said}` : said, home);
3711
+ url = new URL(
3712
+ said.startsWith('/') && !said.startsWith('//') ? `${home.href.replace(/\/$/, '')}${said}` : said,
3713
+ home,
3714
+ );
1820
3715
  } catch {
1821
3716
  // not a URL at all: refused below
1822
3717
  }
1823
- if (!url || origin === null || origin === 'null' || url.origin !== origin) throw new Error(`The results server named a list's proof at “${said}”, which is not on the server itself (${origin ?? base}): it was not fetched.`);
3718
+ if (!url || origin === null || origin === 'null' || url.origin !== origin)
3719
+ throw new Error(
3720
+ `The results server named a list's proof at “${said}”, which is not on the server itself (${origin ?? base}): it was not fetched.`,
3721
+ );
1824
3722
  return url.href;
1825
3723
  };
1826
3724
  // Each value in a query is encoded: a round or a page the server named can't change the request's path.
1827
- const q = (value) => encodeURIComponent(String(value));
3725
+ const q = value => encodeURIComponent(String(value));
1828
3726
  // A win's vouchers; a stash drop's NFT not given yet: the signed-in claim's, those for this wallet
1829
- const vouchersOf = async (round) => {
3727
+ const vouchersOf = async round => {
1830
3728
  try {
1831
3729
  return (await get(`/v1/vouchers?game=${q(project)}&round=${q(round)}&wallet=${q(wallet.address)}`)).vouchers;
1832
3730
  } catch (e) {
1833
3731
  if (e.code !== 'R-SIGN-IN' || !token) throw e;
1834
- const { vouchers } = await ask('/v1/claims', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ game: project, round, token }) });
1835
- return vouchers.filter((v) => v.wallet === wallet.address);
3732
+ const { vouchers } = await ask('/v1/claims', {
3733
+ method: 'POST',
3734
+ headers: { 'content-type': 'application/json' },
3735
+ body: JSON.stringify({ game: project, round, token }),
3736
+ });
3737
+ return vouchers.filter(v => v.wallet === wallet.address);
1836
3738
  }
1837
3739
  };
1838
3740
  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;
3741
+ const address = loot.pda.grant(project, grantId);
3742
+ const grant = await read('Grant', address);
3743
+ if (incarnation != null && BigInt(incarnation) !== BigInt(grant.incarnation)) return false;
3744
+ const shard = await client.account(
3745
+ loot.pda.rounds(address, grant.incarnation, loot.walletShard(grant, wallet.address)),
3746
+ );
3747
+ return loot.roundPaid(loot.readListAccount(shard), wallet.address, round);
1842
3748
  };
1843
3749
  const out = [];
1844
3750
  const take = async (round, grant, claim) => {
1845
3751
  try {
1846
- out.push({ round, grant, signature: await claim() });
3752
+ const r = await claim();
3753
+ out.push({ round, grant, ...(typeof r === 'string' ? { signature: r } : r) });
1847
3754
  } catch (e) {
1848
3755
  out.push({ round, grant, error: e.message });
1849
3756
  }
@@ -1854,20 +3761,29 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1854
3761
  const pagesRead = new Set();
1855
3762
  for (let first = true; first || (page.next && !pagesRead.has(page.next)); first = false) {
1856
3763
  pagesRead.add(page.next);
1857
- page = await get(`/v1/wins?game=${q(project)}&wallet=${q(wallet.address)}${page.next ? `&before=${q(page.next)}` : ''}`);
1858
- for (const win of page.wins.filter((x) => x.grant !== null && x.grant !== undefined)) {
3764
+ page = await get(
3765
+ `/v1/wins?game=${q(project)}&wallet=${q(wallet.address)}${page.next ? `&before=${q(page.next)}` : ''}`,
3766
+ );
3767
+ for (const win of page.wins.filter(x => x.grant !== null && x.grant !== undefined)) {
1859
3768
  // one win that fails (its vouchers refused, a sign-in run out, a list not there) is its own error line, and
1860
3769
  // the rest go on (a refused POST /v1/claims must not stop every later win)
1861
3770
  try {
1862
3771
  for (const v of await vouchersOf(win.round)) {
1863
- const grantKey = (spec.grants ?? []).find((x) => x.id === v.grant)?.key;
3772
+ const grantKey = (spec.grants ?? []).find(x => x.id === v.grant)?.key;
1864
3773
  if (!grantKey) continue;
1865
3774
  if (v.list) {
1866
3775
  // Paid by a list the owner wrote from the draw: its proof names the round.
1867
3776
  const list = await get(proofAt(v.proof));
1868
- const mine = list.winners.find((w) => w.wallet === wallet.address);
3777
+ const mine = list.winners.find(w => w.wallet === wallet.address);
1869
3778
  if (!mine || (await claimed(v.grant, null, list.round))) continue;
1870
- await take(win.round, v.grant, () => this.claimGrant(grantKey, { proof: mine.proof, personHash: mine.personHash, round: list.round, amount: mine.amount ?? 0 }));
3779
+ await take(win.round, v.grant, () =>
3780
+ this.claimGrant(grantKey, {
3781
+ proof: mine.proof,
3782
+ personHash: mine.personHash,
3783
+ round: list.round,
3784
+ amount: mine.amount ?? 0,
3785
+ }),
3786
+ );
1871
3787
  } else if (v.signature && !(await claimed(v.grant, v.incarnation, v.round))) {
1872
3788
  await take(win.round, v.grant, () => this.claimGrant(grantKey, { voucher: v }));
1873
3789
  }
@@ -1883,100 +3799,274 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1883
3799
  /**
1884
3800
  * What this player can finish now, themselves: a box to settle or refund, a contest entry to claim, a held
1885
3801
  * 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
3802
+ * won, a draw that is due (anyone may reveal it, or ask again when the oracle never answered; it takes 0.0008
1887
3803
  * SOL), and a drawn lottery's unpaid places, which anyone may pay so the next round can open. [{ kind, says, … }];
1888
- * pass one to `claim`.
3804
+ * a winner's first claim of a contest of tokens carries `potUnits` and `prizeUnits`, its part as the decision wrote
3805
+ * it (base units, as text). Pass one to `claim`.
1889
3806
  */
1890
3807
  async claims() {
1891
3808
  const me = wallet.address;
1892
3809
  const out = [];
1893
- const tag = (t) => ({ memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS[t])) } });
3810
+ const tag = t => ({ memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS[t])) } });
1894
3811
  const at = (offset, bytes) => ({ memcmp: { offset, bytes } });
1895
3812
  const slot = await slotNow();
1896
3813
  const time = BigInt(now());
1897
- for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Order'), at(1, project), at(33, me)])) {
3814
+ const chainTime = await chainNow();
3815
+ for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [
3816
+ tag('Order'),
3817
+ at(1, project),
3818
+ at(33, me),
3819
+ ])) {
1898
3820
  const o = loot.decode('Order', account.data);
1899
3821
  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.' });
1902
- 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).' });
3822
+ if (loot.orderRefundable(o, slot, chainTime))
3823
+ out.push({ kind: 'refund', order: pubkey, says: 'Your payment back for an order that never finished.' });
3824
+ else if (o.status === 'Rolled' && slot > opened)
3825
+ out.push({ kind: 'settle', order: pubkey, says: 'What your box rolled.' });
3826
+ else if (loot.rollAskable(o, slot, chainTime))
3827
+ out.push({
3828
+ kind: 'settle',
3829
+ order: pubkey,
3830
+ 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).',
3831
+ });
1904
3832
  }
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);
3833
+ for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [
3834
+ tag('Contest'),
3835
+ at(1, project),
3836
+ ])) {
3837
+ const contest = loot.decode('Contest', account.data);
1910
3838
  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 });
3839
+ if (!(off || contest.verdict === 'Won')) continue;
3840
+ const list = await client.account(loot.contestEntries(pubkey, contest));
3841
+ const entry =
3842
+ list?.owner === loot.LOOT_PROGRAM ? loot.decodeEntries(list.data).find(e => e.player === me) : null;
3843
+ if (!entry) continue;
3844
+ const won = !off && Number(contest.winner) === Number(entry.side);
3845
+ const first = won && !(entry.bits & loot.CLAIMED);
3846
+ // Owed: its money back (off) or its part of the pot (won), its own NFT while the contest holds it, a winner's
3847
+ // prize with its first claim, and the losers' NFTs its share still lets it pick. A loser of a contest of tokens
3848
+ // is owed nothing, and never sends a transaction.
3849
+ const picks =
3850
+ won && contest.nft && !(entry.bits & loot.ENDED) && Number(entry.taken) < loot.shareOf(contest, entry);
3851
+ const owed =
3852
+ (contest.nft ? Number(entry.amount) === 1 && (off || won) : BigInt(entry.amount) > 0n && (off || first)) ||
3853
+ first ||
3854
+ picks;
3855
+ if (!owed) continue;
3856
+ const says = off
3857
+ ? 'Your entry back: the contest is off.'
3858
+ : contest.nft
3859
+ ? `Your NFT back${picks ? ', and your pick of the losers’' : ''}.`
3860
+ : contest.prize != null
3861
+ ? 'Your share of the pot, and your prize.'
3862
+ : 'Your share of the pot.';
3863
+ // A winner's part of a contest of tokens is what the decision wrote, whoever claims first (`contestWinnings`).
3864
+ const winnings = first && !contest.nft ? loot.contestWinnings(contest, entry.amount) : null;
3865
+ out.push({
3866
+ kind: 'contest',
3867
+ id: contest.id,
3868
+ side: entry.side,
3869
+ says,
3870
+ ...(winnings ? { potUnits: String(winnings.pot), prizeUnits: String(winnings.prize) } : {}),
3871
+ });
1917
3872
  }
1918
- for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Hold'), at(1, project), at(65, me)])) {
3873
+ // The player's holds; the project's treasury wallet's also those owed to the treasury (a hold whose player is the
3874
+ // project's own address: a dropped stash NFT, a lottery's or an auction's NFT owed to it).
3875
+ const g = await projectNow();
3876
+ const holdsOf = [me, ...(g.treasury === me ? [project] : [])];
3877
+ const holds = (
3878
+ await Promise.all(
3879
+ holdsOf.map(who => client.programAccounts(loot.LOOT_PROGRAM, [tag('Hold'), at(1, project), at(65, who)])),
3880
+ )
3881
+ ).flat();
3882
+ for (const { pubkey, account } of holds) {
3883
+ const hold = loot.decode('Hold', account.data);
3884
+ // A repaid loan is a plain hold that is over (no debt, `free_at` 0): its NFT goes home whenever. An unpaid one's
3885
+ // is the lender's once due.
3886
+ if (!hold.debt && time >= BigInt(hold.free_at))
3887
+ out.push({
3888
+ kind: 'release',
3889
+ asset: hold.asset,
3890
+ hold: pubkey,
3891
+ says:
3892
+ hold.player === project
3893
+ ? 'An NFT owed to the treasury: send it there.'
3894
+ : 'Your collectible back: its hold is over.',
3895
+ });
3896
+ }
3897
+ // A loan this wallet made that is overdue and unpaid: its NFT is the lender's to take.
3898
+ for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [
3899
+ tag('Hold'),
3900
+ at(1, project),
3901
+ ])) {
1919
3902
  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.' });
3903
+ if (hold.debt?.lender === me && time >= BigInt(hold.free_at))
3904
+ out.push({
3905
+ kind: 'release',
3906
+ asset: hold.asset,
3907
+ hold: pubkey,
3908
+ says: 'A loan you made is overdue and unpaid: take its NFT.',
3909
+ });
3910
+ }
3911
+ // A compressed NFT's proof record this wallet paid for and left open: closing it brings its rent back.
3912
+ for (const { account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Proven')])) {
3913
+ const leaf = (() => {
3914
+ try {
3915
+ return loot.decodeProven(account.data);
3916
+ } catch {
3917
+ return null;
3918
+ }
3919
+ })();
3920
+ if (leaf?.payer === me)
3921
+ out.push({
3922
+ kind: 'close-leaf',
3923
+ asset: leaf.asset,
3924
+ tree: leaf.tree,
3925
+ says: 'A compressed NFT’s proof record you paid for: close it, and its rent comes back.',
3926
+ });
1923
3927
  }
1924
3928
  const auctions = spec.auctions ?? [];
1925
- const found = await client.accounts(auctions.map((a) => auctionAt(a)));
3929
+ const found = await client.accounts(auctions.map(a => auctionAt(a)));
1926
3930
  auctions.forEach((a, i) => {
1927
3931
  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.` });
3932
+ if (!x || time < loot.auctionEnds(x)) return;
3933
+ // The lot went at its end, and its seller's account couldn't take the money then: only the money is left to pay.
3934
+ if (BigInt(x.proceeds_owed) > 0n) {
3935
+ if (x.seller === me)
3936
+ out.push({
3937
+ kind: 'auction',
3938
+ key: a.key,
3939
+ 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.`,
3940
+ });
3941
+ return;
3942
+ }
3943
+ if (x.high_bidder === me) out.push({ kind: 'auction', key: a.key, says: `You won “${a.name}”: take it.` });
3944
+ // The seller's own auction, over: ending it pays them (or brings an unsold lot home).
3945
+ else if (x.seller === me)
3946
+ out.push({
3947
+ kind: 'auction',
3948
+ key: a.key,
3949
+ says:
3950
+ BigInt(x.high) > 0n
3951
+ ? `Your auction “${a.name}” is over: end it, and the winning bid is paid to you.`
3952
+ : `Your auction “${a.name}” ended with no bid: take your lot back.`,
3953
+ });
1930
3954
  });
1931
3955
  for (const l of spec.lotteries ?? []) {
1932
3956
  // A lottery the page names but nobody has written yet (a raffle still to open) has nothing to finish.
1933
3957
  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.` });
1947
- }
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.` });
3958
+ const { current, rounds } = await roundOf(l);
3959
+ const bought = r => r.entries.some(e => e.buyer === me);
3960
+ if (current?.status === 'Open' && time >= BigInt(current.terms.draws_at)) {
3961
+ const short = BigInt(current.sold) < BigInt(current.terms.min_sold ?? 0);
3962
+ out.push({
3963
+ kind: 'reveal',
3964
+ key: l.key,
3965
+ says: short
3966
+ ? `“${l.name}” sold fewer tickets than it needs: call it off${bought(current) ? ' and take your ticket payment back, in one step' : ''}.`
3967
+ : `The draw of “${l.name}” is due: reveal the winners.`,
3968
+ });
1952
3969
  }
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).` });
3970
+ for (const { address: round, round: r } of rounds) {
3971
+ // The ask moved the lottery on: a round waiting for the oracle is an older one, asked again by its address,
3972
+ // and after the timeout the next ask cancels it.
3973
+ if (loot.lotteryCancellable(r, chainTime))
3974
+ out.push({
3975
+ kind: 'cancel-draw',
3976
+ key: l.key,
3977
+ round,
3978
+ 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'}.`,
3979
+ });
3980
+ else if (loot.lotteryDrawStuck(r, chainTime))
3981
+ out.push({
3982
+ kind: 'reveal',
3983
+ key: l.key,
3984
+ round,
3985
+ says: `The oracle never answered the draw of “${l.name}”: ask it again.`,
3986
+ });
3987
+ if (r.status !== 'Drawn' && r.status !== 'Cancelled') continue;
3988
+ // What this wallet is owed: a refund, its NFT ticket, its places' parts of the pot and their prizes.
3989
+ const mine = r.entries.flatMap((e, i) => {
3990
+ const parts = e.buyer === me ? loot.partsOwed(r, i) : [];
3991
+ return parts.length ? [{ entry: i, parts }] : [];
3992
+ });
3993
+ if (mine.length)
3994
+ out.push({
3995
+ kind: 'pull',
3996
+ key: l.key,
3997
+ round,
3998
+ pulls: mine,
3999
+ says:
4000
+ r.status === 'Cancelled'
4001
+ ? `Recover your ticket payment for “${l.name}”.`
4002
+ : `Take what you are owed in “${l.name}”.`,
4003
+ });
4004
+ // Anyone may pay everyone else what they are owed, and the parts owed in common (about 0.002 SOL of account
4005
+ // rent for each payee who has no account for it yet, which stays with them): the last pull closes the round,
4006
+ // and its rent goes back to whoever fronted it (the lottery, or its first buyer).
4007
+ const others = r.entries.flatMap((e, i) =>
4008
+ loot.partsOwed(r, i).length && !mine.some(m => m.entry === i) ? [i] : [],
4009
+ );
4010
+ const fronted = r.opener === me;
4011
+ if (others.length || loot.commonOwed(r).length || (loot.roundFinished(r) && !mine.length))
4012
+ out.push({
4013
+ kind: 'payout',
4014
+ key: l.key,
4015
+ round,
4016
+ says: fronted
4017
+ ? `Pay everyone of “${l.name}” what they are owed: the round then closes, and your deposit for it comes back.`
4018
+ : `Pay the others of “${l.name}” what they are owed, so its round can close.`,
4019
+ });
1958
4020
  }
1959
4021
  }
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.` });
4022
+ // A farm idle past more ends than a move can walk: anyone may walk it, in parts.
4023
+ for (const f of spec.farms ?? []) {
4024
+ const farm = await readMaybe('Farm', loot.pda.farm(project, f.id));
4025
+ if (!farm?.keeps_ends) continue;
4026
+ const { left } = await endsOf(f, farm);
4027
+ if (left > loot.MOVE_WALK_ROOM)
4028
+ out.push({
4029
+ kind: 'walk',
4030
+ key: f.key,
4031
+ 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.`,
4032
+ });
1966
4033
  }
1967
4034
  // A sealed schedule that pays this wallet: its runs that are due (vesting nobody can take back).
1968
4035
  for (const sc of spec.schedules ?? []) {
1969
4036
  const schedule = await readMaybe('Schedule', loot.pda.schedule(project, sc.id));
1970
- if (!schedule?.action.from.Vest || schedule.action.to.Wallet?.[0] !== me) continue;
4037
+ if (!schedule) continue;
4038
+ if (!schedule.action.from.Vest) {
4039
+ // Any other schedule that is due: it waits for the project's keys or whoever it pays.
4040
+ const state = await projectNow();
4041
+ const to = schedule.action.to;
4042
+ if (
4043
+ [state.owner, state.authority].includes(me) ||
4044
+ to.Wallet?.[0] === me ||
4045
+ (to.Treasury !== undefined && state.treasury === me)
4046
+ ) {
4047
+ if (loot.runsDue(schedule, chainTime) > 0)
4048
+ out.push({ kind: 'schedule', key: sc.key, says: `“${sc.name ?? sc.key}” is due to run: send it.` });
4049
+ }
4050
+ continue;
4051
+ }
4052
+ if (schedule.action.to.Wallet?.[0] !== me) continue;
1971
4053
  const vault = await client.account(loot.pda.vest(loot.pda.schedule(project, sc.id)));
1972
4054
  const held = vault ? vault.data.subarray(64, 72).reduce((n, b, i) => n | (BigInt(b) << BigInt(8 * i)), 0n) : 0n;
1973
- 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.` });
4055
+ if (loot.runsDue(schedule, chainTime, held) > 0)
4056
+ out.push({ kind: 'vest', key: sc.key, says: `What has vested of “${sc.name ?? sc.key}”: claim it.` });
1974
4057
  }
1975
4058
  // The player's listings whose rental is over: their NFT can go home (or wait for the next renter).
1976
4059
  const mineListed = await client.programAccounts(loot.LOOT_PROGRAM, [tag('Listing'), at(1, project), at(33, me)]);
1977
4060
  for (const { account } of mineListed) {
1978
4061
  const listing = loot.decode('Listing', account.data);
1979
- if (listing.deal.Rent && listing.user !== NOBODY && !loot.renterOf(listing, chainTime)) out.push({ kind: 'unlist', asset: listing.asset, says: listing.stopped ? 'The rental of your stopped listing is over: take your NFT home.' : 'A rental of yours is over: take your NFT home, or leave it listed for the next renter.' });
4062
+ if (listing.deal.Rent && listing.user !== NOBODY && !loot.renterOf(listing, chainTime))
4063
+ out.push({
4064
+ kind: 'unlist',
4065
+ asset: listing.asset,
4066
+ says: listing.stopped
4067
+ ? 'The rental of your stopped listing is over: take your NFT home.'
4068
+ : 'A rental of yours is over: take your NFT home, or leave it listed for the next renter.',
4069
+ });
1980
4070
  }
1981
4071
  // The lookup tables a transaction of this player's too big for a legacy one made (`send`): each is deactivated by
1982
4072
  // the transaction it was made for, and its rent comes back once it has cooled down (about 513 slots). One of this
@@ -1987,12 +4077,26 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1987
4077
  const recent = mineTables.length ? await recentSlots() : [];
1988
4078
  for (const { pubkey, account } of mineTables) {
1989
4079
  const table = readTable({ ...account, owner: account.owner ?? LOOKUP_TABLE_PROGRAM });
1990
- if (!table || table.authority !== me) continue;
4080
+ // The project's standing tables are never a transaction's own: never retired here.
4081
+ if (!table || table.authority !== me || (spec.live?.tables ?? []).includes(pubkey)) continue;
1991
4082
  const step = tableStep(table, slot, recent);
1992
4083
  const rent = show(BigInt(account.lamports ?? 0), 9);
1993
- 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.` });
1994
- else if (step === 'active' && table.addresses[0] === project && slot > table.lastExtendedSlot + TABLE_UNSENT_SLOTS)
1995
- out.push({ kind: 'lookup-table', table: pubkey, says: `A lookup table made for a large transaction of yours that was never sent: retire it, and its ${rent} SOL of rent comes back about two minutes later.` });
4084
+ if (step === 'close')
4085
+ out.push({
4086
+ kind: 'lookup-table',
4087
+ table: pubkey,
4088
+ says: `${rent} SOL of rent back from a lookup table a large transaction of yours used.`,
4089
+ });
4090
+ else if (
4091
+ step === 'active' &&
4092
+ table.addresses[0] === project &&
4093
+ slot > table.lastExtendedSlot + TABLE_UNSENT_SLOTS
4094
+ )
4095
+ out.push({
4096
+ kind: 'lookup-table',
4097
+ table: pubkey,
4098
+ says: `A lookup table made for a large transaction of yours that was never sent: retire it, and its ${rent} SOL of rent comes back about two minutes later.`,
4099
+ });
1996
4100
  }
1997
4101
  return out;
1998
4102
  },
@@ -2002,90 +4106,230 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2002
4106
  * which loser's NFT they take (`{ take: asset }`, the NFT of a losing entry that still holds one; the first found by default):
2003
4107
  * only the winner can, while any are left.
2004
4108
  */
2005
- async claim(c, { take, ...others } = NO_OPTIONS) {
2006
- only(others, ['take'], 'claim');
4109
+ async claim(c, { take, build = false, ...others } = NO_OPTIONS) {
4110
+ only(others, ['take', 'build'], 'claim');
2007
4111
  const me = wallet.address;
4112
+ // `build`: its instructions, unsent, for a step that is one transaction of the wallet's (`claimAll` signs several
4113
+ // at once); null for one that is not (a roll to wait for, a refund, a step of another flow).
4114
+ if (build && !BATCHED.has(c.kind)) return null;
4115
+ const send = build ? async instructions => instructions : sendNow;
2008
4116
  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);
4117
+ if (c.kind === 'settle' || c.kind === 'refund') {
4118
+ // A settle key this order's open funded (made again from this session's signature, or stored): it settles with
4119
+ // no prompt, or gives its SOL back before the refund.
4120
+ const key = keys ? await keys.again(c.order, { ask: false }) : null;
4121
+ const funded = key && BigInt(await client.balance(key.address).catch(() => 0n)) > 0n ? key : null;
4122
+ if (c.kind === 'settle') return (await collect(c.order, funded ? { burner: funded } : {})).settled;
4123
+ if (funded) await emptyKey(funded);
4124
+ return this.refund(c.order);
4125
+ }
2011
4126
  if (c.kind === 'contest') {
2012
4127
  const contestAddress = loot.pda.contest(project, c.id);
2013
4128
  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 = {};
4129
+ const entries = loot.decodeEntries((await client.account(loot.contestEntries(contestAddress, contest))).data);
4130
+ const i = entries.findIndex(e => e.player === me);
4131
+ const own = entries[i];
4132
+ const won = contest.verdict === 'Won' && Number(contest.winner) === Number(own.side);
4133
+ const given = await prizeInputs([won && !(own.bits & loot.CLAIMED) ? contest.prize : null]);
4134
+ // A contest of NFTs: the entry's own NFT while the contest holds it, and for a winner the losers' NFTs its share
4135
+ // lets it pick (`take`: one asset, or several; the first left by default), as many as fit.
4136
+ const nfts = [];
4137
+ const kinds = {};
2018
4138
  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) => {
4139
+ const kindOf = async (asset, to) => {
2022
4140
  const { before, hasAuthority: _, ...kind } = await nftOf(asset, me, { to });
2023
4141
  given.before.push(...before);
2024
- return kind;
4142
+ kinds[asset] = kind;
2025
4143
  };
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) };
4144
+ if (Number(own.amount) === 1) {
4145
+ nfts.push(i);
4146
+ await kindOf(own.nft, me);
4147
+ }
4148
+ const share = won && !(own.bits & loot.ENDED) ? loot.shareOf(contest, own) - Number(own.taken) : 0;
4149
+ const left = entries
4150
+ .map((e, j) => ({ e, j }))
4151
+ .filter(({ e }) => Number(e.side) !== Number(contest.winner) && Number(e.amount) === 1);
4152
+ const wanted = take ? [take].flat() : [];
4153
+ const picked = (wanted.length ? left.filter(({ e }) => wanted.includes(e.nft)) : left).slice(
4154
+ 0,
4155
+ Math.min(share, 4),
4156
+ );
4157
+ if (wanted.length && picked.length < Math.min(wanted.length, share))
4158
+ throw new Error(`${wanted.join(', ')} is not a loser’s NFT left in this contest for you to pick.`);
4159
+ for (const { e, j } of picked) {
4160
+ nfts.push(j);
4161
+ await kindOf(e.nft, me);
2035
4162
  }
2036
4163
  }
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 })]);
4164
+ const claimed = [
4165
+ computeUnitLimit(800_000),
4166
+ ...given.before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
4167
+ loot.claimIx({
4168
+ payer: me,
4169
+ project,
4170
+ projectState: g,
4171
+ id: c.id,
4172
+ contest,
4173
+ entries,
4174
+ player: me,
4175
+ nfts: contest.nft ? nfts : undefined,
4176
+ kinds,
4177
+ collection: given.collection,
4178
+ poolMints: given.poolMints,
4179
+ items: given.items,
4180
+ nft: given.nft,
4181
+ programs,
4182
+ }),
4183
+ ];
4184
+ // A prize that rolls: asked for in the claim's own transaction and settled by a key after (one prompt).
4185
+ return contest.prize?.Roll && !build ? (await sendRolling(claimed)).signature : send(claimed);
2038
4186
  }
2039
4187
  if (c.kind === 'release') {
2040
4188
  const hold = await read('Hold', c.hold);
2041
4189
  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 })]);
4190
+ // An overdue loan's NFT goes to its lender.
4191
+ return send([
4192
+ ...before,
4193
+ loot.releaseIx({
4194
+ signer: me,
4195
+ project,
4196
+ asset: c.asset,
4197
+ ...nft,
4198
+ collection: (await collectionOf(c.asset)) ?? MPL_CORE,
4199
+ hold,
4200
+ address: c.hold,
4201
+ treasury: g.treasury,
4202
+ ...(hold.debt ? { to: hold.debt.lender } : {}),
4203
+ }),
4204
+ ]);
2043
4205
  }
2044
4206
  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);
4207
+ if (c.kind === 'close-leaf') return send([loot.closeLeafIx({ asset: c.asset, tree: c.tree, payer: me })]);
4208
+ if (c.kind === 'walk') return this.walkFarm(c.key);
4209
+ if (c.kind === 'vest' || c.kind === 'schedule') return this.runSchedule(c.key);
2047
4210
  if (c.kind === 'unlist') return this.unlist(c.asset);
2048
4211
  if (c.kind === 'lookup-table') {
2049
4212
  const table = readTable(await client.account(c.table));
2050
4213
  const step = table && tableStep(table, await slotNow(), await recentSlots());
2051
4214
  if (!step || step === 'cooling') return null;
2052
- return send([step === 'close' ? closeTableIx({ table: c.table, authority: me }) : deactivateTableIx({ table: c.table, authority: me })], [], { keepWrapped: true });
4215
+ return send(
4216
+ [
4217
+ step === 'close'
4218
+ ? closeTableIx({ table: c.table, authority: me })
4219
+ : deactivateTableIx({ table: c.table, authority: me }),
4220
+ ],
4221
+ [],
4222
+ { keepWrapped: true },
4223
+ );
2053
4224
  }
4225
+ if (!LOTTERY_STEPS.has(c.kind)) throw new Error(`Nothing to do for a claim of kind ${c.kind}.`);
2054
4226
  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 })]);
4227
+ // An older round, named by its address, needs no lottery: one closed since never strands it.
4228
+ const lottery = c.round
4229
+ ? await readMaybe('Lottery', loot.pda.lottery(project, l.id))
4230
+ : await read('Lottery', loot.pda.lottery(project, l.id));
4231
+ const key = c.round ?? loot.currentRound(loot.pda.lottery(project, l.id), lottery);
4232
+ const found = await client.account(key);
4233
+ const r = found?.owner === loot.LOOT_PROGRAM ? loot.decodeRound(found.data) : null;
4234
+ // The ask: the current round's once due, or an older round's again (after the timeout, it cancels the round).
4235
+ if (c.kind === 'reveal' || c.kind === 'cancel-draw') {
4236
+ const steps = await drawStep(l, lottery, c.round ?? null, r, me);
4237
+ // A draw that cancels the round (short of its least tickets, or the oracle never answered by the timeout)
4238
+ // refunds in the same transaction: this wallet's own parts are pulled right after it.
4239
+ const refunds = r ? await cancelPulls(key, r, me) : [];
4240
+ if (refunds.length) {
4241
+ const all = [
4242
+ computeUnitLimit(1_400_000),
4243
+ ...[...steps, ...refunds].filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
4244
+ ];
4245
+ if ((await dryRun(me, all)).ok) return build ? all : send(all);
4246
+ }
4247
+ return build ? steps : send(steps);
2063
4248
  }
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 })]);
4249
+ if (!r) return null;
4250
+ // Pulls: this wallet's own parts, or (`payout`) everyone's and the parts owed in common, a few to a transaction;
4251
+ // when they are the last owed, the last pull closes the round. A round that owes nothing is only closed.
4252
+ // A named stash NFT that is gone refuses a stranger's pull: only its winner or a project key passes it over.
4253
+ const projectKey = me === g.owner || me === g.authority;
4254
+ const stranger = async (i, p) => {
4255
+ const named = p.Prize ? r.terms.places[Number(p.Prize[0])].prize?.Nft : null;
4256
+ if (!named || projectKey || r.entries[i].buyer === me) return false;
4257
+ const stash = loot.decodeStash((await client.account(loot.pda.stash(project, named.stash)))?.data ?? null);
4258
+ return !stash?.nfts.includes(named.nft);
4259
+ };
4260
+ const theirs = [];
4261
+ if (c.kind !== 'pull')
4262
+ for (const i of r.entries.keys()) {
4263
+ const parts = [];
4264
+ for (const p of loot.partsOwed(r, i)) if (!(await stranger(i, p))) parts.push(p);
4265
+ if (parts.length) theirs.push({ entry: i, parts });
4266
+ }
4267
+ const owed =
4268
+ c.kind === 'pull' ? c.pulls : [...loot.commonOwed(r).map(p => ({ entry: null, parts: [p] })), ...theirs];
4269
+ if (!owed.length) {
4270
+ if (!loot.roundFinished(r)) return null;
4271
+ const close = [loot.pullIx({ payer: me, project, roundAddress: key, round: r, projectState: g, programs })];
4272
+ return build ? close : send(close);
2076
4273
  }
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));
4274
+ const batch = owed.slice(0, 4);
4275
+ const settles = batch.reduce(
4276
+ (n, { entry, parts }) => n + parts.reduce((m, p) => m + pullSettles(r, entry, p), 0),
4277
+ 0,
4278
+ );
4279
+ const closes = settles >= Number(r.owed);
4280
+ const instructions = [computeUnitLimit(1_400_000)];
4281
+ const rolls = [];
4282
+ for (const [k, { entry, parts }] of batch.entries()) {
4283
+ const built = await pulls(key, r, entry, parts, me, { closes: closes && k === batch.length - 1 });
4284
+ instructions.push(...built.instructions.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM));
4285
+ rolls.push(...built.rolls);
2087
4286
  }
2088
- throw new Error(`Nothing to do for a claim of kind ${c.kind}.`);
4287
+ // A place's roll is asked for in the pull's own transaction (catalog P1): the winner's own, with a key that settles
4288
+ // it after (one prompt); someone else's, asked for when the box can give, else left for its winner to ask.
4289
+ if (!rolls.length) return build ? instructions : send(instructions);
4290
+ if (c.kind === 'pull' && !build) return (await sendRolling(instructions)).signature;
4291
+ const asks = [];
4292
+ for (const { order, box } of rolls)
4293
+ asks.push(...(await asksFor(me, order, { project, loot_box: loot.pda.box(project, box) })));
4294
+ const all = [...instructions, ...asks];
4295
+ const asked = (await dryRun(me, all)).ok ? all : instructions;
4296
+ return build ? asked : send(asked);
4297
+ },
4298
+
4299
+ /**
4300
+ * Everything in `list` (by default `claims()`) taken in as few prompts as it can: each step that is one transaction
4301
+ * of the wallet's is built first and the lot signed in one prompt when the wallet signs several at once
4302
+ * (`wallet.signTransactions`); a lottery's steps depend on each other, so one of each lottery a call. The rest (a roll
4303
+ * to settle, a refund, an auction to end…) go one by one after. Returns [{ claim, signature } | { claim, error }].
4304
+ */
4305
+ async claimAll(list) {
4306
+ list ??= await this.claims();
4307
+ const [built, alone, lotteries] = [[], [], new Set()];
4308
+ const out = [];
4309
+ for (const c of list) {
4310
+ if (!BATCHED.has(c.kind)) {
4311
+ alone.push(c);
4312
+ continue;
4313
+ }
4314
+ const lottery = LOTTERY_STEPS.has(c.kind) ? c.key : null;
4315
+ if (lottery !== null && lotteries.has(lottery)) continue;
4316
+ const instructions = await this.claim(c, { build: true }).catch(
4317
+ e => (out.push({ claim: c, error: e.message }), null),
4318
+ );
4319
+ if (!instructions?.length) continue;
4320
+ if (lottery !== null) lotteries.add(lottery);
4321
+ built.push([c, instructions]);
4322
+ }
4323
+ const sent = await sendBatch(built.map(([, instructions]) => instructions));
4324
+ out.push(...built.map(([claim], i) => ({ claim, ...sent[i] })));
4325
+ for (const claim of alone)
4326
+ out.push(
4327
+ await this.claim(claim).then(
4328
+ signature => ({ claim, signature }),
4329
+ e => ({ claim, error: e.message }),
4330
+ ),
4331
+ );
4332
+ return out;
2089
4333
  },
2090
4334
 
2091
4335
  /** The player's collectibles of this project: [{ asset, item, itemId, name, uri, attributes, frozen }]. `item` is its key in the spec. */
@@ -2099,11 +4343,12 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2099
4343
  if (t.source === 'sol') out[t.key] = show(await client.balance(owner), 9);
2100
4344
  else if (mint) {
2101
4345
  const a = await client.account(loot.ata(owner, mint, programOf(mint)));
2102
- out[t.key] = a ? show(new DataView(a.data.buffer, a.data.byteOffset).getBigUint64(64, true), t.decimals ?? 0) : '0';
4346
+ out[t.key] = a
4347
+ ? show(new DataView(a.data.buffer, a.data.byteOffset).getBigUint64(64, true), t.decimals ?? 0)
4348
+ : '0';
2103
4349
  }
2104
4350
  }
2105
4351
  return out;
2106
4352
  },
2107
4353
  };
2108
4354
  }
2109
-