@xgamesfun/solana 0.24.0 → 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 (92) hide show
  1. package/README.md +94 -51
  2. package/bin/xsolana.mjs +23 -8
  3. package/examples/blind-forge.json +2 -4
  4. package/examples/coin-flip-arena.json +2 -5
  5. package/examples/duel-arena.json +1 -4
  6. package/examples/fair-presale.json +1 -3
  7. package/examples/hero-trials.json +14 -2
  8. package/examples/holder-den.json +38 -0
  9. package/examples/holders-drop.json +105 -0
  10. package/examples/ideas.json +788 -788
  11. package/examples/match-predictions.json +24 -0
  12. package/examples/nft-raffle.json +1 -0
  13. package/examples/pack-league.json +108 -0
  14. package/examples/partner-vault.json +19 -3
  15. package/examples/sacrifice-raffle.json +2 -10
  16. package/examples/shelter-fair.json +1 -0
  17. package/examples/stakers-draw.json +3 -7
  18. package/examples/stride-sneakers.json +164 -0
  19. package/examples/trade-in-shop.json +2 -3
  20. package/js/approve/details.mjs +117 -48
  21. package/js/approve/ed25519.mjs +5 -1
  22. package/js/approve/live-check.mjs +83 -26
  23. package/js/approve/page.html +217 -78
  24. package/js/approve/page.js +43 -15
  25. package/js/approve/server.mjs +276 -74
  26. package/js/chain/base58.js +7 -1
  27. package/js/chain/borsh.js +15 -11
  28. package/js/chain/bytes.js +11 -8
  29. package/js/chain/core.js +21 -5
  30. package/js/chain/curve.js +1 -1
  31. package/js/chain/lookup.js +118 -36
  32. package/js/chain/mint.js +6 -2
  33. package/js/chain/pda.js +2 -2
  34. package/js/chain/programs.js +6 -6
  35. package/js/chain/rpc.js +40 -14
  36. package/js/chain/token.js +13 -2
  37. package/js/chain/token2022.js +55 -16
  38. package/js/chain/tx.js +32 -22
  39. package/js/client/accounts.js +270 -80
  40. package/js/client/compressed.js +4 -3
  41. package/js/client/grant.js +165 -40
  42. package/js/client/idl/loot.js +1 -1
  43. package/js/client/idl/loot.json +104 -16
  44. package/js/client/ix.js +2121 -457
  45. package/js/client/pda.js +20 -18
  46. package/js/client/policy.js +12 -5
  47. package/js/client/random.js +1 -1
  48. package/js/client/sizes.js +220 -65
  49. package/js/client/traits.js +14 -5
  50. package/js/connect/connect.mjs +59 -17
  51. package/js/economy/check.mjs +3873 -690
  52. package/js/economy/cost.mjs +380 -56
  53. package/js/economy/examples.mjs +176 -35
  54. package/js/economy/explain.mjs +461 -110
  55. package/js/economy/guide.mjs +282 -59
  56. package/js/economy/legal.mjs +12 -6
  57. package/js/economy/live.mjs +60 -19
  58. package/js/economy/model.mjs +320 -97
  59. package/js/economy/plan.mjs +1022 -203
  60. package/js/economy/schema.json +127 -369
  61. package/js/economy/secrets.mjs +17 -7
  62. package/js/economy/simulate.mjs +771 -178
  63. package/js/economy/txsize.mjs +15 -3
  64. package/js/economy/validate.mjs +61 -24
  65. package/js/mcp/server.mjs +42 -14
  66. package/js/mcp/tools.mjs +293 -80
  67. package/js/play/index.js +1960 -461
  68. package/js/play/keys.js +35 -9
  69. package/js/results/index.js +223 -30
  70. package/js/results/server.js +230 -0
  71. package/js/results/wire.js +64 -16
  72. package/package.json +6 -4
  73. package/release.json +5 -5
  74. package/skills/xsolana-economy/SKILL.md +30 -16
  75. package/skills/xsolana-economy/references/after-launch.md +30 -20
  76. package/skills/xsolana-economy/references/apps.md +47 -36
  77. package/skills/xsolana-economy/references/sentence-to-spec.md +148 -129
  78. package/skills/xsolana-economy/references/telling-the-person.md +9 -6
  79. package/types/chain/core.d.ts +1 -1
  80. package/types/chain/lookup.d.ts +1 -1
  81. package/types/client/accounts.d.ts +24 -3
  82. package/types/client/grant.d.ts +2 -2
  83. package/types/client/idl/loot.d.ts +22 -0
  84. package/types/client/ix.d.ts +54 -45
  85. package/types/client/pda.d.ts +3 -1
  86. package/types/economy/guide.d.mts +5 -0
  87. package/types/economy/model.d.mts +48 -3
  88. package/types/economy/plan.d.mts +44 -2
  89. package/types/play/index.d.ts +8 -19
  90. package/types/results/index.d.ts +7 -6
  91. package/types/results/server.d.ts +126 -0
  92. package/types/results/wire.d.ts +1 -1
package/js/play/index.js CHANGED
@@ -21,11 +21,39 @@
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
23
  import { ownedAssetFilters, parseCoreAsset, parseCoreCollection } 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';
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';
31
59
  import { browserStore, keyring } from './keys.js';
@@ -38,13 +66,13 @@ const BATCHED = new Set(['contest', 'release', 'lookup-table', 'close-leaf', ...
38
66
  const NO_OPTIONS = Object.freeze(/** @type {Record<string, any>} */ ({}));
39
67
 
40
68
  const CHAIN = 'solana:devnet';
41
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
69
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
42
70
  const MPL_CORE = loot.MPL_CORE_PROGRAM;
43
71
  const NOBODY = '11111111111111111111111111111111';
44
72
  /** A table made this many slots ago whose transaction never landed was never sent: its blockhashes ran out long since. */
45
73
  const TABLE_UNSENT_SLOTS = 300n;
46
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. */
47
- const isProveLeaf = (ix) => ix.programId === loot.LOOT_PROGRAM && loot.instructionOf(ix.data)?.name === 'prove_leaf';
75
+ const isProveLeaf = ix => ix.programId === loot.LOOT_PROGRAM && loot.instructionOf(ix.data)?.name === 'prove_leaf';
48
76
  /** A system account must keep this much (rent for 0 bytes) or be emptied. */
49
77
  const RENT_EMPTY = 890_880n;
50
78
  const SIGNATURE_FEE = 5_000n;
@@ -64,10 +92,14 @@ export function walletStandardSigner(wallet, account, chain = CHAIN) {
64
92
  const [out] = await feature.signTransaction({ account, transaction: wire, chain });
65
93
  return out.signedTransaction;
66
94
  },
67
- ...(messages ? { async signMessage(message) {
68
- const [out] = await messages.signMessage({ account, message });
69
- return out.signature;
70
- } } : {}),
95
+ ...(messages
96
+ ? {
97
+ async signMessage(message) {
98
+ const [out] = await messages.signMessage({ account, message });
99
+ return out.signature;
100
+ },
101
+ }
102
+ : {}),
71
103
  };
72
104
  }
73
105
 
@@ -75,7 +107,10 @@ export function walletStandardSigner(wallet, account, chain = CHAIN) {
75
107
  async function freshKey() {
76
108
  const pair = await globalThis.crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify']);
77
109
  const raw = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', pair.publicKey));
78
- 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
+ };
79
114
  }
80
115
 
81
116
  /**
@@ -84,17 +119,28 @@ async function freshKey() {
84
119
  * some other amount.
85
120
  */
86
121
  export const units = (amount, decimals) => {
87
- 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;
88
128
  const m = text === null ? null : /^(\d+)(?:\.(\d+))?$/.exec(text);
89
- 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
+ );
90
133
  const fraction = m[2] ?? '';
91
- 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}.`);
92
136
  return BigInt(m[1] + fraction.padEnd(decimals, '0').slice(0, decimals));
93
137
  };
94
138
  /** A number as plain decimal text (1e-7 → "0.0000001"). */
95
- 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));
96
140
  const show = (u, decimals) => {
97
- const s = BigInt(u).toString().padStart(decimals + 1, '0');
141
+ const s = BigInt(u)
142
+ .toString()
143
+ .padStart(decimals + 1, '0');
98
144
  const f = s.slice(s.length - decimals).replace(/0+$/, '');
99
145
  return `${s.slice(0, s.length - decimals)}${f ? `.${f}` : ''}`;
100
146
  };
@@ -110,17 +156,20 @@ function devnetOnly(client) {
110
156
  if (!/^(https?|wss?):/i.test(String(client.url ?? ''))) return client;
111
157
  let checked = null;
112
158
  // A check that could not be made (the network did not answer) is made again on the next request.
113
- const ready = () => (checked ??= checkCluster(client).catch((e) => {
114
- checked = null;
115
- throw e;
116
- }));
159
+ const ready = () =>
160
+ (checked ??= checkCluster(client).catch(e => {
161
+ checked = null;
162
+ throw e;
163
+ }));
117
164
  return new Proxy(client, {
118
165
  get(target, key) {
119
166
  const value = Reflect.get(target, key, target);
120
- return typeof value === 'function' ? async (...args) => {
121
- await ready();
122
- return value.apply(target, args);
123
- } : value;
167
+ return typeof value === 'function'
168
+ ? async (...args) => {
169
+ await ready();
170
+ return value.apply(target, args);
171
+ }
172
+ : value;
124
173
  },
125
174
  });
126
175
  }
@@ -133,34 +182,50 @@ function devnetOnly(client) {
133
182
  * be made again from the wallet's signature is kept, encrypted (`browserStore()` in a browser; null keeps keys in memory
134
183
  * only).
135
184
  */
136
- export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc = null, wallet, programs = {}, now = () => Math.floor(Date.now() / 1000), keyStore = browserStore() }) {
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
+ }) {
137
194
  const live = spec?.live;
138
195
  if (!live?.project) throw new Error('This economy is not live yet: its xsolana.json has no "live" section.');
139
- if (rpc && typeof rpc.call !== 'function') throw new Error('The rpc given has no call(method, params): a custom rpc needs it (the devnet check, the slot and compressed NFTs are read through it), as well as accounts, account, latestBlockhash, send, confirm and programAccounts.');
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
+ );
140
200
  const client = devnetOnly(rpc ?? rpcClient(notMainnet(rpcUrl)));
141
201
  const project = live.project;
142
202
  /** The settle keys, each named by its job's account (`keys.js`): made again after a reload (`recover`). */
143
203
  const keys = wallet?.address ? keyring({ wallet, project, store: keyStore }) : null;
144
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. */
145
- const jobKey = async (name) => (keys ? keys.key(name) : freshKey());
205
+ const jobKey = async name => (keys ? keys.key(name) : freshKey());
146
206
  /** The project's Core collections: an NFT of one of them is the project's own (a project may have none). */
147
207
  const collections = live.collections ?? (live.collection ? [live.collection] : []);
148
- const ours = (collection) => collection != null && collections.includes(collection);
149
- 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]));
150
210
  const currencyKey = spec.currency;
151
- const mintOf = (key) => {
211
+ const mintOf = key => {
152
212
  const t = tokens.get(key ?? currencyKey);
153
213
  if (!t) throw new Error(`No token “${key}”.`);
154
- 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);
155
215
  };
156
- const decimalsOf = (key) => {
216
+ const decimalsOf = key => {
157
217
  const t = tokens.get(key ?? currencyKey);
158
- return t?.source === 'sol' ? 9 : t?.decimals ?? 0;
218
+ return t?.source === 'sol' ? 9 : (t?.decimals ?? 0);
159
219
  };
160
- programs = { ...Object.fromEntries([...tokens.values()].map(t => [mintOf(t.key), t.program === 'token-2022' ? TOKEN_2022_PROGRAM : TOKEN_PROGRAM])), ...programs };
161
- const programOf = (mint) => programs[mint] ?? TOKEN_PROGRAM;
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,
225
+ };
226
+ const programOf = mint => programs[mint] ?? TOKEN_PROGRAM;
162
227
  const part = (list, key) => {
163
- const p = (spec[list] ?? []).find((x) => x.key === key);
228
+ const p = (spec[list] ?? []).find(x => x.key === key);
164
229
  if (!p) throw new Error(`No ${list.replace(/s$/, '')} “${key}” in this economy.`);
165
230
  return p;
166
231
  };
@@ -176,7 +241,8 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
176
241
  return a?.owner === loot.LOOT_PROGRAM ? loot.decode(name, a.data) : null;
177
242
  };
178
243
  /** The key of the token a lottery's tickets are paid in: the spec's `ticketCurrency`, or the token of its mint. */
179
- const ticketCurrencyOf = (l, lottery) => l.ticketCurrency ?? [...tokens.keys()].find((k) => mintOf(k) === lottery.terms.mint) ?? currencyKey;
244
+ const ticketCurrencyOf = (l, lottery) =>
245
+ l.ticketCurrency ?? [...tokens.keys()].find(k => mintOf(k) === lottery.terms.mint) ?? currencyKey;
180
246
  /** Farm hooks shared by all actions. Static lists resolve automatically. For dynamic hooks,
181
247
  * transfers[mint] describes every actual transfer, including fee-adjusted reward credit/payout, and in a farm that
182
248
  * shares its pool the move's last one when a seat gave rewards back in it (an early exit, a watched
@@ -184,39 +250,52 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
184
250
  * Explicit extra replaces resolution and must include all token hook/memo and Core adapter accounts. */
185
251
  const farmTokens = async (mints, create, { extra, transfers = {} } = {}) => {
186
252
  const selected = [...new Set(mints)];
187
- const resolved = await Promise.all(selected.map(async (mint) => {
188
- if (extra !== undefined) {
189
- const account = await client.account(mint);
190
- if (![TOKEN_PROGRAM, TOKEN_2022_PROGRAM].includes(account?.owner)) throw new Error(`There is no supported token ${mint} on this network.`);
191
- return { mint, tokenProgram: account.owner, extraAccounts: [] };
192
- }
193
- try {
194
- return { mint, ...await resolveTransfers(client, mint, transfers[mint] ?? []) };
195
- } catch (cause) {
196
- throw new Error(`Cannot resolve farm transfers for ${mint}: ${cause.message} Supply exact transfers keyed by mint, or explicit extra hook/memo accounts.`, { cause });
197
- }
198
- }));
199
- 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
+ };
200
275
  return {
201
276
  programs: tokenPrograms,
202
- extra: extra ?? resolved.flatMap((r) => r.extraAccounts),
203
- 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
+ ),
204
281
  };
205
282
  };
206
- const farmCollection = async (asset) => {
283
+ const farmCollection = async asset => {
207
284
  const account = await client.account(asset);
208
285
  // Core tombstones and missing assets remain valid farm-cleanup paths.
209
286
  return account?.owner === MPL_CORE && account.data[0] === 1
210
- ? parseCoreAsset(account.data).collection ?? collections[0]
287
+ ? (parseCoreAsset(account.data).collection ?? collections[0])
211
288
  : collections[0];
212
289
  };
213
290
  /** The Core collection an asset is in, or null. */
214
- const collectionOf = async (asset) => {
291
+ const collectionOf = async asset => {
215
292
  const account = await client.account(asset);
216
- 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;
217
296
  };
218
297
  /** A Token Metadata NFT's { programmable, ruleSet } from its metadata, or null for anything else (a Core asset). */
219
- const tokenMetadataOf = async (asset) => {
298
+ const tokenMetadataOf = async asset => {
220
299
  const account = await client.account(asset);
221
300
  if (account?.owner !== TOKEN_PROGRAM) return null;
222
301
  return loot.decodeTokenMetadata((await client.account(loot.tokenMetadata.metadata(asset)))?.data);
@@ -237,7 +316,9 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
237
316
  const fresh = loot.freshNftAccount({ payer, holder, mint: asset, index, there: await client.account(address) });
238
317
  if (fresh) return { into: fresh.address, before: fresh.instructions };
239
318
  }
240
- 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
+ );
241
322
  }
242
323
  /**
243
324
  * What a route needs of an NFT, whatever its kind: { collection, hasAuthority, tokenMetadata, compressed, before }.
@@ -254,12 +335,19 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
254
335
  const proof = await client.call('getAssetProof', { id: asset });
255
336
  const tree = await client.account(das.compression.tree);
256
337
  const compressed = loot.compressedFromDas({ asset: das, proof, treeData: tree.data });
257
- 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
+ };
258
343
  }
259
344
  }
260
345
  const tokenMetadata = await tokenMetadataOf(asset);
261
346
  // A Token Metadata mint is taken only as an NFT: one token, no decimals, none more ever minted (NotAnNft).
262
- 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
+ );
263
351
  const { into, before } = tokenMetadata && to ? await nftInto(asset, to, payer) : { into: null, before: [] };
264
352
  return {
265
353
  collection: await farmCollection(asset),
@@ -276,7 +364,7 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
276
364
  */
277
365
  async function prizeInputs(prizes, signer = wallet.address, winner = wallet.address) {
278
366
  const out = { poolMints: {}, items: {}, nfts: {}, nft: undefined, collection: collections[0], before: [] };
279
- out.boxes = await boxesOf(prizes.filter(Boolean).flatMap((p) => (p.Roll ? [p.Roll[0]] : [])));
367
+ out.boxes = await boxesOf(prizes.filter(Boolean).flatMap(p => (p.Roll ? [p.Roll[0]] : [])));
280
368
  for (const prize of prizes.filter(Boolean)) {
281
369
  const pool = prize.Tokens?.from.Pool?.[0];
282
370
  if (pool !== undefined && !(pool in out.poolMints)) {
@@ -297,33 +385,50 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
297
385
  return out;
298
386
  }
299
387
  /** The Sheet the project keeps of an NFT it has no authority over: { key: value } of what the project wrote. */
300
- const sheetOf = async (nft) => {
388
+ const sheetOf = async nft => {
301
389
  const found = await client.account(loot.pda.sheet(project, nft));
302
- 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
+ );
303
396
  };
304
397
  let projectState = null;
305
398
  const projectNow = async () => (projectState ??= await read('Project', project));
306
399
  /** A spec auction's address: it names the seller, which xsolana.json records once it is live. */
307
- const auctionAt = (a) => {
400
+ const auctionAt = a => {
308
401
  if (!a.seller) throw new Error(`The auction “${a.key}” has no seller in xsolana.json: publish the economy again.`);
309
402
  return loot.pda.auction(project, a.seller, a.id);
310
403
  };
311
404
 
312
- 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;
313
406
  /** Who holds a collectible now. */
314
- const ownerOf = async (asset) => parseCoreAsset((await client.account(asset)).data).owner;
407
+ const ownerOf = async asset => parseCoreAsset((await client.account(asset)).data).owner;
315
408
  /** `{ asset, farm }` for a Core asset on its seat in one of the economy's farms of these custodies, or null. */
316
409
  const seatedIn = async (asset, custodies = ['escrow', 'freeze']) => {
317
- 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
+ )) {
318
413
  if (await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), asset))) return { asset, farm: f.id };
319
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
+ }
320
424
  return null;
321
425
  };
322
426
  /** The same for one the project holds: staked in escrow. */
323
- 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;
324
429
  /** The same for one the project locks in the player's wallet: staked in a farm that freezes (a soulbound Core asset
325
430
  * carries no lock to tell it is staked, so it goes alone). */
326
- const lockedIn = async (asset) => {
431
+ const lockedIn = async asset => {
327
432
  const seated = await seatedIn(asset, ['freeze']);
328
433
  if (!seated) return null;
329
434
  const account = await client.account(asset);
@@ -331,7 +436,9 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
331
436
  };
332
437
  /** The player's collectibles of this project (in any of its collections), each with the item it is (its "item_id" attribute). */
333
438
  async function owned(owner = wallet?.address) {
334
- 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();
335
442
  return found.map(({ pubkey, account }) => {
336
443
  const a = parseCoreAsset(account.data);
337
444
  const itemId = a.attributes?.item_id !== undefined ? Number(a.attributes.item_id) : null;
@@ -342,7 +449,10 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
342
449
  * a stake or a compound's transaction (`loot.stakeRefreshFirst`). */
343
450
  async function refreshOf(f, farm, seat) {
344
451
  const { before, ...nft } = seat.asset !== NOBODY ? await nftOf(seat.asset) : { before: [] };
345
- return [...before, loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by: seat.player, seat, ...nft })];
452
+ return [
453
+ ...before,
454
+ loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by: seat.player, seat, ...nft }),
455
+ ];
346
456
  }
347
457
  /**
348
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
@@ -360,14 +470,18 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
360
470
  if (gate.Staked) {
361
471
  const { farm, at_least: atLeast } = gate.Staked;
362
472
  const seat = await readMaybe('Seat', loot.pda.seat(farm, wallet.address));
363
- 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);
364
474
  const t = f ? tokens.get(f.stakes) : null;
365
475
  const needs = t ? `${show(atLeast, decimalsOf(t.key))} ${t.symbol ?? t.key}` : `${atLeast} (in base units)`;
366
476
  const where = f ? `“${f.name}”` : 'its farm';
367
- 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}.`);
368
479
  // Only a locked stake counts: its tier's lock still running.
