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