369
480
  const lock = BigInt((await read('Farm', farm)).tiers[seat.tier]?.lock ?? 0);
370
- 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
+ );
371
485
  return null;
372
486
  }
373
487
  if (gate.Wallets) {
@@ -379,30 +493,38 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
379
493
  if (!rules.length) return null;
380
494
  const rule = rules[0];
381
495
  const atLeast = rule.test?.AtLeast?.[0];
382
- const equals = rule.test?.Equals?.text;
496
+ const equals = rule.test?.OneOf?.texts?.length === 1 ? rule.test.OneOf.texts[0] : undefined;
383
497
  const item = rule.of?.Item?.[0];
384
- 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));
385
499
  const other = named ?? null;
386
500
  const passes = (r, c) => {
387
501
  if (r.of?.Item?.[0] !== undefined && c.itemId !== Number(r.of.Item[0])) return false;
388
502
  const value = c.attributes?.[r.key];
389
503
  if (r.test?.AtLeast?.[0] !== undefined) return Number(value) >= Number(r.test.AtLeast[0]);
390
- 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));
391
506
  return true;
392
507
  };
393
- const pass = (c) => rules.every((r) => passes(r, c));
508
+ const pass = c => rules.every(r => passes(r, c));
394
509
  if (other) {
395
510
  const found = await client.programAccounts(MPL_CORE, ownedAssetFilters(wallet.address, other));
396
511
  for (const { pubkey, account } of found) {
397
512
  const a = parseCoreAsset(account.data);
398
513
  if (pass({ attributes: { ...a.attributes, ...(await sheetOf(pubkey)) } })) return { asset: pubkey };
399
514
  }
400
- 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
+ );
401
518
  }
402
519
  const found = (await owned()).find(pass);
403
520
  if (found) return found.asset;
404
- const needs = item !== undefined ? `a ${(spec.items ?? []).find((it) => it.id === Number(item))?.name ?? 'collectible of this project'}` : 'a collectible of this project';
405
- 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
+ );
406
528
  }
407
529
 
408
530
  /**
@@ -425,13 +547,17 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
425
547
  */
426
548
  async function unwrapAfter(instructions) {
427
549
  const wrapped = associatedTokenAddress(wallet.address, NATIVE_MINT);
428
- const opens = instructions.some((ix) => ix.programId === ASSOCIATED_TOKEN_PROGRAM && ix.keys[1]?.pubkey === wrapped);
429
- 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
+ );
430
554
  if (closes) return instructions;
431
555
  const close = closeTokenAccount(wrapped, wallet.address, wallet.address);
432
556
  if (opens || (await client.account(wrapped))) return [...instructions, close];
433
- const touches = instructions.some((ix) => ix.keys?.some((k) => k.pubkey === wrapped));
434
- 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;
435
561
  }
436
562
 
437
563
  /**
@@ -465,13 +591,27 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
465
591
  // A compressed NFT's record is closed in the transaction that used it, its prover signing: its rent comes back.
466
592
  instructions = loot.withLeafCloses(instructions);
467
593
  if (!keepWrapped) instructions = await unwrapAfter(instructions);
468
- const sign = async (message) => {
594
+ const sign = async message => {
469
595
  const { signers } = readTransaction(wireTransaction(message));
470
- 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
+ );
471
601
  return wallet.signTransaction(wireTransaction(message, sigs));
472
602
  };
473
603
  // The project first in a table, so claims() knows a table whose later steps were never sent as this project's.
474
- return (await sendFitting({ rpc: client, payer: wallet.address, instructions, sign, alone: isProveLeaf, first: [project], standing: await standing() }).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;
475
615
  }
476
616
 
477
617
  const sendNow = (...args) => send(...args);
@@ -482,7 +622,11 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
482
622
  * and for a step too big for one transaction. Returns each step's { signature } or { error }.
483
623
  */
484
624
  async function sendBatch(lists) {
485
- const settled = (p) => p.then((signature) => ({ signature }), (e) => ({ error: e.message }));
625
+ const settled = p =>
626
+ p.then(
627
+ signature => ({ signature }),
628
+ e => ({ error: e.message }),
629
+ );
486
630
  let signed = null;
487
631
  let messages = [];
488
632
  let lastValidBlockHeight = null;
@@ -490,10 +634,18 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
490
634
  const latest = await client.latestBlockhash();
491
635
  lastValidBlockHeight = latest.lastValidBlockHeight;
492
636
  const tables = await standing();
493
- for (const l of lists) messages.push(compileFitting({ payer: wallet.address, blockhash: latest.blockhash, instructions: await unwrapAfter(loot.withLeafCloses(l)), tables }));
494
- const wires = messages.filter(Boolean).map((m) => wireTransaction(m));
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));
495
647
  const back = wires.length > 1 ? await wallet.signTransactions(wires).catch(() => null) : null;
496
- if (back) signed = messages.map((m) => (m ? back.shift() : null));
648
+ if (back) signed = messages.map(m => (m ? back.shift() : null));
497
649
  }
498
650
  const out = [];
499
651
  for (const [i, l] of lists.entries()) {
@@ -516,13 +668,22 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
516
668
  function inWords(e) {
517
669
  // The program logs an error's name only (its text is compiled out): the IDL's sentence for it.
518
670
  const logged = e?.logs ? loot.lootError(e.logs) : null;
519
- if (logged) throw Object.assign(new Error(`${logged.msg} (${logged.name}).`, { cause: e }), { code: logged.code, name: logged.name, logs: e.logs });
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
+ });
520
677
  const said = String(e?.message ?? '');
521
678
  const m = /custom program error: 0x([0-9a-f]+)/i.exec(said);
522
- 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));
523
680
  const known = m && (!failed || failed.includes(loot.LOOT_PROGRAM)) ? loot.ERRORS[parseInt(m[1], 16)] : null;
524
681
  if (!known) throw e;
525
- 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
+ });
526
687
  }
527
688
 
528
689
  /**
@@ -531,7 +692,16 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
531
692
  */
532
693
  async function sendAs(key, instructions) {
533
694
  instructions = loot.withLeafCloses(instructions);
534
- return (await sendFitting({ rpc: client, payer: key.address, instructions, sign: async (message) => wireTransaction(message, [await key.sign(message)]), table: false, standing: await standing() }).catch(inWords)).signature;
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;
535
705
  }
536
706
 
537
707
  /**
@@ -542,12 +712,50 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
542
712
  const prize = farm.tiers[tier]?.prize ?? null;
543
713
  const given = await prizeInputs([prize]);
544
714
  // The rewards' accounts as the stake resolved them (a hook that needs each transfer named is sized without them).
545
- const token = { ...staking, before: [createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, farm.rewards_mint, staking.programs[farm.rewards_mint] ?? TOKEN_PROGRAM)] };
546
- 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
+ ];
547
747
  for (const forfeit of [false, true]) {
548
- const instructions = exit(forfeit).filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM);
549
- if (!sendPlan({ payer: wallet.address, instructions: [computeUnitLimit(1_400_000), ...instructions], alone: isProveLeaf }))
550
- 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
+ );
551
759
  }
552
760
  }
553
761
 
@@ -565,13 +773,19 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
565
773
  */
566
774
  async function keyFor(pays = SIGNATURE_FEE, key = null) {
567
775
  key ??= await freshKey();
568
- return { key, fund: transferLamports(wallet.address, key.address, (await rentEmpty()) + BigInt(pays) + SIGNATURE_FEE) };
776
+ return {
777
+ key,
778
+ fund: transferLamports(wallet.address, key.address, (await rentEmpty()) + BigInt(pays) + SIGNATURE_FEE),
779
+ };
569
780
  }
570
781
 
571
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
572
783
  * a transaction's fee: a fee payer stays rent-exempt or ends empty. */
573
784
  let rentMinimum = null;
574
- const rentEmpty = async () => (rentMinimum ??= BigInt(await client.call('getMinimumBalanceForRentExemption', [0]).catch(() => Number(RENT_EMPTY))));
785
+ const rentEmpty = async () =>
786
+ (rentMinimum ??= BigInt(
787
+ await client.call('getMinimumBalanceForRentExemption', [0]).catch(() => Number(RENT_EMPTY)),
788
+ ));
575
789
 
576
790
  /** Whether `instructions` would land as `payer` sends them now (a dry run, with the standing tables when they need
577
791
  * them), and the accounts `watch` as they would be after: { ok, accounts, error }, `error` loot's when it refused
@@ -581,7 +795,7 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
581
795
  const message = compileFitting({ payer, blockhash, instructions, tables: await standing() });
582
796
  if (!message) return { ok: false, accounts: [], error: null };
583
797
  let error = null;
584
- const r = await client.simulate(wireTransaction(message), { accounts: watch, replaceBlockhash: true }).catch((e) => {
798
+ const r = await client.simulate(wireTransaction(message), { accounts: watch, replaceBlockhash: true }).catch(e => {
585
799
  error = loot.lootError(e?.logs ?? []);
586
800
  return null;
587
801
  });
@@ -592,7 +806,8 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
592
806
  * than that fee (a settle of its that failed on chain took its fee: the wallet then pays the move, one more prompt). */
593
807
  async function emptyKey(key) {
594
808
  const left = BigInt(await client.balance(key.address).catch(() => 0n));
595
- if (left >= (await rentEmpty()) + SIGNATURE_FEE) await sendAs(key, [transferLamports(key.address, wallet.address, left - SIGNATURE_FEE)]).catch(() => null);
809
+ if (left >= (await rentEmpty()) + SIGNATURE_FEE)
810
+ await sendAs(key, [transferLamports(key.address, wallet.address, left - SIGNATURE_FEE)]).catch(() => null);
596
811
  else if (left > 0n) await send([transferLamports(key.address, wallet.address, left)], [key]).catch(() => null);
597
812
  // A key kept in storage is dropped once it holds nothing.
598
813
  if (key.name && !(BigInt(await client.balance(key.address).catch(() => 1n)) > 0n)) keys?.forget(key.name);
@@ -618,7 +833,13 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
618
833
  * too big a transaction), the step goes alone and its rolls wait in `claims()`. Returns { signature, rolls }.
619
834
  */
620
835
  async function sendRolling(instructions) {
621
- const watch = [...new Set(instructions.filter((ix) => ix.programId === loot.LOOT_PROGRAM).flatMap((ix) => ix.keys.filter((k) => k.isWritable && !k.isSigner).map((k) => k.pubkey)))];
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
+ ];
622
843
  const after = (await dryRun(wallet.address, instructions, watch)).accounts;
623
844
  const waiting = watch.flatMap((address, i) => {
624
845
  const a = after[i];
@@ -634,20 +855,36 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
634
855
  const box = await read('LootBox', order.loot_box);
635
856
  // Each order its own key, named by it (it can be made again after a reload, `recover`): its settle's fee, a spare
636
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.
637
- const { key, fund } = await keyFor(SIGNATURE_FEE * 3n + loot.settleRent(box, Number(order.opens ?? 1), await itemsOf(box)), await jobKey(orderAddress));
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
+ );
638
862
  asks.push(...(await asksFor(wallet.address, orderAddress, order, key.address)));
639
863
  funds.push(fund);
640
864
  burners.push(key);
641
865
  }
642
866
  const all = [...instructions, ...asks, ...funds];
643
867
  const tried = await dryRun(wallet.address, all);
644
- if (tried.error?.name === 'SoldOut') throw Object.assign(new Error('The box this rolls has nothing left to give right now (SoldOut): nothing was sent. Try again once it is restocked.'), { code: tried.error.code, name: 'SoldOut' });
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
+ );
645
875
  // Too heavy to be answered: a step sent alone would pay for a roll that can only fail.
646
- if (tried.error?.name === 'PackTooLarge') throw Object.assign(new Error('The box this rolls is too heavy for the oracle\'s answer as it stands (PackTooLarge): nothing was sent.'), { code: tried.error.code, name: 'PackTooLarge' });
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
+ );
647
883
  if (!tried.ok) return { signature: await send(instructions), rolls: [] };
648
884
  const signature = await send(all);
649
885
  const rolls = [];
650
- for (const [k, [orderAddress]] of waiting.entries()) rolls.push(await collect(orderAddress, { burner: burners[k], opened: true }));
886
+ for (const [k, [orderAddress]] of waiting.entries())
887
+ rolls.push(await collect(orderAddress, { burner: burners[k], opened: true }));
651
888
  return { signature, rolls };
652
889
  }
653
890
 
@@ -669,12 +906,16 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
669
906
  const { burner } = options;
670
907
  if (!burner) throw e;
671
908
  await emptyKey(burner);
672
- if (!(await client.account(orderAddress))) return { order: orderAddress, settled: 'elsewhere', says: 'The order was settled already.' };
909
+ if (!(await client.account(orderAddress)))
910
+ return { order: orderAddress, settled: 'elsewhere', says: 'The order was settled already.' };
673
911
  throw e;
674
912
  }
675
913
  }
676
914
 
677
- async function collectOnce(orderAddress, { timeoutMs = 60_000, burner = null, putBack = [], opened = false, landed = null, keepBurner = false } = {}) {
915
+ async function collectOnce(
916
+ orderAddress,
917
+ { timeoutMs = 60_000, burner = null, putBack = [], opened = false, landed = null, keepBurner = false } = {},
918
+ ) {
678
919
  const started = Date.now();
679
920
  // `landed`: the slot one's own open landed in; the order is read from a node that has seen it.
680
921
  const current = async () => {
@@ -700,16 +941,29 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
700
941
  }
701
942
  // A free roll whose answer found nothing at all in stock waits again: for a restock, or for its player to end it.
702
943
  if (order?.status === 'Waiting') {
703
- throw new Error('The box this rolls had nothing left to give when the roll landed, so the roll waits: collect it again once the box is restocked. Collected while the box is still empty, the player\'s own ask ends it with nothing.');
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
+ );
704
947
  }
705
948
  if (!order) {
706
949
  if (burner && !keepBurner) await emptyKey(burner);
707
950
  // One's own open still not seen after that: say so plainly, never that it was settled (claims() lists it).
708
- if (opened) return { order: orderAddress, settled: null, says: 'The network doesn’t show this order yet: collect it again in a moment (claims() lists it).' };
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
+ };
709
957
  return { order: orderAddress, settled: 'elsewhere', says: 'The order was settled already.' };
710
958
  }
711
- 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).');
712
- 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
+ );
713
967
  if (order.status === 'Failed') throw new Error('The roll failed (the box changed or ran out): refund the order.');
714
968
  // An order closes only in a later slot than the one it was opened in.
715
969
  for (let i = 0; i < 20 && client.call; i++) {
@@ -727,17 +981,26 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
727
981
  }
728
982
  // A compressed prize is minted into the tree its box names (any of the project's with room), the next NFT it mints.
729
983
  const treeOf = async () => {
730
- if (!order.won.some((w) => w.Compressed)) return {};
984
+ if (!order.won.some(w => w.Compressed)) return {};
731
985
  const box = await read('LootBox', order.loot_box);
732
- const tree = box.entries.flatMap((e) => e.prizes).find((p) => p.Compressed)?.Compressed.tree;
986
+ const tree = box.entries.flatMap(e => e.prizes).find(p => p.Compressed)?.Compressed.tree;
733
987
  const config = (await client.account(loot.pda.treeConfig(tree)))?.data;
734
988
  // An item's collection without Bubblegum's plugin mints nothing: the prizes after it take its place in the tree.
735
989
  const unminted = [];
736
- for (const c of new Set(order.won.filter((w) => w.Compressed).map((w) => items[w.Compressed.item]?.collection).filter(Boolean))) {
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
+ )) {
737
996
  const data = (await client.account(c))?.data;
738
997
  if (!data || !parseCoreCollection(data).bubblegum) unminted.push(c);
739
998
  }
740
- return { tree, unminted, treeMinted: config ? config.subarray(80, 88).reduce((n, b, i) => n | (BigInt(b) << BigInt(8 * i)), 0n) : 0n };
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
+ };
741
1004
  };
742
1005
  const holders = await holdersOf(order.shares);
743
1006
  // Given by its fate to a wallet that is an account of the loot program, the NFT goes back to the player.
@@ -753,15 +1016,34 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
753
1016
  if (won.Nft && !putBack.includes(won.Nft.nft)) {
754
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
755
1018
  // settle with anything before it is the wallet's to send.
756
- const { before, hasAuthority: _, ...kind } = await nftOf(won.Nft.nft, signer, { to: order.player, payer: wallet.address });
1019
+ const {
1020
+ before,
1021
+ hasAuthority: _,
1022
+ ...kind
1023
+ } = await nftOf(won.Nft.nft, signer, { to: order.player, payer: wallet.address });
757
1024
  nfts[won.Nft.nft] = kind;
758
1025
  proving.push(...before);
759
1026
  }
760
1027
  }
761
1028
  // The NFT the roll was on: its kind, and for a compressed one a proof first.
762
- const { before: subjectProof, ...subject } = order.subject && order.subject !== NOBODY ? await nftOf(order.subject, signer) : { before: [] };
763
- const ix = loot.settleIx({ signer, orderAddress, order, projectState: g, items, nfts, putBack, subject, handBack, holders, programs, upto, ...(await treeOf()) });
764
- 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) };
765
1047
  };
766
1048
  // An order whose settle doesn't fit one transaction (the instruction trace, 64 accounts, 1,232 bytes) settles in
767
1049
  // parts, each its own transaction, from where the last stopped. The first splits the payment, its shares' accounts
@@ -772,9 +1054,12 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
772
1054
  const signatures = [];
773
1055
  // A key the order doesn't name can't settle it (`Order::settler`): then the player signs.
774
1056
  const by = burner && !putBack.length && order.settler === burner.address ? burner.address : wallet.address;
775
- const fits = async (instructions) => Boolean(compileFitting({ payer: by, blockhash: NOBODY, instructions, tables: await standing() })) && transactionLocks(by, instructions) <= 64;
776
- const part = async (end) => [computeUnitLimit(loot.settleComputeUnits(order)), (await settleBy(by, end)).ix];
777
- const sendPart = async (end) => signatures.push(by === wallet.address ? await send(await part(end)) : await sendAs(burner, await part(end)));
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)));
778
1063
  for (;;) {
779
1064
  const from = Number(order.settled ?? 0);
780
1065
  if (from >= order.won.length) break;
@@ -797,16 +1082,18 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
797
1082
  // player's own choice: they sign.
798
1083
  let signature = null;
799
1084
  let emptied = false;
800
- const byBurner = burner && !putBack.length && order.settler === burner.address ? await settleBy(burner.address) : null;
1085
+ const byBurner =
1086
+ burner && !putBack.length && order.settler === burner.address ? await settleBy(burner.address) : null;
801
1087
  const alone = byBurner && !byBurner.before.length ? [...budget, byBurner.ix] : null;
802
- const legacy = (instructions) => Boolean(compileFitting({ payer: burner.address, blockhash: NOBODY, instructions }));
1088
+ const legacy = instructions => Boolean(compileFitting({ payer: burner.address, blockhash: NOBODY, instructions }));
803
1089
  const tried = alone && legacy(alone) ? await dryRun(burner.address, alone, [burner.address]) : null;
804
1090
  if (tried?.ok) {
805
1091
  // Its last settle gives back all the burner holds after it, in the same transaction, as the dry run says it will
806
1092
  // hold (its fee and what the settle opened taken); when that doesn't land as one, the burner empties itself after.
807
1093
  const left = BigInt(tried.accounts[0]?.lamports ?? 0);
808
1094
  const back = !keepBurner && left > 0n ? [...alone, transferLamports(burner.address, wallet.address, left)] : null;
809
- if (back && legacy(back) && (await dryRun(burner.address, back)).ok) signature = await sendAs(burner, back).catch(() => null);
1095
+ if (back && legacy(back) && (await dryRun(burner.address, back)).ok)
1096
+ signature = await sendAs(burner, back).catch(() => null);
810
1097
  emptied = Boolean(signature);
811
1098
  signature ??= await sendAs(burner, alone).catch(() => null);
812
1099
  }
@@ -818,8 +1105,15 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
818
1105
  // The burner gives back what it holds after its last settle (a purchase of several orders: after the last order's).
819
1106
  if (burner && !keepBurner && !emptied) await emptyKey(burner);
820
1107
  else if (burner && emptied) keys?.forget(burner.name);
821
- const assets = wonAssets(order).map((a) => (putBack.includes(a) ? null : a));
822
- return { order: orderAddress, won: order.won, assets, settled: signature, ...(signatures.length ? { parts: [...signatures, signature] } : {}), ...(assets.find(Boolean) ? { asset: assets.find(Boolean) } : {}) };
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
+ };
823
1117
  }
824
1118
 
825
1119
  /**
@@ -827,7 +1121,7 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
827
1121
  * over; null for tokens. A normal item is the project's backend's to deliver.
828
1122
  */
829
1123
  function wonAssets(order) {
830
- return order.won.map((won) => {
1124
+ return order.won.map(won => {
831
1125
  if (won.Item) return loot.pda.itemAsset(loot.pda.item(project, won.Item.item), won.Item.serial);
832
1126
  if (won.Nft) return won.Nft.nft;
833
1127
  // A compressed NFT's asset id is the tree's count when it was minted: read from its settle's receipt or the
@@ -849,7 +1143,7 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
849
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. */
850
1144
  async function itemsOf(box) {
851
1145
  const out = {};
852
- for (const p of box.entries.flatMap((e) => e.prizes)) {
1146
+ for (const p of box.entries.flatMap(e => e.prizes)) {
853
1147
  const id = p.Item?.[0] ?? p.Compressed?.item;
854
1148
  if (id !== undefined && !(id in out)) out[id] = await readMaybe('Item', loot.pda.item(project, id));
855
1149
  }
@@ -858,9 +1152,18 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
858
1152
 
859
1153
  /** The mint of each pool a box's tokens come from (null for a pool not opened). */
860
1154
  async function poolMintsOf(box) {
861
- const prizePools = [...new Set(box.entries.flatMap((e) => e.prizes).map((p) => p.Tokens?.from.Pool?.[0]).filter((p) => p !== undefined))];
862
- const pools = prizePools.length ? await client.accounts(prizePools.map((p) => loot.pda.pool(project, p))) : [];
863
- 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
+ );
864
1167
  }
865
1168
 
866
1169
  /** The instructions that ask the oracle for the roll of `order` (at `orderAddress`), `payer` paying it. Each prize
@@ -869,15 +1172,30 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
869
1172
  async function asksFor(payer, orderAddress, order, settler) {
870
1173
  const box = await read('LootBox', order.loot_box);
871
1174
  const poolMints = await poolMintsOf(box);
872
- const mints = [...Object.values(poolMints), ...box.entries.flatMap((e) => e.prizes.map((p) => p.Tokens?.from.Mint?.[0]))].filter((x) => x && !programs[x]);
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]);
873
1179
  const owners = mints.length ? await client.accounts(mints) : [];
874
1180
  const known = { ...programs, ...Object.fromEntries(mints.map((x, i) => [x, owners[i]?.owner ?? TOKEN_PROGRAM])) };
875
- return loot.askRollIx({ payer, orderAddress, order, box, settler, poolMints, items: await itemsOf(box), programs: known });
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
+ });
876
1191
  }
877
1192
 
878
1193
  /** Asks the oracle for a roll a mission, a recipe or a prize made (`Waiting`): the player pays the oracle. */
879
1194
  async function askRoll(orderAddress) {
880
- return send([computeUnitLimit(400_000), ...(await asksFor(wallet.address, orderAddress, await read('Order', orderAddress)))]);
1195
+ return send([
1196
+ computeUnitLimit(400_000),
1197
+ ...(await asksFor(wallet.address, orderAddress, await read('Order', orderAddress))),
1198
+ ]);
881
1199
  }
882
1200
 
883
1201
  /**
@@ -887,11 +1205,18 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
887
1205
  * the wallet is asked.
888
1206
  */
889
1207
  function boundOf(box, name, { maxPrice, listing } = {}, key) {
890
- 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}”.`);
891
1210
  const given = maxPrice ?? listing?.priceUnits;
892
- 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
+ );
893
1215
  const bound = BigInt(given);
894
- 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
+ );
895
1220
  return bound;
896
1221
  }
897
1222
 
@@ -901,10 +1226,13 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
901
1226
  * was given, or what is left of it once the options it takes are read; `takes`: those it takes, said in the error.
902
1227
  */
903
1228
  function only(options, takes, method) {
904
- 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);
905
1230
  if (!unknown.length) return;
906
- const hint = unknown.includes('choice') && takes.includes('chosenRecipient') ? ' (a buyer’s choice is `chosenRecipient`)' : '';
907
- 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
+ );
908
1236
  }
909
1237
  /** What `buy` and `openBox` take: a box bought at once (`buyBox`) or opened (`open`). */
910
1238
  const BOX_OPTIONS = ['shown', 'listProof', 'maxPrice', 'listing', 'chosenRecipient', 'on', 'oneSignature', 'count'];
@@ -919,20 +1247,43 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
919
1247
  * a roll the order it is made in; with `rolls`, what each roll gave. `orNull`: a pack (more than one draw in all)
920
1248
  * whose purchase doesn't fit one legacy transaction, or the instruction trace, returns null, sending nothing.
921
1249
  */
922
- async function buyBox(boxId, part, { shown = null, listProof, maxPrice, listing, chosenRecipient = null, count = 1, orNull = false } = {}) {
1250
+ async function buyBox(
1251
+ boxId,
1252
+ part,
1253
+ { shown = null, listProof, maxPrice, listing, chosenRecipient = null, count = 1, orNull = false } = {},
1254
+ ) {
923
1255
  const g = await projectNow();
924
1256
  const box = await read('LootBox', loot.pda.box(project, boxId));
925
1257
  if (!Number.isInteger(count) || count < 1) throw new Error('Buy a whole number, one at least (count).');
926
- const bound = boundOf({ ...box, currencyKey: part?.currency }, part?.name ?? `box ${boxId}`, { maxPrice, listing }, part?.key) * BigInt(count);
1258
+ const bound =
1259
+ boundOf({ ...box, currencyKey: part?.currency }, part?.name ?? `box ${boxId}`, { maxPrice, listing }, part?.key) *
1260
+ BigInt(count);
927
1261
  shown ??= await passing(box.gate, 'buy this');
928
1262
  listProof = listProofOf(box.gate, part?.gate?.list, listProof);
929
1263
  const mint = box.currency;
930
1264
  const [entry] = box.entries;
931
1265
  const given = await prizeInputs(entry.prizes);
932
1266
  const nonce = freshNonce();
933
- 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);
934
1268
  const holders = await holdersOf(loot.chosenShares(box.shares, chosenRecipient));
935
- const ix = loot.buyIx({ player: wallet.address, project, box, maxPrice: bound, count, treasury: g.treasury, chosenRecipient, chosen: choosesHolder ? chosenRecipient : null, nonce, shown, listProof, items: given.items, poolMints: given.poolMints, nfts: given.nfts, holders, programs });
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
+ });
936
1287
  const made = {};
937
1288
  const gives = loot.buyGives(box, count);
938
1289
  const assets = gives.map(({ prize, k }) => {
@@ -940,20 +1291,35 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
940
1291
  const id = prize.Item[0];
941
1292
  const item = given.items[id];
942
1293
  made[id] = (made[id] ?? 0) + 1;
943
- 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;
944
1297
  }
945
1298
  if (prize.Nft) return prize.Nft.nft;
946
1299
  if (prize.Roll) return loot.pda.order(project, wallet.address, loot.prizeRollNonce(nonce + BigInt(k)));
947
1300
  return null;
948
1301
  });
949
- const rollsMade = gives.filter((x) => x.prize.Roll).length;
950
- const instructions = withSolWrap(wallet.address, mint, bound, [computeUnitLimit(rollsMade || gives.length > 2 ? 1_400_000 : 600_000), ...given.before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), ix]);
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
+ ]);
951
1308
  if (orNull && gives.length > 1) {
952
- const fits = compileFitting({ payer: wallet.address, blockhash: NOBODY, instructions }) && transactionLocks(wallet.address, instructions) <= 64;
1309
+ const fits =
1310
+ compileFitting({ payer: wallet.address, blockhash: NOBODY, instructions }) &&
1311
+ transactionLocks(wallet.address, instructions) <= 64;
953
1312
  if (!fits || !(await dryRun(wallet.address, instructions)).ok) return null;
954
1313
  }
955
- const { signature, rolls } = rollsMade ? await sendRolling(instructions) : { signature: await send(instructions), rolls: [] };
956
- return { signature, assets, ...(assets.find(Boolean) ? { asset: assets.find(Boolean) } : {}), ...(rolls.length ? { rolls } : {}) };
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
+ };
957
1323
  }
958
1324
 
959
1325
  /**
@@ -963,19 +1329,36 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
963
1329
  * referrer, a plot). `on`: the NFT a box that rolls on one takes (an NFT whose price it is, a sword to enchant).
964
1330
  * `listProof`: for a list gate.
965
1331
  */
966
- async function open(boxId, part, { shown = null, on = null, maxPrice, listing, oneSignature = true, chosenRecipient = null, listProof, count = 1 } = {}) {
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
+ ) {
967
1346
  const g = await projectNow();
968
1347
  const box = await read('LootBox', loot.pda.box(project, boxId));
969
1348
  // `count` packs in one purchase: one wallet prompt, one oracle answer, each at the price the one before it left.
970
1349
  if (!Number.isInteger(count) || count < 1) throw new Error('Open a whole number of packs, one at least (count).');
971
- const bound = boundOf({ ...box, currencyKey: part?.currency }, part?.name ?? `box ${boxId}`, { maxPrice, listing }, part?.key) * BigInt(count);
1350
+ const bound =
1351
+ boundOf({ ...box, currencyKey: part?.currency }, part?.name ?? `box ${boxId}`, { maxPrice, listing }, part?.key) *
1352
+ BigInt(count);
972
1353
  // Each item a prize names: a fresh one's tables go with the purchase.
973
1354
  const items = await itemsOf(box);
974
1355
  // A box that rolls on an NFT takes the one the player brings (`on`), whatever its kind.
975
1356
  if (box.subject && !on) throw new Error('This box rolls on an NFT: say which (on).');
976
1357
  const brought = box.subject ? await nftOf(on) : { before: [] };
977
1358
  const { before: proving, hasAuthority, ...kind } = brought;
978
- 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;
979
1362
  if (!box.subject) shown ??= await passing(box.gate, 'buy this');
980
1363
  listProof = listProofOf(box.gate, part?.gate?.list, listProof);
981
1364
  const mint = box.currency;
@@ -983,9 +1366,12 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
983
1366
  const nonce = box.subject && loot.boxHolds(box) ? loot.nonceOn(on) : freshNonce();
984
1367
  // Each pool's mint, and each mint tokens are minted from: what is won waits in the project's vault for its mint.
985
1368
  const poolMints = await poolMintsOf(box);
986
- 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);
987
1373
  const prizeMints = [...new Set([...Object.values(poolMints).filter(Boolean), ...minted])];
988
- 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);
989
1375
  const chosen = chosenRecipient && choosesHolder ? chosenRecipient : null;
990
1376
  // A purchase of more opens than one order takes (`loot.opensAnOrder`: what one oracle answer draws, and an order's
991
1377
  // rent and fee within 0.01 SOL) is several orders, each its own answer, all in this one transaction: one prompt.
@@ -994,10 +1380,36 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
994
1380
  for (let left = count, k = 0n; left > 0; left -= each, k++) {
995
1381
  const n = Math.min(each, left);
996
1382
  // Its key, named by the order as its settler (made again after a reload: `recover`), when the settle needs no prompt.
997
- const settlerKey = oneSignature ? ((await jobKey(loot.pda.order(project, wallet.address, nonce + k))) ?? (await freshKey())) : null;
998
- orders.push({ nonce: nonce + k, n, settlerKey, ix: loot.openBoxIx({ settler: settlerKey?.address, player: wallet.address, project, box, nonce: nonce + k, maxPrice: (bound / BigInt(count)) * BigInt(n), count: n, items, shown, listProof, subject, chosenRecipient, chosen, poolMints, programs }) });
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
+ });
999
1408
  }
1000
- const before = [...proving, createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, mint, programOf(mint))];
1409
+ const before = [
1410
+ ...proving,
1411
+ createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, mint, programOf(mint)),
1412
+ ];
1001
1413
  // (A compressed NFT's proof brings its own compute limit, the transaction's only one.)
1002
1414
  let solPrize = false;
1003
1415
  if (oneSignature) {
@@ -1005,13 +1417,24 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1005
1417
  // sale pays: a referrer, a plot's holder, the treasury), so the burner's settle opens none of them.
1006
1418
  for (const prizeMint of prizeMints) {
1007
1419
  solPrize ||= prizeMint === NATIVE_MINT;
1008
- before.push(createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, prizeMint, programOf(prizeMint)));
1420
+ before.push(
1421
+ createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, prizeMint, programOf(prizeMint)),
1422
+ );
1009
1423
  }
1010
1424
  const shares = loot.chosenShares(box.shares, chosenRecipient);
1011
1425
  const holders = await holdersOf(shares);
1012
- 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
+ );
1013
1435
  const paid = new Set([g.treasury, ...payees]);
1014
- 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)));
1015
1438
  // A sale that pays the player themselves (the owner buying from their own shop) pays into their wrapped-SOL
1016
1439
  // account: it stays open for the settle too.
1017
1440
  solPrize ||= mint === NATIVE_MINT && paid.has(wallet.address);
@@ -1019,25 +1442,47 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1019
1442
  // Each order's burner, named by the order (made again after a reload: `recover`), holds the fee of each settle part
1020
1443
  // its order may take (`settleParts`) and of its give-back, and what its settle may mint or open (`settleRent`: the
1021
1444
  // order lends nothing); it gives back what it doesn't use.
1022
- const addresses = orders.map((o) => loot.pda.order(project, wallet.address, o.nonce));
1445
+ const addresses = orders.map(o => loot.pda.order(project, wallet.address, o.nonce));
1023
1446
  const burners = [];
1024
1447
  const after = [];
1025
1448
  if (oneSignature) {
1026
1449
  for (const o of orders) {
1027
- const { key, fund } = await keyFor(SIGNATURE_FEE * BigInt(loot.settleParts(box, o.n) + 1) + loot.settleRent(box, o.n, items), o.settlerKey);
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
+ );
1028
1454
  burners.push(key);
1029
1455
  after.push(fund);
1030
1456
  }
1031
1457
  }
1032
1458
  // A SOL prize, or a share of the sale, lands in the player's wrapped-SOL account, which the prompt-less settle
1033
1459
  // can't open: it stays open, and the player's next transaction unwraps it.
1034
- let instructions = withSolWrap(wallet.address, mint, BigInt(box.price) * BigInt(count), [...before, ...orders.map((o) => o.ix), ...after]);
1035
- 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
+ );
1036
1474
  const signature = await send(instructions, [], { keepWrapped: solPrize });
1037
1475
  // The slot it landed in: the order is read from a node that has seen it (a load-balanced RPC's node lagging
1038
1476
  // behind answers nothing for an order just opened).
1039
1477
  const landed = (await client.signatureStatus?.(signature).catch(() => null))?.slot ?? null;
1040
- return { signature, orderAddress: addresses[0], orderAddresses: addresses, burner: burners[0] ?? null, burners, landed };
1478
+ return {
1479
+ signature,
1480
+ orderAddress: addresses[0],
1481
+ orderAddresses: addresses,
1482
+ burner: burners[0] ?? null,
1483
+ burners,
1484
+ landed,
1485
+ };
1041
1486
  }
1042
1487
 
1043
1488
  /**
@@ -1046,13 +1491,22 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1046
1491
  * program would refuse them too: TermsChanged).
1047
1492
  */
1048
1493
  async function shownListing(asset, listing, kind) {
1049
- 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
+ );
1050
1498
  if (listing.asset !== asset) throw new Error(`That listing is of ${listing.asset}, not ${asset}.`);
1051
- 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
+ );
1052
1503
  const listed = await readMaybe('Listing', loot.pda.listing(project, asset));
1053
1504
  if (!listed) throw new Error(`${asset} is not listed any more.`);
1054
1505
  const [was, is] = [loot.listingTerms(listing), loot.listingTerms(listed)];
1055
- if (was.some((b, i) => b !== is[i])) throw new Error('This listing has changed since the player was shown it: show them its terms as they are now first.');
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
+ );
1056
1510
  }
1057
1511
 
1058
1512
  /** A box bought at once when it needs no roll (`buyBox`: a shop item, a bundle, a fixed pack of any count), or
@@ -1066,11 +1520,19 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1066
1520
  if (bought) return bought;
1067
1521
  }
1068
1522
  const { orderAddresses, burners, landed } = await open(boxId, part, options);
1069
- if (orderAddresses.length === 1) return collect(orderAddresses[0], { burner: burners[0] ?? null, opened: true, landed });
1523
+ if (orderAddresses.length === 1)
1524
+ return collect(orderAddresses[0], { burner: burners[0] ?? null, opened: true, landed });
1070
1525
  // Several orders of one purchase: each collected in turn by its own burner.
1071
1526
  const got = [];
1072
- for (const [k, address] of orderAddresses.entries()) got.push(await collect(address, { burner: burners[k] ?? null, opened: true, landed }));
1073
- return { ...got.at(-1), orders: got, won: got.flatMap((g) => g.won ?? []), assets: got.flatMap((g) => g.assets ?? []), parts: got.flatMap((g) => g.parts ?? [g.settled]) };
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
+ };
1074
1536
  }
1075
1537
 
1076
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`). */
@@ -1100,8 +1562,17 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1100
1562
  async function walking(f, farm, instructions) {
1101
1563
  const { left } = await endsOf(f, farm);
1102
1564
  if (!left) return instructions;
1103
- if (left > loot.MOVE_WALK_ROOM) throw Object.assign(new Error(`“${f.name ?? f.key}” has passed ${left} times seats stop earning since anyone last moved it, more than one transaction can walk: walk it first (app.walkFarm: anyone may, it is free, and nothing is lost by the wait).`), { name: 'FarmNeedsWalk' });
1104
- return [computeUnitLimit(loot.WALK_COMPUTE_UNITS), ...instructions.filter((x) => !(x.programId === COMPUTE_BUDGET_PROGRAM && x.data[0] === 2))];
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
+ ];
1105
1576
  }
1106
1577
 
1107
1578
  /**
@@ -1112,8 +1583,13 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1112
1583
  const at = await chainNow();
1113
1584
  if (loot.farmOpenFor(farm, tier, at)) return;
1114
1585
  const ends = new Date(Number(farm.ends_at) * 1000).toISOString();
1115
- 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.`);
1116
- 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
+ );
1117
1593
  }
1118
1594
 
1119
1595
  /** Who holds each collectible a split's holder shares name now: { asset: holder }. */
@@ -1131,11 +1607,16 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1131
1607
  async function roundOf(l) {
1132
1608
  const address = loot.pda.lottery(project, l.id);
1133
1609
  const lottery = await read('Lottery', address);
1134
- const back = [1, 2].filter((k) => lottery.round >= k).map((k) => loot.pda.round(address, lottery.incarnation, lottery.round - k));
1610
+ const back = [1, 2]
1611
+ .filter(k => lottery.round >= k)
1612
+ .map(k => loot.pda.round(address, lottery.incarnation, lottery.round - k));
1135
1613
  const keys = [loot.currentRound(address, lottery), ...back];
1136
1614
  const found = await client.accounts(keys);
1137
- const rounds = keys.map((key, i) => ({ address: key, round: found[i]?.owner === loot.LOOT_PROGRAM ? loot.decodeRound(found[i].data) : null }));
1138
- return { address, lottery, current: rounds[0].round, rounds: rounds.filter((r) => r.round) };
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) };
1139
1620
  }
1140
1621
 
1141
1622
  /**
@@ -1145,7 +1626,7 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1145
1626
  */
1146
1627
  async function hookTail(mints) {
1147
1628
  const out = new Map();
1148
- for (const mint of new Set(mints.filter((x) => x && x !== NOBODY))) {
1629
+ for (const mint of new Set(mints.filter(x => x && x !== NOBODY))) {
1149
1630
  const resolved = await resolveTransfers(client, mint).catch(() => null);
1150
1631
  for (const m of resolved?.extraAccounts ?? []) {
1151
1632
  const had = out.get(m.pubkey);
@@ -1155,7 +1636,14 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1155
1636
  return [...out.values()];
1156
1637
  }
1157
1638
  /** The mints a round's ask and pulls move: its pot's, its payment's and each token run's. */
1158
- const roundMints = (r, poolMints = {}) => [r.terms.mint, loot.paymentMint(r.terms), ...r.terms.places.map((p) => p.prize?.Tokens?.from).filter(Boolean).map((from) => (from.Pool ? poolMints[from.Pool[0]] : from.Mint?.[0] ?? from.Vest?.[0]))];
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
+ ];
1159
1647
 
1160
1648
  /** Each pool a round's places give tokens from, mapped to its mint (one never opened: the economy's currency). */
1161
1649
  async function roundPoolMints(terms) {
@@ -1169,12 +1657,38 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1169
1657
  return out;
1170
1658
  }
1171
1659
 
1172
- /** The ask for a round's draw (`round` decoded, `key` its address; null for the current round with no record). */
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). */
1173
1663
  async function drawStep(l, lottery, key, round, signer) {
1174
1664
  const terms = round?.terms ?? lottery.terms;
1175
1665
  const poolMints = await roundPoolMints(terms);
1176
1666
  const extra = await hookTail(roundMints({ terms }, poolMints));
1177
- return [computeUnitLimit(600_000), loot.drawLotteryIx({ payer: signer, project, id: l.id, lottery, round, roundAddress: key, poolMints, extra })];
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
+ ];
1178
1692
  }
1179
1693
 
1180
1694
  /**
@@ -1183,16 +1697,38 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1183
1697
  * after the draw in its transaction; none otherwise. The round's close rides on them when they are all it owes.
1184
1698
  */
1185
1699
  async function cancelPulls(key, r, who) {
1186
- const short = r.status === 'Open' && BigInt(r.sold) < BigInt(r.terms.min_sold ?? 0) && BigInt(now()) >= BigInt(r.terms.draws_at);
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);
1187
1704
  if (!short && !loot.lotteryCancellable(r, await chainNow())) return [];
1188
1705
  // As the program cancels it: every entry's own part owed, and what the ask moved in owed back.
1189
1706
  const potOwed = BigInt(r.pot_arrived) > BigInt(r.to_pot) ? BigInt(r.pot_arrived) - BigInt(r.to_pot) : 0n;
1190
- const pools = r.prizes.filter((p) => !p.minted && BigInt(p.arrived) > 0n).length;
1191
- const cancelled = { ...r, status: 'Cancelled', pot_owed: potOwed, owed: Number(r.owns) + (potOwed > 0n ? 1 : 0) + pools };
1192
- const mine = r.entries.flatMap((e, i) => (e.buyer === who && loot.partsOwed(cancelled, i).length ? [{ entry: i, parts: loot.partsOwed(cancelled, i) }] : [])).slice(0, 4);
1193
- const settles = mine.reduce((n, { entry, parts }) => n + parts.reduce((m, p) => m + pullSettles(cancelled, entry, p), 0), 0);
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
+ );
1194
1725
  const out = [];
1195
- for (const [k, { entry, parts }] of mine.entries()) out.push(...(await pulls(key, cancelled, entry, parts, who, { closes: settles >= cancelled.owed && k === mine.length - 1 })).instructions);
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
+ );
1196
1732
  return out;
1197
1733
  }
1198
1734
 
@@ -1203,7 +1739,9 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1203
1739
  const pullSettles = (r, i, part) => {
1204
1740
  if (part === 'Own' || part.Common) return 1;
1205
1741
  const run = Number((part.Pot ?? part.Prize)[0]);
1206
- return part.Pot || loot.tokenRun(r, run) ? loot.placesPulled(r, i, run, Boolean(part.Prize)) : loot.prizePlacesOwed(r, i, run).length;
1742
+ return part.Pot || loot.tokenRun(r, run)
1743
+ ? loot.placesPulled(r, i, run, Boolean(part.Prize))
1744
+ : loot.prizePlacesOwed(r, i, run).length;
1207
1745
  };
1208
1746
 
1209
1747
  /**
@@ -1217,7 +1755,7 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1217
1755
  const rolls = [];
1218
1756
  const g = await projectNow();
1219
1757
  // A run's token prize is in its own mint: the prizes the round recorded name each.
1220
- const extra = await hookTail([...roundMints(r), ...r.prizes.map((p) => p.mint)]);
1758
+ const extra = await hookTail([...roundMints(r), ...r.prizes.map(p => p.mint)]);
1221
1759
  for (const [k, part] of parts.entries()) {
1222
1760
  const more = {};
1223
1761
  if (part === 'Own' && e.nft !== NOBODY) {
@@ -1234,7 +1772,9 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1234
1772
  const given = r.terms.places[run].prize;
1235
1773
  const inputs = await prizeInputs([given], signer, e.buyer);
1236
1774
  out.push(...inputs.before);
1237
- const stash = loot.ownStash(r.terms.places[run]) ? loot.decodeStash((await client.account(loot.pda.lotteryStash(r.lottery)))?.data ?? null) : null;
1775
+ const stash = loot.ownStash(r.terms.places[run])
1776
+ ? loot.decodeStash((await client.account(loot.pda.lotteryStash(r.lottery)))?.data ?? null)
1777
+ : null;
1238
1778
  if (stash) {
1239
1779
  const predicted = loot.stashNfts(r, stash);
1240
1780
  for (const place of loot.prizePlacesOwed(r, i, run)) {
@@ -1245,16 +1785,36 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1245
1785
  out.push(...before);
1246
1786
  }
1247
1787
  }
1248
- const stashes = given.Nft ? { [given.Nft.stash]: loot.decodeStash((await client.account(loot.pda.stash(project, given.Nft.stash)))?.data ?? null) ?? { nfts: [] } } : {};
1249
- more.prize = { stash, stashes, nfts: inputs.nfts, collection: inputs.collection, poolMints: inputs.poolMints, items: inputs.items, projectMint: mintOf() };
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
+ };
1250
1804
  more.nonce = pullNonce(key, i);
1251
1805
  rolls.push(...loot.pullRolls({ project, round: r, entry: i, part, nonce: more.nonce }));
1252
1806
  } else if (part.Common) {
1253
- // Where a part owed in common goes: a share's payee (a collectible's holder now), the pot, a pool; one that can't
1254
- // take it (a pool never opened, the pot closed since, an account gone) sends it to the treasury.
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.
1255
1810
  const c = Number(part.Common[0]);
1256
1811
  const n = r.terms.shares.length;
1257
- const to = c < n ? r.terms.shares[c].to : c === n ? { Account: [loot.pda.pot(r.lottery)] } : r.terms.places[Number(r.prizes[c - n - 1].run)].prize.Tokens.from;
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;
1258
1818
  if (to.Holder) more.holders = await holdersOf([{ to }]);
1259
1819
  const target = to.Pool ? loot.pda.pool(project, to.Pool[0]) : to.Account?.[0];
1260
1820
  // A pot or pool made again in another mint can't take it either: the program sends it to the treasury.
@@ -1264,7 +1824,20 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1264
1824
  }
1265
1825
  const last = k === parts.length - 1;
1266
1826
  const round = closes && last ? { ...r, owed: pullSettles(r, i, part) } : { ...r, owed: Number.MAX_SAFE_INTEGER };
1267
- out.push(loot.pullIx({ payer: signer, project, roundAddress: key, round, entry: i ?? 0, part, projectState: g, programs, extra, ...more }));
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
+ );
1268
1841
  }
1269
1842
  return { instructions: out, rolls };
1270
1843
  }
@@ -1277,13 +1850,23 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1277
1850
 
1278
1851
  /** The shop: every item with a price (or free), its price now and what is left. */
1279
1852
  async shop() {
1280
- const items = (spec.items ?? []).filter((it) => Number.isInteger(it.shopBox));
1281
- 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
+ );
1282
1857
  return items.map((it, i) => {
1283
1858
  const box = accounts[2 * i] && loot.decode('LootBox', accounts[2 * i].data);
1284
1859
  const item = accounts[2 * i + 1] && loot.decode('Item', accounts[2 * i + 1].data);
1285
1860
  const dec = decimalsOf(currencyKey);
1286
- 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
+ };
1287
1870
  });
1288
1871
  },
1289
1872
 
@@ -1291,11 +1874,23 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1291
1874
  * (`whenOut`: 'stopSales', it isn't sold until restocked; 'drawLeft', it draws among what is left). */
1292
1875
  async boxes() {
1293
1876
  const list = spec.boxes ?? [];
1294
- 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)));
1295
1878
  return list.map((b, i) => {
1296
1879
  const box = accounts[i] && loot.decode('LootBox', accounts[i].data);
1297
1880
  const total = box ? box.entries.reduce((n, e) => n + e.weight, 0) : 0;
1298
- return { key: b.key, name: b.name, price: box ? show(box.price, decimalsOf(b.currency)) : null, priceUnits: box?.price ?? null, currency: tokens.get(b.currency ?? currencyKey)?.symbol, active: Boolean(box?.active), whenOut: box?.when_out === 'DrawLeft' ? 'drawLeft' : 'stopSales', outcomes: (b.outcomes ?? []).map((o, j) => ({ ...o, chancePercent: box ? (100 * box.entries[j].weight) / total : o.chance })) };
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
+ };
1299
1894
  });
1300
1895
  },
1301
1896
 
@@ -1335,16 +1930,25 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1335
1930
  async recover() {
1336
1931
  if (!keys) throw new Error('Connect a wallet first.');
1337
1932
  const me = wallet.address;
1338
- const tag = (t) => ({ memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS[t])) } });
1933
+ const tag = t => ({ memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS[t])) } });
1339
1934
  const at = (offset, bytes) => ({ memcmp: { offset, bytes } });
1340
1935
  const orders = new Map();
1341
- for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Order'), at(1, project), at(33, me)])) orders.set(pubkey, loot.decode('Order', account.data));
1342
- const holds = (await client.programAccounts(loot.LOOT_PROGRAM, [tag('Hold'), at(1, project), at(65, me)])).map((h) => h.pubkey);
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
+ );
1343
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).
1344
- const names = [...new Set([...orders.keys(), ...holds, ...(spec.auctions ?? []).map((a) => auctionAt(a)), ...keys.stored()])];
1946
+ const names = [
1947
+ ...new Set([...orders.keys(), ...holds, ...(spec.auctions ?? []).map(a => auctionAt(a)), ...keys.stored()]),
1948
+ ];
1345
1949
  if (!names.length) return [];
1346
- const made = (await Promise.all(names.map((name) => keys.again(name)))).filter(Boolean);
1347
- const held = made.length ? await client.accounts(made.map((k) => k.address)) : [];
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)) : [];
1348
1952
  const [slot, time] = [await slotNow(), await chainNow()];
1349
1953
  const out = [];
1350
1954
  for (const [i, key] of made.entries()) {
@@ -1356,8 +1960,12 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1356
1960
  const order = orders.get(key.name);
1357
1961
  const due = order ? loot.orderRefundable(order, slot, time) : false;
1358
1962
  if (order?.status === 'Rolled' && slot > BigInt(order.requested_slot) && !due) {
1359
- const got = await collect(key.name, { burner: key }).catch((e) => ({ error: e.message }));
1360
- out.push({ name: key.name, key: key.address, ...(got.error ? { error: got.error } : { settled: got.settled, won: got.won }) });
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
+ });
1361
1969
  } else if (order && ['Pending', 'Asked', 'Waiting'].includes(order.status) && !due) {
1362
1970
  out.push({ name: key.name, key: key.address, waiting: order.status });
1363
1971
  } else {
@@ -1372,14 +1980,18 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1372
1980
  async refund(orderAddress) {
1373
1981
  const order = await read('Order', orderAddress);
1374
1982
  // An NFT the project held for the roll goes back.
1375
- 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 }) : {};
1376
1988
  return send([...before, loot.refundIx({ signer: wallet.address, orderAddress, order, subject, programs })]);
1377
1989
  },
1378
1990
 
1379
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
1380
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
1381
1993
  * roll there with nothing, no oracle (a lot its winner paid for fails, for its refund). */
1382
- askRoll: (orderAddress) => askRoll(orderAddress),
1994
+ askRoll: orderAddress => askRoll(orderAddress),
1383
1995
 
1384
1996
  /**
1385
1997
  * Pays a payment of the economy (an entry, an attempt, a tip). `amount`: what the player chose, in whole units, for
@@ -1396,21 +2008,54 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1396
2008
  if (chosen !== undefined && !p.any) throw new Error(`“${p.name}” has a set amount: ${p.amount}.`);
1397
2009
  const amount = chosen === undefined ? least : units(chosen, decimalsOf(p.currency));
1398
2010
  if (amount < least || amount <= 0n) throw new Error(`“${p.name}” takes at least ${p.amount}.`);
1399
- 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]));
1400
2012
  // `pay` takes its shares from the payer each time: a "chosen" share pays the wallet they name, and with none named
1401
2013
  // it is left out, so what it would pay goes to the treasury with the rest (as a purchase's does).
1402
- if (chosenRecipient && chosenRecipient === wallet.address) throw new Error('A payer can’t name their own wallet for a share they choose.');
1403
- 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 => {
1404
2017
  const [kind, key] = s.to.split(':');
1405
2018
  const bps = Math.round(s.percent * 100);
1406
- 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
+ );
1407
2023
  if (kind === 'chosen') return chosenRecipient ? [{ to: { Wallet: [chosenRecipient] }, bps }] : [];
1408
- const pot = (lotteryKey) => loot.pda.pot(loot.pda.lottery(project, part('lotteries', lotteryKey).id));
1409
- 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] };
1410
2046
  return [{ to, bps }];
1411
2047
  });
1412
- holders ??= shares.some((x) => x.to.Holder) ? await holdersOf(shares) : undefined;
1413
- 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
+ });
1414
2059
  return send(withSolWrap(wallet.address, mint, amount, [ix]));
1415
2060
  },
1416
2061
 
@@ -1422,7 +2067,8 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1422
2067
  async payNft(asset, { to = null, reference, ...others } = NO_OPTIONS) {
1423
2068
  only(others, ['to', 'reference'], 'payNft');
1424
2069
  // An NFT paid goes to a wallet: never the project's address or an account of the program (ToTheProject).
1425
- 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.');
1426
2072
  const { before, ...nft } = await nftOf(asset);
1427
2073
  return send([...before, loot.payNftIx({ player: wallet.address, project, asset, to, ...nft, reference })]);
1428
2074
  },
@@ -1438,15 +2084,25 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1438
2084
  for (const l of list) {
1439
2085
  const there = await client.account(loot.pda.lottery(project, l.id));
1440
2086
  // The terms on sale are the round's own copy once someone bought into it, the lottery's before.
1441
- const { lottery, current } = there?.owner === loot.LOOT_PROGRAM ? await roundOf(l) : { lottery: null, current: null };
2087
+ const { lottery, current } =
2088
+ there?.owner === loot.LOOT_PROGRAM ? await roundOf(l) : { lottery: null, current: null };
1442
2089
  const copy = current ?? lottery;
1443
2090
  const terms = copy?.terms;
1444
- const currency = lottery ? ticketCurrencyOf(l, lottery) : l.ticketCurrency ?? currencyKey;
2091
+ const currency = lottery ? ticketCurrencyOf(l, lottery) : (l.ticketCurrency ?? currencyKey);
1445
2092
  const ended = lottery && Number(lottery.every) === 0 && lottery.round > 0;
1446
2093
  out.push({
1447
- key: l.key, name: l.name, price: terms ? show(terms.price, decimalsOf(currency)) : null, priceUnits: terms?.price ?? null,
1448
- currency: tokens.get(currency)?.symbol ?? currency, status: !lottery ? null : ended ? 'Ended' : current?.status ?? 'Open', round: lottery?.round ?? null, sold: lottery ? current?.sold ?? 0n : null,
1449
- drawsAt: terms ? Number(terms.draws_at) : null, nftTicket: Boolean(lottery?.terms.nft_ticket), incarnation: lottery?.incarnation ?? null, revision: copy?.revision ?? null,
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,
1450
2106
  });
1451
2107
  }
1452
2108
  return out;
@@ -1461,31 +2117,54 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1461
2117
  * passes its rule: `nft`, its address (held by the project until the draw, then burned, given on or stashed; back to
1462
2118
  * the player if the round is cancelled). `listProof`: for a list gate.
1463
2119
  */
1464
- 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
+ ) {
1465
2125
  only(others, ['shown', 'chosenRecipient', 'listProof', 'nft', 'listing', 'maxPrice'], 'buyTickets');
1466
2126
  const l = part('lotteries', lotteryKey);
1467
2127
  const name = l.name ?? lotteryKey;
1468
- 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}”.`);
1469
2130
  const given = maxPrice ?? listing?.priceUnits;
1470
- if (given === undefined || given === null) throw new Error(`Say the terms the player was shown for “${name}”: pass the lottery lotteries() showed (listing), or maxPrice (a ticket's most, in base units). The price read now is never the bound.`);
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
+ );
1471
2135
  const { lottery: onChain, current: openRound } = await roundOf(l);
1472
2136
  // A round whose draw is due takes no more tickets: its draw goes first, in this same transaction (the buyer pays
1473
2137
  // the oracle's fee for it), and moves the lottery on, so this purchase is the next round's first. A lottery that
1474
2138
  // draws once has no next round.
1475
2139
  const due = openRound?.status === 'Open' && BigInt(now()) >= BigInt(openRound.terms.draws_at) ? openRound : null;
1476
- if (due && Number(onChain.every) === 0) throw new Error(`The one draw of “${l.name}” is due: it takes no more tickets. Reveal it (claims() lists it).`);
1477
- const draw = due ? (await drawStep(l, onChain, null, due, wallet.address)).filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM) : [];
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
+ : [];
1478
2145
  const current = due ? { ...onChain, round: onChain.round + 1 } : onChain;
1479
2146
  const round = due ? null : openRound;
1480
2147
  // The terms signed are those shown: the round's own copy once someone bought into it, the lottery's before. A
1481
2148
  // lottery written again or changed since is refused here, and by the program (TermsChanged) if it changes after
1482
2149
  // this read.
1483
2150
  const { terms, revision } = round ?? current;
1484
- if (listing && (listing.incarnation == null || listing.revision == null)) throw new Error(`That listing of “${name}” names no terms (incarnation, revision): pass the lottery lotteries() showed.`);
1485
- if (listing && (BigInt(listing.incarnation) !== BigInt(current.incarnation) || Number(listing.revision) !== Number(revision))) throw new Error(`“${name}” has changed since the player was shown it: show them the lottery as it is now first.`);
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
+ );
1486
2162
  const lottery = current;
1487
2163
  const currency = ticketCurrencyOf(l, lottery);
1488
- if (BigInt(terms.price) > BigInt(given)) throw new Error(`A ticket of “${name}” now costs ${show(terms.price, decimalsOf(currency))}, more than the ${show(BigInt(given), decimalsOf(currency))} the player was shown: show them the new price first.`);
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
+ );
1489
2168
  if (Number(lottery.every) === 0 && lottery.round > 0) throw new Error(`“${l.name}” has drawn and ended.`);
1490
2169
  shown ??= await passing(lottery.gate, 'buy a ticket');
1491
2170
  listProof = listProofOf(lottery.gate, l.gate?.list, listProof);
@@ -1500,13 +2179,30 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1500
2179
  }
1501
2180
  const mint = loot.paymentMint(terms);
1502
2181
  const { shares } = terms;
1503
- const choosesHolder = shares.some((s) => s.to.Chosen && s.to.Chosen.item != null);
2182
+ const choosesHolder = shares.some(s => s.to.Chosen && s.to.Chosen.item != null);
1504
2183
  // A share the buyer chooses is paid now, from their own account: to the holder of the collectible they chose (read
1505
2184
  // now), the wallet they named, or with no choice the treasury.
1506
2185
  const holders = choosesHolder && chosenRecipient ? await holdersOf([{ to: { Holder: [chosenRecipient] } }]) : {};
1507
2186
  // A payment mint with a transfer hook: its accounts ride along.
1508
2187
  const extra = await hookTail([mint]);
1509
- const ix = loot.buyTicketsIx({ player: wallet.address, project, id: l.id, lottery, round, mint, count: BigInt(count), shown, listProof, chosenRecipient, chosen: choosesHolder ? chosenRecipient : null, nft, holders, treasury: (await projectNow()).treasury, programs, extra });
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
+ });
1510
2206
  // An overdue round nobody bought into moves to its next date by itself when a ticket is bought.
1511
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
1512
2208
  // buyer when it is cancelled, each sized before the NFT goes in.
@@ -1514,14 +2210,42 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1514
2210
  const g = await projectNow();
1515
2211
  const entry = { buyer: wallet.address, nft: paidWith, start: 0n, end: 1n, amount: 0n, paid: 0n };
1516
2212
  for (const status of ['Drawn', 'Cancelled']) {
1517
- const r = { lottery: loot.pda.lottery(project, l.id), status, terms, accounts: [], owed: 2, opener: wallet.address, entries: [entry] };
1518
- const exit = [computeUnitLimit(1_400_000), ...before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), loot.pullIx({ payer: wallet.address, project, roundAddress: loot.currentRound(r.lottery, lottery), round: r, entry: 0, part: 'Own', projectState: g, ticket: kindOnly(nft), programs })];
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
+ ];
1519
2237
  if (!sendPlan({ payer: wallet.address, instructions: exit, alone: isProveLeaf }))
1520
- throw new Error(`Not bought: this NFT couldn't come out of “${l.name}” again (${status === 'Drawn' ? 'handed on once the round is drawn' : 'back to you if the round is cancelled'}): that transaction would lock more accounts than one can (${transactionLocks(wallet.address, exit, { asV0: true })} with its lookup table; at most 64), so it would stay with the lottery.`);
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
+ );
1521
2241
  }
1522
2242
  }
1523
2243
  const buy = [...before, ...withSolWrap(wallet.address, mint, BigInt(terms.price) * BigInt(count), [ix])];
1524
- return send(draw.length ? [computeUnitLimit(800_000), ...draw, ...buy.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM)] : buy);
2244
+ return send(
2245
+ draw.length
2246
+ ? [computeUnitLimit(800_000), ...draw, ...buy.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM)]
2247
+ : buy,
2248
+ );
1525
2249
  },
1526
2250
 
1527
2251
  /**
@@ -1552,13 +2276,27 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1552
2276
  if (mine && BigInt(mine.amount) > 0n && Number(mine.tier) !== Number(tier)) {
1553
2277
  const [from, to] = [Number(farm.tiers[mine.tier].exit_bps), Number(farm.tiers[tier].exit_bps)];
1554
2278
  const locked = (await chainNow()) < BigInt(mine.since) + BigInt(farm.tiers[mine.tier].lock);
1555
- if (locked && !(to === 0 || (from > 0 && to >= from))) throw new Error('While its lock runs, this stake can only move to a tier with no early exit, or one whose early exit costs at least as much as its own.');
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
+ );
1556
2283
  }
1557
2284
  const token = await farmTokens([mint], [], { extra, transfers });
1558
- const ix = loot.stakeTokensIx({ player: wallet.address, project, id: f.id, farm, amount: value, tier, shown, ...token });
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
+ });
1559
2295
  // A seat with a booster on (its boost is read again), or one whose growth the stake changes, is refreshed first, in
1560
2296
  // the same transaction.
1561
- const before = loot.stakeRefreshFirst(farm, mine, { tier, now: await chainNow() }) ? await refreshOf(f, farm, mine) : [];
2297
+ const before = loot.stakeRefreshFirst(farm, mine, { tier, now: await chainNow() })
2298
+ ? await refreshOf(f, farm, mine)
2299
+ : [];
1562
2300
  return send(await walking(f, farm, [...before, ...withSolWrap(wallet.address, mint, value, [ix])]));
1563
2301
  },
1564
2302
 
@@ -1579,9 +2317,18 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1579
2317
  const first = [];
1580
2318
  if (seat && loot.refreshFirst(farm, seat, value, forfeit)) {
1581
2319
  const { before, ...nft } = seat.asset !== NOBODY ? await nftOf(seat.asset) : { before: [] };
1582
- first.push(...before, loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by, seat, ...nft, extra: token.extra }));
2320
+ first.push(
2321
+ ...before,
2322
+ loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by, seat, ...nft, extra: token.extra }),
2323
+ );
1583
2324
  }
1584
- return send(await walking(f, farm, [...token.before, ...first, loot.unstakeTokensIx({ player: wallet.address, project, id: f.id, farm, amount: value, forfeit, ...token })]));
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
+ );
1585
2332
  },
1586
2333
 
1587
2334
  /**
@@ -1596,20 +2343,30 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1596
2343
  await openFor(f, farm, tier);
1597
2344
  const token = await farmTokens([farm.rewards_mint], [], { extra, transfers });
1598
2345
  const { before, ...nft } = await nftOf(asset);
1599
- 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;
1600
2350
  const previous = seat && seat.player !== wallet.address ? seat.player : null;
1601
2351
  // A booster boosts a stake: a farm of tokens takes one only on a seat with tokens staked (NotFarmed). In a farm
1602
2352
  // whose growth writes the profile, the seat is refreshed first, in the same transaction (`boosterRefreshFirst`).
1603
2353
  const refresh = [];
1604
2354
  if (farm.farmed.Tokens) {
1605
2355
  const mine = await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), wallet.address));
1606
- if (!mine || BigInt(mine.amount) === 0n) throw new Error(`Stake some ${f.stakes} in “${f.name ?? f.key}” first: a booster boosts a stake.`);
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.`);
1607
2358
  if (loot.boosterRefreshFirst(farm)) refresh.push(...(await refreshOf(f, farm, mine)));
1608
2359
  }
1609
2360
  // Its way out must land too: its unstake from this tier, with the tier's
1610
2361
  // prize and as a forfeit, is sized before it goes in, and an NFT that could never come back out isn't staked.
1611
2362
  if (!farm.farmed.Tokens) await exitFits(f, farm, asset, nft, before, tier, token);
1612
- return send(await walking(f, farm, [...refresh, ...before, loot.stakeNftIx({ player: wallet.address, project, id: f.id, farm, asset, ...nft, previous, tier, ...token })]));
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
+ );
1613
2370
  },
1614
2371
 
1615
2372
  /**
@@ -1622,9 +2379,15 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1622
2379
  only(others, ['forfeit', 'extra', 'transfers'], 'unstakeNft');
1623
2380
  const f = part('farms', farmKey);
1624
2381
  const farm = await read('Farm', loot.pda.farm(project, f.id));
1625
- 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
+ );
1626
2387
  // Held in escrow, it comes back into the player's account (frozen in place, it never left it).
1627
- 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
+ });
1628
2391
  // Leaving a mission that rolls: its roll is made in an order of the player's, which `collect` then finishes; its
1629
2392
  // prize's roll (a chest) at the next nonce.
1630
2393
  const seat = await read('Seat', loot.pda.seat(loot.pda.farm(project, f.id), asset));
@@ -1642,13 +2405,41 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1642
2405
  const at = loot.pda.order(project, wallet.address, loot.namedRollNonce(boxes[roll], asset, nonce));
1643
2406
  const there = await readMaybe('Order', at);
1644
2407
  if (there && !(there.status === 'Waiting' && BigInt(there.asked_first) !== 0n))
1645
- throw new Error(`This NFT's last mission roll (${at}) hasn't finished: it must be asked, answered and settled before the NFT can leave. claims() lists the step; collect it (app.collect), then leave.`);
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
+ );
1646
2411
  }
1647
- const ix = loot.unstakeNftIx({ player: wallet.address, project, id: f.id, farm, asset, ...nft, forfeit, roll, nonce, tier: seat.tier, projectCollection: given.collection, poolMints: given.poolMints, items: given.items, nft: given.nft, boxes, ...token });
1648
- const { signature, rolls } = await sendRolling(await walking(f, farm, [...before, ...given.before, ...token.before, ix]));
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
+ );
1649
2433
  const rolled = rolls.length ? { rolled: rolls[0] } : {};
1650
- if (roll != null) return { signature, roll: loot.pda.order(project, wallet.address, loot.namedRollNonce(boxes[roll], asset, nonce)), ...rolled };
1651
- return prize?.Roll && !forfeit ? { signature, roll: loot.pda.order(project, wallet.address, loot.prizeRollNonce(nonce + 1n)), ...rolled } : signature;
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;
1652
2443
  },
1653
2444
 
1654
2445
  /**
@@ -1673,7 +2464,8 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1673
2464
  const farm = await read('Farm', loot.pda.farm(project, f.id));
1674
2465
  const { ends } = await endsOf(f, farm);
1675
2466
  const out = [];
1676
- for (const ix of loot.walkFarmParts({ project, id: f.id, farm, ends, now: await chainNow(), perTx })) out.push(await send([computeUnitLimit(loot.WALK_COMPUTE_UNITS), ix]));
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]));
1677
2469
  return out;
1678
2470
  },
1679
2471
 
@@ -1691,7 +2483,12 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1691
2483
  const held = seat.asset !== NOBODY;
1692
2484
  const { before, ...nft } = held ? await nftOf(seat.asset) : { before: [], hasAuthority: true };
1693
2485
  // A refresh moves no tokens (a farm's rewards are numbers until a seat is paid): only a collectible's own tail.
1694
- return send(await walking(f, farm, [...before, loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by, seat, ...nft, extra: extra ?? [] })]));
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
+ );
1695
2492
  },
1696
2493
 
1697
2494
  /**
@@ -1717,7 +2514,14 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1717
2514
  // A compound stakes more: a seat with a booster on, or whose growth reads the amount, is refreshed first.
1718
2515
  const seat = compound ? await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), by)) : null;
1719
2516
  const first = loot.stakeRefreshFirst(farm, seat, { compound: true }) ? await refreshOf(f, farm, seat) : [];
1720
- return send(await walking(f, farm, [...before, ...token.before, ...first, loot.harvestIx({ player: wallet.address, project, id: f.id, farm, by, held, tree, compound, ...token })]));
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
+ );
1721
2525
  },
1722
2526
 
1723
2527
  /**
@@ -1745,13 +2549,15 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1745
2549
  const f = part('farms', farmKey);
1746
2550
  const farmAddress = loot.pda.farm(project, f.id);
1747
2551
  const farm = await read('Farm', farmAddress);
1748
- 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);
1749
2553
  const nfts = f.stakes === 'collectibles';
1750
2554
  // A seat names its player (offset 1), not its farm: a seat of this farm is the one at its own address here.
1751
- const found = (await client.programAccounts(loot.LOOT_PROGRAM, [
1752
- { memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS.Seat)) } },
1753
- { memcmp: { offset: 1, bytes: player } },
1754
- ])).filter(({ pubkey, account }) => {
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 }) => {
1755
2561
  const seat = loot.decode('Seat', account.data);
1756
2562
  return pubkey === loot.pda.seat(farmAddress, nfts ? seat.asset : player);
1757
2563
  });
@@ -1760,7 +2566,8 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1760
2566
  // (nothing is signed): the player, else the wallet looking, else the project's authority.
1761
2567
  let payer;
1762
2568
  const funded = async () => {
1763
- 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;
1764
2571
  return player;
1765
2572
  };
1766
2573
  const out = [];
@@ -1774,7 +2581,9 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1774
2581
  const by = nfts ? seat.asset : player;
1775
2582
  const ix = loot.refreshSeatIx({ signer: payer, project, id: f.id, farm, by, seat, ...nft });
1776
2583
  const message = compileMessage({ payer, blockhash, instructions: [...before, ix] });
1777
- 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];
1778
2587
  if (after?.data) [seat, counted] = [loot.decode('Seat', after.data), true];
1779
2588
  } catch {
1780
2589
  // The seat as it was last credited.
@@ -1803,7 +2612,7 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1803
2612
  /** The recipes: whether each is on, and its version now (what `craft` takes as the terms the player was shown). */
1804
2613
  async recipes() {
1805
2614
  const list = spec.recipes ?? [];
1806
- 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)));
1807
2616
  return list.map((r, i) => {
1808
2617
  const recipe = accounts[i] && loot.decode('Recipe', accounts[i].data);
1809
2618
  return { key: r.key, name: r.name, active: Boolean(recipe?.active), version: recipe ? recipe.version : null };
@@ -1821,16 +2630,22 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1821
2630
  only(others, ['version', 'listing', 'extra'], 'craft');
1822
2631
  const r = part('recipes', recipeKey);
1823
2632
  version ??= listing?.version ?? undefined;
1824
- if (version === undefined || version === null) throw new Error(`Say which version of “${r.name ?? recipeKey}” the player was shown: pass the listing recipes() showed (listing), or its version. The version read now is never signed in its place.`);
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
+ );
1825
2637
  const current = await read('Recipe', loot.pda.recipe(project, r.id));
1826
2638
  const recipe = { ...current, version };
1827
- const pays = recipe.inputs.filter((i) => i.Pay).map((i) => i.Pay);
1828
- 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 })));
1829
2641
  const treasury = (await projectNow()).treasury;
1830
2642
  // An NFT a `show` line takes, by its address: as the project's, a Core asset of another collection, or a Token
1831
2643
  // Metadata mint, which the craft reads differently.
1832
2644
  // Where the collectibles of input `i` start among those handed in.
1833
- const at = (i) => recipe.inputs.slice(0, i).reduce((n, x) => n + (x.Burn?.count ?? (x.Use || x.Show || x.Staked || x.Trade ? 1 : 0)), 0);
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);
1834
2649
  const shownAt = new Set(recipe.inputs.flatMap((input, i) => (input.Show ? [at(i)] : [])));
1835
2650
  // An NFT a `trade` line takes in, any kind: read for its kind (a compressed one proven first).
1836
2651
  const tradedAt = new Set(recipe.inputs.flatMap((input, i) => (input.Trade ? [at(i)] : [])));
@@ -1840,39 +2655,47 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1840
2655
  // One a `staked` line takes: on its seat in the farm the line names, escrowed or frozen there.
1841
2656
  const stakedAt = new Map(recipe.inputs.flatMap((input, i) => (input.Staked ? [[at(i), input.Staked.farm]] : [])));
1842
2657
  // The places a raise lowers a number on.
1843
- const lowered = new Set((recipe.raises ?? []).filter((raise) => BigInt(raise.shift.by) < 0n).map((raise) => Number(raise.place)));
1844
- const handed = await Promise.all(assets.map(async (nft, i) => {
1845
- if (stakedAt.has(i) && typeof nft === 'string') {
1846
- const farm = stakedAt.get(i);
1847
- if (!(await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, farm), nft)))) {
1848
- const name = (spec.farms ?? []).find((f) => f.id === farm)?.name ?? `farm ${farm}`;
1849
- throw new Error(`${nft} is not staked in “${name}”, where this recipe takes it.`);
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 };
1850
2670
  }
1851
- return { asset: nft, farm };
1852
- }
1853
- if (keptAt.has(i) && typeof nft === 'string') return (await escrowedIn(nft)) ?? (await lockedIn(nft)) ?? nft;
1854
- if (tradedAt.has(i) && typeof nft === 'string') {
1855
- const { before, ...kind } = await nftOf(nft);
1856
- proving.push(...before);
1857
- return { asset: nft, ...kind };
1858
- }
1859
- if (!shownAt.has(i) || typeof nft !== 'string') return nft;
1860
- // Shown on a seat of a farm that freezes, with a raise lowering a number on it: its seat and farm go with it.
1861
- const seated = lowered.has(i) ? await lockedIn(nft) : null;
1862
- const on = seated ? { farm: seated.farm } : {};
1863
- const account = await client.account(nft);
1864
- if (account?.owner !== MPL_CORE) return { mint: nft, owner: wallet.address, ...on };
1865
- const theirs = await collectionOf(nft);
1866
- return ours(theirs) ? (seated ? { asset: nft, ...on } : nft) : { asset: nft, collection: theirs, ...on };
1867
- }));
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
+ );
1868
2687
  // A recipe that rolls makes its roll in an order of the player's, which `collect` then finishes.
1869
- const rolls = recipe.outputs.some((o) => o.RollOn || o.Prize?.[0]?.Roll);
2688
+ const rolls = recipe.outputs.some(o => o.RollOn || o.Prize?.[0]?.Roll);
1870
2689
  // A roll on a collectible kept is named by that collectible's nonce (`nonceOn`): on a box that holds it, its
1871
2690
  // order is there, one at a time on it (`namedRollNonce`).
1872
- const rollOn = recipe.outputs.find((o) => o.RollOn)?.RollOn;
1873
- const nonce = rollOn ? loot.nonceOn(typeof assets[rollOn.place] === 'string' ? assets[rollOn.place] : assets[rollOn.place].asset) : freshNonce();
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();
1874
2695
  // A roll on a kept collectible (`RollOn`) passes its box's prize mints as a roll given does.
1875
- 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
+ );
1876
2699
  // Each item a line takes passes its collection.
1877
2700
  const items = { ...given.items };
1878
2701
  for (const input of recipe.inputs) {
@@ -1881,31 +2704,62 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1881
2704
  }
1882
2705
  // The collectibles it makes, each at its item's next serial.
1883
2706
  const counts = {};
1884
- const made = recipe.outputs.flatMap((o) => {
2707
+ const made = recipe.outputs.flatMap(o => {
1885
2708
  const id = o.Prize?.[0]?.Item?.[0];
1886
2709
  if (id === undefined || items[id]?.kind !== 'Collectible') return [];
1887
2710
  counts[id] = (counts[id] ?? 0) + 1;
1888
2711
  return [loot.pda.itemAsset(loot.pda.item(project, id), Number(items[id].issued) + counts[id])];
1889
2712
  });
1890
- const before = [...proving, ...given.before].filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM);
1891
- let instructions = [computeUnitLimit(rolls ? 1_400_000 : 1_000_000), ...before, loot.craftIx({ player: wallet.address, project, collection: collections[0], id: r.id, recipe, assets: handed, times, nonce, holders, programs, treasury, poolMints: given.poolMints, items, nfts: given.nfts, boxes: given.boxes, extra })];
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
+ ];
1892
2736
  // Fuel bought for a staked collectible counts once its seat is refreshed: in the same transaction.
1893
2737
  for (const raise of recipe.raises ?? []) {
1894
2738
  const asset = assets[raise.place];
1895
2739
  if (typeof asset !== 'string') continue;
1896
- for (const f of (spec.farms ?? []).filter((x) => x.upkeepAttribute === raise.shift.key)) {
2740
+ for (const f of (spec.farms ?? []).filter(x => x.upkeepAttribute === raise.shift.key)) {
1897
2741
  const seat = await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), asset));
1898
2742
  if (!seat) continue;
1899
2743
  const farm = await read('Farm', loot.pda.farm(project, f.id));
1900
2744
  const { before, ...nft } = await nftOf(asset);
1901
- 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
+ );
1902
2749
  }
1903
2750
  }
1904
2751
  for (const p of pays) instructions = withSolWrap(wallet.address, p.mint, p.amount * BigInt(times), instructions);
1905
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
1906
2753
  // craft's own transaction and settled by a key after: one prompt.
1907
- const on = rollOn && (typeof assets[rollOn.place] === 'string' ? assets[rollOn.place] : assets[rollOn.place].asset);
1908
- const roll = rolls ? loot.pda.order(project, wallet.address, rollOn ? loot.namedRollNonce(given.boxes[rollOn.box_id], on, nonce) : loot.prizeRollNonce(nonce)) : null;
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;
1909
2763
  const { signature, rolls: rolled } = await sendRolling(instructions);
1910
2764
  return { signature, made, ...(roll ? { roll } : {}), ...(rolled.length ? { rolled: rolled[0] } : {}) };
1911
2765
  },
@@ -1917,7 +2771,17 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1917
2771
  async hold(asset, seconds) {
1918
2772
  const freeAt = BigInt(typeof seconds === 'object' ? seconds.until : now() + Number(seconds));
1919
2773
  const { before, hasAuthority: _, ...nft } = await nftOf(asset);
1920
- 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
+ ]);
1921
2785
  return { signature, freeAt };
1922
2786
  },
1923
2787
 
@@ -1934,12 +2798,30 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1934
2798
  if (!offer.deal.Lend) throw new Error(`The offer ${address} buys NFTs; it lends nothing.`);
1935
2799
  const account = await client.account(offer.mint);
1936
2800
  if (!account) throw new Error(`The loan's token ${offer.mint} is not on this cluster.`);
1937
- const lender = (who) => (who === maker ? ' That is the lender: they could keep you from repaying and take your NFT when the loan is due.' : '');
1938
- const risks = mintPowers(account).map((p) => {
1939
- if (p.power === 'freeze') return { ...p, says: `${p.holder} can freeze accounts of the loan's token, which makes repaying fail.${lender(p.holder)}` };
1940
- 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)}` };
1941
- 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)}` };
1942
- 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
+ };
1943
2825
  });
1944
2826
  const { amount, repay, lasts } = offer.deal.Lend;
1945
2827
  return { address, offer, mint: offer.mint, program: account.owner, amount, repay, lasts, risks };
@@ -1954,14 +2836,32 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1954
2836
  async borrow(maker, id, asset, { due, acceptRisks = false, extra = [], ...others } = NO_OPTIONS) {
1955
2837
  only(others, ['due', 'acceptRisks', 'extra'], 'borrow');
1956
2838
  const loan = await this.loanOffer(maker, id);
1957
- 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
+ );
1958
2843
  const lasts = BigInt(loan.lasts);
1959
2844
  due = BigInt(due ?? BigInt(now()) + lasts - (lasts > 120n ? 60n : 0n));
1960
2845
  const { before, ...nft } = await nftOf(asset);
1961
2846
  const collection = nft.collection ?? (await collectionOf(asset)) ?? MPL_CORE;
1962
2847
  const loanPrograms = { ...programs, [loan.mint]: loan.program };
1963
- const ix = loot.borrowIx({ borrower: wallet.address, project, id, offer: loan.offer, due, asset, ...nft, collection, programs: loanPrograms, extra });
1964
- 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
+ ]);
1965
2865
  return { signature, due, hold: loot.pda.hold(asset, due) };
1966
2866
  },
1967
2867
 
@@ -1972,7 +2872,13 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1972
2872
  async loan(asset, due) {
1973
2873
  const hold = await readMaybe('Hold', loot.pda.hold(asset, due));
1974
2874
  if (!hold?.debt) return null;
1975
- return { lender: hold.debt.lender, mint: hold.debt.mint, repay: BigInt(hold.debt.repay), due: Number(hold.free_at), borrower: hold.player };
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
+ };
1976
2882
  },
1977
2883
 
1978
2884
  /**
@@ -1988,22 +2894,48 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
1988
2894
  only(others, ['repay', 'extra'], 'repay');
1989
2895
  const hold = await read('Hold', loot.pda.hold(asset, due));
1990
2896
  if (!hold.debt) throw new Error('This NFT is held for no loan left to repay.');
1991
- if (repay === undefined || repay === null) throw new Error('Say the debt the payer was shown (repay, from loan()): the amount read now is never paid in its place.');
1992
- if (BigInt(repay) !== BigInt(hold.debt.repay)) throw new Error(`This loan now asks ${hold.debt.repay}, not the ${repay} shown: show the payer the loan as it is now first.`);
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
+ );
1993
2905
  const { before, hasAuthority: _, ...nft } = await nftOf(asset, wallet.address, { to: hold.player });
1994
2906
  const collection = nft.collection ?? (await collectionOf(asset)) ?? MPL_CORE;
1995
2907
  // A pNFT's or a compressed NFT's release opens something (a token account and record, a proof record): its key
1996
2908
  // is funded for it, and makes its own proof and accounts as it sends the release.
1997
2909
  const heavy = before.some(isProveLeaf) || Boolean(nft.tokenMetadata?.programmable);
1998
- const opens = heavy ? [] : before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM);
2910
+ const opens = heavy ? [] : before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM);
1999
2911
  const at = loot.pda.hold(asset, due);
2000
- const { key, fund } = await keyFor(heavy ? loot.SETTLE_RENT_UNIT + SIGNATURE_FEE : SIGNATURE_FEE, await jobKey(at));
2912
+ const { key, fund } = await keyFor(
2913
+ heavy ? loot.SETTLE_RENT_UNIT + SIGNATURE_FEE : SIGNATURE_FEE,
2914
+ await jobKey(at),
2915
+ );
2001
2916
  const repaid = loot.repayIx({ payer: wallet.address, project, hold, repay, programs, extra });
2002
- const signature = await send(withSolWrap(wallet.address, hold.debt.mint, BigInt(hold.debt.repay), [...opens, repaid, fund]));
2917
+ const signature = await send(
2918
+ withSolWrap(wallet.address, hold.debt.mint, BigInt(hold.debt.repay), [...opens, repaid, fund]),
2919
+ );
2003
2920
  // Repaid, the hold is a plain one that is over (no debt, `free_at` 0) at the address its loan named.
2004
- const release = async (signer) => {
2005
- const { before: first, hasAuthority: _h, ...kind } = heavy ? await nftOf(asset, signer, { to: hold.player }) : { before: [], ...nft };
2006
- return [...first, loot.releaseIx({ signer, project, asset, ...kind, collection, hold: { ...hold, debt: null, free_at: 0 }, address: at })];
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
+ ];
2007
2939
  };
2008
2940
  const released = await keyRun(key, release);
2009
2941
  return { signature, released };
@@ -2022,11 +2954,39 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2022
2954
  if (contest.nft) {
2023
2955
  if (!staked) throw new Error('This contest takes an NFT: say which (nft).');
2024
2956
  const { before, ...kind } = await nftOf(staked);
2025
- 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
+ ]);
2026
2972
  }
2027
- const t = [...tokens.values()].find((x) => mintOf(x.key) === contest.mint);
2028
- const value = units(amount, t?.source === 'sol' ? 9 : t?.decimals ?? 0);
2029
- 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
+ );
2030
2990
  },
2031
2991
 
2032
2992
  /**
@@ -2036,13 +2996,22 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2036
2996
  */
2037
2997
  async auctions() {
2038
2998
  const list = spec.auctions ?? [];
2039
- const accounts = await client.accounts(list.map((a) => auctionAt(a)));
2999
+ const accounts = await client.accounts(list.map(a => auctionAt(a)));
2040
3000
  const at = BigInt(now());
2041
- const wrapped = wallet?.address ? await client.account(associatedTokenAddress(wallet.address, NATIVE_MINT)) : null;
2042
- 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';
2043
3007
  return list.map((a, i) => {
2044
3008
  const x = accounts[i] && loot.decode('Auction', accounts[i].data);
2045
- 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
+ };
2046
3015
  if (!x) return { ...base, ended: true };
2047
3016
  // Bids of NFTs count NFTs.
2048
3017
  const dec = x.nft_bids ? 0 : decimalsOf(a.currency);
@@ -2090,7 +3059,8 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2090
3059
  const bundle = nfts ?? (Array.isArray(amount) ? amount : null);
2091
3060
  if (!bundle?.length) throw new Error('This auction takes NFTs as bids: say which (nfts).');
2092
3061
  if (bundle.length > loot.MAX_BID_NFTS) throw new Error(`A bid holds at most ${loot.MAX_BID_NFTS} NFTs.`);
2093
- if (auction.held.length >= loot.MAX_BID_NFTS) throw new Error(`The high bid holds ${loot.MAX_BID_NFTS} NFTs, the most a bid holds: it can't be topped.`);
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.`);
2094
3064
  const before = [];
2095
3065
  const kindOf = async (asset, to) => {
2096
3066
  const { before: proof, ...kind } = await nftOf(asset, wallet.address, { to });
@@ -2102,8 +3072,18 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2102
3072
  // The bid it tops goes back to its bidder: into a fresh account of theirs when their own can't take it.
2103
3073
  const held = [];
2104
3074
  for (const asset of auction.held) held.push(await kindOf(asset, auction.high_bidder));
2105
- const ix = loot.bidIx({ bidder: wallet.address, project, id: a.id, auction, shown, listProof, nfts: bid, held, programs });
2106
- 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]);
2107
3087
  }
2108
3088
  const mint = auction.mint;
2109
3089
  const value = units(amount, decimalsOf(a.currency));
@@ -2113,11 +3093,25 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2113
3093
  const have = await client.account(wrapped);
2114
3094
  const held = have ? new DataView(have.data.buffer, have.data.byteOffset).getBigUint64(64, true) : 0n;
2115
3095
  instructions.push(createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, NATIVE_MINT));
2116
- 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));
2117
3098
  }
2118
3099
  // A Token-2022 account can ask for a memo on every transfer into it: the Memo program rides along.
2119
- const memo = programOf(mint) === TOKEN_2022_PROGRAM ? [{ pubkey: MEMO_PROGRAM, isSigner: false, isWritable: false }] : [];
2120
- 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
+ );
2121
3115
  return send(instructions, [], { keepWrapped: true });
2122
3116
  },
2123
3117
 
@@ -2134,19 +3128,46 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2134
3128
  const g = await projectNow();
2135
3129
  // The lot went at an earlier end: this one pays what its seller is owed and closes the auction.
2136
3130
  if (BigInt(auction.proceeds_owed) > 0n) {
2137
- const memo = programOf(auction.mint) === TOKEN_2022_PROGRAM ? [{ pubkey: MEMO_PROGRAM, isSigner: false, isWritable: false }] : [];
2138
- return { signature: await send([loot.endAuctionIx({ signer: wallet.address, project, projectState: g, id: a.id, auction, programs, extra: memo })]), owedPaid: auction.proceeds_owed };
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
+ };
2139
3149
  }
2140
3150
  const sold = auction.high > 0n;
2141
3151
  const item = auction.lot.Item ? await read('Item', loot.pda.item(project, auction.lot.Item[0])) : null;
2142
- const made = sold && item?.kind === 'Collectible' ? loot.pda.itemAsset(loot.pda.item(project, auction.lot.Item[0]), auction.pin) : null;
2143
- 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
+ : [];
2144
3160
  holders ??= sold ? await holdersOf(auction.shares) : {};
2145
3161
  const lot = auction.lot.Asset?.[0];
2146
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
2147
3163
  // tokens would go to: the seller of an NFT, the treasury for a new item.
2148
- const { before, hasAuthority: _, ...nft } = lot ? await nftOf(lot, wallet.address, { to: sold ? auction.high_bidder : auction.seller }) : { before: [] };
2149
- 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;
2150
3171
  const proceeds = auction.lot.Asset ? auction.seller : g.treasury;
2151
3172
  const held = [];
2152
3173
  if (sold && auction.nft_bids) {
@@ -2156,25 +3177,77 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2156
3177
  held.push(kind);
2157
3178
  }
2158
3179
  }
2159
- const ix = loot.endAuctionIx({ signer: wallet.address, project, projectState: g, id: a.id, auction, item, ...nft, collection, held, holders, programs, extra: memo });
2160
- let all = [computeUnitLimit(800_000), ...before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), ix];
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];
2161
3195
  // An end that can't fit one transaction (a compressed lot's long proof, a hooked mint, many shares) makes each NFT
2162
3196
  // a hold for whoever it goes to, then sends each home with its own release: anyone may, and the rent comes back.
2163
- const holds = lot || (sold && auction.nft_bids) ? !sendPlan({ payer: wallet.address, instructions: all, alone: isProveLeaf }) : false;
3197
+ const holds =
3198
+ lot || (sold && auction.nft_bids)
3199
+ ? !sendPlan({ payer: wallet.address, instructions: all, alone: isProveLeaf })
3200
+ : false;
2164
3201
  if (holds) {
2165
3202
  // The end's own transaction funds a throwaway key that sends each release after it (anyone may): each one's
2166
3203
  // fees, and what it may open (a pNFT's token account and record, a compressed NFT's proof record). One the key
2167
3204
  // can't send, the wallet does.
2168
- const outs = [...(lot ? [[lot, sold ? auction.high_bidder : auction.seller]] : []), ...(sold && auction.nft_bids ? auction.held.map((x) => [x, proceeds]) : [])];
2169
- const { key, fund } = await keyFor(BigInt(outs.length) * (2n * SIGNATURE_FEE + loot.SETTLE_RENT_UNIT), await jobKey(auctionAt(a)));
2170
- all = [computeUnitLimit(800_000), loot.endAuctionIx({ signer: wallet.address, project, projectState: g, id: a.id, auction, item, ...nft, collection, held, hold: true, holders, programs, extra: memo }), fund];
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
+ ];
2171
3232
  const signature = await send(all);
2172
3233
  const releases = [];
2173
3234
  for (const [asset, to] of outs) {
2174
- const release = async (signer) => {
3235
+ const release = async signer => {
2175
3236
  const hold = await read('Hold', loot.pda.hold(asset, 0));
2176
3237
  const { before: proof, hasAuthority: _h, ...kind } = await nftOf(asset, signer, { to });
2177
- return [...proof, loot.releaseIx({ signer, project, asset, ...kind, collection: kind.compressed || kind.tokenMetadata ? undefined : (await collectionOf(asset)) ?? MPL_CORE, hold, to })];
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
+ ];
2178
3251
  };
2179
3252
  const steps = await release(key.address);
2180
3253
  const byKey = (await dryRun(key.address, steps)).ok ? await sendAs(key, steps).catch(() => null) : null;
@@ -2185,9 +3258,25 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2185
3258
  }
2186
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
2187
3260
  // ending it asks in the same transaction and a key settles it.
2188
- const roll = sold && auction.lot.Roll ? loot.pda.order(project, auction.high_bidder, loot.prizeRollNonce(loot.prizeNonce(auctionAt(a), BigInt(auction.last_bid_at)))) : null;
2189
- const { signature, rolls } = roll && auction.high_bidder === wallet.address ? await sendRolling(all) : { signature: await send(all), rolls: [] };
2190
- return { signature, ...(sold ? { winner: auction.high_bidder } : {}), ...(made ? { asset: made } : {}), ...(roll ? { order: roll } : {}), ...(rolls.length ? { rolled: rolls[0] } : {}) };
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
+ };
2191
3280
  },
2192
3281
 
2193
3282
  /**
@@ -2209,11 +3298,26 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2209
3298
  // run; a long backlog of those takes a few calls).
2210
3299
  const due = Math.min(loot.runsDue(schedule, await chainNow(), held), most ?? Infinity);
2211
3300
  if (!due) return null;
2212
- 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;
2213
3304
  holders ??= await holdersOf([{ to: schedule.action.to }]);
2214
3305
  const treasury = (await projectNow()).treasury;
2215
3306
  // A sealed fixed amount's backlog is a step for each halving: room for the longest the program takes a call.
2216
- return send([computeUnitLimit(1_400_000), loot.runScheduleIx({ payer: wallet.address, project, id: sc.id, schedule, poolMint, holders, programs, treasury, upto: Number(schedule.runs) + due })]);
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
+ ]);
2217
3321
  },
2218
3322
 
2219
3323
  // ---------------------------------------------------------------- listings
@@ -2226,7 +3330,18 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2226
3330
  only(others, ['price', 'currency', 'shares'], 'listForSale');
2227
3331
  const mint = mintOf(currency);
2228
3332
  const { before, ...nft } = await nftOf(asset);
2229
- 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
+ ]);
2230
3345
  },
2231
3346
 
2232
3347
  /**
@@ -2237,14 +3352,29 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2237
3352
  only(others, ['price', 'lasts', 'maxPeriods', 'currency', 'shares'], 'listForRent');
2238
3353
  const mint = mintOf(currency);
2239
3354
  const { before, ...nft } = await nftOf(asset);
2240
- 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
+ ]);
2241
3367
  },
2242
3368
 
2243
3369
  /** The listing of `asset` (one account read), or null: { listing, asset, maker, mint, deal, renter, until }. */
2244
3370
  async listing(asset) {
2245
3371
  const listing = await readMaybe('Listing', loot.pda.listing(project, asset));
2246
3372
  if (!listing) return null;
2247
- 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
+ };
2248
3378
  },
2249
3379
 
2250
3380
  /** Who rents `asset` now (one account read, then a check that the project still holds it), or null. */
@@ -2260,7 +3390,11 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2260
3390
  /** The project's listings, a maker's (`maker`) or all: one search of the program's accounts. */
2261
3391
  async listings({ maker, ...others } = NO_OPTIONS) {
2262
3392
  only(others, ['maker'], 'listings');
2263
- 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
+ ];
2264
3398
  const time = await chainNow();
2265
3399
  return (await client.programAccounts(loot.LOOT_PROGRAM, filters)).map(({ pubkey, account }) => {
2266
3400
  const listing = loot.decode('Listing', account.data);
@@ -2277,7 +3411,20 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2277
3411
  await shownListing(asset, listing, 'Sell');
2278
3412
  const { before, ...nft } = await nftOf(asset, wallet.address, { to: wallet.address });
2279
3413
  const holders = await holdersOf(listing.shares ?? []);
2280
- 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
+ ]);
2281
3428
  },
2282
3429
 
2283
3430
  /**
@@ -2291,7 +3438,21 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2291
3438
  const total = BigInt(listing.deal.Rent.price) * BigInt(periods);
2292
3439
  // A compressed NFT is read by its record, proven first in the same transaction.
2293
3440
  const { before, compressed } = await nftOf(asset);
2294
- 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
+ ]);
2295
3456
  },
2296
3457
 
2297
3458
  /** Takes a listed NFT home (its maker). While a rental runs it only stops the listing; call again once it is over. */
@@ -2308,17 +3469,43 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2308
3469
  * nothing but the lottery can take it while a place names it; the winner gets it before any ticket money moves.
2309
3470
  * `price` (whole units of `currency`) a ticket, `drawsAt` (Unix seconds) its one draw, `minSold` the least tickets
2310
3471
  * (fewer: the round is cancelled and refunded), `shares` where the ticket money goes (the seller's own wallet by
2311
- * 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.
2312
3475
  */
2313
- 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
+ ) {
2314
3489
  only(others, ['price', 'currency', 'drawsAt', 'minSold', 'perWallet', 'shares'], 'raffle');
2315
3490
  const kind = await nftOf(nft);
2316
3491
  const { before, hasAuthority: _, ...piece } = kind;
2317
3492
  const lotteryState = await readMaybe('Lottery', loot.pda.lottery(project, id));
2318
- await send([...before, loot.stashIx({ signer: wallet.address, project, lottery: id, lotteryState, asset: nft, ...piece })]);
3493
+ await send([
3494
+ ...before,
3495
+ loot.stashIx({ signer: wallet.address, project, lottery: id, lotteryState, asset: nft, ...piece }),
3496
+ ]);
2319
3497
  const args = {
2320
- price: units(price, decimalsOf(currency)), mint: mintOf(currency), shares, places: [{ bps: 0, prize: { Held: [nft] } }],
2321
- 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),
2322
3509
  };
2323
3510
  return send([loot.upsertIx({ owner: wallet.address, project, def: { Lottery: { id, args } }, lotteryState })]);
2324
3511
  },
@@ -2327,7 +3514,18 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2327
3514
  async unraffle(id, nft) {
2328
3515
  const { before, hasAuthority: _, ...piece } = await nftOf(nft, wallet.address, { to: wallet.address });
2329
3516
  const lotteryState = await readMaybe('Lottery', loot.pda.lottery(project, id));
2330
- return send([...before, loot.stashIx({ signer: wallet.address, project, lottery: id, lotteryState, asset: nft, out: wallet.address, ...piece })]);
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
+ ]);
2331
3529
  },
2332
3530
 
2333
3531
  /** Turns what waits in the player's wrapped-SOL account (a bid that was topped) into plain SOL. */
@@ -2348,7 +3546,14 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2348
3546
  */
2349
3547
  async claimGrant(grantKey, options = {}) {
2350
3548
  only(options, ['voucher', 'proof', 'personHash', 'round', 'amount', 'shown', 'listProof'], 'claimGrant');
2351
- 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;
2352
3557
  const gspec = part('grants', grantKey);
2353
3558
  const address = loot.pda.grant(project, gspec.id);
2354
3559
  const grant = await read('Grant', address);
@@ -2357,7 +3562,10 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2357
3562
  // What the grant has paid this wallet: its count of claims (an open claim's roll is named by it), and the rounds
2358
3563
  // paid (one paid already is skipped).
2359
3564
  const bits = grant.shard_bits;
2360
- const [claimsShard, roundsShard] = await client.accounts([loot.pda.claims(address, grant.incarnation, loot.shardOf(loot.limitKey('wallet', wallet.address), bits)), loot.pda.rounds(address, grant.incarnation, loot.walletShard(grant, wallet.address))]);
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
+ ]);
2361
3569
  const count = loot.readLimit(loot.readListAccount(claimsShard), loot.limitKey('wallet', wallet.address)).count;
2362
3570
  const paidRounds = loot.readListAccount(roundsShard);
2363
3571
  // What its prize needs: a pool's mint, the item, an NFT's kind (a compressed one proven first).
@@ -2369,23 +3577,50 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2369
3577
  let proof = [];
2370
3578
  const keys = gspec.keys ?? [];
2371
3579
  if (voucher) {
2372
- if (voucher.incarnation == null || BigInt(voucher.incarnation) !== grant.incarnation) throw new Error('This voucher belongs to a different grant incarnation.');
2373
- const message = loot.voucherBytes({ grant: address, incarnation: grant.incarnation, wallet: wallet.address, personHash: voucher.personHash, rounds: voucher.rounds, round: voucher.round, expires: voucher.expires, amount: BigInt(voucher.amount ?? 0) });
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
+ });
2374
3592
  const signature = typeof voucher.signature === 'string' ? decodeBase58(voucher.signature) : voucher.signature;
2375
3593
  instructions.push(loot.ed25519Ix({ publicKey: voucher.publicKey, signature, message }));
2376
3594
  // A key under the grant's key root (a promo code's) proves it is one of them.
2377
3595
  const at = keys.indexOf(voucher.publicKey);
2378
- 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
+ : []);
2379
3604
  } else if (given) {
2380
3605
  proof = given;
2381
3606
  } else if ((gspec.list ?? []).length) {
2382
3607
  // An entry is an address (the grant's amount, round 0), or { wallet, amount, round } (whole units of the grant's
2383
3608
  // token): a wallet listed under several rounds claims each once (`options.round`, a number; the first by default).
2384
- 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));
2385
3610
  const mint = grant.gives.Tokens?.from.Mint?.[0] ?? poolMint;
2386
- const decimals = mint && list.some((e) => e.amount !== undefined) ? (await client.account(mint))?.data?.[44] ?? 0 : 0;
2387
- const leaves = list.map((e) => loot.listLeaf(e.wallet, null, loot.listRound(e.round ?? 0), e.amount !== undefined ? units(e.amount, decimals) : 0n));
2388
- 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
+ );
2389
3624
  if (at < 0) throw new Error('This wallet is not on the list for this grant.');
2390
3625
  proof = loot.merkleProof(leaves, at);
2391
3626
  listRound = loot.listRound(list[at].round ?? 0);
@@ -2396,10 +3631,39 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2396
3631
  // What this claim gives: the amount the voucher or the list's leaf names (0: the grant's prize as it is).
2397
3632
  const amount = BigInt((voucher ? voucher.amount : listAmount) ?? 0);
2398
3633
  const most = grant.gives.Tokens?.amount;
2399
- if (amount > 0n && (most === undefined || amount > BigInt(most))) throw new Error(`This claim names ${amount} tokens, more than this grant gives${most === undefined ? ' (it gives no fixed amount of tokens)' : ` (${most})`}.`);
2400
- instructions.push(loot.claimGrantIx({ project, id: gspec.id, grant, wallet: wallet.address, signer: wallet.address, count, rounds: voucher?.rounds, round: voucher?.round ?? listRound, expires: voucher?.expires, personHash: voucher?.personHash ?? listPerson, amount, proof, paidRounds, poolMint, item, nft: prized.nft, shown, listProof: gateProof, programs, voucherKey: voucher?.publicKey }));
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
+ );
2401
3662
  // A claim's records and prizes can pass the 200,000 compute units a transaction has by default.
2402
- const all = [computeUnitLimit(grant.gives.Roll ? 1_400_000 : loot.CLAIM_COMPUTE_UNITS), ...instructions.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM)];
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
+ ];
2403
3667
  if (!grant.gives.Roll) return send(all);
2404
3668
  // A roll's orders are asked for in the claim's own transaction and settled by a key after: one prompt.
2405
3669
  const { signature, rolls } = await sendRolling(all);
@@ -2424,45 +3688,62 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2424
3688
  const r = await fetchImpl(path.startsWith('http') ? path : `${base}${path}`, init);
2425
3689
  // The status first: a proxy's error page is no JSON, and says nothing a player can act on but its status.
2426
3690
  const body = await r.json().catch(() => null);
2427
- if (!r.ok) throw Object.assign(new Error(body?.error?.why ?? `The results server answered ${r.status}.`), { code: body?.error?.code, status: r.status });
2428
- 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.`);
2429
3698
  return body;
2430
3699
  };
2431
- const get = (path) => ask(path);
3700
+ const get = path => ask(path);
2432
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
2433
3702
  // origin. Any other place is refused before anything is fetched, so a server's answer can't send the page to
2434
3703
  // another host. A server given as a path (such as "/results") is on the page's own origin.
2435
- const proofAt = (proof) => {
3704
+ const proofAt = proof => {
2436
3705
  const said = String(proof ?? '');
2437
3706
  let origin = null;
2438
3707
  let url = null;
2439
3708
  try {
2440
3709
  const home = new URL(`${base}/`, globalThis.location?.href);
2441
3710
  origin = home.origin;
2442
- 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
+ );
2443
3715
  } catch {
2444
3716
  // not a URL at all: refused below
2445
3717
  }
2446
- 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
+ );
2447
3722
  return url.href;
2448
3723
  };
2449
3724
  // Each value in a query is encoded: a round or a page the server named can't change the request's path.
2450
- const q = (value) => encodeURIComponent(String(value));
3725
+ const q = value => encodeURIComponent(String(value));
2451
3726
  // A win's vouchers; a stash drop's NFT not given yet: the signed-in claim's, those for this wallet
2452
- const vouchersOf = async (round) => {
3727
+ const vouchersOf = async round => {
2453
3728
  try {
2454
3729
  return (await get(`/v1/vouchers?game=${q(project)}&round=${q(round)}&wallet=${q(wallet.address)}`)).vouchers;
2455
3730
  } catch (e) {
2456
3731
  if (e.code !== 'R-SIGN-IN' || !token) throw e;
2457
- const { vouchers } = await ask('/v1/claims', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ game: project, round, token }) });
2458
- 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);
2459
3738
  }
2460
3739
  };
2461
3740
  const claimed = async (grantId, incarnation, round) => {
2462
3741
  const address = loot.pda.grant(project, grantId);
2463
3742
  const grant = await read('Grant', address);
2464
3743
  if (incarnation != null && BigInt(incarnation) !== BigInt(grant.incarnation)) return false;
2465
- const shard = await client.account(loot.pda.rounds(address, grant.incarnation, loot.walletShard(grant, wallet.address)));
3744
+ const shard = await client.account(
3745
+ loot.pda.rounds(address, grant.incarnation, loot.walletShard(grant, wallet.address)),
3746
+ );
2466
3747
  return loot.roundPaid(loot.readListAccount(shard), wallet.address, round);
2467
3748
  };
2468
3749
  const out = [];
@@ -2480,20 +3761,29 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2480
3761
  const pagesRead = new Set();
2481
3762
  for (let first = true; first || (page.next && !pagesRead.has(page.next)); first = false) {
2482
3763
  pagesRead.add(page.next);
2483
- page = await get(`/v1/wins?game=${q(project)}&wallet=${q(wallet.address)}${page.next ? `&before=${q(page.next)}` : ''}`);
2484
- 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)) {
2485
3768
  // one win that fails (its vouchers refused, a sign-in run out, a list not there) is its own error line, and
2486
3769
  // the rest go on (a refused POST /v1/claims must not stop every later win)
2487
3770
  try {
2488
3771
  for (const v of await vouchersOf(win.round)) {
2489
- const grantKey = (spec.grants ?? []).find((x) => x.id === v.grant)?.key;
3772
+ const grantKey = (spec.grants ?? []).find(x => x.id === v.grant)?.key;
2490
3773
  if (!grantKey) continue;
2491
3774
  if (v.list) {
2492
3775
  // Paid by a list the owner wrote from the draw: its proof names the round.
2493
3776
  const list = await get(proofAt(v.proof));
2494
- const mine = list.winners.find((w) => w.wallet === wallet.address);
3777
+ const mine = list.winners.find(w => w.wallet === wallet.address);
2495
3778
  if (!mine || (await claimed(v.grant, null, list.round))) continue;
2496
- 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
+ );
2497
3787
  } else if (v.signature && !(await claimed(v.grant, v.incarnation, v.round))) {
2498
3788
  await take(win.round, v.grant, () => this.claimGrant(grantKey, { voucher: v }));
2499
3789
  }
@@ -2511,56 +3801,112 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2511
3801
  * collectible to take home, an auction they won (or one off by the clock: their bid back), a lottery place they
2512
3802
  * won, a draw that is due (anyone may reveal it, or ask again when the oracle never answered; it takes 0.0008
2513
3803
  * SOL), and a drawn lottery's unpaid places, which anyone may pay so the next round can open. [{ kind, says, … }];
2514
- * 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`.
2515
3806
  */
2516
3807
  async claims() {
2517
3808
  const me = wallet.address;
2518
3809
  const out = [];
2519
- 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])) } });
2520
3811
  const at = (offset, bytes) => ({ memcmp: { offset, bytes } });
2521
3812
  const slot = await slotNow();
2522
3813
  const time = BigInt(now());
2523
3814
  const chainTime = await chainNow();
2524
- for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Order'), at(1, project), at(33, me)])) {
3815
+ for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [
3816
+ tag('Order'),
3817
+ at(1, project),
3818
+ at(33, me),
3819
+ ])) {
2525
3820
  const o = loot.decode('Order', account.data);
2526
3821
  const opened = BigInt(o.requested_slot);
2527
- if (loot.orderRefundable(o, slot, chainTime)) out.push({ kind: 'refund', order: pubkey, says: 'Your payment back for an order that never finished.' });
2528
- else if (o.status === 'Rolled' && slot > opened) out.push({ kind: 'settle', order: pubkey, says: 'What your box rolled.' });
2529
- else if (loot.rollAskable(o, slot, chainTime)) out.push({ kind: 'settle', order: pubkey, says: 'A roll you won (a mission’s, a recipe’s, a prize’s): ask for it and see what it gives (one whose box has nothing left to give waits until it has).' });
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
+ });
2530
3832
  }
2531
- for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Contest'), at(1, project)])) {
3833
+ for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [
3834
+ tag('Contest'),
3835
+ at(1, project),
3836
+ ])) {
2532
3837
  const contest = loot.decode('Contest', account.data);
2533
3838
  const off = loot.contestOff(contest, time);
2534
3839
  if (!(off || contest.verdict === 'Won')) continue;
2535
3840
  const list = await client.account(loot.contestEntries(pubkey, contest));
2536
- const entry = list?.owner === loot.LOOT_PROGRAM ? loot.decodeEntries(list.data).find((e) => e.player === me) : null;
3841
+ const entry =
3842
+ list?.owner === loot.LOOT_PROGRAM ? loot.decodeEntries(list.data).find(e => e.player === me) : null;
2537
3843
  if (!entry) continue;
2538
3844
  const won = !off && Number(contest.winner) === Number(entry.side);
2539
3845
  const first = won && !(entry.bits & loot.CLAIMED);
2540
3846
  // Owed: its money back (off) or its part of the pot (won), its own NFT while the contest holds it, a winner's
2541
3847
  // prize with its first claim, and the losers' NFTs its share still lets it pick. A loser of a contest of tokens
2542
3848
  // is owed nothing, and never sends a transaction.
2543
- const picks = won && contest.nft && !(entry.bits & loot.ENDED) && Number(entry.taken) < loot.shareOf(contest, entry);
2544
- const owed = (contest.nft ? Number(entry.amount) === 1 && (off || won) : BigInt(entry.amount) > 0n && (off || first)) || first || picks;
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;
2545
3855
  if (!owed) continue;
2546
- const says = off ? 'Your entry back: the contest is off.' : contest.nft ? `Your NFT back${picks ? ', and your pick of the losers’' : ''}.` : contest.prize != null ? 'Your share of the pot, and your prize.' : 'Your share of the pot.';
2547
- out.push({ kind: 'contest', id: contest.id, side: entry.side, says });
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
+ });
2548
3872
  }
2549
3873
  // The player's holds; the project's treasury wallet's also those owed to the treasury (a hold whose player is the
2550
3874
  // project's own address: a dropped stash NFT, a lottery's or an auction's NFT owed to it).
2551
3875
  const g = await projectNow();
2552
3876
  const holdsOf = [me, ...(g.treasury === me ? [project] : [])];
2553
- const holds = (await Promise.all(holdsOf.map((who) => client.programAccounts(loot.LOOT_PROGRAM, [tag('Hold'), at(1, project), at(65, who)])))).flat();
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();
2554
3882
  for (const { pubkey, account } of holds) {
2555
3883
  const hold = loot.decode('Hold', account.data);
2556
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
2557
3885
  // is the lender's once due.
2558
- if (!hold.debt && time >= BigInt(hold.free_at)) out.push({ kind: 'release', asset: hold.asset, hold: pubkey, says: hold.player === project ? 'An NFT owed to the treasury: send it there.' : 'Your collectible back: its hold is over.' });
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
+ });
2559
3896
  }
2560
3897
  // A loan this wallet made that is overdue and unpaid: its NFT is the lender's to take.
2561
- for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Hold'), at(1, project)])) {
3898
+ for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [
3899
+ tag('Hold'),
3900
+ at(1, project),
3901
+ ])) {
2562
3902
  const hold = loot.decode('Hold', account.data);
2563
- if (hold.debt?.lender === me && time >= BigInt(hold.free_at)) out.push({ kind: 'release', asset: hold.asset, hold: pubkey, says: 'A loan you made is overdue and unpaid: take its NFT.' });
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
+ });
2564
3910
  }
2565
3911
  // A compressed NFT's proof record this wallet paid for and left open: closing it brings its rent back.
2566
3912
  for (const { account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Proven')])) {
@@ -2571,50 +3917,106 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2571
3917
  return null;
2572
3918
  }
2573
3919
  })();
2574
- if (leaf?.payer === me) out.push({ kind: 'close-leaf', asset: leaf.asset, tree: leaf.tree, says: 'A compressed NFT’s proof record you paid for: close it, and its rent comes back.' });
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
+ });
2575
3927
  }
2576
3928
  const auctions = spec.auctions ?? [];
2577
- const found = await client.accounts(auctions.map((a) => auctionAt(a)));
3929
+ const found = await client.accounts(auctions.map(a => auctionAt(a)));
2578
3930
  auctions.forEach((a, i) => {
2579
3931
  const x = found[i] && loot.decode('Auction', found[i].data);
2580
3932
  if (!x || time < loot.auctionEnds(x)) return;
2581
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.
2582
3934
  if (BigInt(x.proceeds_owed) > 0n) {
2583
- if (x.seller === me) out.push({ kind: 'auction', key: a.key, says: `The winner of “${a.name}” has the lot, and your account couldn’t take the money then: end it again once it can, and it is paid to you.` });
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
+ });
2584
3941
  return;
2585
3942
  }
2586
3943
  if (x.high_bidder === me) out.push({ kind: 'auction', key: a.key, says: `You won “${a.name}”: take it.` });
2587
3944
  // The seller's own auction, over: ending it pays them (or brings an unsold lot home).
2588
- else if (x.seller === me) out.push({ kind: 'auction', key: a.key, says: BigInt(x.high) > 0n ? `Your auction “${a.name}” is over: end it, and the winning bid is paid to you.` : `Your auction “${a.name}” ended with no bid: take your lot back.` });
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
+ });
2589
3954
  });
2590
3955
  for (const l of spec.lotteries ?? []) {
2591
3956
  // A lottery the page names but nobody has written yet (a raffle still to open) has nothing to finish.
2592
3957
  if (!(await client.account(loot.pda.lottery(project, l.id)))) continue;
2593
3958
  const { current, rounds } = await roundOf(l);
2594
- const bought = (r) => r.entries.some((e) => e.buyer === me);
3959
+ const bought = r => r.entries.some(e => e.buyer === me);
2595
3960
  if (current?.status === 'Open' && time >= BigInt(current.terms.draws_at)) {
2596
3961
  const short = BigInt(current.sold) < BigInt(current.terms.min_sold ?? 0);
2597
- out.push({ kind: 'reveal', key: l.key, says: short ? `“${l.name}” sold fewer tickets than it needs: call it off${bought(current) ? ' and take your ticket payment back, in one step' : ''}.` : `The draw of “${l.name}” is due: reveal the winners.` });
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
+ });
2598
3969
  }
2599
3970
  for (const { address: round, round: r } of rounds) {
2600
3971
  // The ask moved the lottery on: a round waiting for the oracle is an older one, asked again by its address,
2601
3972
  // and after the timeout the next ask cancels it.
2602
- if (loot.lotteryCancellable(r, chainTime)) out.push({ kind: 'cancel-draw', key: l.key, round, says: `The oracle never answered the draw of “${l.name}”: call it off${bought(r) ? ' and take your ticket payment back, in one step' : ', so every ticket is refunded'}.` });
2603
- else if (loot.lotteryDrawStuck(r, chainTime)) out.push({ kind: 'reveal', key: l.key, round, says: `The oracle never answered the draw of “${l.name}”: ask it again.` });
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
+ });
2604
3987
  if (r.status !== 'Drawn' && r.status !== 'Cancelled') continue;
2605
3988
  // What this wallet is owed: a refund, its NFT ticket, its places' parts of the pot and their prizes.
2606
3989
  const mine = r.entries.flatMap((e, i) => {
2607
3990
  const parts = e.buyer === me ? loot.partsOwed(r, i) : [];
2608
3991
  return parts.length ? [{ entry: i, parts }] : [];
2609
3992
  });
2610
- if (mine.length) out.push({ kind: 'pull', key: l.key, round, pulls: mine, says: r.status === 'Cancelled' ? `Recover your ticket payment for “${l.name}”.` : `Take what you are owed in “${l.name}”.` });
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
+ });
2611
4004
  // Anyone may pay everyone else what they are owed, and the parts owed in common (about 0.002 SOL of account
2612
4005
  // rent for each payee who has no account for it yet, which stays with them): the last pull closes the round,
2613
4006
  // and its rent goes back to whoever fronted it (the lottery, or its first buyer).
2614
- const others = r.entries.flatMap((e, i) => (loot.partsOwed(r, i).length && !mine.some((m) => m.entry === i) ? [i] : []));
4007
+ const others = r.entries.flatMap((e, i) =>
4008
+ loot.partsOwed(r, i).length && !mine.some(m => m.entry === i) ? [i] : [],
4009
+ );
2615
4010
  const fronted = r.opener === me;
2616
4011
  if (others.length || loot.commonOwed(r).length || (loot.roundFinished(r) && !mine.length))
2617
- out.push({ kind: 'payout', key: l.key, round, says: fronted ? `Pay everyone of “${l.name}” what they are owed: the round then closes, and your deposit for it comes back.` : `Pay the others of “${l.name}” what they are owed, so its round can close.` });
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
+ });
2618
4020
  }
2619
4021
  }
2620
4022
  // A farm idle past more ends than a move can walk: anyone may walk it, in parts.
@@ -2622,7 +4024,12 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2622
4024
  const farm = await readMaybe('Farm', loot.pda.farm(project, f.id));
2623
4025
  if (!farm?.keeps_ends) continue;
2624
4026
  const { left } = await endsOf(f, farm);
2625
- if (left > loot.MOVE_WALK_ROOM) out.push({ kind: 'walk', key: f.key, says: `“${f.name ?? f.key}” must be caught up before anyone can move it: ${left} seats’ end times to walk, in ${Math.ceil(left / loot.WALK_PER_TX)} transactions. It is free but for the network fee, and nothing is lost by the wait.` });
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
+ });
2626
4033
  }
2627
4034
  // A sealed schedule that pays this wallet: its runs that are due (vesting nobody can take back).
2628
4035
  for (const sc of spec.schedules ?? []) {
@@ -2632,21 +4039,34 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2632
4039
  // Any other schedule that is due: it waits for the project's keys or whoever it pays.
2633
4040
  const state = await projectNow();
2634
4041
  const to = schedule.action.to;
2635
- if ([state.owner, state.authority].includes(me) || to.Wallet?.[0] === me || (to.Treasury !== undefined && state.treasury === me)) {
2636
- if (loot.runsDue(schedule, chainTime) > 0) out.push({ kind: 'schedule', key: sc.key, says: `“${sc.name ?? sc.key}” is due to run: send it.` });
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.` });
2637
4049
  }
2638
4050
  continue;
2639
4051
  }
2640
4052
  if (schedule.action.to.Wallet?.[0] !== me) continue;
2641
4053
  const vault = await client.account(loot.pda.vest(loot.pda.schedule(project, sc.id)));
2642
4054
  const held = vault ? vault.data.subarray(64, 72).reduce((n, b, i) => n | (BigInt(b) << BigInt(8 * i)), 0n) : 0n;
2643
- if (loot.runsDue(schedule, chainTime, held) > 0) out.push({ kind: 'vest', key: sc.key, says: `What has vested of “${sc.name ?? sc.key}”: claim it.` });
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.` });
2644
4057
  }
2645
4058
  // The player's listings whose rental is over: their NFT can go home (or wait for the next renter).
2646
4059
  const mineListed = await client.programAccounts(loot.LOOT_PROGRAM, [tag('Listing'), at(1, project), at(33, me)]);
2647
4060
  for (const { account } of mineListed) {
2648
4061
  const listing = loot.decode('Listing', account.data);
2649
- 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
+ });
2650
4070
  }
2651
4071
  // The lookup tables a transaction of this player's too big for a legacy one made (`send`): each is deactivated by
2652
4072
  // the transaction it was made for, and its rent comes back once it has cooled down (about 513 slots). One of this
@@ -2661,9 +4081,22 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2661
4081
  if (!table || table.authority !== me || (spec.live?.tables ?? []).includes(pubkey)) continue;
2662
4082
  const step = tableStep(table, slot, recent);
2663
4083
  const rent = show(BigInt(account.lamports ?? 0), 9);
2664
- if (step === 'close') out.push({ kind: 'lookup-table', table: pubkey, says: `${rent} SOL of rent back from a lookup table a large transaction of yours used.` });
2665
- else if (step === 'active' && table.addresses[0] === project && slot > table.lastExtendedSlot + TABLE_UNSENT_SLOTS)
2666
- 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
+ });
2667
4100
  }
2668
4101
  return out;
2669
4102
  },
@@ -2679,7 +4112,7 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2679
4112
  // `build`: its instructions, unsent, for a step that is one transaction of the wallet's (`claimAll` signs several
2680
4113
  // at once); null for one that is not (a roll to wait for, a refund, a step of another flow).
2681
4114
  if (build && !BATCHED.has(c.kind)) return null;
2682
- const send = build ? async (instructions) => instructions : sendNow;
4115
+ const send = build ? async instructions => instructions : sendNow;
2683
4116
  const g = await projectNow();
2684
4117
  if (c.kind === 'settle' || c.kind === 'refund') {
2685
4118
  // A settle key this order's open funded (made again from this session's signature, or stored): it settles with
@@ -2694,7 +4127,7 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2694
4127
  const contestAddress = loot.pda.contest(project, c.id);
2695
4128
  const contest = await read('Contest', contestAddress);
2696
4129
  const entries = loot.decodeEntries((await client.account(loot.contestEntries(contestAddress, contest))).data);
2697
- const i = entries.findIndex((e) => e.player === me);
4130
+ const i = entries.findIndex(e => e.player === me);
2698
4131
  const own = entries[i];
2699
4132
  const won = contest.verdict === 'Won' && Number(contest.winner) === Number(own.side);
2700
4133
  const given = await prizeInputs([won && !(own.bits & loot.CLAIMED) ? contest.prize : null]);
@@ -2713,16 +4146,41 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2713
4146
  await kindOf(own.nft, me);
2714
4147
  }
2715
4148
  const share = won && !(own.bits & loot.ENDED) ? loot.shareOf(contest, own) - Number(own.taken) : 0;
2716
- const left = entries.map((e, j) => ({ e, j })).filter(({ e }) => Number(e.side) !== Number(contest.winner) && Number(e.amount) === 1);
4149
+ const left = entries
4150
+ .map((e, j) => ({ e, j }))
4151
+ .filter(({ e }) => Number(e.side) !== Number(contest.winner) && Number(e.amount) === 1);
2717
4152
  const wanted = take ? [take].flat() : [];
2718
- const picked = (wanted.length ? left.filter(({ e }) => wanted.includes(e.nft)) : left).slice(0, Math.min(share, 4));
2719
- if (wanted.length && picked.length < Math.min(wanted.length, share)) throw new Error(`${wanted.join(', ')} is not a loser’s NFT left in this contest for you to pick.`);
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.`);
2720
4159
  for (const { e, j } of picked) {
2721
4160
  nfts.push(j);
2722
4161
  await kindOf(e.nft, me);
2723
4162
  }
2724
4163
  }
2725
- const claimed = [computeUnitLimit(800_000), ...given.before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), loot.claimIx({ payer: me, project, projectState: g, id: c.id, contest, entries, player: me, nfts: contest.nft ? nfts : undefined, kinds, collection: given.collection, poolMints: given.poolMints, items: given.items, nft: given.nft, programs })];
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
+ ];
2726
4184
  // A prize that rolls: asked for in the claim's own transaction and settled by a key after (one prompt).
2727
4185
  return contest.prize?.Roll && !build ? (await sendRolling(claimed)).signature : send(claimed);
2728
4186
  }
@@ -2730,7 +4188,20 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2730
4188
  const hold = await read('Hold', c.hold);
2731
4189
  const { before, hasAuthority: _, ...nft } = await nftOf(c.asset, me, { to: me });
2732
4190
  // An overdue loan's NFT goes to its lender.
2733
- return send([...before, loot.releaseIx({ signer: me, project, asset: c.asset, ...nft, collection: (await collectionOf(c.asset)) ?? MPL_CORE, hold, address: c.hold, treasury: g.treasury, ...(hold.debt ? { to: hold.debt.lender } : {}) })]);
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
+ ]);
2734
4205
  }
2735
4206
  if (c.kind === 'auction') return (await this.endAuction(c.key)).signature;
2736
4207
  if (c.kind === 'close-leaf') return send([loot.closeLeafIx({ asset: c.asset, tree: c.tree, payer: me })]);
@@ -2741,12 +4212,22 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2741
4212
  const table = readTable(await client.account(c.table));
2742
4213
  const step = table && tableStep(table, await slotNow(), await recentSlots());
2743
4214
  if (!step || step === 'cooling') return null;
2744
- 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
+ );
2745
4224
  }
2746
4225
  if (!LOTTERY_STEPS.has(c.kind)) throw new Error(`Nothing to do for a claim of kind ${c.kind}.`);
2747
4226
  const l = part('lotteries', c.key);
2748
4227
  // An older round, named by its address, needs no lottery: one closed since never strands it.
2749
- const lottery = c.round ? await readMaybe('Lottery', loot.pda.lottery(project, l.id)) : await read('Lottery', loot.pda.lottery(project, l.id));
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));
2750
4231
  const key = c.round ?? loot.currentRound(loot.pda.lottery(project, l.id), lottery);
2751
4232
  const found = await client.account(key);
2752
4233
  const r = found?.owner === loot.LOOT_PROGRAM ? loot.decodeRound(found.data) : null;
@@ -2757,7 +4238,10 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2757
4238
  // refunds in the same transaction: this wallet's own parts are pulled right after it.
2758
4239
  const refunds = r ? await cancelPulls(key, r, me) : [];
2759
4240
  if (refunds.length) {
2760
- const all = [computeUnitLimit(1_400_000), ...[...steps, ...refunds].filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM)];
4241
+ const all = [
4242
+ computeUnitLimit(1_400_000),
4243
+ ...[...steps, ...refunds].filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
4244
+ ];
2761
4245
  if ((await dryRun(me, all)).ok) return build ? all : send(all);
2762
4246
  }
2763
4247
  return build ? steps : send(steps);
@@ -2774,25 +4258,30 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2774
4258
  return !stash?.nfts.includes(named.nft);
2775
4259
  };
2776
4260
  const theirs = [];
2777
- if (c.kind !== 'pull') for (const i of r.entries.keys()) {
2778
- const parts = [];
2779
- for (const p of loot.partsOwed(r, i)) if (!(await stranger(i, p))) parts.push(p);
2780
- if (parts.length) theirs.push({ entry: i, parts });
2781
- }
2782
- const owed = c.kind === 'pull' ? c.pulls : [...loot.commonOwed(r).map((p) => ({ entry: null, parts: [p] })), ...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];
2783
4269
  if (!owed.length) {
2784
4270
  if (!loot.roundFinished(r)) return null;
2785
4271
  const close = [loot.pullIx({ payer: me, project, roundAddress: key, round: r, projectState: g, programs })];
2786
4272
  return build ? close : send(close);
2787
4273
  }
2788
4274
  const batch = owed.slice(0, 4);
2789
- const settles = batch.reduce((n, { entry, parts }) => n + parts.reduce((m, p) => m + pullSettles(r, entry, p), 0), 0);
4275
+ const settles = batch.reduce(
4276
+ (n, { entry, parts }) => n + parts.reduce((m, p) => m + pullSettles(r, entry, p), 0),
4277
+ 0,
4278
+ );
2790
4279
  const closes = settles >= Number(r.owed);
2791
4280
  const instructions = [computeUnitLimit(1_400_000)];
2792
4281
  const rolls = [];
2793
4282
  for (const [k, { entry, parts }] of batch.entries()) {
2794
4283
  const built = await pulls(key, r, entry, parts, me, { closes: closes && k === batch.length - 1 });
2795
- instructions.push(...built.instructions.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM));
4284
+ instructions.push(...built.instructions.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM));
2796
4285
  rolls.push(...built.rolls);
2797
4286
  }
2798
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
@@ -2800,7 +4289,8 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2800
4289
  if (!rolls.length) return build ? instructions : send(instructions);
2801
4290
  if (c.kind === 'pull' && !build) return (await sendRolling(instructions)).signature;
2802
4291
  const asks = [];
2803
- for (const { order, box } of rolls) asks.push(...(await asksFor(me, order, { project, loot_box: loot.pda.box(project, box) })));
4292
+ for (const { order, box } of rolls)
4293
+ asks.push(...(await asksFor(me, order, { project, loot_box: loot.pda.box(project, box) })));
2804
4294
  const all = [...instructions, ...asks];
2805
4295
  const asked = (await dryRun(me, all)).ok ? all : instructions;
2806
4296
  return build ? asked : send(asked);
@@ -2823,14 +4313,22 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2823
4313
  }
2824
4314
  const lottery = LOTTERY_STEPS.has(c.kind) ? c.key : null;
2825
4315
  if (lottery !== null && lotteries.has(lottery)) continue;
2826
- const instructions = await this.claim(c, { build: true }).catch((e) => (out.push({ claim: c, error: e.message }), null));
4316
+ const instructions = await this.claim(c, { build: true }).catch(
4317
+ e => (out.push({ claim: c, error: e.message }), null),
4318
+ );
2827
4319
  if (!instructions?.length) continue;
2828
4320
  if (lottery !== null) lotteries.add(lottery);
2829
4321
  built.push([c, instructions]);
2830
4322
  }
2831
4323
  const sent = await sendBatch(built.map(([, instructions]) => instructions));
2832
4324
  out.push(...built.map(([claim], i) => ({ claim, ...sent[i] })));
2833
- for (const claim of alone) out.push(await this.claim(claim).then((signature) => ({ claim, signature }), (e) => ({ claim, error: e.message })));
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
+ );
2834
4332
  return out;
2835
4333
  },
2836
4334
 
@@ -2845,11 +4343,12 @@ export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc
2845
4343
  if (t.source === 'sol') out[t.key] = show(await client.balance(owner), 9);
2846
4344
  else if (mint) {
2847
4345
  const a = await client.account(loot.ata(owner, mint, programOf(mint)));
2848
- 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';
2849
4349
  }
2850
4350
  }
2851
4351
  return out;
2852
4352
  },
2853
4353
  };
2854
4354
  }
2855
-