@xgamesfun/solana 0.0.0-stage → 0.20.1
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/DISCLAIMER.md +41 -0
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +172 -2
- package/bin/xsolana.mjs +189 -0
- package/examples/artwork-auction.json +51 -0
- package/examples/blind-forge.json +128 -0
- package/examples/coin-flip-arena.json +108 -0
- package/examples/duel-arena.json +137 -0
- package/examples/elements-set.json +281 -0
- package/examples/event-badges.json +67 -0
- package/examples/expedition-guild.json +161 -0
- package/examples/fair-presale.json +48 -0
- package/examples/falling-drop.json +90 -0
- package/examples/guild-missions.json +144 -0
- package/examples/halving-seasons.json +112 -0
- package/examples/hero-trials.json +96 -0
- package/examples/ideas.json +789 -0
- package/examples/level-ladder.json +65 -0
- package/examples/merge-2048.json +128 -0
- package/examples/nft-raffle.json +33 -0
- package/examples/paper-hands-farm.json +56 -0
- package/examples/partner-vault.json +115 -0
- package/examples/sacrifice-raffle.json +118 -0
- package/examples/scorched-treasury.json +84 -0
- package/examples/shelter-fair.json +164 -0
- package/examples/spark-pity.json +153 -0
- package/examples/stakers-draw.json +125 -0
- package/examples/star-fleet.json +157 -0
- package/examples/starforce.json +116 -0
- package/examples/stud-farm.json +134 -0
- package/examples/team-vesting.json +70 -0
- package/examples/trade-in-shop.json +168 -0
- package/js/approve/details.mjs +262 -0
- package/js/approve/ed25519.mjs +36 -0
- package/js/approve/live-check.mjs +144 -0
- package/js/approve/page.html +86 -0
- package/js/approve/page.js +203 -0
- package/js/approve/server.mjs +711 -0
- package/js/chain/base58.js +73 -0
- package/js/chain/borsh.js +261 -0
- package/js/chain/bytes.js +141 -0
- package/js/chain/core.js +169 -0
- package/js/chain/curve.js +52 -0
- package/js/chain/lookup.js +273 -0
- package/js/chain/mint.js +96 -0
- package/js/chain/pda.js +66 -0
- package/js/chain/programs.js +102 -0
- package/js/chain/rpc.js +217 -0
- package/js/chain/sha256.js +68 -0
- package/js/chain/token.js +85 -0
- package/js/chain/token2022.js +247 -0
- package/js/chain/tx.js +224 -0
- package/js/client/accounts.js +347 -0
- package/js/client/compressed.js +46 -0
- package/js/client/grant.js +240 -0
- package/js/client/idl/loot.js +2 -0
- package/js/client/idl/loot.json +17064 -0
- package/js/client/index.js +9 -0
- package/js/client/ix.js +2156 -0
- package/js/client/pda.js +110 -0
- package/js/client/policy.js +48 -0
- package/js/client/sizes.js +54 -0
- package/js/client/traits.js +44 -0
- package/js/connect/connect.mjs +182 -0
- package/js/economy/check.mjs +1991 -0
- package/js/economy/cost.mjs +170 -0
- package/js/economy/examples.mjs +114 -0
- package/js/economy/explain.mjs +235 -0
- package/js/economy/guide.mjs +231 -0
- package/js/economy/legal.mjs +15 -0
- package/js/economy/live.mjs +107 -0
- package/js/economy/model.mjs +583 -0
- package/js/economy/plan.mjs +887 -0
- package/js/economy/schema.json +2923 -0
- package/js/economy/secrets.mjs +60 -0
- package/js/economy/simulate.mjs +952 -0
- package/js/economy/txsize.mjs +45 -0
- package/js/economy/validate.mjs +85 -0
- package/js/index.js +10 -0
- package/js/mcp/server.mjs +113 -0
- package/js/mcp/tools.mjs +479 -0
- package/js/play/index.js +2109 -0
- package/js/results/index.js +184 -0
- package/js/results/wire.js +90 -0
- package/package.json +100 -4
- package/release.json +8 -0
- package/skills/xsolana-economy/SKILL.md +105 -0
- package/skills/xsolana-economy/references/after-launch.md +114 -0
- package/skills/xsolana-economy/references/apps.md +157 -0
- package/skills/xsolana-economy/references/sentence-to-spec.md +172 -0
- package/skills/xsolana-economy/references/telling-the-person.md +93 -0
- package/types/chain/base58.d.ts +10 -0
- package/types/chain/borsh.d.ts +23 -0
- package/types/chain/bytes.d.ts +45 -0
- package/types/chain/core.d.ts +50 -0
- package/types/chain/curve.d.ts +1 -0
- package/types/chain/lookup.d.ts +191 -0
- package/types/chain/mint.d.ts +25 -0
- package/types/chain/pda.d.ts +25 -0
- package/types/chain/programs.d.ts +65 -0
- package/types/chain/rpc.d.ts +66 -0
- package/types/chain/sha256.d.ts +1 -0
- package/types/chain/token.d.ts +141 -0
- package/types/chain/token2022.d.ts +53 -0
- package/types/chain/tx.d.ts +60 -0
- package/types/client/accounts.d.ts +178 -0
- package/types/client/compressed.d.ts +29 -0
- package/types/client/grant.d.ts +125 -0
- package/types/client/idl/loot.d.ts +4467 -0
- package/types/client/index.d.ts +8 -0
- package/types/client/ix.d.ts +1687 -0
- package/types/client/pda.d.ts +91 -0
- package/types/client/policy.d.ts +23 -0
- package/types/client/sizes.d.ts +13 -0
- package/types/client/traits.d.ts +12 -0
- package/types/economy/check.d.mts +105 -0
- package/types/economy/cost.d.mts +68 -0
- package/types/economy/examples.d.mts +27 -0
- package/types/economy/explain.d.mts +19 -0
- package/types/economy/guide.d.mts +140 -0
- package/types/economy/legal.d.mts +12 -0
- package/types/economy/live.d.mts +109 -0
- package/types/economy/model.d.mts +363 -0
- package/types/economy/plan.d.mts +1013 -0
- package/types/economy/secrets.d.mts +4 -0
- package/types/economy/simulate.d.mts +156 -0
- package/types/economy/txsize.d.mts +17 -0
- package/types/economy/validate.d.mts +3 -0
- package/types/index.d.ts +9 -0
- package/types/mcp/tools.d.mts +627 -0
- package/types/play/index.d.ts +469 -0
- package/types/results/index.d.ts +155 -0
- package/types/results/wire.d.ts +122 -0
package/js/play/index.js
ADDED
|
@@ -0,0 +1,2109 @@
|
|
|
1
|
+
// What a player does with a live economy, from an app: list the shop and the boxes, buy, open a box and collect what
|
|
2
|
+
// it rolled, pay, buy tickets, stake and harvest, craft, enter a contest, bid in an auction, claim a grant, see their
|
|
3
|
+
// collectibles.
|
|
4
|
+
// Browser-safe, no Solana SDK. The player's wallet signs every transaction; this module never holds a key (a craft's
|
|
5
|
+
// new collectibles get fresh keys made here, which sign only their own creation; a box's settle is signed by a
|
|
6
|
+
// throwaway key the open funded with just enough for its fee, which hands the rest back in the same transaction).
|
|
7
|
+
//
|
|
8
|
+
// import { solanaApp, walletStandardSigner } from '@xgamesfun/solana/play';
|
|
9
|
+
// const app = solanaApp({ spec, rpcUrl: 'https://api.devnet.solana.com', wallet: walletStandardSigner(wallet, account) });
|
|
10
|
+
// const items = await app.shop(); // [{ key, name, price, priceUnits, currency, left }]
|
|
11
|
+
// const { asset } = await app.buy('sword', { listing: items[0] }); // the price shown is the most it pays
|
|
12
|
+
// const { won } = await app.openBox('chest', { listing }); // waits for the roll, settles it: one prompt
|
|
13
|
+
// for (const c of await app.claims()) await app.claim(c); // what this player can finish: wins, payouts, refunds
|
|
14
|
+
// await app.claimWins({ server, token }); // prizes a results server vouched for
|
|
15
|
+
//
|
|
16
|
+
// Nothing here waits on a server. Whatever a player is owed, they can take themselves (`claims`), and public work
|
|
17
|
+
// that is due rides along in the next player's own transaction (an empty lottery round is moved on by the next
|
|
18
|
+
// ticket buyer).
|
|
19
|
+
//
|
|
20
|
+
// `spec` is the live xsolana.json (its `live` section names the project). `wallet` is { address, signTransaction(wire) →
|
|
21
|
+
// signed wire }: `walletStandardSigner` makes one from a Wallet Standard wallet (Phantom, Solflare, Backpack).
|
|
22
|
+
import { decodeBase58, encodeBase58 } from '../chain/base58.js';
|
|
23
|
+
import { ownedAssetFilters, parseCoreAsset } from '../chain/core.js';
|
|
24
|
+
import { ASSOCIATED_TOKEN_PROGRAM, associatedTokenAddress, COMPUTE_BUDGET_PROGRAM, NATIVE_MINT, SYSVAR_SLOT_HASHES, TOKEN_2022_PROGRAM, TOKEN_PROGRAM } from '../chain/pda.js';
|
|
25
|
+
import { closeTokenAccount, computeUnitLimit, createAssociatedTokenAccountIdempotent, requestHeapFrame, syncNative, transferLamports, withSolWrap } from '../chain/programs.js';
|
|
26
|
+
import { checkCluster, notMainnet, rpcClient } from '../chain/rpc.js';
|
|
27
|
+
import { MEMO_PROGRAM, mintPowers, resolveTransfers } from '../chain/token2022.js';
|
|
28
|
+
import { compileFitting, closeTableIx, deactivateTableIx, readTable, sendFitting, sendPlan, slotHashes, tablesOf, tableStep, transactionLocks, LOOKUP_TABLE_PROGRAM } from '../chain/lookup.js';
|
|
29
|
+
import { compileMessage, readTransaction, wireTransaction } from '../chain/tx.js';
|
|
30
|
+
import * as loot from '../client/index.js';
|
|
31
|
+
|
|
32
|
+
// The options of a call made without any: read-only, and typed so the generated declarations take any option.
|
|
33
|
+
const NO_OPTIONS = Object.freeze(/** @type {Record<string, any>} */ ({}));
|
|
34
|
+
|
|
35
|
+
const CHAIN = 'solana:devnet';
|
|
36
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
37
|
+
const MPL_CORE = loot.MPL_CORE_PROGRAM;
|
|
38
|
+
const NOBODY = '11111111111111111111111111111111';
|
|
39
|
+
/** A table made this many slots ago whose transaction never landed was never sent: its blockhashes ran out long since. */
|
|
40
|
+
const TABLE_UNSENT_SLOTS = 300n;
|
|
41
|
+
/** A compressed NFT's `prove_leaf`: its record stays good while the leaf is unchanged, so it may go in a transaction of its own first. */
|
|
42
|
+
const PROVE_LEAF = loot.IDL.instructions.find((i) => i.name === 'prove_leaf').discriminator;
|
|
43
|
+
const isProveLeaf = (ix) => ix.programId === loot.LOOT_PROGRAM && PROVE_LEAF.every((b, i) => ix.data[i] === b);
|
|
44
|
+
/** A system account must keep this much (rent for 0 bytes) or be emptied. */
|
|
45
|
+
const RENT_EMPTY = 890_880n;
|
|
46
|
+
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
|
+
|
|
50
|
+
/** A signer from a Wallet Standard wallet and one of its accounts: the wallet signs, the app sends. */
|
|
51
|
+
export function walletStandardSigner(wallet, account, chain = CHAIN) {
|
|
52
|
+
const feature = wallet.features?.['solana:signTransaction'];
|
|
53
|
+
if (!feature) throw new Error(`${wallet.name} cannot sign transactions.`);
|
|
54
|
+
return {
|
|
55
|
+
address: account.address,
|
|
56
|
+
async signTransaction(wire) {
|
|
57
|
+
const [out] = await feature.signTransaction({ account, transaction: wire, chain });
|
|
58
|
+
return out.signedTransaction;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A fresh ed25519 key (WebCrypto) that signs only its own account's creation: a crafted collectible's. */
|
|
64
|
+
async function freshKey() {
|
|
65
|
+
const pair = await globalThis.crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify']);
|
|
66
|
+
const raw = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', pair.publicKey));
|
|
67
|
+
return { address: encodeBase58(raw), sign: async (message) => new Uint8Array(await globalThis.crypto.subtle.sign('Ed25519', pair.privateKey, message)) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* An amount in whole units of a token ("12", "0.5", or a number) in its base units. Only a plain decimal is taken, with
|
|
72
|
+
* at most the token's decimals: "0x10", "1.2.3", "", "1e3", "-1" and an extra decimal place are refused, never read as
|
|
73
|
+
* some other amount.
|
|
74
|
+
*/
|
|
75
|
+
export const units = (amount, decimals) => {
|
|
76
|
+
const text = typeof amount === 'number' && Number.isFinite(amount) && amount >= 0 ? plainNumber(amount) : typeof amount === 'string' ? amount.trim() : null;
|
|
77
|
+
const m = text === null ? null : /^(\d+)(?:\.(\d+))?$/.exec(text);
|
|
78
|
+
if (!m) throw new Error(`${typeof amount === 'string' ? JSON.stringify(amount) : String(amount)} is not an amount: write a plain number like 12 or 0.5.`);
|
|
79
|
+
const fraction = m[2] ?? '';
|
|
80
|
+
if (fraction.length > decimals && /[1-9]/.test(fraction.slice(decimals))) throw new Error(`${text} has more decimals than the token's ${decimals}.`);
|
|
81
|
+
return BigInt(m[1] + fraction.padEnd(decimals, '0').slice(0, decimals));
|
|
82
|
+
};
|
|
83
|
+
/** A number as plain decimal text (1e-7 → "0.0000001"). */
|
|
84
|
+
const plainNumber = (n) => (/e/i.test(String(n)) ? n.toFixed(20).replace(/\.?0+$/, '') : String(n));
|
|
85
|
+
const show = (u, decimals) => {
|
|
86
|
+
const s = BigInt(u).toString().padStart(decimals + 1, '0');
|
|
87
|
+
const f = s.slice(s.length - decimals).replace(/0+$/, '');
|
|
88
|
+
return `${s.slice(0, s.length - decimals)}${f ? `.${f}` : ''}`;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `client`, kept to devnet: a URL that names mainnet is refused at once, and before its first request goes out the
|
|
93
|
+
* chain's genesis block is checked, once (`checkCluster`), so an RPC whose URL hides mainnet or testnet is refused
|
|
94
|
+
* whatever it says. Every RPC reached over http(s) is checked: the app's own client, and one given whose `url` is
|
|
95
|
+
* http(s). A client with no such URL (a local chain of the caller's own, like the tests' LiteSVM) is used as it is.
|
|
96
|
+
*/
|
|
97
|
+
function devnetOnly(client) {
|
|
98
|
+
if (typeof client.url === 'string') notMainnet(client.url);
|
|
99
|
+
if (!/^(https?|wss?):/i.test(String(client.url ?? ''))) return client;
|
|
100
|
+
let checked = null;
|
|
101
|
+
// A check that could not be made (the network did not answer) is made again on the next request.
|
|
102
|
+
const ready = () => (checked ??= checkCluster(client).catch((e) => {
|
|
103
|
+
checked = null;
|
|
104
|
+
throw e;
|
|
105
|
+
}));
|
|
106
|
+
return new Proxy(client, {
|
|
107
|
+
get(target, key) {
|
|
108
|
+
const value = Reflect.get(target, key, target);
|
|
109
|
+
return typeof value === 'function' ? async (...args) => {
|
|
110
|
+
await ready();
|
|
111
|
+
return value.apply(target, args);
|
|
112
|
+
} : value;
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The player's side of a live economy. `rpc` may be given instead of `rpcUrl` (any object with the client's rpc
|
|
119
|
+
* methods: accounts, account, latestBlockhash, send, confirm, programAccounts, and call(method, params), the raw JSON
|
|
120
|
+
* 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.
|
|
122
|
+
*/
|
|
123
|
+
export function solanaApp({ spec, rpcUrl = 'https://api.devnet.solana.com', rpc = null, wallet, programs = {}, now = () => Math.floor(Date.now() / 1000) }) {
|
|
124
|
+
const live = spec?.live;
|
|
125
|
+
if (!live?.project) throw new Error('This economy is not live yet: its xsolana.json has no "live" section.');
|
|
126
|
+
if (rpc && typeof rpc.call !== 'function') throw new Error('The rpc given has no call(method, params): a custom rpc needs it (the devnet check, the slot and compressed NFTs are read through it), as well as accounts, account, latestBlockhash, send, confirm and programAccounts.');
|
|
127
|
+
const client = devnetOnly(rpc ?? rpcClient(notMainnet(rpcUrl)));
|
|
128
|
+
const project = live.project;
|
|
129
|
+
/** The project's Core collections: an NFT of one of them is the project's own (a project may have none). */
|
|
130
|
+
const collections = live.collections ?? (live.collection ? [live.collection] : []);
|
|
131
|
+
const ours = (collection) => collection != null && collections.includes(collection);
|
|
132
|
+
const tokens = new Map((spec.tokens ?? []).map((t) => [t.key, t]));
|
|
133
|
+
const currencyKey = spec.currency;
|
|
134
|
+
const mintOf = (key) => {
|
|
135
|
+
const t = tokens.get(key ?? currencyKey);
|
|
136
|
+
if (!t) throw new Error(`No token “${key}”.`);
|
|
137
|
+
return t.source === 'sol' ? NATIVE_MINT : spec.live.mints?.[t.key] ?? t.mint;
|
|
138
|
+
};
|
|
139
|
+
const decimalsOf = (key) => {
|
|
140
|
+
const t = tokens.get(key ?? currencyKey);
|
|
141
|
+
return t?.source === 'sol' ? 9 : t?.decimals ?? 0;
|
|
142
|
+
};
|
|
143
|
+
programs = { ...Object.fromEntries([...tokens.values()].map(t => [mintOf(t.key), t.program === 'token-2022' ? TOKEN_2022_PROGRAM : TOKEN_PROGRAM])), ...programs };
|
|
144
|
+
const programOf = (mint) => programs[mint] ?? TOKEN_PROGRAM;
|
|
145
|
+
const part = (list, key) => {
|
|
146
|
+
const p = (spec[list] ?? []).find((x) => x.key === key);
|
|
147
|
+
if (!p) throw new Error(`No ${list.replace(/s$/, '')} “${key}” in this economy.`);
|
|
148
|
+
return p;
|
|
149
|
+
};
|
|
150
|
+
const read = async (name, address) => {
|
|
151
|
+
const a = await client.account(address);
|
|
152
|
+
if (!a) throw new Error(`Nothing at ${address}: is the economy live on this cluster?`);
|
|
153
|
+
if (a.owner !== loot.LOOT_PROGRAM) throw new Error(`${address} is not a loot account.`);
|
|
154
|
+
return loot.decode(name, a.data);
|
|
155
|
+
};
|
|
156
|
+
/** A loot account, or null when there is none yet. */
|
|
157
|
+
const readMaybe = async (name, address) => {
|
|
158
|
+
const a = await client.account(address);
|
|
159
|
+
return a?.owner === loot.LOOT_PROGRAM ? loot.decode(name, a.data) : null;
|
|
160
|
+
};
|
|
161
|
+
/** 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;
|
|
163
|
+
/** Farm hooks shared by all actions. Static lists resolve automatically. For dynamic hooks,
|
|
164
|
+
* transfers[mint] describes every actual transfer, including fee-adjusted reward credit/payout, and in a farm that
|
|
165
|
+
* shares its pool the move's last one when a seat gave rewards back in it (an early exit, a lapsed upkeep, a watched
|
|
166
|
+
* NFT gone): from the farm's rewards vault back to its pool, the project the authority.
|
|
167
|
+
* Explicit extra replaces resolution and must include all token hook/memo and Core adapter accounts. */
|
|
168
|
+
const farmTokens = async (mints, create, { extra, transfers = {} } = {}) => {
|
|
169
|
+
const selected = [...new Set(mints)];
|
|
170
|
+
const resolved = await Promise.all(selected.map(async (mint) => {
|
|
171
|
+
if (extra !== undefined) {
|
|
172
|
+
const account = await client.account(mint);
|
|
173
|
+
if (![TOKEN_PROGRAM, TOKEN_2022_PROGRAM].includes(account?.owner)) throw new Error(`There is no supported token ${mint} on this network.`);
|
|
174
|
+
return { mint, tokenProgram: account.owner, extraAccounts: [] };
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
return { mint, ...await resolveTransfers(client, mint, transfers[mint] ?? []) };
|
|
178
|
+
} catch (cause) {
|
|
179
|
+
throw new Error(`Cannot resolve farm transfers for ${mint}: ${cause.message} Supply exact transfers keyed by mint, or explicit extra hook/memo accounts.`, { cause });
|
|
180
|
+
}
|
|
181
|
+
}));
|
|
182
|
+
const tokenPrograms = { ...programs, ...Object.fromEntries(resolved.map(({ mint, tokenProgram }) => [mint, tokenProgram])) };
|
|
183
|
+
return {
|
|
184
|
+
programs: tokenPrograms,
|
|
185
|
+
extra: extra ?? resolved.flatMap((r) => r.extraAccounts),
|
|
186
|
+
before: [...new Set(create)].map((mint) => createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, mint, tokenPrograms[mint])),
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
const farmCollection = async (asset) => {
|
|
190
|
+
const account = await client.account(asset);
|
|
191
|
+
// Core tombstones and missing assets remain valid farm-cleanup paths.
|
|
192
|
+
return account?.owner === MPL_CORE && account.data[0] === 1
|
|
193
|
+
? parseCoreAsset(account.data).collection ?? collections[0]
|
|
194
|
+
: collections[0];
|
|
195
|
+
};
|
|
196
|
+
/** The Core collection an asset is in, or null. */
|
|
197
|
+
const collectionOf = async (asset) => {
|
|
198
|
+
const account = await client.account(asset);
|
|
199
|
+
return account?.owner === MPL_CORE && account.data[0] === 1 ? parseCoreAsset(account.data).collection ?? null : null;
|
|
200
|
+
};
|
|
201
|
+
/** A Token Metadata NFT's { programmable, ruleSet } from its metadata, or null for anything else (a Core asset). */
|
|
202
|
+
const tokenMetadataOf = async (asset) => {
|
|
203
|
+
const account = await client.account(asset);
|
|
204
|
+
if (account?.owner !== TOKEN_PROGRAM) return null;
|
|
205
|
+
return loot.decodeTokenMetadata((await client.account(loot.tokenMetadata.metadata(asset)))?.data);
|
|
206
|
+
};
|
|
207
|
+
/**
|
|
208
|
+
* Where a Token Metadata NFT handed to `holder` goes: { into, before }. Into their associated account (`into` null)
|
|
209
|
+
* while it is not there (the program opens it) or takes it; when it is there but another key owns it or it is frozen,
|
|
210
|
+
* Token Metadata refuses it and the hand-out would fail for everyone (an outbid NFT bid, a cancelled round's ticket),
|
|
211
|
+
* so `payer` opens a fresh account of the holder's in the same transaction (`loot.freshNftAccount`) and it goes there.
|
|
212
|
+
*/
|
|
213
|
+
async function nftInto(asset, holder, payer) {
|
|
214
|
+
const own = await client.account(loot.ata(holder, asset));
|
|
215
|
+
if (!own || loot.takesNft(own, holder, asset)) return { into: null, before: [] };
|
|
216
|
+
// (the rent is the fixed one for 165 bytes, never the RPC's figure: the account is the holder's)
|
|
217
|
+
for (let index = 0; index < loot.FRESH_NFT_ACCOUNTS; index++) {
|
|
218
|
+
const { address } = loot.freshNftAccount({ payer, holder, mint: asset, index });
|
|
219
|
+
// nothing there, only lamports someone sent there, or one opened before (a hand-out that did not land) that takes it
|
|
220
|
+
const fresh = loot.freshNftAccount({ payer, holder, mint: asset, index, there: await client.account(address) });
|
|
221
|
+
if (fresh) return { into: fresh.address, before: fresh.instructions };
|
|
222
|
+
}
|
|
223
|
+
throw new Error(`${holder}'s accounts of the NFT ${asset} can't take it (owned by another key, or frozen), and no fresh one could be opened.`);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* What a route needs of an NFT, whatever its kind: { collection, hasAuthority, tokenMetadata, compressed, before }.
|
|
227
|
+
* A compressed NFT (no account at its address) is read from the RPC's DAS methods (`getAsset`, `getAssetProof`),
|
|
228
|
+
* which the page's RPC must offer, and proven first in the same transaction (`before`). `to`: the wallet the route
|
|
229
|
+
* hands it to, when it hands it to one: a Token Metadata NFT then names the account it goes into (`nftInto`), a fresh
|
|
230
|
+
* one opened by `payer` (the signer by default).
|
|
231
|
+
*/
|
|
232
|
+
async function nftOf(asset, signer = wallet.address, { to, payer = signer } = {}) {
|
|
233
|
+
const account = await client.account(asset);
|
|
234
|
+
if (!account) {
|
|
235
|
+
const das = await client.call('getAsset', { id: asset }).catch(() => null);
|
|
236
|
+
if (das?.compression?.compressed) {
|
|
237
|
+
const proof = await client.call('getAssetProof', { id: asset });
|
|
238
|
+
const tree = await client.account(das.compression.tree);
|
|
239
|
+
const compressed = loot.compressedFromDas({ asset: das, proof, treeData: tree.data });
|
|
240
|
+
return { compressed, hasAuthority: false, before: [computeUnitLimit(1_000_000), loot.proveLeafIx({ signer, compressed })] };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const tokenMetadata = await tokenMetadataOf(asset);
|
|
244
|
+
// A Token Metadata mint is taken only as an NFT: one token, no decimals, none more ever minted (NotAnNft).
|
|
245
|
+
if (tokenMetadata && account && !loot.isNftMint(asset, account.data)) throw new Error(`${asset} is a semi-fungible item (several units, or a mint that can still mint): only an NFT is taken.`);
|
|
246
|
+
const { into, before } = tokenMetadata && to ? await nftInto(asset, to, payer) : { into: null, before: [] };
|
|
247
|
+
return {
|
|
248
|
+
collection: await farmCollection(asset),
|
|
249
|
+
hasAuthority: ours(await collectionOf(asset)),
|
|
250
|
+
tokenMetadata: tokenMetadata && into ? { ...tokenMetadata, into } : tokenMetadata,
|
|
251
|
+
before: tokenMetadata ? [computeUnitLimit(400_000), ...before] : [],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* What `loot.prizeMetas` needs to give these prizes (`null`s are skipped): each pool's mint, each item (its kind, its
|
|
256
|
+
* collection and its next serial; null for one never written), each NFT's kind (`nfts`, and `nft` for one), the
|
|
257
|
+
* project's first collection, a compressed NFT's proof, which goes first in the transaction (`before`), and for a
|
|
258
|
+
* roll its box (`boxes`, null for one never written) and the mint of each pool its prizes name (`rollPools`, null for
|
|
259
|
+
* one never opened): a route the player pays or asks for passes the box's prize mints (`loot.rollMints`).
|
|
260
|
+
*/
|
|
261
|
+
async function prizeInputs(prizes, signer = wallet.address, winner = wallet.address) {
|
|
262
|
+
const out = { poolMints: {}, items: {}, nfts: {}, nft: undefined, collection: collections[0], before: [], boxes: {}, rollPools: {} };
|
|
263
|
+
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
|
+
const pool = prize.Tokens?.from.Pool?.[0];
|
|
270
|
+
if (pool !== undefined && !(pool in out.poolMints)) {
|
|
271
|
+
const account = await client.account(loot.pda.pool(project, pool));
|
|
272
|
+
// A pool never opened gives nothing; the economy's currency stands in for its mint's place.
|
|
273
|
+
out.poolMints[pool] = account ? encodeBase58(account.data.subarray(0, 32)) : mintOf();
|
|
274
|
+
}
|
|
275
|
+
const id = prize.Item?.[0] ?? prize.Compressed?.item;
|
|
276
|
+
if (id !== undefined && !(id in out.items)) out.items[id] = await readMaybe('Item', loot.pda.item(project, id));
|
|
277
|
+
// An NFT of a stash, or one a lottery's place holds in the lottery's own stash.
|
|
278
|
+
const asset = prize.Nft?.nft ?? prize.Held?.[0];
|
|
279
|
+
if (asset && !out.nfts[asset]) {
|
|
280
|
+
const { before, hasAuthority: _, ...nft } = await nftOf(asset, signer, { to: winner });
|
|
281
|
+
out.nfts[asset] = out.nft = nft;
|
|
282
|
+
out.before.push(...before);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
/** The Sheet the project keeps of an NFT it has no authority over: { key: value } of what the project wrote. */
|
|
288
|
+
const sheetOf = async (nft) => {
|
|
289
|
+
const found = await client.account(loot.pda.sheet(project, nft));
|
|
290
|
+
return Object.fromEntries((loot.decodeSheet(found?.owner === loot.LOOT_PROGRAM ? found.data : null)?.attributes ?? []).map((a) => [a.key, a.value]));
|
|
291
|
+
};
|
|
292
|
+
let projectState = null;
|
|
293
|
+
const projectNow = async () => (projectState ??= await read('Project', project));
|
|
294
|
+
/** A spec auction's address: it names the seller, which xsolana.json records once it is live. */
|
|
295
|
+
const auctionAt = (a) => {
|
|
296
|
+
if (!a.seller) throw new Error(`The auction “${a.key}” has no seller in xsolana.json: publish the economy again.`);
|
|
297
|
+
return loot.pda.auction(project, a.seller, a.id);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const itemKeyOf = (id) => (spec.items ?? []).find((it) => it.id === Number(id))?.key ?? null;
|
|
301
|
+
/** Who holds a collectible now. */
|
|
302
|
+
const ownerOf = async (asset) => parseCoreAsset((await client.account(asset)).data).owner;
|
|
303
|
+
/** `{ asset, farm }` for a Core asset on its seat in one of the economy's farms of these custodies, or null. */
|
|
304
|
+
const seatedIn = async (asset, custodies = ['escrow', 'freeze']) => {
|
|
305
|
+
for (const f of (spec.farms ?? []).filter((x) => x.stakes === 'collectibles' && custodies.includes(x.custody ?? 'escrow'))) {
|
|
306
|
+
if (await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), asset))) return { asset, farm: f.id };
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
};
|
|
310
|
+
/** The same for one the project holds: staked in escrow. */
|
|
311
|
+
const escrowedIn = async (asset) => ((await ownerOf(asset).catch(() => null)) === project ? seatedIn(asset, ['escrow']) : null);
|
|
312
|
+
/** The player's collectibles of this project (in any of its collections), each with the item it is (its "item_id" attribute). */
|
|
313
|
+
async function owned(owner = wallet?.address) {
|
|
314
|
+
const found = (await Promise.all(collections.map((c) => client.programAccounts(MPL_CORE, ownedAssetFilters(owner, c))))).flat();
|
|
315
|
+
return found.map(({ pubkey, account }) => {
|
|
316
|
+
const a = parseCoreAsset(account.data);
|
|
317
|
+
const itemId = a.attributes?.item_id !== undefined ? Number(a.attributes.item_id) : null;
|
|
318
|
+
return { asset: pubkey, ...a, itemId, item: itemId === null ? null : itemKeyOf(itemId) };
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* 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
|
+
* 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
|
|
324
|
+
* authority over finds the player's Core assets of it (with what the project wrote in their Sheets); a Token Metadata NFT
|
|
325
|
+
* is not looked for: pass it as `shown: { mint, owner }`. Throws a plain sentence when none passes, or the wallet is not on the list.
|
|
326
|
+
* A stake gate shows nothing: the client passes the wallet's seat in its farm; here it is only read, to say plainly
|
|
327
|
+
* when it holds too little or its lock is over (the program counts a stake only while its tier's lock runs, and
|
|
328
|
+
* only one made before this second).
|
|
329
|
+
*/
|
|
330
|
+
async function passing(gate, what) {
|
|
331
|
+
if (!gate) return null;
|
|
332
|
+
// A list is passed with a proof, not an NFT (`listProofOf`).
|
|
333
|
+
if (gate.List) return null;
|
|
334
|
+
if (gate.Staked) {
|
|
335
|
+
const { farm, at_least: atLeast } = gate.Staked;
|
|
336
|
+
const seat = await readMaybe('Seat', loot.pda.seat(farm, wallet.address));
|
|
337
|
+
const f = (spec.farms ?? []).find((x) => loot.pda.farm(project, x.id) === farm);
|
|
338
|
+
const t = f ? tokens.get(f.stakes) : null;
|
|
339
|
+
const needs = t ? `${show(atLeast, decimalsOf(t.key))} ${t.symbol ?? t.key}` : `${atLeast} (in base units)`;
|
|
340
|
+
const where = f ? `“${f.name}”` : 'its farm';
|
|
341
|
+
if (!seat || BigInt(seat.amount) < BigInt(atLeast)) throw new Error(`You need at least ${needs} staked in ${where} to ${what}.`);
|
|
342
|
+
// Only a locked stake counts: its tier's lock still running.
|
|
343
|
+
const lock = BigInt((await read('Farm', farm)).tiers[seat.tier]?.lock ?? 0);
|
|
344
|
+
if (lock === 0n || BigInt(now()) >= BigInt(seat.since) + lock) throw new Error(`Your stake in ${where} counts only while it is locked: stake in a tier with a lock (staking again starts a new one) to ${what}.`);
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
if (gate.Wallets) {
|
|
348
|
+
if (gate.Wallets[0].includes(wallet.address)) return null;
|
|
349
|
+
throw new Error(`Only the wallets this names may ${what}.`);
|
|
350
|
+
}
|
|
351
|
+
// A farm's gate is a list of rules one NFT must all pass; any other gate one rule.
|
|
352
|
+
const rules = Array.isArray(gate) ? gate : [gate.Holds ? gate.Holds[0] : gate];
|
|
353
|
+
if (!rules.length) return null;
|
|
354
|
+
const rule = rules[0];
|
|
355
|
+
const atLeast = rule.test?.AtLeast?.[0];
|
|
356
|
+
const equals = rule.test?.Equals?.text;
|
|
357
|
+
const item = rule.of?.Item?.[0];
|
|
358
|
+
const named = rules.map((r) => r.of?.Collection?.[0]).find((c) => c && !ours(c));
|
|
359
|
+
const other = named ?? null;
|
|
360
|
+
const passes = (r, c) => {
|
|
361
|
+
if (r.of?.Item?.[0] !== undefined && c.itemId !== Number(r.of.Item[0])) return false;
|
|
362
|
+
const value = c.attributes?.[r.key];
|
|
363
|
+
if (r.test?.AtLeast?.[0] !== undefined) return Number(value) >= Number(r.test.AtLeast[0]);
|
|
364
|
+
if (r.test?.Equals?.text !== undefined) return String(value) === String(r.test.Equals.text);
|
|
365
|
+
return true;
|
|
366
|
+
};
|
|
367
|
+
const pass = (c) => rules.every((r) => passes(r, c));
|
|
368
|
+
if (other) {
|
|
369
|
+
const found = await client.programAccounts(MPL_CORE, ownedAssetFilters(wallet.address, other));
|
|
370
|
+
for (const { pubkey, account } of found) {
|
|
371
|
+
const a = parseCoreAsset(account.data);
|
|
372
|
+
if (pass({ attributes: { ...a.attributes, ...(await sheetOf(pubkey)) } })) return { asset: pubkey };
|
|
373
|
+
}
|
|
374
|
+
throw new Error(`You need an NFT of the collection ${other}${atLeast !== undefined ? ` with ${rule.key} ${atLeast} or more` : equals !== undefined ? ` whose ${rule.key} is ${equals}` : ''} to ${what}.`);
|
|
375
|
+
}
|
|
376
|
+
const found = (await owned()).find(pass);
|
|
377
|
+
if (found) return found.asset;
|
|
378
|
+
const needs = item !== undefined ? `a ${(spec.items ?? []).find((it) => it.id === Number(item))?.name ?? 'collectible of this project'}` : 'a collectible of this project';
|
|
379
|
+
throw new Error(`You need ${needs}${atLeast !== undefined ? ` with ${rule.key} ${atLeast} or more` : equals !== undefined ? ` whose ${rule.key} is ${equals}` : ''} to ${what}.`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* For a list gate (`List`: a Merkle root of wallets, `sha256("loot-list-v1" | wallet)`), the proof that the player is
|
|
384
|
+
* on it: `given` (as a page or a results server hands it over), or made from the wallets xsolana.json lists for the part
|
|
385
|
+
* (`list`, its gate's `list`). Undefined for any other gate. Throws a plain sentence when the wallet is not on it.
|
|
386
|
+
*/
|
|
387
|
+
function listProofOf(gate, list, given) {
|
|
388
|
+
if (!gate?.List) return undefined;
|
|
389
|
+
if (given) return given;
|
|
390
|
+
if (!(list ?? []).includes(wallet.address)) throw new Error('This wallet is not on the list for this.');
|
|
391
|
+
// The list as the plan wrote its root: filled to a power of two (`gateList`).
|
|
392
|
+
return loot.gateList(list).proofOf(wallet.address);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* SOL paid out to the player (a prize, rewards, a refund, a grant) lands in their wrapped-SOL account: close it at
|
|
397
|
+
* the end, so the player keeps plain SOL. When the transaction pays into it and nothing opens it first, the player
|
|
398
|
+
* opens it here (the program would otherwise open it, unseen), and has the rent back at the close.
|
|
399
|
+
*/
|
|
400
|
+
async function unwrapAfter(instructions) {
|
|
401
|
+
const wrapped = associatedTokenAddress(wallet.address, NATIVE_MINT);
|
|
402
|
+
const opens = instructions.some((ix) => ix.programId === ASSOCIATED_TOKEN_PROGRAM && ix.keys[1]?.pubkey === wrapped);
|
|
403
|
+
const closes = instructions.some((ix) => ix.programId === TOKEN_PROGRAM && ix.data?.[0] === 9 && ix.keys[0]?.pubkey === wrapped);
|
|
404
|
+
if (closes) return instructions;
|
|
405
|
+
const close = closeTokenAccount(wrapped, wallet.address, wallet.address);
|
|
406
|
+
if (opens || (await client.account(wrapped))) return [...instructions, close];
|
|
407
|
+
const touches = instructions.some((ix) => ix.keys?.some((k) => k.pubkey === wrapped));
|
|
408
|
+
return touches ? [createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, NATIVE_MINT), ...instructions, close] : instructions;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Build, have the wallet sign, send and confirm. Extra signers (fresh keys) sign first. Returns the signature.
|
|
413
|
+
* `keepWrapped`: leave the player's wrapped-SOL account open (a bid's: what is topped comes back there).
|
|
414
|
+
* Its size is checked before the wallet signs (`sendFitting`): a transaction too big for a legacy one goes as one v0
|
|
415
|
+
* transaction with a lookup table the wallet makes first (one to three more approvals, and the table's rent until the
|
|
416
|
+
* 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.
|
|
418
|
+
*/
|
|
419
|
+
async function send(instructions, extra = [], { keepWrapped = false } = {}) {
|
|
420
|
+
if (!wallet?.address) throw new Error('Connect a wallet first.');
|
|
421
|
+
if (!keepWrapped) instructions = await unwrapAfter(instructions);
|
|
422
|
+
const sign = async (message) => {
|
|
423
|
+
const { signers } = readTransaction(wireTransaction(message));
|
|
424
|
+
const sigs = await Promise.all(signers.map(async (s) => (extra.find((k) => k.address === s) ? extra.find((k) => k.address === s).sign(message) : new Uint8Array(64))));
|
|
425
|
+
return wallet.signTransaction(wireTransaction(message, sigs));
|
|
426
|
+
};
|
|
427
|
+
// 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;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Throws `e` with loot's reason in words in place of a bare code: a wallet whose own dry run failed often says only
|
|
433
|
+
* "custom program error: 0x17b5". A code is loot's when the logs name loot as the program that failed, or, with no
|
|
434
|
+
* logs, when it is one of loot's (Anchor's codes start at 6000; the token programs' are small).
|
|
435
|
+
*/
|
|
436
|
+
function inWords(e) {
|
|
437
|
+
const said = String(e?.message ?? '');
|
|
438
|
+
const m = /custom program error: 0x([0-9a-f]+)/i.exec(said);
|
|
439
|
+
const failed = e?.logs?.find((line) => / failed: custom program error: 0x/i.test(line));
|
|
440
|
+
const known = m && (!failed || failed.includes(loot.LOOT_PROGRAM)) ? loot.ERRORS[parseInt(m[1], 16)] : null;
|
|
441
|
+
if (!known) throw e;
|
|
442
|
+
throw Object.assign(new Error(`${known.msg} (${known.name}).`, { cause: e }), { code: parseInt(m[1], 16), name: known.name, logs: e.logs });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Build, sign with `key` alone (it pays), send and confirm: a throwaway key's transaction. No wallet prompt. Legacy
|
|
447
|
+
* only: the key holds no rent for a lookup table.
|
|
448
|
+
*/
|
|
449
|
+
async function sendAs(key, instructions) {
|
|
450
|
+
return (await sendFitting({ rpc: client, payer: key.address, instructions, sign: async (message) => wireTransaction(message, [await key.sign(message)]), table: false }).catch(inWords)).signature;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Refuses, in words, to stake `asset` in tier `tier` of farm `f` when its unstake (with the tier's prize, and as a
|
|
455
|
+
* forfeit) couldn't be sent even as a v0 transaction with a lookup table, its proof sent first (`sendPlan`).
|
|
456
|
+
*/
|
|
457
|
+
async function exitFits(f, farm, asset, nft, before, tier, staking) {
|
|
458
|
+
const prize = farm.tiers[tier]?.prize ?? null;
|
|
459
|
+
const given = await prizeInputs([prize]);
|
|
460
|
+
// The rewards' accounts as the stake resolved them (a hook that needs each transfer named is sized without them).
|
|
461
|
+
const token = { ...staking, before: [createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, farm.rewards_mint, staking.programs[farm.rewards_mint] ?? TOKEN_PROGRAM)] };
|
|
462
|
+
const exit = (forfeit) => [...before, ...(forfeit ? [] : [...given.before, ...token.before]), loot.unstakeNftIx({ player: wallet.address, project, id: f.id, farm, asset, ...nft, forfeit, roll: farm.tiers[tier]?.roll ?? null, nonce: 1n, tier, projectCollection: given.collection, poolMints: given.poolMints, items: given.items, nft: given.nft, ...token })];
|
|
463
|
+
for (const forfeit of [false, true]) {
|
|
464
|
+
const instructions = exit(forfeit).filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM);
|
|
465
|
+
if (!sendPlan({ payer: wallet.address, instructions: [computeUnitLimit(1_400_000), ...instructions], alone: isProveLeaf }))
|
|
466
|
+
throw new Error(`Not staked: this NFT couldn't come back out of “${f.name ?? f.key}”. Its unstake${forfeit ? ', even giving up the rewards,' : ''} would lock more accounts than one transaction can (${transactionLocks(wallet.address, [computeUnitLimit(1_400_000), ...instructions], { asV0: true })} with its lookup table; at most 64), so it would stay in the farm.`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** An NFT's kind as a route's `ticket` or `nft` takes it: without its asset. */
|
|
471
|
+
const kindOnly = ({ asset: _, ...kind }) => kind;
|
|
472
|
+
|
|
473
|
+
/** A throwaway key's leftover back to the player: all it holds less its own fee, so the key ends empty. */
|
|
474
|
+
const giveBack = (burner) => transferLamports(burner.address, wallet.address, BURNER_LAMPORTS - SIGNATURE_FEE);
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Waits for an order's roll (the oracle answers a moment after the open), then settles it. With `burner` (the
|
|
478
|
+
* throwaway key the open funded), the burner signs and pays the settle, and gives the rest back: no wallet prompt.
|
|
479
|
+
* Anyone may settle a rolled order: when someone did first, the burner only gives its lamports back. `putBack`: NFTs
|
|
480
|
+
* won that the player puts back into their stash in place of taking them (one whose collection now refuses to let it
|
|
481
|
+
* move); the player signs that settle.
|
|
482
|
+
*/
|
|
483
|
+
async function collect(orderAddress, options = {}) {
|
|
484
|
+
only(options, ['timeoutMs', 'burner', 'putBack', 'opened', 'landed'], 'collect');
|
|
485
|
+
try {
|
|
486
|
+
return await collectOnce(orderAddress, options);
|
|
487
|
+
} catch (e) {
|
|
488
|
+
// The throwaway key is never left holding the player's SOL: whatever stopped the collect (a roll not in yet, a
|
|
489
|
+
// settle someone else sent first), it gives its lamports back before the error is said. Collecting again later
|
|
490
|
+
// needs no burner: the player signs that settle.
|
|
491
|
+
const { burner } = options;
|
|
492
|
+
if (!burner) throw e;
|
|
493
|
+
// All it holds now. While it can pay its own fee and stay rent-exempt until the transfer empties it, it sends it
|
|
494
|
+
// itself; after a settle of its that failed on chain (its fee taken) it holds only its rent, and the player's
|
|
495
|
+
// wallet pays the fee of the transaction that empties it (one more prompt, only then).
|
|
496
|
+
const left = BigInt(await client.balance(burner.address).catch(() => 0n));
|
|
497
|
+
if (left >= BURNER_LAMPORTS) await sendAs(burner, [transferLamports(burner.address, wallet.address, left - SIGNATURE_FEE)]).catch(() => null);
|
|
498
|
+
else if (left > 0n) await send([transferLamports(burner.address, wallet.address, left)], [burner]).catch(() => null);
|
|
499
|
+
if (!(await client.account(orderAddress))) return { order: orderAddress, settled: 'elsewhere', says: 'The order was settled already.' };
|
|
500
|
+
throw e;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function collectOnce(orderAddress, { timeoutMs = 60_000, burner = null, putBack = [], opened = false, landed = null } = {}) {
|
|
505
|
+
const started = Date.now();
|
|
506
|
+
// `landed`: the slot one's own open landed in; the order is read from a node that has seen it.
|
|
507
|
+
const current = async () => {
|
|
508
|
+
const account = await client.account(orderAddress, 'confirmed', landed != null ? { minContextSlot: landed } : {});
|
|
509
|
+
return account?.owner === loot.LOOT_PROGRAM ? loot.decode('Order', account.data) : null;
|
|
510
|
+
};
|
|
511
|
+
let order = await current();
|
|
512
|
+
// Right after one's own confirmed open, no order is "not seen yet", never "settled already": a node of the RPC may
|
|
513
|
+
// lag behind the one that confirmed it. It is read again for a few seconds first.
|
|
514
|
+
for (let i = 0; !order && opened && i < 6; i++) {
|
|
515
|
+
await sleep(1000);
|
|
516
|
+
order = await current();
|
|
517
|
+
}
|
|
518
|
+
// A roll a mission or a recipe made waits to be asked for (or asked again, the oracle never having answered):
|
|
519
|
+
// anyone asks, here the player.
|
|
520
|
+
if (order && loot.rollAskable(order, await slotNow())) {
|
|
521
|
+
await askRoll(orderAddress);
|
|
522
|
+
order = await current();
|
|
523
|
+
}
|
|
524
|
+
while (['Pending', 'Asked'].includes(order?.status) && Date.now() - started < timeoutMs) {
|
|
525
|
+
await sleep(1500);
|
|
526
|
+
order = await current();
|
|
527
|
+
}
|
|
528
|
+
if (!order) {
|
|
529
|
+
if (burner) await sendAs(burner, [giveBack(burner)]);
|
|
530
|
+
// One's own open still not seen after that: say so plainly, never that it was settled (claims() lists it).
|
|
531
|
+
if (opened) return { order: orderAddress, settled: null, says: 'The network doesn’t show this order yet: collect it again in a moment (claims() lists it).' };
|
|
532
|
+
return { order: orderAddress, settled: 'elsewhere', says: 'The order was settled already.' };
|
|
533
|
+
}
|
|
534
|
+
if (order.status === 'Pending') throw new Error('The oracle has not rolled yet: try collecting again in a minute (an order never rolled refunds after about 10 minutes).');
|
|
535
|
+
if (order.status === 'Asked') throw new Error('The oracle has not rolled yet: try collecting again in a minute (a roll never answered is asked again after about 10 minutes).');
|
|
536
|
+
if (order.status === 'Failed') throw new Error('The roll failed (the box changed or ran out): refund the order.');
|
|
537
|
+
// An order closes only in a later slot than the one it was opened in.
|
|
538
|
+
for (let i = 0; i < 20 && client.call; i++) {
|
|
539
|
+
const slot = BigInt(await client.call('getSlot', [{ commitment: 'confirmed' }]));
|
|
540
|
+
if (slot > order.requested_slot) break;
|
|
541
|
+
await sleep(400);
|
|
542
|
+
}
|
|
543
|
+
const g = await projectNow();
|
|
544
|
+
// What the roll set aside: each item it gave (its kind and collection) and each NFT's kind (a compressed one
|
|
545
|
+
// proven first).
|
|
546
|
+
const items = {};
|
|
547
|
+
for (const won of order.won) if (won.Item && !(won.Item.item in items)) items[won.Item.item] = await read('Item', loot.pda.item(project, won.Item.item));
|
|
548
|
+
const holders = await holdersOf(order.shares);
|
|
549
|
+
// Given by its fate to a wallet that is an account of the loot program, the NFT goes back to the player.
|
|
550
|
+
const gives = order.held ? order.fate?.then?.Give?.[0] : undefined;
|
|
551
|
+
const handBack = gives !== undefined && loot.handsBack(await client.account(gives));
|
|
552
|
+
/** The settle as `signer` sends it, and what goes before it: each compressed NFT's proof, a fresh token account. */
|
|
553
|
+
const settleBy = async (signer) => {
|
|
554
|
+
const nfts = {};
|
|
555
|
+
const proving = [];
|
|
556
|
+
for (const won of order.won) {
|
|
557
|
+
if (won.Nft && !putBack.includes(won.Nft.nft)) {
|
|
558
|
+
// A Token Metadata NFT's settle is the wallet's to send (it pays rent: `paysRent`), so the wallet opens any fresh account.
|
|
559
|
+
const { before, hasAuthority: _, ...kind } = await nftOf(won.Nft.nft, signer, { to: order.player, payer: wallet.address });
|
|
560
|
+
nfts[won.Nft.nft] = kind;
|
|
561
|
+
proving.push(...before);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
// The NFT the roll was on: its kind, and for a compressed one a proof first.
|
|
565
|
+
const { before: subjectProof, ...subject } = order.subject !== NOBODY ? await nftOf(order.subject, signer) : { before: [] };
|
|
566
|
+
const ix = loot.settleIx({ signer, orderAddress, order, projectState: g, items, nfts, putBack, subject, handBack, holders, programs });
|
|
567
|
+
return { ix, nfts, before: [...subjectProof, ...proving].filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM) };
|
|
568
|
+
};
|
|
569
|
+
// Room for every move the shape makes (`loot.settleComputeUnits`), and for a settle of several NFTs the bigger heap
|
|
570
|
+
// the program's allocator uses (`loot.settleHeapFrame`).
|
|
571
|
+
const heap = loot.settleHeapFrame(order);
|
|
572
|
+
const budget = [computeUnitLimit(loot.settleComputeUnits(order)), ...(heap ? [requestHeapFrame(heap)] : [])];
|
|
573
|
+
// The settle opens any account it pays into that is not there, paid by whoever signs it. A burner holds no rent
|
|
574
|
+
// to spare: the open made the player's accounts of the prizes' mints and of the payees, so it settles alone unless
|
|
575
|
+
// the settle pays rent of its own (a roll won makes an order; a Token Metadata NFT goes into a new token account),
|
|
576
|
+
// needs a proof first, or doesn't fit a legacy transaction (a lookup table's rent is the wallet's to pay). An NFT put
|
|
577
|
+
// back into its stash (one its collection no longer lets move) is the player's own choice: they sign.
|
|
578
|
+
let signature;
|
|
579
|
+
const byBurner = burner && !putBack.length ? await settleBy(burner.address) : null;
|
|
580
|
+
const paysRent = (built) => order.won.some((w) => w.Roll || (w.Nft && built.nfts[w.Nft.nft]?.tokenMetadata));
|
|
581
|
+
const alone = byBurner && !paysRent(byBurner) && !byBurner.before.length ? [...budget, byBurner.ix, giveBack(burner)] : null;
|
|
582
|
+
// The burner's transaction counts its compute limit: a settle within about 40 bytes of the legacy limit goes from the
|
|
583
|
+
// wallet (one prompt) rather than without one, as a settle with the default limit can run out (205,794 units on
|
|
584
|
+
// devnet) and the burner holds the fee of one try only.
|
|
585
|
+
if (alone && compileFitting({ payer: burner.address, blockhash: NOBODY, instructions: alone })) signature = await sendAs(burner, alone);
|
|
586
|
+
else {
|
|
587
|
+
// The wallet's settle: as a v0 transaction with a lookup table when it doesn't fit a legacy one (`send`).
|
|
588
|
+
const { ix, before } = await settleBy(wallet.address);
|
|
589
|
+
signature = await send([...budget, ...before, ix]);
|
|
590
|
+
if (burner) await sendAs(burner, [giveBack(burner)]);
|
|
591
|
+
}
|
|
592
|
+
const assets = wonAssets(orderAddress, order).map((a) => (putBack.includes(a) ? null : a));
|
|
593
|
+
return { order: orderAddress, won: order.won, assets, settled: signature, ...(assets.find(Boolean) ? { asset: assets.find(Boolean) } : {}) };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* For each prize an order won, what it is now: the collectible minted (`["asset", item, serial]`), the NFT handed
|
|
598
|
+
* over, the order a roll won is made in; null for tokens. A normal item is the project's backend's to deliver.
|
|
599
|
+
*/
|
|
600
|
+
function wonAssets(orderAddress, order) {
|
|
601
|
+
return order.won.map((won, k) => {
|
|
602
|
+
if (won.Item) return loot.pda.itemAsset(loot.pda.item(project, won.Item.item), won.Item.serial);
|
|
603
|
+
if (won.Nft) return won.Nft.nft;
|
|
604
|
+
if (won.Roll) return loot.pda.order(project, order.player, loot.rollNonce(orderAddress, order.requested_slot, k));
|
|
605
|
+
return null;
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** A nonce for an order no other order of the player's is at. */
|
|
610
|
+
const freshNonce = () => BigInt(Date.now()) * 1000n + BigInt(Math.floor(Math.random() * 1000));
|
|
611
|
+
|
|
612
|
+
/** The mint of each pool a box's tokens come from (null for a pool not opened). */
|
|
613
|
+
async function poolMintsOf(box) {
|
|
614
|
+
const prizePools = [...new Set(box.entries.flatMap((e) => e.prizes).map((p) => p.Tokens?.from.Pool?.[0]).filter((p) => p !== undefined))];
|
|
615
|
+
const pools = prizePools.length ? await client.accounts(prizePools.map((p) => loot.pda.pool(project, p))) : [];
|
|
616
|
+
return Object.fromEntries(prizePools.map((p, i) => [p, pools[i] ? encodeBase58(pools[i].data.subarray(0, 32)) : null]));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Asks the oracle for a roll a mission, a recipe or a prize made (`Waiting`): the player pays the oracle. */
|
|
620
|
+
async function askRoll(orderAddress) {
|
|
621
|
+
const order = await read('Order', orderAddress);
|
|
622
|
+
const box = await read('LootBox', order.loot_box);
|
|
623
|
+
const poolMints = await poolMintsOf(box);
|
|
624
|
+
return send([computeUnitLimit(400_000), ...loot.askRollIx({ payer: wallet.address, orderAddress, order, box, poolMints, programs })]);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* The most the player agreed to pay for `box`, in base units: `maxPrice` (base units), or the `listing` that
|
|
629
|
+
* `shop()` or `boxes()` showed them (its `priceUnits`). Never the price read now: a price the owner or other buyers
|
|
630
|
+
* moved since the player saw it would be paid without their say. A price above it is refused here, in words, before
|
|
631
|
+
* the wallet is asked.
|
|
632
|
+
*/
|
|
633
|
+
function boundOf(box, name, { maxPrice, listing } = {}, key) {
|
|
634
|
+
if (listing && key !== undefined && listing.key !== undefined && listing.key !== key) throw new Error(`That listing is “${listing.key}”, not “${key}”.`);
|
|
635
|
+
const given = maxPrice ?? listing?.priceUnits;
|
|
636
|
+
if (given === undefined || given === null) throw new Error(`Say the most the player agreed to pay for “${name}”: pass the listing shop() or boxes() showed (listing), or maxPrice in base units. The price read now is never the bound.`);
|
|
637
|
+
const bound = BigInt(given);
|
|
638
|
+
if (BigInt(box.price) > bound) throw new Error(`The price of “${name}” is now ${show(box.price, decimalsOf(box.currencyKey))}, more than the ${show(bound, decimalsOf(box.currencyKey))} the player was shown: show them the new price first.`);
|
|
639
|
+
return bound;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Refuses an option a method doesn't take, so a misspelled or outdated one fails here instead of being ignored (a page
|
|
644
|
+
* passing `{ choice }` for a referrer would send every referral share to the treasury). `options`: what the method
|
|
645
|
+
* was given, or what is left of it once the options it takes are read; `takes`: those it takes, said in the error.
|
|
646
|
+
*/
|
|
647
|
+
function only(options, takes, method) {
|
|
648
|
+
const unknown = Object.keys(options ?? {}).filter((k) => !takes.includes(k) && options[k] !== undefined);
|
|
649
|
+
if (!unknown.length) return;
|
|
650
|
+
const hint = unknown.includes('choice') && takes.includes('chosenRecipient') ? ' (a buyer’s choice is `chosenRecipient`)' : '';
|
|
651
|
+
throw new Error(`${method} doesn’t take ${unknown.map((k) => `“${k}”`).join(', ')}${hint}. It takes: ${takes.join(', ')}.`);
|
|
652
|
+
}
|
|
653
|
+
/** 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'];
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Buys a box that needs no roll (a shop item, a bundle: one entry, nothing random, no NFT rolled on) in one
|
|
658
|
+
* transaction: the price is split and every prize given at once, with no order to settle. `shown` (an NFT for its
|
|
659
|
+
* gate), `listProof` (for a list gate), `chosenRecipient` (the wallet, or collectible, a share the box lets the buyer
|
|
660
|
+
* name goes to). Returns { signature, assets }: for each prize, the collectible minted or the NFT given (null for
|
|
661
|
+
* tokens or a normal item), and for a roll the order it is made in.
|
|
662
|
+
*/
|
|
663
|
+
async function buyBox(boxId, part, { shown = null, listProof, maxPrice, listing, chosenRecipient = null } = {}) {
|
|
664
|
+
const g = await projectNow();
|
|
665
|
+
const box = await read('LootBox', loot.pda.box(project, boxId));
|
|
666
|
+
const bound = boundOf({ ...box, currencyKey: part?.currency }, part?.name ?? `box ${boxId}`, { maxPrice, listing }, part?.key);
|
|
667
|
+
shown ??= await passing(box.gate, 'buy this');
|
|
668
|
+
listProof = listProofOf(box.gate, part?.gate?.list, listProof);
|
|
669
|
+
const mint = box.currency;
|
|
670
|
+
const [entry] = box.entries;
|
|
671
|
+
const given = await prizeInputs(entry.prizes);
|
|
672
|
+
const nonce = freshNonce();
|
|
673
|
+
const choosesHolder = box.shares.some((s) => s.to.Chosen && s.to.Chosen.item != null);
|
|
674
|
+
const holders = await holdersOf(loot.chosenShares(box.shares, chosenRecipient));
|
|
675
|
+
const ix = loot.buyIx({ player: wallet.address, project, box, maxPrice: bound, treasury: g.treasury, chosenRecipient, chosen: choosesHolder ? chosenRecipient : null, nonce, shown, listProof, items: given.items, poolMints: given.poolMints, nfts: given.nfts, boxes: given.boxes, rollPools: given.rollPools, holders, programs });
|
|
676
|
+
const made = {};
|
|
677
|
+
const assets = entry.prizes.map((prize, k) => {
|
|
678
|
+
if (prize.Item) {
|
|
679
|
+
const id = prize.Item[0];
|
|
680
|
+
const item = given.items[id];
|
|
681
|
+
made[id] = (made[id] ?? 0) + 1;
|
|
682
|
+
return item?.kind === 'Collectible' ? loot.pda.itemAsset(loot.pda.item(project, id), Number(item.issued) + made[id]) : null;
|
|
683
|
+
}
|
|
684
|
+
if (prize.Nft) return prize.Nft.nft;
|
|
685
|
+
if (prize.Roll) return loot.pda.order(project, wallet.address, loot.prizeRollNonce(nonce + BigInt(k)));
|
|
686
|
+
return null;
|
|
687
|
+
});
|
|
688
|
+
const signature = await send(withSolWrap(wallet.address, mint, box.price, [computeUnitLimit(600_000), ...given.before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), ix]));
|
|
689
|
+
return { signature, assets, ...(assets.find(Boolean) ? { asset: assets.find(Boolean) } : {}) };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Opens a box that rolls. `oneSignature` (the default): the same transaction funds a throwaway key with its settle's
|
|
694
|
+
* fee, and makes the player's account of each mint the box's tokens are in, so the settle needs no second wallet
|
|
695
|
+
* prompt. `chosenRecipient`: the wallet (or collectible) the player names for a share the box lets them choose (a
|
|
696
|
+
* referrer, a plot). `on`: the NFT a box that rolls on one takes (an NFT whose price it is, a sword to enchant).
|
|
697
|
+
* `listProof`: for a list gate.
|
|
698
|
+
*/
|
|
699
|
+
async function open(boxId, part, { shown = null, on = null, maxPrice, listing, oneSignature = true, chosenRecipient = null, listProof } = {}) {
|
|
700
|
+
const g = await projectNow();
|
|
701
|
+
const box = await read('LootBox', loot.pda.box(project, boxId));
|
|
702
|
+
const bound = boundOf({ ...box, currencyKey: part?.currency }, part?.name ?? `box ${boxId}`, { maxPrice, listing }, part?.key);
|
|
703
|
+
// A box that rolls on an NFT takes the one the player brings (`on`), whatever its kind.
|
|
704
|
+
if (box.subject && !on) throw new Error('This box rolls on an NFT: say which (on).');
|
|
705
|
+
const brought = box.subject ? await nftOf(on) : { before: [] };
|
|
706
|
+
const { before: proving, hasAuthority, ...kind } = brought;
|
|
707
|
+
const subject = box.subject ? { asset: on, hasAuthority, ...kind, collection: kind.collection ?? (await collectionOf(on)) ?? MPL_CORE } : undefined;
|
|
708
|
+
if (!box.subject) shown ??= await passing(box.gate, 'buy this');
|
|
709
|
+
listProof = listProofOf(box.gate, part?.gate?.list, listProof);
|
|
710
|
+
const mint = box.currency;
|
|
711
|
+
// A roll that moves the numbers of the NFT it rolls on is at that NFT's nonce: one at a time on it.
|
|
712
|
+
const nonce = box.subject && loot.boxHolds(box) ? loot.nonceOn(on) : freshNonce();
|
|
713
|
+
// Each pool's mint, and each mint tokens are minted from: what is won waits in the project's vault for its mint.
|
|
714
|
+
const poolMints = await poolMintsOf(box);
|
|
715
|
+
const minted = box.entries.flatMap((e) => e.prizes).map((p) => p.Tokens?.from.Mint?.[0]).filter(Boolean);
|
|
716
|
+
const prizeMints = [...new Set([...Object.values(poolMints).filter(Boolean), ...minted])];
|
|
717
|
+
const choosesHolder = box.shares.some((s) => s.to.Chosen && s.to.Chosen.item != null);
|
|
718
|
+
const chosen = chosenRecipient && choosesHolder ? chosenRecipient : null;
|
|
719
|
+
const ix = loot.openBoxIx({ player: wallet.address, project, box, nonce, maxPrice: bound, shown, listProof, subject, chosenRecipient, chosen, poolMints, programs });
|
|
720
|
+
const before = [...proving, createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, mint, programOf(mint))];
|
|
721
|
+
// (A compressed NFT's proof brings its own compute limit, the transaction's only one.)
|
|
722
|
+
let burner = null;
|
|
723
|
+
let solPrize = false;
|
|
724
|
+
if (oneSignature) {
|
|
725
|
+
// The burner's settle pays no rent: the player opens here every account it will pay into (their prize's, and
|
|
726
|
+
// each wallet a share of the sale pays: a referrer, a plot's holder, the treasury).
|
|
727
|
+
burner = await freshKey();
|
|
728
|
+
for (const prizeMint of prizeMints) {
|
|
729
|
+
solPrize ||= prizeMint === NATIVE_MINT;
|
|
730
|
+
before.push(createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, prizeMint, programOf(prizeMint)));
|
|
731
|
+
}
|
|
732
|
+
const shares = loot.chosenShares(box.shares, chosenRecipient);
|
|
733
|
+
const holders = await holdersOf(shares);
|
|
734
|
+
const payees = shares.flatMap((s) => (s.to === 'Treasury' ? [g.treasury] : s.to.Wallet ? [s.to.Wallet[0]] : s.to.Holder && holders[s.to.Holder[0]] ? [holders[s.to.Holder[0]]] : []));
|
|
735
|
+
const paid = new Set([g.treasury, ...payees]);
|
|
736
|
+
for (const payee of paid) before.push(createAssociatedTokenAccountIdempotent(wallet.address, payee, mint, programOf(mint)));
|
|
737
|
+
// A sale that pays the player themselves (the owner buying from their own shop) pays into their wrapped-SOL
|
|
738
|
+
// account: it stays open for the settle too.
|
|
739
|
+
solPrize ||= mint === NATIVE_MINT && paid.has(wallet.address);
|
|
740
|
+
}
|
|
741
|
+
const after = burner ? [transferLamports(wallet.address, burner.address, BURNER_LAMPORTS)] : [];
|
|
742
|
+
// A SOL prize, or a share of the sale, lands in the player's wrapped-SOL account, which the prompt-less settle
|
|
743
|
+
// can't open: it stays open, and the player's next transaction unwraps it.
|
|
744
|
+
let instructions = withSolWrap(wallet.address, mint, box.price, [...before, ix, ...after]);
|
|
745
|
+
if (solPrize) instructions = instructions.filter((x) => !(x.programId === TOKEN_PROGRAM && x.data?.[0] === 9 && x.keys[0]?.pubkey === associatedTokenAddress(wallet.address, NATIVE_MINT)));
|
|
746
|
+
const signature = await send(instructions, [], { keepWrapped: solPrize });
|
|
747
|
+
// The slot it landed in: the order is read from a node that has seen it (a load-balanced RPC's node lagging
|
|
748
|
+
// behind answers nothing for an order just opened).
|
|
749
|
+
const landed = (await client.signatureStatus?.(signature).catch(() => null))?.slot ?? null;
|
|
750
|
+
return { signature, orderAddress: loot.pda.order(project, wallet.address, nonce), burner, landed };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* A prize of a share of a pool that comes to nothing is refused when it is given at once (a buy's, a craft's:
|
|
755
|
+
* PrizeNotCovered): say the pool is empty before the player signs.
|
|
756
|
+
*/
|
|
757
|
+
async function poolShareGiven(prizes) {
|
|
758
|
+
for (const p of prizes) {
|
|
759
|
+
const bps = p?.Tokens?.amount.Bps?.[0];
|
|
760
|
+
const pool = p?.Tokens?.from.Pool?.[0];
|
|
761
|
+
if (bps === undefined || pool === undefined) continue;
|
|
762
|
+
const data = (await client.account(loot.pda.pool(project, pool)))?.data;
|
|
763
|
+
const held = data ? data.subarray(64, 72).reduce((n, b, i) => n | (BigInt(b) << BigInt(8 * i)), 0n) : 0n;
|
|
764
|
+
if ((held * BigInt(bps)) / 10_000n === 0n) throw new Error(`Its prize is a share of pool ${pool}, and the pool is empty: nothing to give yet.`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Holds the listing the player was shown against the one listed now: refused, in words, when none is given, when it
|
|
770
|
+
* is of another NFT or another kind of deal (`Sell`, `Rent`), and when its terms differ from those listed now (the
|
|
771
|
+
* program would refuse them too: TermsChanged).
|
|
772
|
+
*/
|
|
773
|
+
async function shownListing(asset, listing, kind) {
|
|
774
|
+
if (!listing) throw new Error('Say the terms the player was shown: pass the listing listing() or listings() showed (listing). The listing read now is never signed in its place.');
|
|
775
|
+
if (listing.asset !== asset) throw new Error(`That listing is of ${listing.asset}, not ${asset}.`);
|
|
776
|
+
if (!listing.deal?.[kind]) throw new Error(kind === 'Sell' ? 'That listing is for rent, not for sale.' : 'That listing is for sale, not for rent.');
|
|
777
|
+
const listed = await readMaybe('Listing', loot.pda.listing(project, asset));
|
|
778
|
+
if (!listed) throw new Error(`${asset} is not listed any more.`);
|
|
779
|
+
const [was, is] = [loot.listingTerms(listing), loot.listingTerms(listed)];
|
|
780
|
+
if (was.some((b, i) => b !== is[i])) throw new Error('This listing has changed since the player was shown it: show them its terms as they are now first.');
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** A box bought at once when it needs no roll (`buyBox`), or opened and collected. */
|
|
784
|
+
async function buyOrOpen(boxId, part, options) {
|
|
785
|
+
const box = await read('LootBox', loot.pda.box(project, boxId));
|
|
786
|
+
if (!loot.boxRolls(box.entries) && !box.subject) {
|
|
787
|
+
await poolShareGiven(box.entries.flatMap((e) => e.prizes ?? []));
|
|
788
|
+
return buyBox(boxId, part, options);
|
|
789
|
+
}
|
|
790
|
+
const { orderAddress, burner, landed } = await open(boxId, part, options);
|
|
791
|
+
return collect(orderAddress, { burner, opened: true, landed });
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** The current slot (an order's deadlines count in slots). */
|
|
795
|
+
const slotNow = async () => BigInt(await client.call('getSlot', [{ commitment: 'confirmed' }]));
|
|
796
|
+
/** The slots the SlotHashes sysvar holds now (a lookup table closes once its deactivation slot has left them). */
|
|
797
|
+
const recentSlots = async () => slotHashes((await client.account(SYSVAR_SLOT_HASHES))?.data);
|
|
798
|
+
/** The chain's clock (Unix seconds), read from its Clock sysvar; the computer's (`now`) when it can't be read. */
|
|
799
|
+
const chainNow = async () => {
|
|
800
|
+
const clock = await client.account('SysvarC1ock11111111111111111111111111111111').catch(() => null);
|
|
801
|
+
if (!clock?.data || clock.data.length < 40) return BigInt(now());
|
|
802
|
+
const v = clock.data.subarray(32, 40).reduce((n, b, i) => n | (BigInt(b) << BigInt(8 * i)), 0n);
|
|
803
|
+
return v >= 1n << 63n ? v - (1n << 64n) : v;
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Refuses a stake a farm would refuse (`FarmOver`) before anything is signed: a farm with an end takes none at its end
|
|
808
|
+
* or after it, nor one at a tier whose lock would run past it. `f` is the spec's farm, `farm` the decoded one.
|
|
809
|
+
*/
|
|
810
|
+
async function openFor(f, farm, tier) {
|
|
811
|
+
const at = await chainNow();
|
|
812
|
+
if (loot.farmOpenFor(farm, tier, at)) return;
|
|
813
|
+
const ends = new Date(Number(farm.ends_at) * 1000).toISOString();
|
|
814
|
+
if (at >= BigInt(farm.ends_at)) throw new Error(`“${f.name ?? f.key}” ended at ${ends}: it takes no stake any more. Stakes in it can still leave and harvest.`);
|
|
815
|
+
throw new Error(`“${f.name ?? f.key}” ends at ${ends}, and lock tier ${tier} would run past it: stake at a tier with a shorter lock.`);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Who holds each collectible a split's holder shares name now: { asset: holder }. */
|
|
819
|
+
async function holdersOf(shares) {
|
|
820
|
+
const holders = {};
|
|
821
|
+
for (const s of shares) if (s.to.Holder) holders[s.to.Holder[0]] = await ownerOf(s.to.Holder[0]).catch(() => null);
|
|
822
|
+
return holders;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* A lottery's current round: the lottery, its purchases ([{ address, value }], each a LotteryContribution; every one
|
|
827
|
+
* still there is of this round), and for a drawn round whose purchases are all finished, the winning purchases still
|
|
828
|
+
* to be paid ([{ contribution, winner }]).
|
|
829
|
+
*/
|
|
830
|
+
async function roundOf(l) {
|
|
831
|
+
const address = loot.pda.lottery(project, l.id);
|
|
832
|
+
const lottery = await read('Lottery', address);
|
|
833
|
+
const found = await client.programAccounts(loot.LOOT_PROGRAM, [{ memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS.LotteryContribution)) } }, { memcmp: { offset: 1, bytes: address } }]);
|
|
834
|
+
const contributions = found.map(({ pubkey, account }) => ({ address: pubkey, value: loot.decode('LotteryContribution', account.data) }));
|
|
835
|
+
if (lottery.status !== 'Drawn' || BigInt(lottery.pending) > 0n) return { lottery, contributions, winners: [] };
|
|
836
|
+
// Once every purchase is finished, those left are the winners not paid yet.
|
|
837
|
+
const winners = contributions.filter(({ value }) => value.done).map((contribution) => ({ contribution, winner: contribution.value.player }));
|
|
838
|
+
return { lottery, contributions, winners };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
return {
|
|
842
|
+
project,
|
|
843
|
+
get address() {
|
|
844
|
+
return wallet?.address ?? null;
|
|
845
|
+
},
|
|
846
|
+
|
|
847
|
+
/** The shop: every item with a price (or free), its price now and what is left. */
|
|
848
|
+
async shop() {
|
|
849
|
+
const items = (spec.items ?? []).filter((it) => Number.isInteger(it.shopBox));
|
|
850
|
+
const accounts = await client.accounts(items.flatMap((it) => [loot.pda.box(project, it.shopBox), loot.pda.item(project, it.id)]));
|
|
851
|
+
return items.map((it, i) => {
|
|
852
|
+
const box = accounts[2 * i] && loot.decode('LootBox', accounts[2 * i].data);
|
|
853
|
+
const item = accounts[2 * i + 1] && loot.decode('Item', accounts[2 * i + 1].data);
|
|
854
|
+
const dec = decimalsOf(currencyKey);
|
|
855
|
+
return { key: it.key, name: it.name, price: box ? show(box.price, dec) : null, priceUnits: box?.price ?? null, currency: tokens.get(currencyKey)?.symbol ?? currencyKey, active: Boolean(box?.active && item?.active), left: item?.max_supply ? item.max_supply - item.issued : null };
|
|
856
|
+
});
|
|
857
|
+
},
|
|
858
|
+
|
|
859
|
+
/** The boxes: price now and each outcome's chance. */
|
|
860
|
+
async boxes() {
|
|
861
|
+
const list = spec.boxes ?? [];
|
|
862
|
+
const accounts = await client.accounts(list.map((b) => loot.pda.box(project, b.id)));
|
|
863
|
+
return list.map((b, i) => {
|
|
864
|
+
const box = accounts[i] && loot.decode('LootBox', accounts[i].data);
|
|
865
|
+
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 })) };
|
|
867
|
+
});
|
|
868
|
+
},
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Buys a shop item, in one transaction (`buy`: the price split and the item given at once). A listing that rolls
|
|
872
|
+
* (an NFT at random, or one it rolls on) is opened and settled instead. Returns { signature, assets, asset } for a
|
|
873
|
+
* purchase, or what `collect` returns for an open.
|
|
874
|
+
*/
|
|
875
|
+
async buy(itemKey, options = {}) {
|
|
876
|
+
only(options, BOX_OPTIONS, 'buy');
|
|
877
|
+
const it = part('items', itemKey);
|
|
878
|
+
if (!Number.isInteger(it.shopBox)) throw new Error(`“${it.name}” is not in the shop.`);
|
|
879
|
+
return buyOrOpen(it.shopBox, it, options);
|
|
880
|
+
},
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* 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.
|
|
885
|
+
*/
|
|
886
|
+
async openBox(boxKey, options = {}) {
|
|
887
|
+
only(options, BOX_OPTIONS, 'openBox');
|
|
888
|
+
const b = part('boxes', boxKey);
|
|
889
|
+
return buyOrOpen(b.id, b, options);
|
|
890
|
+
},
|
|
891
|
+
|
|
892
|
+
/** Collects an order opened earlier (its roll arrived after the app stopped waiting). */
|
|
893
|
+
collect,
|
|
894
|
+
|
|
895
|
+
/** Gives an order's payment back once its time has come (a roll that failed or never came). */
|
|
896
|
+
async refund(orderAddress) {
|
|
897
|
+
const order = await read('Order', orderAddress);
|
|
898
|
+
// An NFT the project held for the roll goes back.
|
|
899
|
+
const { before = [], hasAuthority: _, ...subject } = order.held ? await nftOf(order.subject, wallet.address, { to: order.player }) : {};
|
|
900
|
+
return send([...before, loot.refundIx({ signer: wallet.address, orderAddress, order, subject, programs })]);
|
|
901
|
+
},
|
|
902
|
+
|
|
903
|
+
/** Asks the oracle for a roll a mission, a recipe or a prize made (anyone may; the asker pays the oracle). */
|
|
904
|
+
askRoll: (orderAddress) => askRoll(orderAddress),
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Pays a payment of the economy (an entry, an attempt, a tip). `amount`: what the player chose, in whole units, for
|
|
908
|
+
* a payment with `any` (at least its `amount`). `chosenRecipient`: the wallet the player names for a share the
|
|
909
|
+
* payment lets them choose (a referrer), never their own. `holders`: asset → holder, for a holder's share (read
|
|
910
|
+
* now when not given).
|
|
911
|
+
*/
|
|
912
|
+
async pay(paymentKey, { amount: chosen, holders, chosenRecipient = null, ...others } = NO_OPTIONS) {
|
|
913
|
+
only(others, ['amount', 'holders', 'chosenRecipient'], 'pay');
|
|
914
|
+
const p = part('payments', paymentKey);
|
|
915
|
+
const g = await projectNow();
|
|
916
|
+
const mint = mintOf(p.currency);
|
|
917
|
+
const least = units(p.amount, decimalsOf(p.currency));
|
|
918
|
+
if (chosen !== undefined && !p.any) throw new Error(`“${p.name}” has a set amount: ${p.amount}.`);
|
|
919
|
+
const amount = chosen === undefined ? least : units(chosen, decimalsOf(p.currency));
|
|
920
|
+
if (amount < least || amount <= 0n) throw new Error(`“${p.name}” takes at least ${p.amount}.`);
|
|
921
|
+
const pools = new Map((spec.pools ?? []).map((x) => [x.key, x.index]));
|
|
922
|
+
// `pay` takes its shares from the payer each time: a "chosen" share pays the wallet they name, and with none named
|
|
923
|
+
// it is left out, so what it would pay goes to the treasury with the rest (as a purchase's does).
|
|
924
|
+
if (chosenRecipient && chosenRecipient === wallet.address) throw new Error('A payer can’t name their own wallet for a share they choose.');
|
|
925
|
+
const shares = (p.split ?? []).flatMap((s) => {
|
|
926
|
+
const [kind, key] = s.to.split(':');
|
|
927
|
+
const bps = Math.round(s.percent * 100);
|
|
928
|
+
if (kind === 'chosen' && key) throw new Error(`“${p.name}” pays the holder of a “${key}” the payer names: a payment can’t check the collectible, so its split takes a chosen wallet only.`);
|
|
929
|
+
if (kind === 'chosen') return chosenRecipient ? [{ to: { Wallet: [chosenRecipient] }, bps }] : [];
|
|
930
|
+
const pot = (lotteryKey) => loot.pda.pot(loot.pda.lottery(project, part('lotteries', lotteryKey).id));
|
|
931
|
+
const to = kind === 'burn' ? 'Burn' : kind === 'pool' ? { Pool: [pools.get(key)] } : kind === 'account' ? { Account: [key] } : kind === 'wallet' ? { Wallet: [key] } : kind === 'treasury' ? 'Treasury' : kind === 'lottery' ? { Account: [pot(key)] } : { Holder: [key] };
|
|
932
|
+
return [{ to, bps }];
|
|
933
|
+
});
|
|
934
|
+
holders ??= shares.some((x) => x.to.Holder) ? await holdersOf(shares) : undefined;
|
|
935
|
+
const ix = loot.payIx({ player: wallet.address, project, mint, treasury: g.treasury, amount, shares, holders, programs });
|
|
936
|
+
return send(withSolWrap(wallet.address, mint, amount, [ix]));
|
|
937
|
+
},
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Pays with one of the player's NFTs, any kind (`pay_nft`): burned (`to` null), or given to the wallet `to` (the
|
|
941
|
+
* treasury, a partner), straight from the player. The receipt (`PaidNft`) is the record an app admits the player
|
|
942
|
+
* by, with `reference` (32 bytes, or text the client hashes). Returns the signature.
|
|
943
|
+
*/
|
|
944
|
+
async payNft(asset, { to = null, reference, ...others } = NO_OPTIONS) {
|
|
945
|
+
only(others, ['to', 'reference'], 'payNft');
|
|
946
|
+
// An NFT paid goes to a wallet: never the project's address or an account of the program (ToTheProject).
|
|
947
|
+
if (to && (to === project || (await client.account(to))?.owner === loot.LOOT_PROGRAM)) throw new Error('An NFT paid can’t go to the project or an account of the program: name a wallet, or burn it.');
|
|
948
|
+
const { before, ...nft } = await nftOf(asset);
|
|
949
|
+
return send([...before, loot.payNftIx({ player: wallet.address, project, asset, to, ...nft, reference })]);
|
|
950
|
+
},
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* The lotteries: each one's ticket price now, its round and status, and its terms (`incarnation`, `revision`),
|
|
954
|
+
* which `buyTickets` takes as the terms the player was shown. A lottery not written yet (a raffle still to open)
|
|
955
|
+
* has a null price.
|
|
956
|
+
*/
|
|
957
|
+
async lotteries() {
|
|
958
|
+
const list = spec.lotteries ?? [];
|
|
959
|
+
const accounts = await client.accounts(list.map((l) => loot.pda.lottery(project, l.id)));
|
|
960
|
+
return list.map((l, i) => {
|
|
961
|
+
const lottery = accounts[i]?.owner === loot.LOOT_PROGRAM ? loot.decode('Lottery', accounts[i].data) : null;
|
|
962
|
+
const currency = lottery ? ticketCurrencyOf(l, lottery) : l.ticketCurrency ?? currencyKey;
|
|
963
|
+
return {
|
|
964
|
+
key: l.key, name: l.name, price: lottery ? show(lottery.price, decimalsOf(currency)) : null, priceUnits: lottery?.price ?? null,
|
|
965
|
+
currency: tokens.get(currency)?.symbol ?? currency, status: lottery?.status ?? null, round: lottery?.round ?? null, sold: lottery?.sold ?? null,
|
|
966
|
+
drawsAt: lottery ? Number(lottery.draws_at) : null, nftTicket: Boolean(lottery?.nft_ticket), incarnation: lottery?.incarnation ?? null, revision: lottery?.revision ?? null,
|
|
967
|
+
};
|
|
968
|
+
});
|
|
969
|
+
},
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Buys `count` tickets of a lottery, on the terms the player was shown: `listing`, the lottery as `lotteries()`
|
|
973
|
+
* showed it (a lottery changed since, its price or anything else, is refused rather than paid), or `maxPrice`, the
|
|
974
|
+
* most a ticket may cost, in base units. One of them is required: the price read now is never the bound.
|
|
975
|
+
* `chosenRecipient`: the wallet (or collectible) the player names for a share the lottery lets them choose (a
|
|
976
|
+
* referrer). A lottery whose ticket costs an NFT (`nft_ticket`) sells one ticket for one of the player's NFTs that
|
|
977
|
+
* passes its rule: `nft`, its address (held by the project until the draw, then burned, given on or stashed; back to
|
|
978
|
+
* the player if the round is cancelled). `listProof`: for a list gate.
|
|
979
|
+
*/
|
|
980
|
+
async buyTickets(lotteryKey, count = 1, { shown, chosenRecipient = null, listProof, nft: paidWith, listing, maxPrice, ...others } = NO_OPTIONS) {
|
|
981
|
+
only(others, ['shown', 'chosenRecipient', 'listProof', 'nft', 'listing', 'maxPrice'], 'buyTickets');
|
|
982
|
+
const l = part('lotteries', lotteryKey);
|
|
983
|
+
const name = l.name ?? lotteryKey;
|
|
984
|
+
if (listing && listing.key !== undefined && listing.key !== l.key) throw new Error(`That listing is “${listing.key}”, not “${l.key}”.`);
|
|
985
|
+
const given = maxPrice ?? listing?.priceUnits;
|
|
986
|
+
if (given === undefined || given === null) throw new Error(`Say the terms the player was shown for “${name}”: pass the lottery lotteries() showed (listing), or maxPrice (a ticket's most, in base units). The price read now is never the bound.`);
|
|
987
|
+
const current = await read('Lottery', loot.pda.lottery(project, l.id));
|
|
988
|
+
// The terms signed are those shown: a lottery written again or changed since is refused here, and by the
|
|
989
|
+
// program (TermsChanged) if it changes after this read.
|
|
990
|
+
if (listing && (listing.incarnation == null || listing.revision == null)) throw new Error(`That listing of “${name}” names no terms (incarnation, revision): pass the lottery lotteries() showed.`);
|
|
991
|
+
if (listing && (BigInt(listing.incarnation) !== BigInt(current.incarnation) || Number(listing.revision) !== Number(current.revision))) throw new Error(`“${name}” has changed since the player was shown it: show them the lottery as it is now first.`);
|
|
992
|
+
const lottery = listing ? { ...current, incarnation: BigInt(listing.incarnation), revision: Number(listing.revision) } : current;
|
|
993
|
+
const currency = ticketCurrencyOf(l, lottery);
|
|
994
|
+
if (BigInt(lottery.price) > BigInt(given)) throw new Error(`A ticket of “${name}” now costs ${show(lottery.price, decimalsOf(currency))}, more than the ${show(BigInt(given), decimalsOf(currency))} the player was shown: show them the new price first.`);
|
|
995
|
+
if (lottery.status === 'Ended') throw new Error(`“${l.name}” has drawn and ended.`);
|
|
996
|
+
shown ??= await passing(lottery.gate, 'buy a ticket');
|
|
997
|
+
listProof = listProofOf(lottery.gate, l.gate?.list, listProof);
|
|
998
|
+
let nft;
|
|
999
|
+
const before = [];
|
|
1000
|
+
if (lottery.nft_ticket) {
|
|
1001
|
+
if (!paidWith) throw new Error(`A ticket of “${l.name}” costs an NFT: say which (nft).`);
|
|
1002
|
+
if (BigInt(count) !== 1n) throw new Error('An NFT pays for one ticket at a time.');
|
|
1003
|
+
const { before: proof, ...kind } = await nftOf(paidWith);
|
|
1004
|
+
nft = { asset: paidWith, ...kind };
|
|
1005
|
+
before.push(...proof);
|
|
1006
|
+
}
|
|
1007
|
+
const mint = l.ticketCurrency ? mintOf(l.ticketCurrency) : lottery.mint;
|
|
1008
|
+
const choosesHolder = lottery.shares.some((s) => s.to.Chosen && s.to.Chosen.item != null);
|
|
1009
|
+
const nonce = freshNonce();
|
|
1010
|
+
const ix = loot.buyTicketsIx({ player: wallet.address, project, id: l.id, lottery, mint, count: BigInt(count), nonce, shown, listProof, chosenRecipient, chosen: choosesHolder ? chosenRecipient : null, nft, programs });
|
|
1011
|
+
// A round that reached its date with no ticket sold moves on by itself when a ticket is bought.
|
|
1012
|
+
const due = lottery.status === 'Open' && BigInt(now()) >= BigInt(lottery.draws_at);
|
|
1013
|
+
if (due && BigInt(lottery.sold) > 0n) throw new Error('This round’s draw is due: reveal it first (claims() lists it), then buy for the next one.');
|
|
1014
|
+
// A ticket paid with an NFT: its way out must land too, the NFT handed on at the draw or back to its
|
|
1015
|
+
// buyer when the round is cancelled, each sized before the NFT goes in.
|
|
1016
|
+
if (nft) {
|
|
1017
|
+
const g = await projectNow();
|
|
1018
|
+
const contribution = { player: wallet.address, asset: paidWith, done: false, round: lottery.round, count: 1n, shares: lottery.shares };
|
|
1019
|
+
const contributionAddress = loot.pda.contribution(loot.pda.lottery(project, l.id), wallet.address, nonce);
|
|
1020
|
+
for (const status of ['Drawn', 'Cancelled']) {
|
|
1021
|
+
const exit = [computeUnitLimit(1_400_000), ...before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), loot.finishLotteryContributionIx({ payer: wallet.address, project, id: l.id, lottery: { ...lottery, status }, contributionAddress, contribution, treasury: g.treasury, ticket: kindOnly(nft), programs })];
|
|
1022
|
+
if (!sendPlan({ payer: wallet.address, instructions: exit, alone: isProveLeaf }))
|
|
1023
|
+
throw new Error(`Not bought: this NFT couldn't come out of “${l.name}” again (${status === 'Drawn' ? 'handed on at the draw' : 'back to you if the round is cancelled'}): that transaction would lock more accounts than one can (${transactionLocks(wallet.address, exit, { asV0: true })} with its lookup table; at most 64), so it would stay with the lottery.`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
return send([...before, ...withSolWrap(wallet.address, mint, lottery.price * BigInt(count), [ix])]);
|
|
1027
|
+
},
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Writes an NFT's traits from the project's traits tree (`traits` in the spec) into its Sheet, where rules read them:
|
|
1031
|
+
* anyone may, paying the Sheet's rent. `attributes` ([{ key, value }]) and `proof` come from the project's tree
|
|
1032
|
+
* (`loot.traitsTree`); `key` names the tree (a collection's address), the spec's first when left out. Traits the
|
|
1033
|
+
* Sheet already has are left as they are.
|
|
1034
|
+
*/
|
|
1035
|
+
async proveTraits(nft, attributes, proof, key = spec.traits?.[0]?.collection) {
|
|
1036
|
+
return send([loot.proveTraitsIx({ signer: wallet.address, project, key, nft, attributes, proof })]);
|
|
1037
|
+
},
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Stakes `amount` (whole units) of a farm's token at lock tier `tier`. A farm with an end takes no stake at its end
|
|
1041
|
+
* or after it, nor one whose lock would run past it: refused here, before the wallet signs.
|
|
1042
|
+
*/
|
|
1043
|
+
async stake(farmKey, amount, tier = 0, { shown, extra, transfers, ...others } = NO_OPTIONS) {
|
|
1044
|
+
only(others, ['shown', 'extra', 'transfers'], 'stake');
|
|
1045
|
+
const f = part('farms', farmKey);
|
|
1046
|
+
const farm = await read('Farm', loot.pda.farm(project, f.id));
|
|
1047
|
+
await openFor(f, farm, tier);
|
|
1048
|
+
shown ??= await passing(farm.gates, 'stake here');
|
|
1049
|
+
const mint = farm.farmed.Tokens.mint;
|
|
1050
|
+
const value = units(amount, decimalsOf(f.stakes));
|
|
1051
|
+
// Moving to, or topping up on, a tier whose rewards wait for its lock (an early exit) first takes what the seat
|
|
1052
|
+
// may take now (HarvestFirst), in the same transaction; a seat can't move to a larger exit fee while locked.
|
|
1053
|
+
const mine = await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), wallet.address));
|
|
1054
|
+
let harvestFirst = false;
|
|
1055
|
+
if (mine && BigInt(mine.amount) > 0n) {
|
|
1056
|
+
const [from, to] = [farm.tiers[mine.tier], farm.tiers[tier]];
|
|
1057
|
+
const locked = (await chainNow()) < BigInt(mine.since) + BigInt(from.lock);
|
|
1058
|
+
if (locked && Number(to.exit_bps) > Number(from.exit_bps)) throw new Error('While its lock runs, this stake can\'t move to a tier with a larger early-exit fee.');
|
|
1059
|
+
harvestFirst = Number(to.exit_bps) > 0 && !(Number(from.exit_bps) > 0 && locked);
|
|
1060
|
+
}
|
|
1061
|
+
const token = await farmTokens([mint, farm.rewards_mint], harvestFirst ? [farm.rewards_mint] : [], { extra, transfers });
|
|
1062
|
+
const ix = loot.stakeTokensIx({ player: wallet.address, project, id: f.id, farm, amount: value, tier, shown, ...token });
|
|
1063
|
+
const harvest = harvestFirst ? [...token.before, loot.harvestIx({ player: wallet.address, project, id: f.id, farm, ...token })] : [];
|
|
1064
|
+
// A new tier on a seat with a booster that grows: its growth so far is written first, in the same transaction.
|
|
1065
|
+
const seat = farm.growth.length ? await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), wallet.address)) : null;
|
|
1066
|
+
const before = [];
|
|
1067
|
+
if (seat && seat.tier !== tier && seat.asset !== NOBODY) {
|
|
1068
|
+
const { before: proof, ...nft } = await nftOf(seat.asset);
|
|
1069
|
+
before.push(...proof, loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by: wallet.address, seat, ...nft }));
|
|
1070
|
+
}
|
|
1071
|
+
return send([...before, ...harvest, ...withSolWrap(wallet.address, mint, value, [ix])]);
|
|
1072
|
+
},
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Takes `amount` out once its lock has run (0: only rewards); `forfeit` gives up rewards that cannot move. Before the
|
|
1076
|
+
* lock, a tier with an early exit lets it leave for its fee, and gives up what the seat is owed; in a farm that
|
|
1077
|
+
* unbonds, what is taken out waits first (`withdraw` takes it out once free).
|
|
1078
|
+
*/
|
|
1079
|
+
async unstake(farmKey, amount = 0, { forfeit = false, extra, transfers, ...others } = NO_OPTIONS) {
|
|
1080
|
+
only(others, ['forfeit', 'extra', 'transfers'], 'unstake');
|
|
1081
|
+
const f = part('farms', farmKey);
|
|
1082
|
+
const farm = await read('Farm', loot.pda.farm(project, f.id));
|
|
1083
|
+
const value = units(amount, decimalsOf(f.stakes));
|
|
1084
|
+
const mints = [...(value > 0n ? [farm.farmed.Tokens.mint] : []), ...(!forfeit ? [farm.rewards_mint] : [])];
|
|
1085
|
+
const token = await farmTokens(mints, mints, { extra, transfers });
|
|
1086
|
+
return send([...token.before, loot.unstakeTokensIx({ player: wallet.address, project, id: f.id, farm, amount: value, forfeit, ...token })]);
|
|
1087
|
+
},
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Stakes one of the player's NFTs in a farm (its own seat, or as the booster of their token seat), of any kind: a
|
|
1091
|
+
* Core asset, a Token Metadata NFT's mint (legacy or pNFT), or a compressed NFT's asset id. In a farm that watches,
|
|
1092
|
+
* an NFT whose last staker sold it takes their seat over. A farm with an end takes none at its end or after it.
|
|
1093
|
+
*/
|
|
1094
|
+
async stakeNft(farmKey, asset, tier = 0, { extra, transfers, ...others } = NO_OPTIONS) {
|
|
1095
|
+
only(others, ['extra', 'transfers'], 'stakeNft');
|
|
1096
|
+
const f = part('farms', farmKey);
|
|
1097
|
+
const farm = await read('Farm', loot.pda.farm(project, f.id));
|
|
1098
|
+
await openFor(f, farm, tier);
|
|
1099
|
+
const token = await farmTokens([farm.rewards_mint], [], { extra, transfers });
|
|
1100
|
+
const { before, ...nft } = await nftOf(asset);
|
|
1101
|
+
const seat = farm.custody === 'Watch' && farm.farmed === 'Collectibles' ? await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), asset)) : null;
|
|
1102
|
+
const previous = seat && seat.player !== wallet.address ? seat.player : null;
|
|
1103
|
+
// A booster boosts a stake: a farm of tokens takes one only on a seat with tokens staked (NotFarmed). Putting
|
|
1104
|
+
// one on starts the lock again, so on a tier whose rewards wait for its lock what the seat may take now is
|
|
1105
|
+
// harvested first, in the same transaction (HarvestFirst).
|
|
1106
|
+
const harvest = [];
|
|
1107
|
+
if (farm.farmed.Tokens) {
|
|
1108
|
+
const mine = await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), wallet.address));
|
|
1109
|
+
if (!mine || BigInt(mine.amount) === 0n) throw new Error(`Stake some ${f.stakes} in “${f.name ?? f.key}” first: a booster boosts a stake.`);
|
|
1110
|
+
const t = farm.tiers[mine.tier];
|
|
1111
|
+
if (Number(t.exit_bps) > 0 && (await chainNow()) >= BigInt(mine.since) + BigInt(t.lock)) {
|
|
1112
|
+
const rewards = await farmTokens([farm.rewards_mint], [farm.rewards_mint], { extra, transfers });
|
|
1113
|
+
harvest.push(...rewards.before, loot.harvestIx({ player: wallet.address, project, id: f.id, farm, ...rewards }));
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
// Its way out must land too: its unstake from this tier, with the tier's
|
|
1117
|
+
// prize and as a forfeit, is sized before it goes in, and an NFT that could never come back out isn't staked.
|
|
1118
|
+
if (!farm.farmed.Tokens) await exitFits(f, farm, asset, nft, before, tier, token);
|
|
1119
|
+
return send([...before, ...harvest, loot.stakeNftIx({ player: wallet.address, project, id: f.id, farm, asset, ...nft, previous, tier, ...token })]);
|
|
1120
|
+
},
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Releases the collectible after its lock: it grows on its way out (a farm with `growth`), and from its own seat
|
|
1124
|
+
* takes its tier's prize (a mission: `prize.rs`). `forfeit` gives up rewards, and the prize, that cannot move.
|
|
1125
|
+
* Returns the signature, or { signature, roll } when it makes a roll (a mission's result on the NFT, or a chest),
|
|
1126
|
+
* which `collect` then finishes.
|
|
1127
|
+
*/
|
|
1128
|
+
async unstakeNft(farmKey, asset, { forfeit = false, extra, transfers, ...others } = NO_OPTIONS) {
|
|
1129
|
+
only(others, ['forfeit', 'extra', 'transfers'], 'unstakeNft');
|
|
1130
|
+
const f = part('farms', farmKey);
|
|
1131
|
+
const farm = await read('Farm', loot.pda.farm(project, f.id));
|
|
1132
|
+
const token = await farmTokens(forfeit ? [] : [farm.rewards_mint], forfeit || farm.farmed.Tokens ? [] : [farm.rewards_mint], { extra, transfers });
|
|
1133
|
+
// Held in escrow, it comes back into the player's account (frozen in place, it never left it).
|
|
1134
|
+
const { before, ...nft } = await nftOf(asset, wallet.address, { to: farm.custody === 'Escrow' ? wallet.address : undefined });
|
|
1135
|
+
// Leaving a mission that rolls: its roll is made in an order of the player's, which `collect` then finishes; its
|
|
1136
|
+
// prize's roll (a chest) at the next nonce.
|
|
1137
|
+
const seat = await read('Seat', loot.pda.seat(loot.pda.farm(project, f.id), asset));
|
|
1138
|
+
const roll = farm.tiers[seat.tier]?.roll ?? null;
|
|
1139
|
+
const prize = farm.tiers[seat.tier]?.prize ?? null;
|
|
1140
|
+
// A forfeit takes no prize: none of its accounts (`unstakeNftIx`).
|
|
1141
|
+
const given = await prizeInputs([forfeit ? null : prize]);
|
|
1142
|
+
// A mission's roll on the NFT is at the NFT's nonce (`nonceOn`): one at a time on it.
|
|
1143
|
+
const nonce = roll != null ? loot.nonceOn(asset) : freshNonce();
|
|
1144
|
+
const ix = loot.unstakeNftIx({ player: wallet.address, project, id: f.id, farm, asset, ...nft, forfeit, roll, nonce, tier: seat.tier, projectCollection: given.collection, poolMints: given.poolMints, items: given.items, nft: given.nft, ...token });
|
|
1145
|
+
const signature = await send([...before, ...given.before, ...token.before, ix]);
|
|
1146
|
+
if (roll != null) return { signature, roll: loot.pda.order(project, wallet.address, nonce) };
|
|
1147
|
+
return prize?.Roll && !forfeit ? { signature, roll: loot.pda.order(project, wallet.address, loot.prizeRollNonce(nonce + 1n)) } : signature;
|
|
1148
|
+
},
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Writes what a staked collectible has grown (a farm with `growth`: its points and level, on it or in its Sheet)
|
|
1152
|
+
* and reads its boosts again. Anyone may send it; the player pays for what the write adds. `by`: the collectible,
|
|
1153
|
+
* or the player for their token seat's booster.
|
|
1154
|
+
*/
|
|
1155
|
+
async refresh(farmKey, by, { extra, transfers, ...others } = NO_OPTIONS) {
|
|
1156
|
+
only(others, ['extra', 'transfers'], 'refresh');
|
|
1157
|
+
const f = part('farms', farmKey);
|
|
1158
|
+
const farm = await read('Farm', loot.pda.farm(project, f.id));
|
|
1159
|
+
const seat = await read('Seat', loot.pda.seat(loot.pda.farm(project, f.id), by));
|
|
1160
|
+
const held = seat.asset !== NOBODY;
|
|
1161
|
+
const { before, ...nft } = held ? await nftOf(seat.asset) : { before: [], hasAuthority: true };
|
|
1162
|
+
// Like every move, it brings the farm's rewards in (and sends back what a seat gave up): the mint's hook accounts.
|
|
1163
|
+
const token = await farmTokens([farm.rewards_mint], [], { extra, transfers });
|
|
1164
|
+
return send([...before, loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by, seat, ...nft, extra: token.extra })]);
|
|
1165
|
+
},
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Collects what a seat owes (the player's own, or one of their collectibles' seats). In a farm that watches, it
|
|
1169
|
+
* shows the seat's NFT is still theirs: the Core asset, or their token account of a Token Metadata NFT.
|
|
1170
|
+
*/
|
|
1171
|
+
async harvest(farmKey, by = wallet.address, { compound = false, extra, transfers, ...others } = NO_OPTIONS) {
|
|
1172
|
+
only(others, ['compound', 'extra', 'transfers'], 'harvest');
|
|
1173
|
+
const f = part('farms', farmKey);
|
|
1174
|
+
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
|
+
const token = await farmTokens([farm.rewards_mint], [farm.rewards_mint], { extra, transfers });
|
|
1183
|
+
// In a farm that watches: what shows the seat's NFT is still the player's (a compressed one proven first).
|
|
1184
|
+
let [held, tree, before] = [null, null, []];
|
|
1185
|
+
if (farm.custody === 'Watch') {
|
|
1186
|
+
const { asset } = await read('Seat', loot.pda.seat(loot.pda.farm(project, f.id), by));
|
|
1187
|
+
if (asset !== NOBODY) {
|
|
1188
|
+
const nft = await nftOf(asset);
|
|
1189
|
+
held = nft.compressed ? loot.pda.leaf(asset) : nft.tokenMetadata ? loot.ata(wallet.address, asset) : asset;
|
|
1190
|
+
tree = nft.compressed?.tree ?? null;
|
|
1191
|
+
before = nft.compressed ? nft.before : [];
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
return send([...before, ...token.before, loot.harvestIx({ player: wallet.address, project, id: f.id, farm, by, held, tree, compound, ...token })]);
|
|
1195
|
+
},
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* Adds what a token seat is owed to its stake, without restarting its lock (a farm of tokens whose rewards are the
|
|
1199
|
+
* staked token). The farm's toll, if any, is taken first.
|
|
1200
|
+
*/
|
|
1201
|
+
async compound(farmKey, opts = {}) {
|
|
1202
|
+
only(opts, ['extra', 'transfers'], 'compound');
|
|
1203
|
+
return this.harvest(farmKey, wallet.address, { ...opts, compound: true });
|
|
1204
|
+
},
|
|
1205
|
+
|
|
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
|
+
/**
|
|
1213
|
+
* 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
|
+
* no booster), what it holds staked (`amount`, in whole units of the staked token, a number for display that can
|
|
1215
|
+
* round past 2^53 base units; `amountUnits`, in base units exactly, a bigint; 1 for an NFT), its tier, when it
|
|
1216
|
+
* came in (`since`) and when its lock is over (`freeAt`, unix seconds; null when the NFT says it, `lockKey`), its
|
|
1217
|
+
* boost in percent, and what it is owed now: `waiting` (whole units of the rewards, `rewards` their token) and
|
|
1218
|
+
* `owedUnits` (base units). What it is owed is the program's own sum, from a refresh of the seat dry-run on the
|
|
1219
|
+
* network (nothing is signed or sent): what the farm released or its rate paid since its last move is in it, by the
|
|
1220
|
+
* farm's rounding. When the dry run fails, it is what the seat was last credited, and `counted` is false.
|
|
1221
|
+
* One search of the chain, and one dry run per seat: read it when the page opens and after a stake or a harvest.
|
|
1222
|
+
*/
|
|
1223
|
+
async staked(farmKey, player = wallet?.address) {
|
|
1224
|
+
if (!player) throw new Error('Connect a wallet first.');
|
|
1225
|
+
const f = part('farms', farmKey);
|
|
1226
|
+
const farmAddress = loot.pda.farm(project, f.id);
|
|
1227
|
+
const farm = await read('Farm', farmAddress);
|
|
1228
|
+
const rewards = [...tokens.values()].find((t) => mintOf(t.key) === farm.rewards_mint);
|
|
1229
|
+
const nfts = f.stakes === 'collectibles';
|
|
1230
|
+
const found = await client.programAccounts(loot.LOOT_PROGRAM, [
|
|
1231
|
+
{ memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS.Seat)) } },
|
|
1232
|
+
{ memcmp: { offset: 1, bytes: farmAddress } },
|
|
1233
|
+
{ memcmp: { offset: 33, bytes: player } },
|
|
1234
|
+
]);
|
|
1235
|
+
const token = await farmTokens([farm.rewards_mint], []).catch(() => ({ extra: [] }));
|
|
1236
|
+
const { blockhash } = await client.latestBlockhash();
|
|
1237
|
+
// Who the dry run names as the refresh's signer and fee payer: the network wants one that holds SOL, whoever it is
|
|
1238
|
+
// (nothing is signed): the player, else the wallet looking, else the project's authority.
|
|
1239
|
+
let payer;
|
|
1240
|
+
const funded = async () => {
|
|
1241
|
+
for (const a of [player, wallet?.address, live.authority].filter(Boolean)) if ((await client.balance(a).catch(() => 0n)) > 0n) return a;
|
|
1242
|
+
return player;
|
|
1243
|
+
};
|
|
1244
|
+
const out = [];
|
|
1245
|
+
for (const { pubkey, account } of found) {
|
|
1246
|
+
let seat = loot.decode('Seat', account.data);
|
|
1247
|
+
const held = seat.asset !== NOBODY;
|
|
1248
|
+
let counted = false;
|
|
1249
|
+
try {
|
|
1250
|
+
payer ??= await funded();
|
|
1251
|
+
const { before, ...nft } = held ? await nftOf(seat.asset, payer) : { before: [] };
|
|
1252
|
+
const by = nfts ? seat.asset : player;
|
|
1253
|
+
const ix = loot.refreshSeatIx({ signer: payer, project, id: f.id, farm, by, seat, ...nft, extra: token.extra });
|
|
1254
|
+
const message = compileMessage({ payer, blockhash, instructions: [...before, ix] });
|
|
1255
|
+
const after = (await client.simulate(wireTransaction(message), { accounts: [pubkey], replaceBlockhash: true })).accounts[0];
|
|
1256
|
+
if (after?.data) [seat, counted] = [loot.decode('Seat', after.data), true];
|
|
1257
|
+
} catch {
|
|
1258
|
+
// The seat as it was last credited.
|
|
1259
|
+
}
|
|
1260
|
+
const lock = BigInt(farm.tiers[seat.tier]?.lock ?? 0);
|
|
1261
|
+
out.push({
|
|
1262
|
+
asset: held ? seat.asset : null,
|
|
1263
|
+
amount: nfts ? 1 : Number(show(seat.amount, decimalsOf(f.stakes))),
|
|
1264
|
+
amountUnits: nfts ? 1n : BigInt(seat.amount),
|
|
1265
|
+
tier: seat.tier,
|
|
1266
|
+
since: Number(seat.since),
|
|
1267
|
+
freeAt: farm.lock_key ? null : Number(BigInt(seat.since) + lock),
|
|
1268
|
+
...(farm.lock_key ? { lockKey: farm.lock_key } : {}),
|
|
1269
|
+
boostPercent: Number(seat.boost_bps) / 100,
|
|
1270
|
+
rewards: rewards?.key ?? null,
|
|
1271
|
+
waiting: show(seat.owed, rewards ? decimalsOf(rewards.key) : 0),
|
|
1272
|
+
owedUnits: BigInt(seat.owed),
|
|
1273
|
+
counted,
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
return out;
|
|
1277
|
+
},
|
|
1278
|
+
|
|
1279
|
+
/** The recipes: whether each is on, and its version now (what `craft` takes as the terms the player was shown). */
|
|
1280
|
+
async recipes() {
|
|
1281
|
+
const list = spec.recipes ?? [];
|
|
1282
|
+
const accounts = await client.accounts(list.map((r) => loot.pda.recipe(project, r.id)));
|
|
1283
|
+
return list.map((r, i) => {
|
|
1284
|
+
const recipe = accounts[i] && loot.decode('Recipe', accounts[i].data);
|
|
1285
|
+
return { key: r.key, name: r.name, active: Boolean(recipe?.active), version: recipe ? recipe.version : null };
|
|
1286
|
+
});
|
|
1287
|
+
},
|
|
1288
|
+
|
|
1289
|
+
/**
|
|
1290
|
+
* Crafts a recipe, handing in `assets` (the collectibles it burns or uses, in the recipe's order). `version`: the
|
|
1291
|
+
* recipe's version the page showed the player (`recipes()`, or the listing itself as `listing`), so a recipe the
|
|
1292
|
+
* 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.
|
|
1294
|
+
*/
|
|
1295
|
+
async craft(recipeKey, assets = [], times = 1, { version, listing, ...others } = NO_OPTIONS) {
|
|
1296
|
+
only(others, ['version', 'listing'], 'craft');
|
|
1297
|
+
const r = part('recipes', recipeKey);
|
|
1298
|
+
version ??= listing?.version ?? undefined;
|
|
1299
|
+
if (version === undefined || version === null) throw new Error(`Say which version of “${r.name ?? recipeKey}” the player was shown: pass the listing recipes() showed (listing), or its version. The version read now is never signed in its place.`);
|
|
1300
|
+
const current = await read('Recipe', loot.pda.recipe(project, r.id));
|
|
1301
|
+
const recipe = { ...current, version };
|
|
1302
|
+
await poolShareGiven(recipe.outputs.filter((o) => o.Prize).map((o) => o.Prize[0] ?? o.Prize));
|
|
1303
|
+
const pays = recipe.inputs.filter((i) => i.Pay).map((i) => i.Pay);
|
|
1304
|
+
const holders = await holdersOf(pays.map((p) => ({ to: p.to })));
|
|
1305
|
+
const treasury = (await projectNow()).treasury;
|
|
1306
|
+
// An NFT a `show` line takes, by its address: as the project's, a Core asset of another collection, or a Token
|
|
1307
|
+
// Metadata mint, which the craft reads differently.
|
|
1308
|
+
// Where the collectibles of input `i` start among those handed in.
|
|
1309
|
+
const at = (i) => recipe.inputs.slice(0, i).reduce((n, x) => n + (x.Burn?.count ?? (x.Use || x.Renew || x.Show || x.Staked || x.Trade ? 1 : 0)), 0);
|
|
1310
|
+
const shownAt = new Set(recipe.inputs.flatMap((input, i) => (input.Show ? [at(i)] : [])));
|
|
1311
|
+
// An NFT a `trade` line takes in, any kind: read for its kind (a compressed one proven first).
|
|
1312
|
+
const tradedAt = new Set(recipe.inputs.flatMap((input, i) => (input.Trade ? [at(i)] : [])));
|
|
1313
|
+
const proving = [];
|
|
1314
|
+
// A collectible used or renewed that is staked in escrow, in the project's hands: its farm, found by its seat.
|
|
1315
|
+
const keptAt = new Set(recipe.inputs.flatMap((input, i) => (input.Use || input.Renew ? [at(i)] : [])));
|
|
1316
|
+
// One a `staked` line takes: on its seat in the farm the line names, escrowed or frozen there.
|
|
1317
|
+
const stakedAt = new Map(recipe.inputs.flatMap((input, i) => (input.Staked ? [[at(i), input.Staked.farm]] : [])));
|
|
1318
|
+
const handed = await Promise.all(assets.map(async (nft, i) => {
|
|
1319
|
+
if (stakedAt.has(i) && typeof nft === 'string') {
|
|
1320
|
+
const farm = stakedAt.get(i);
|
|
1321
|
+
if (!(await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, farm), nft)))) {
|
|
1322
|
+
const name = (spec.farms ?? []).find((f) => f.id === farm)?.name ?? `farm ${farm}`;
|
|
1323
|
+
throw new Error(`${nft} is not staked in “${name}”, where this recipe takes it.`);
|
|
1324
|
+
}
|
|
1325
|
+
return { asset: nft, farm };
|
|
1326
|
+
}
|
|
1327
|
+
if (keptAt.has(i) && typeof nft === 'string') return (await escrowedIn(nft)) ?? nft;
|
|
1328
|
+
if (tradedAt.has(i) && typeof nft === 'string') {
|
|
1329
|
+
const { before, ...kind } = await nftOf(nft);
|
|
1330
|
+
proving.push(...before);
|
|
1331
|
+
return { asset: nft, ...kind };
|
|
1332
|
+
}
|
|
1333
|
+
if (!shownAt.has(i) || typeof nft !== 'string') return nft;
|
|
1334
|
+
const account = await client.account(nft);
|
|
1335
|
+
if (account?.owner !== MPL_CORE) return { mint: nft, owner: wallet.address };
|
|
1336
|
+
const theirs = await collectionOf(nft);
|
|
1337
|
+
return ours(theirs) ? nft : { asset: nft, collection: theirs };
|
|
1338
|
+
}));
|
|
1339
|
+
// A recipe that rolls makes its roll in an order of the player's, which `collect` then finishes.
|
|
1340
|
+
const rolls = recipe.outputs.some((o) => o.RollOn || o.Prize?.[0]?.Roll);
|
|
1341
|
+
// A roll on a collectible kept is at that collectible's nonce (`nonceOn`): one at a time on it.
|
|
1342
|
+
const rollOn = recipe.outputs.find((o) => o.RollOn)?.RollOn;
|
|
1343
|
+
const nonce = rollOn ? loot.nonceOn(typeof assets[rollOn.place] === 'string' ? assets[rollOn.place] : assets[rollOn.place].asset) : freshNonce();
|
|
1344
|
+
// A roll on a kept collectible (`RollOn`) passes its box's prize mints as a roll given does.
|
|
1345
|
+
const given = await prizeInputs(recipe.outputs.map((o) => o.Prize?.[0] ?? (o.RollOn ? { Roll: [o.RollOn.box_id] } : null)));
|
|
1346
|
+
// Each item a line takes passes its collection.
|
|
1347
|
+
const items = { ...given.items };
|
|
1348
|
+
for (const input of recipe.inputs) {
|
|
1349
|
+
const id = (input.Burn ?? input.Use ?? input.Renew ?? input.Staked)?.item;
|
|
1350
|
+
if (id !== undefined && !(id in items)) items[id] = await read('Item', loot.pda.item(project, id));
|
|
1351
|
+
}
|
|
1352
|
+
// The collectibles it makes, each at its item's next serial.
|
|
1353
|
+
const counts = {};
|
|
1354
|
+
const made = recipe.outputs.flatMap((o) => {
|
|
1355
|
+
const id = o.Prize?.[0]?.Item?.[0];
|
|
1356
|
+
if (id === undefined || items[id]?.kind !== 'Collectible') return [];
|
|
1357
|
+
counts[id] = (counts[id] ?? 0) + 1;
|
|
1358
|
+
return [loot.pda.itemAsset(loot.pda.item(project, id), Number(items[id].issued) + counts[id])];
|
|
1359
|
+
});
|
|
1360
|
+
const before = [...proving, ...given.before].filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM);
|
|
1361
|
+
let instructions = [computeUnitLimit(1_000_000), ...before, loot.craftIx({ player: wallet.address, project, collection: collections[0], id: r.id, recipe, assets: handed, times, nonce, holders, programs, treasury, poolMints: given.poolMints, items, nfts: given.nfts, boxes: given.boxes, rollPools: given.rollPools })];
|
|
1362
|
+
// Fuel bought for a staked collectible counts once its seat is refreshed: in the same transaction.
|
|
1363
|
+
for (const raise of recipe.raises ?? []) {
|
|
1364
|
+
const asset = assets[raise.place];
|
|
1365
|
+
if (typeof asset !== 'string') continue;
|
|
1366
|
+
for (const f of (spec.farms ?? []).filter((x) => x.upkeepAttribute === raise.key)) {
|
|
1367
|
+
const seat = await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), asset));
|
|
1368
|
+
if (!seat) continue;
|
|
1369
|
+
const farm = await read('Farm', loot.pda.farm(project, f.id));
|
|
1370
|
+
const { before, ...nft } = await nftOf(asset);
|
|
1371
|
+
instructions.push(...before, loot.refreshSeatIx({ signer: wallet.address, project, id: f.id, farm, by: asset, seat, ...nft }));
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
for (const p of pays) instructions = withSolWrap(wallet.address, p.mint, p.amount * BigInt(times), instructions);
|
|
1375
|
+
const signature = await send(instructions);
|
|
1376
|
+
// A roll on a collectible kept is at its nonce; a prize's roll at `prizeRollNonce` of the craft's.
|
|
1377
|
+
return { signature, made, ...(rolls ? { roll: loot.pda.order(project, wallet.address, rollOn ? nonce : loot.prizeRollNonce(nonce)) } : {}) };
|
|
1378
|
+
},
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* Hands an NFT of any kind to the project until a time (a hold): `seconds` from now, or `{ until }` (Unix seconds).
|
|
1382
|
+
* The hold's address names its end. Returns { signature, freeAt }.
|
|
1383
|
+
*/
|
|
1384
|
+
async hold(asset, seconds) {
|
|
1385
|
+
const freeAt = BigInt(typeof seconds === 'object' ? seconds.until : now() + Number(seconds));
|
|
1386
|
+
const { before, hasAuthority: _, ...nft } = await nftOf(asset);
|
|
1387
|
+
const signature = await send([...before, loot.holdIx({ player: wallet.address, project, asset, ...nft, collection: nft.collection ?? (await collectionOf(asset)) ?? MPL_CORE, freeAt })]);
|
|
1388
|
+
return { signature, freeAt };
|
|
1389
|
+
},
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* A lender's offer to borrow against: `maker`'s offer number `id` in this project. Returns { address, offer, mint,
|
|
1393
|
+
* amount, repay, lasts, risks }: `risks`, in words, each power over the loan's token that could make repaying fail
|
|
1394
|
+
* (its freeze authority, a Token-2022 pause, a transfer hook, a permanent delegate) and who holds it. The program
|
|
1395
|
+
* takes such tokens, and a lender who holds one can stop every repayment and take the NFT once it is due: show the
|
|
1396
|
+
* risks before the player borrows.
|
|
1397
|
+
*/
|
|
1398
|
+
async loanOffer(maker, id) {
|
|
1399
|
+
const address = loot.pda.offer(project, maker, id);
|
|
1400
|
+
const offer = await read('Offer', address);
|
|
1401
|
+
if (!offer.deal.Lend) throw new Error(`The offer ${address} buys NFTs; it lends nothing.`);
|
|
1402
|
+
const account = await client.account(offer.mint);
|
|
1403
|
+
if (!account) throw new Error(`The loan's token ${offer.mint} is not on this cluster.`);
|
|
1404
|
+
const lender = (who) => (who === maker ? ' That is the lender: they could keep you from repaying and take your NFT when the loan is due.' : '');
|
|
1405
|
+
const risks = mintPowers(account).map((p) => {
|
|
1406
|
+
if (p.power === 'freeze') return { ...p, says: `${p.holder} can freeze accounts of the loan's token, which makes repaying fail.${lender(p.holder)}` };
|
|
1407
|
+
if (p.power === 'pause') return { ...p, says: `${p.holder ?? 'Its pause authority'} can pause every transfer of the loan's token, which makes repaying fail${p.paused ? ' (it is paused now)' : ''}.${lender(p.holder)}` };
|
|
1408
|
+
if (p.power === 'hook') return { ...p, says: `Every transfer of the loan's token runs the program ${p.holder}${p.authority ? `, which ${p.authority} can change` : ''}; it can refuse a repayment.${lender(p.authority)}` };
|
|
1409
|
+
return { ...p, says: `${p.holder} can move anyone's tokens of the loan's token, the ones you would repay with too.${lender(p.holder)}` };
|
|
1410
|
+
});
|
|
1411
|
+
const { amount, repay, lasts } = offer.deal.Lend;
|
|
1412
|
+
return { address, offer, mint: offer.mint, program: account.owner, amount, repay, lasts, risks };
|
|
1413
|
+
},
|
|
1414
|
+
|
|
1415
|
+
/**
|
|
1416
|
+
* Borrows against the player's NFT `asset` from `maker`'s offer `id`: the NFT is held until the loan is repaid, or
|
|
1417
|
+
* goes to the lender once it is due (`due`, Unix seconds; by default the longest the offer allows, less a minute).
|
|
1418
|
+
* A loan whose token someone could freeze, pause, hook or move (`loanOffer`'s `risks`) is refused unless
|
|
1419
|
+
* `acceptRisks` is true: the page shows the player who holds that power first. `extra`: a transfer hook's accounts.
|
|
1420
|
+
*/
|
|
1421
|
+
async borrow(maker, id, asset, { due, acceptRisks = false, extra = [], ...others } = NO_OPTIONS) {
|
|
1422
|
+
only(others, ['due', 'acceptRisks', 'extra'], 'borrow');
|
|
1423
|
+
const loan = await this.loanOffer(maker, id);
|
|
1424
|
+
if (loan.risks.length && acceptRisks !== true) throw new Error(`Repaying this loan can be blocked. ${loan.risks.map((r) => r.says).join(' ')} Borrow only if the player accepts this: pass acceptRisks: true.`);
|
|
1425
|
+
const lasts = BigInt(loan.lasts);
|
|
1426
|
+
due = BigInt(due ?? BigInt(now()) + lasts - (lasts > 120n ? 60n : 0n));
|
|
1427
|
+
const { before, ...nft } = await nftOf(asset);
|
|
1428
|
+
const collection = nft.collection ?? (await collectionOf(asset)) ?? MPL_CORE;
|
|
1429
|
+
const loanPrograms = { ...programs, [loan.mint]: loan.program };
|
|
1430
|
+
const ix = loot.borrowIx({ borrower: wallet.address, project, id, offer: loan.offer, due, asset, ...nft, collection, programs: loanPrograms, extra });
|
|
1431
|
+
const signature = await send([...before, createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, loan.mint, loan.program), ix]);
|
|
1432
|
+
return { signature, due, hold: loot.pda.hold(asset, due) };
|
|
1433
|
+
},
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* Backs `side` of contest number `id` (the authority opens contests while the project runs) with `amount`; a contest
|
|
1437
|
+
* of NFTs takes one of the player's NFTs that passes its rule instead (`nft`, its address), held until it is
|
|
1438
|
+
* claimed. `listProof`: for a list gate.
|
|
1439
|
+
*/
|
|
1440
|
+
async enter(id, side, amount, { shown, listProof, nft: staked, ...others } = NO_OPTIONS) {
|
|
1441
|
+
only(others, ['shown', 'listProof', 'nft'], 'enter');
|
|
1442
|
+
const contest = await read('Contest', loot.pda.contest(project, id));
|
|
1443
|
+
shown ??= await passing(contest.gate, 'enter');
|
|
1444
|
+
listProof = listProofOf(contest.gate, null, listProof);
|
|
1445
|
+
if (contest.nft) {
|
|
1446
|
+
if (!staked) throw new Error('This contest takes an NFT: say which (nft).');
|
|
1447
|
+
const { before, ...kind } = await nftOf(staked);
|
|
1448
|
+
return send([...before, loot.enterIx({ player: wallet.address, project, id, contest, side, amount: 1n, shown, listProof, nft: { asset: staked, ...kind }, programs })]);
|
|
1449
|
+
}
|
|
1450
|
+
const t = [...tokens.values()].find((x) => mintOf(x.key) === contest.mint);
|
|
1451
|
+
const value = units(amount, t?.source === 'sol' ? 9 : t?.decimals ?? 0);
|
|
1452
|
+
return send(withSolWrap(wallet.address, contest.mint, value, [loot.enterIx({ player: wallet.address, project, id, contest, side, amount: value, shown, listProof, programs })]));
|
|
1453
|
+
},
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* The auctions: each one's lot, high bid, the least the next bid must be, when it ends and how long is left, and
|
|
1457
|
+
* whether the player leads. One no longer on chain has ended. `waiting`: SOL that came back to the player's
|
|
1458
|
+
* wrapped-SOL account from a bid that was topped (`unwrap` turns it into plain SOL).
|
|
1459
|
+
*/
|
|
1460
|
+
async auctions() {
|
|
1461
|
+
const list = spec.auctions ?? [];
|
|
1462
|
+
const accounts = await client.accounts(list.map((a) => auctionAt(a)));
|
|
1463
|
+
const at = BigInt(now());
|
|
1464
|
+
const wrapped = wallet?.address ? await client.account(associatedTokenAddress(wallet.address, NATIVE_MINT)) : null;
|
|
1465
|
+
const waiting = wrapped ? show(new DataView(wrapped.data.buffer, wrapped.data.byteOffset).getBigUint64(64, true), 9) : '0';
|
|
1466
|
+
return list.map((a, i) => {
|
|
1467
|
+
const x = accounts[i] && loot.decode('Auction', accounts[i].data);
|
|
1468
|
+
const base = { key: a.key, name: a.name, lot: a.lot, currency: tokens.get(a.currency ?? currencyKey)?.symbol ?? a.currency ?? currencyKey };
|
|
1469
|
+
if (!x) return { ...base, ended: true };
|
|
1470
|
+
// Bids of NFTs count NFTs.
|
|
1471
|
+
const dec = x.nft_bids ? 0 : decimalsOf(a.currency);
|
|
1472
|
+
const ends = loot.auctionEnds(x);
|
|
1473
|
+
const bid = x.high > 0n;
|
|
1474
|
+
const nextBid = loot.leastBid(x);
|
|
1475
|
+
return {
|
|
1476
|
+
...base,
|
|
1477
|
+
ended: false,
|
|
1478
|
+
reserve: show(x.reserve, dec),
|
|
1479
|
+
high: bid ? show(x.high, dec) : null,
|
|
1480
|
+
highBidder: bid ? x.high_bidder : null,
|
|
1481
|
+
leading: bid && x.high_bidder === wallet?.address,
|
|
1482
|
+
nextBid: nextBid === null ? null : show(nextBid, dec),
|
|
1483
|
+
nextBidUnits: nextBid,
|
|
1484
|
+
startsAt: Number(x.starts_at),
|
|
1485
|
+
endsAt: Number(ends),
|
|
1486
|
+
secondsLeft: Number(ends > at ? ends - at : 0n),
|
|
1487
|
+
open: nextBid !== null && at >= x.starts_at && at < ends,
|
|
1488
|
+
over: at >= ends,
|
|
1489
|
+
...(x.nft_bids ? { nftBids: true, held: x.held } : {}),
|
|
1490
|
+
...(tokens.get(a.currency ?? currencyKey)?.source === 'sol' ? { waiting } : {}),
|
|
1491
|
+
};
|
|
1492
|
+
});
|
|
1493
|
+
},
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Bids `amount` (whole units) on an auction. The bid it tops goes back to its bidder in the same transaction
|
|
1497
|
+
* (the program opens their account again if they closed it, paid by this bidder). In SOL, only what the player's
|
|
1498
|
+
* wrapped-SOL account does not hold already is wrapped, and the account stays open: if this bid is topped, it comes
|
|
1499
|
+
* back there.
|
|
1500
|
+
*/
|
|
1501
|
+
async bid(auctionKey, amount, { shown, listProof, nfts, ...others } = NO_OPTIONS) {
|
|
1502
|
+
only(others, ['shown', 'listProof', 'nfts'], 'bid');
|
|
1503
|
+
const a = part('auctions', auctionKey);
|
|
1504
|
+
const auction = await read('Auction', auctionAt(a));
|
|
1505
|
+
shown ??= await passing(auction.gate, 'bid');
|
|
1506
|
+
listProof = listProofOf(auction.gate, a.gate?.list, listProof);
|
|
1507
|
+
// An auction of NFT bids: a bid is a bundle of the player's NFTs (`nfts`, their addresses, or `amount` read as
|
|
1508
|
+
// them), and the bid it tops goes back to its bidder in the same transaction.
|
|
1509
|
+
if (auction.nft_bids) {
|
|
1510
|
+
const bundle = nfts ?? (Array.isArray(amount) ? amount : null);
|
|
1511
|
+
if (!bundle?.length) throw new Error('This auction takes NFTs as bids: say which (nfts).');
|
|
1512
|
+
const before = [];
|
|
1513
|
+
const kindOf = async (asset, to) => {
|
|
1514
|
+
const { before: proof, ...kind } = await nftOf(asset, wallet.address, { to });
|
|
1515
|
+
before.push(...proof);
|
|
1516
|
+
return { asset, ...kind };
|
|
1517
|
+
};
|
|
1518
|
+
const bid = [];
|
|
1519
|
+
for (const asset of bundle) bid.push(await kindOf(asset));
|
|
1520
|
+
// The bid it tops goes back to its bidder: into a fresh account of theirs when their own can't take it.
|
|
1521
|
+
const held = [];
|
|
1522
|
+
for (const asset of auction.held) held.push(await kindOf(asset, auction.high_bidder));
|
|
1523
|
+
const ix = loot.bidIx({ bidder: wallet.address, project, id: a.id, auction, shown, listProof, nfts: bid, held, programs });
|
|
1524
|
+
return send([computeUnitLimit(800_000), ...before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), ix]);
|
|
1525
|
+
}
|
|
1526
|
+
const mint = auction.mint;
|
|
1527
|
+
const value = units(amount, decimalsOf(a.currency));
|
|
1528
|
+
const instructions = [];
|
|
1529
|
+
if (mint === NATIVE_MINT) {
|
|
1530
|
+
const wrapped = associatedTokenAddress(wallet.address, NATIVE_MINT);
|
|
1531
|
+
const have = await client.account(wrapped);
|
|
1532
|
+
const held = have ? new DataView(have.data.buffer, have.data.byteOffset).getBigUint64(64, true) : 0n;
|
|
1533
|
+
instructions.push(createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, NATIVE_MINT));
|
|
1534
|
+
if (value > held) instructions.push(transferLamports(wallet.address, wrapped, value - held), syncNative(wrapped));
|
|
1535
|
+
}
|
|
1536
|
+
// A Token-2022 account can ask for a memo on every transfer into it: the Memo program rides along.
|
|
1537
|
+
const memo = programOf(mint) === TOKEN_2022_PROGRAM ? [{ pubkey: MEMO_PROGRAM, isSigner: false, isWritable: false }] : [];
|
|
1538
|
+
instructions.push(loot.bidIx({ bidder: wallet.address, project, id: a.id, auction, amount: value, shown, listProof, programs, extra: memo }));
|
|
1539
|
+
return send(instructions, [], { keepWrapped: true });
|
|
1540
|
+
},
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* Ends an auction that is over (anyone can; the winner usually does): the lot to the highest bidder, a new
|
|
1544
|
+
* 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.
|
|
1546
|
+
*/
|
|
1547
|
+
async endAuction(auctionKey, { holders, ...others } = NO_OPTIONS) {
|
|
1548
|
+
only(others, ['holders'], 'endAuction');
|
|
1549
|
+
const a = part('auctions', auctionKey);
|
|
1550
|
+
const auction = await read('Auction', auctionAt(a));
|
|
1551
|
+
const g = await projectNow();
|
|
1552
|
+
const sold = auction.high > 0n;
|
|
1553
|
+
const item = auction.lot.Item ? await read('Item', loot.pda.item(project, auction.lot.Item[0])) : null;
|
|
1554
|
+
const made = sold && item?.kind === 'Collectible' ? loot.pda.itemAsset(loot.pda.item(project, auction.lot.Item[0]), auction.serial) : null;
|
|
1555
|
+
const memo = programOf(auction.mint) === TOKEN_2022_PROGRAM ? [{ pubkey: MEMO_PROGRAM, isSigner: false, isWritable: false }] : [];
|
|
1556
|
+
holders ??= sold ? await holdersOf(auction.shares) : {};
|
|
1557
|
+
const lot = auction.lot.Asset?.[0];
|
|
1558
|
+
// The lot goes to its winner (back to its seller, unsold); a winning bid of NFTs to whoever the rest of a bid in
|
|
1559
|
+
// tokens would go to: the seller of an NFT, the treasury for a new item.
|
|
1560
|
+
const { before, hasAuthority: _, ...nft } = lot ? await nftOf(lot, wallet.address, { to: sold ? auction.high_bidder : auction.seller }) : { before: [] };
|
|
1561
|
+
const collection = lot && !nft.compressed && !nft.tokenMetadata ? (await collectionOf(lot)) ?? MPL_CORE : undefined;
|
|
1562
|
+
const proceeds = auction.lot.Asset ? auction.seller : g.treasury;
|
|
1563
|
+
const held = [];
|
|
1564
|
+
if (sold && auction.nft_bids) {
|
|
1565
|
+
for (const asset of auction.held) {
|
|
1566
|
+
const { before: proof, ...kind } = await nftOf(asset, wallet.address, { to: proceeds });
|
|
1567
|
+
before.push(...proof);
|
|
1568
|
+
held.push(kind);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
const ix = loot.endAuctionIx({ signer: wallet.address, project, projectState: g, id: a.id, auction, item, ...nft, collection, held, holders, programs, extra: memo });
|
|
1572
|
+
const signature = await send([computeUnitLimit(800_000), ...before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), ix]);
|
|
1573
|
+
// A roll lot sold: the winner's roll, made at the default nonce, waits for anyone to ask it (`collect`).
|
|
1574
|
+
const roll = sold && auction.lot.Roll ? loot.pda.order(project, auction.high_bidder, loot.prizeRollNonce(loot.prizeNonce(auctionAt(a), BigInt(auction.last_bid_at)))) : null;
|
|
1575
|
+
return { signature, ...(sold ? { winner: auction.high_bidder } : {}), ...(made ? { asset: made } : {}), ...(roll ? { order: roll } : {}) };
|
|
1576
|
+
},
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* Runs a schedule that is due (an emission into a pool, a rent or a drip): anyone may, and nothing is paid to
|
|
1580
|
+
* whoever sends it but the network fee. `holders`: asset → holder, for a schedule that pays a collectible's holder.
|
|
1581
|
+
* Returns null when it isn't due yet.
|
|
1582
|
+
*/
|
|
1583
|
+
async runSchedule(scheduleKey, { holders, most = 20, ...others } = NO_OPTIONS) {
|
|
1584
|
+
only(others, ['holders', 'most'], 'runSchedule');
|
|
1585
|
+
const sc = part('schedules', scheduleKey);
|
|
1586
|
+
const address = loot.pda.schedule(project, sc.id);
|
|
1587
|
+
const schedule = await read('Schedule', address);
|
|
1588
|
+
const from = schedule.action.from;
|
|
1589
|
+
// A sealed schedule's vault: past its last run, whatever arrived since is due once more.
|
|
1590
|
+
const vault = from.Vest ? await client.account(loot.pda.vest(address)) : null;
|
|
1591
|
+
const held = vault ? vault.data.subarray(64, 72).reduce((n, b, i) => n | (BigInt(b) << BigInt(8 * i)), 0n) : 0n;
|
|
1592
|
+
// Runs due by the chain's clock, each its own instruction: one too many would fail them all.
|
|
1593
|
+
const due = Math.min(loot.runsDue(schedule, await chainNow(), held), most);
|
|
1594
|
+
if (!due) return null;
|
|
1595
|
+
const poolMint = from.Pool ? encodeBase58((await client.account(loot.pda.pool(project, from.Pool[0]))).data.subarray(0, 32)) : null;
|
|
1596
|
+
holders ??= await holdersOf([{ to: schedule.action.to }]);
|
|
1597
|
+
const treasury = (await projectNow()).treasury;
|
|
1598
|
+
return send(Array.from({ length: due }, () => loot.runScheduleIx({ payer: wallet.address, project, id: sc.id, schedule, poolMint, holders, programs, treasury })));
|
|
1599
|
+
},
|
|
1600
|
+
|
|
1601
|
+
// ---------------------------------------------------------------- listings
|
|
1602
|
+
|
|
1603
|
+
/**
|
|
1604
|
+
* Lists one of the player's NFTs (any kind) for sale at `price` (whole units of `currency`, the economy's by
|
|
1605
|
+
* default), `shares` taken first (a royalty, a platform's cut). The project holds it until it sells or is unlisted.
|
|
1606
|
+
*/
|
|
1607
|
+
async listForSale(asset, { price, currency, shares = [], ...others } = NO_OPTIONS) {
|
|
1608
|
+
only(others, ['price', 'currency', 'shares'], 'listForSale');
|
|
1609
|
+
const mint = mintOf(currency);
|
|
1610
|
+
const { before, ...nft } = await nftOf(asset);
|
|
1611
|
+
return send([...before, loot.openListingIx({ maker: wallet.address, project, asset, mint, deal: { Sell: { price: units(price, decimalsOf(currency)) } }, shares, ...nft })]);
|
|
1612
|
+
},
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Lists one of the player's NFTs for rent: `price` (whole units) a period of `lasts` seconds, a renter taking 1 to
|
|
1616
|
+
* `maxPeriods` at once. Nobody can end a rental early; the next renter takes it once one is over, until it is unlisted.
|
|
1617
|
+
*/
|
|
1618
|
+
async listForRent(asset, { price, lasts, maxPeriods = 1, currency, shares = [], ...others } = NO_OPTIONS) {
|
|
1619
|
+
only(others, ['price', 'lasts', 'maxPeriods', 'currency', 'shares'], 'listForRent');
|
|
1620
|
+
const mint = mintOf(currency);
|
|
1621
|
+
const { before, ...nft } = await nftOf(asset);
|
|
1622
|
+
return send([...before, loot.openListingIx({ maker: wallet.address, project, asset, mint, deal: { Rent: { price: units(price, decimalsOf(currency)), lasts, max_periods: maxPeriods } }, shares, ...nft })]);
|
|
1623
|
+
},
|
|
1624
|
+
|
|
1625
|
+
/** The listing of `asset` (one account read), or null: { listing, asset, maker, mint, deal, renter, until }. */
|
|
1626
|
+
async listing(asset) {
|
|
1627
|
+
const listing = await readMaybe('Listing', loot.pda.listing(project, asset));
|
|
1628
|
+
if (!listing) return null;
|
|
1629
|
+
return { listing: loot.pda.listing(project, asset), ...listing, renter: loot.renterOf(listing, await chainNow()) };
|
|
1630
|
+
},
|
|
1631
|
+
|
|
1632
|
+
/** Who rents `asset` now (one account read, then a check that the project still holds it), or null. */
|
|
1633
|
+
async rentedTo(asset) {
|
|
1634
|
+
const listing = await readMaybe('Listing', loot.pda.listing(project, asset));
|
|
1635
|
+
const renter = loot.renterOf(listing, await chainNow());
|
|
1636
|
+
if (!renter) return null;
|
|
1637
|
+
const account = await client.account(asset);
|
|
1638
|
+
const holds = account?.owner === MPL_CORE ? parseCoreAsset(account.data).owner === project : true;
|
|
1639
|
+
return holds ? renter : null;
|
|
1640
|
+
},
|
|
1641
|
+
|
|
1642
|
+
/** The project's listings, a maker's (`maker`) or all: one search of the program's accounts. */
|
|
1643
|
+
async listings({ maker, ...others } = NO_OPTIONS) {
|
|
1644
|
+
only(others, ['maker'], 'listings');
|
|
1645
|
+
const filters = [{ memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS.Listing)) } }, { memcmp: { offset: 1, bytes: project } }, ...(maker ? [{ memcmp: { offset: 33, bytes: maker } }] : [])];
|
|
1646
|
+
const time = await chainNow();
|
|
1647
|
+
return (await client.programAccounts(loot.LOOT_PROGRAM, filters)).map(({ pubkey, account }) => {
|
|
1648
|
+
const listing = loot.decode('Listing', account.data);
|
|
1649
|
+
return { listing: pubkey, ...listing, renter: loot.renterOf(listing, time) };
|
|
1650
|
+
});
|
|
1651
|
+
},
|
|
1652
|
+
|
|
1653
|
+
/**
|
|
1654
|
+
* Buys a listed NFT at the terms the player saw: `listing`, as `listing()` or `listings()` showed it. It is
|
|
1655
|
+
* required: the terms signed are those, never the listing read now, so a price or a share changed since is refused.
|
|
1656
|
+
*/
|
|
1657
|
+
async buyListing(asset, { listing, ...others } = NO_OPTIONS) {
|
|
1658
|
+
only(others, ['listing'], 'buyListing');
|
|
1659
|
+
await shownListing(asset, listing, 'Sell');
|
|
1660
|
+
const { before, ...nft } = await nftOf(asset, wallet.address, { to: wallet.address });
|
|
1661
|
+
const holders = await holdersOf(listing.shares ?? []);
|
|
1662
|
+
return send([...before, ...withSolWrap(wallet.address, listing.mint, listing.deal.Sell.price, [loot.takeListingIx({ taker: wallet.address, project, projectState: await projectNow(), listing, holders, programs, ...nft })])]);
|
|
1663
|
+
},
|
|
1664
|
+
|
|
1665
|
+
/**
|
|
1666
|
+
* Rents a listed NFT for `periods` periods, at the terms the player saw: `listing`, as `listing()` or `listings()`
|
|
1667
|
+
* showed it (required, as for `buyListing`).
|
|
1668
|
+
*/
|
|
1669
|
+
async rentListing(asset, { periods = 1, listing, ...others } = NO_OPTIONS) {
|
|
1670
|
+
only(others, ['periods', 'listing'], 'rentListing');
|
|
1671
|
+
await shownListing(asset, listing, 'Rent');
|
|
1672
|
+
const holders = await holdersOf(listing.shares ?? []);
|
|
1673
|
+
const total = BigInt(listing.deal.Rent.price) * BigInt(periods);
|
|
1674
|
+
// A compressed NFT is read by its record, proven first in the same transaction.
|
|
1675
|
+
const { before, compressed } = await nftOf(asset);
|
|
1676
|
+
return send([...(compressed ? before : []), ...withSolWrap(wallet.address, listing.mint, total, [loot.takeListingIx({ taker: wallet.address, project, projectState: await projectNow(), listing, periods, compressed, holders, programs })])]);
|
|
1677
|
+
},
|
|
1678
|
+
|
|
1679
|
+
/** Takes a listed NFT home (its maker). While a rental runs it only stops the listing; call again once it is over. */
|
|
1680
|
+
async unlist(asset) {
|
|
1681
|
+
const listing = await read('Listing', loot.pda.listing(project, asset));
|
|
1682
|
+
const { before, ...nft } = await nftOf(asset, wallet.address, { to: wallet.address });
|
|
1683
|
+
return send([...before, loot.closeListingIx({ maker: wallet.address, project, listing, ...nft })]);
|
|
1684
|
+
},
|
|
1685
|
+
|
|
1686
|
+
// ---------------------------------------------------------------- raffles
|
|
1687
|
+
|
|
1688
|
+
/**
|
|
1689
|
+
* The project's owner raffles an NFT they hold, as lottery `id`: the NFT goes into the lottery's own stash, where
|
|
1690
|
+
* nothing but the lottery can take it while a place names it; the winner gets it before any ticket money moves.
|
|
1691
|
+
* `price` (whole units of `currency`) a ticket, `drawsAt` (Unix seconds) its one draw, `minSold` the least tickets
|
|
1692
|
+
* (fewer: the round is cancelled and refunded), `shares` where the ticket money goes (the seller's own wallet by
|
|
1693
|
+
* default: the treasury). A seller runs it in a project of their own, whose owner they are.
|
|
1694
|
+
*/
|
|
1695
|
+
async raffle(id, nft, { price, currency, drawsAt, minSold = 0, perWallet = 0, shares = [{ to: 'Treasury', bps: 10_000 }], ...others } = NO_OPTIONS) {
|
|
1696
|
+
only(others, ['price', 'currency', 'drawsAt', 'minSold', 'perWallet', 'shares'], 'raffle');
|
|
1697
|
+
const kind = await nftOf(nft);
|
|
1698
|
+
const { before, hasAuthority: _, ...piece } = kind;
|
|
1699
|
+
await send([...before, loot.stashIx({ signer: wallet.address, project, lottery: id, asset: nft, ...piece })]);
|
|
1700
|
+
const args = {
|
|
1701
|
+
price: units(price, decimalsOf(currency)), mint: mintOf(currency), shares, places: [{ bps: 0, prize: { Held: [nft] } }],
|
|
1702
|
+
ticket_mint: NOBODY, every: 0n, draws_at: BigInt(drawsAt), gate: null, per_wallet: perWallet, min_sold: BigInt(minSold),
|
|
1703
|
+
};
|
|
1704
|
+
return send([loot.upsertIx({ owner: wallet.address, project, def: { Lottery: { id, args } } })]);
|
|
1705
|
+
},
|
|
1706
|
+
|
|
1707
|
+
/** 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
|
+
async unraffle(id, nft) {
|
|
1709
|
+
const { before, hasAuthority: _, ...piece } = await nftOf(nft, wallet.address, { to: wallet.address });
|
|
1710
|
+
return send([...before, loot.stashIx({ signer: wallet.address, project, lottery: id, asset: nft, out: wallet.address, ...piece })]);
|
|
1711
|
+
},
|
|
1712
|
+
|
|
1713
|
+
/** Turns what waits in the player's wrapped-SOL account (a bid that was topped) into plain SOL. */
|
|
1714
|
+
async unwrap() {
|
|
1715
|
+
const wrapped = associatedTokenAddress(wallet.address, NATIVE_MINT);
|
|
1716
|
+
if (!(await client.account(wrapped))) return null;
|
|
1717
|
+
return send([closeTokenAccount(wrapped, wallet.address, wallet.address)]);
|
|
1718
|
+
},
|
|
1719
|
+
|
|
1720
|
+
/**
|
|
1721
|
+
* Claims a grant for the connected wallet: from the approved list (the proof comes from xsolana.json, or is given:
|
|
1722
|
+
* `proof`, `personHash` and the `round` the list pays, as a results server's list answers them), or with a voucher
|
|
1723
|
+
* a server signed ({ publicKey, signature, incarnation, round, expires, personHash, amount }). A list in xsolana.json names
|
|
1724
|
+
* no round (zeros). `amount` (a voucher's, or `options.amount` for a list's leaf): the tokens this claim gives, in
|
|
1725
|
+
* 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
|
+
* the grant's prize as it is. A grant that gives a roll of a box makes the wallet's roll, which `claims()` then
|
|
1727
|
+
* lists and `collect` asks for and settles.
|
|
1728
|
+
*/
|
|
1729
|
+
async claimGrant(grantKey, options = {}) {
|
|
1730
|
+
only(options, ['voucher', 'proof', 'personHash', 'round', 'amount', 'shown', 'listProof'], 'claimGrant');
|
|
1731
|
+
let { voucher = null, proof: given = null, personHash: listPerson = null, round: listRound = null, amount: listAmount = 0n, shown } = options;
|
|
1732
|
+
const gspec = part('grants', grantKey);
|
|
1733
|
+
const address = loot.pda.grant(project, gspec.id);
|
|
1734
|
+
const grant = await read('Grant', address);
|
|
1735
|
+
shown ??= await passing(grant.gate, 'claim this');
|
|
1736
|
+
const gateProof = listProofOf(grant.gate, gspec.gate?.list, options.listProof);
|
|
1737
|
+
const receipt = await client.account(loot.pda.grantReceipt(address, grant.incarnation, wallet.address));
|
|
1738
|
+
const count = loot.readReceiptAccount(receipt)?.count ?? 0;
|
|
1739
|
+
// What its prize needs: a pool's mint, the item, an NFT's kind (a compressed one proven first).
|
|
1740
|
+
const prized = await prizeInputs([grant.gives]);
|
|
1741
|
+
const pool = grant.gives.Tokens?.from.Pool?.[0];
|
|
1742
|
+
const poolMint = pool !== undefined ? prized.poolMints[pool] : null;
|
|
1743
|
+
const item = prized.items[grant.gives.Item?.[0] ?? grant.gives.Compressed?.item] ?? null;
|
|
1744
|
+
const instructions = [...prized.before];
|
|
1745
|
+
let proof = [];
|
|
1746
|
+
const keys = gspec.keys ?? [];
|
|
1747
|
+
if (voucher) {
|
|
1748
|
+
if (voucher.incarnation == null || BigInt(voucher.incarnation) !== grant.incarnation) throw new Error('This voucher belongs to a different grant incarnation.');
|
|
1749
|
+
const message = loot.voucherBytes({ grant: address, incarnation: grant.incarnation, wallet: wallet.address, personHash: voucher.personHash, round: voucher.round, expires: voucher.expires, amount: BigInt(voucher.amount ?? 0) });
|
|
1750
|
+
const signature = typeof voucher.signature === 'string' ? decodeBase58(voucher.signature) : voucher.signature;
|
|
1751
|
+
instructions.push(loot.ed25519Ix({ publicKey: voucher.publicKey, signature, message }));
|
|
1752
|
+
// A key under the grant's key root (a promo code's) proves it is one of them.
|
|
1753
|
+
const at = keys.indexOf(voucher.publicKey);
|
|
1754
|
+
proof = given ?? (at >= 0 ? loot.merkleProof(keys.map((k) => loot.keyLeaf(k)), at) : []);
|
|
1755
|
+
} else if (given) {
|
|
1756
|
+
proof = given;
|
|
1757
|
+
} else if ((gspec.list ?? []).length) {
|
|
1758
|
+
// An entry is an address (the grant's amount, round 0), or { wallet, amount, round } (whole units of the grant's
|
|
1759
|
+
// token): a wallet listed under several rounds claims each once (`options.round`, a number; the first by default).
|
|
1760
|
+
const list = gspec.list.map((e) => (typeof e === 'string' ? { wallet: e } : e));
|
|
1761
|
+
const mint = grant.gives.Tokens?.from.Mint?.[0] ?? poolMint;
|
|
1762
|
+
const decimals = mint && list.some((e) => e.amount !== undefined) ? (await client.account(mint))?.data?.[44] ?? 0 : 0;
|
|
1763
|
+
const leaves = list.map((e) => loot.listLeaf(e.wallet, null, loot.listRound(e.round ?? 0), e.amount !== undefined ? units(e.amount, decimals) : 0n));
|
|
1764
|
+
const at = list.findIndex((e) => e.wallet === wallet.address && (typeof listRound !== 'number' || (e.round ?? 0) === listRound));
|
|
1765
|
+
if (at < 0) throw new Error('This wallet is not on the list for this grant.');
|
|
1766
|
+
proof = loot.merkleProof(leaves, at);
|
|
1767
|
+
listRound = loot.listRound(list[at].round ?? 0);
|
|
1768
|
+
if (list[at].amount !== undefined) listAmount = units(list[at].amount, decimals);
|
|
1769
|
+
} else if ((gspec.vouchers ?? []).length || keys.length) {
|
|
1770
|
+
throw new Error('This grant pays a wallet a voucher key signs for: pass the voucher.');
|
|
1771
|
+
}
|
|
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
|
+
// What this claim gives: the amount the voucher or the list's leaf names (0: the grant's prize as it is).
|
|
1779
|
+
const amount = BigInt((voucher ? voucher.amount : listAmount) ?? 0);
|
|
1780
|
+
const most = grant.gives.Tokens?.amount.Fixed?.[0];
|
|
1781
|
+
if (amount > 0n && (most === undefined || amount > BigInt(most))) throw new Error(`This claim names ${amount} tokens, more than this grant gives${most === undefined ? ' (it gives no fixed amount of tokens)' : ` (${most})`}.`);
|
|
1782
|
+
instructions.push(loot.claimGrantIx({ project, id: gspec.id, grant, wallet: wallet.address, signer: wallet.address, count, round: voucher?.round ?? listRound, expires: voucher?.expires, personHash: voucher?.personHash ?? listPerson, amount, proof, poolMint, item, nft: prized.nft, boxes: prized.boxes, rollPools: prized.rollPools, shown, listProof: gateProof, programs, voucherKey: voucher?.publicKey }));
|
|
1783
|
+
return send(instructions);
|
|
1784
|
+
},
|
|
1785
|
+
|
|
1786
|
+
/**
|
|
1787
|
+
* Takes every prize a results server vouched for this wallet's wins, or put on a list (`server`: its URL;
|
|
1788
|
+
* a results server), page by page. Each pays only this wallet, within the grant's limits. Returns [{ round, grant,
|
|
1789
|
+
* signature }] for each claim made, and [{ round, grant, error }] for one that failed, without stopping the rest;
|
|
1790
|
+
* wins already claimed are skipped (the program pays a wallet each round once per grant incarnation).
|
|
1791
|
+
*
|
|
1792
|
+
* `token`: the player's play-host sign-in for this game. A drop that gives NFTs from a stash gives one only at a
|
|
1793
|
+
* signed-in claim, first come first served (the server answers `R-SIGN-IN` to a voucher request with no sign-in, so
|
|
1794
|
+
* a stranger can't hand them out): with the token such a win is claimed through `POST /v1/claims`; without it, it is
|
|
1795
|
+
* [{ round, grant, error }].
|
|
1796
|
+
*/
|
|
1797
|
+
async claimWins({ server, token = null, fetchImpl = globalThis.fetch, ...others } = NO_OPTIONS) {
|
|
1798
|
+
only(others, ['server', 'token', 'fetchImpl'], 'claimWins');
|
|
1799
|
+
const base = server.replace(/\/$/, '');
|
|
1800
|
+
const ask = async (path, init) => {
|
|
1801
|
+
const r = await fetchImpl(path.startsWith('http') ? path : `${base}${path}`, init);
|
|
1802
|
+
// The status first: a proxy's error page is no JSON, and says nothing a player can act on but its status.
|
|
1803
|
+
const body = await r.json().catch(() => null);
|
|
1804
|
+
if (!r.ok) throw Object.assign(new Error(body?.error?.why ?? `The results server answered ${r.status}.`), { code: body?.error?.code, status: r.status });
|
|
1805
|
+
if (body === null || typeof body !== 'object') throw new Error(`The results server answered ${r.status} with no JSON.`);
|
|
1806
|
+
return body;
|
|
1807
|
+
};
|
|
1808
|
+
const get = (path) => ask(path);
|
|
1809
|
+
// Where a list's proof is, as the server named it: a path on the server itself, or a whole URL on the server's own
|
|
1810
|
+
// origin. Any other place is refused before anything is fetched, so a server's answer can't send the page to
|
|
1811
|
+
// another host. A server given as a path (such as "/results") is on the page's own origin.
|
|
1812
|
+
const proofAt = (proof) => {
|
|
1813
|
+
const said = String(proof ?? '');
|
|
1814
|
+
let origin = null;
|
|
1815
|
+
let url = null;
|
|
1816
|
+
try {
|
|
1817
|
+
const home = new URL(`${base}/`, globalThis.location?.href);
|
|
1818
|
+
origin = home.origin;
|
|
1819
|
+
url = new URL(said.startsWith('/') && !said.startsWith('//') ? `${home.href.replace(/\/$/, '')}${said}` : said, home);
|
|
1820
|
+
} catch {
|
|
1821
|
+
// not a URL at all: refused below
|
|
1822
|
+
}
|
|
1823
|
+
if (!url || origin === null || origin === 'null' || url.origin !== origin) throw new Error(`The results server named a list's proof at “${said}”, which is not on the server itself (${origin ?? base}): it was not fetched.`);
|
|
1824
|
+
return url.href;
|
|
1825
|
+
};
|
|
1826
|
+
// Each value in a query is encoded: a round or a page the server named can't change the request's path.
|
|
1827
|
+
const q = (value) => encodeURIComponent(String(value));
|
|
1828
|
+
// A win's vouchers; a stash drop's NFT not given yet: the signed-in claim's, those for this wallet
|
|
1829
|
+
const vouchersOf = async (round) => {
|
|
1830
|
+
try {
|
|
1831
|
+
return (await get(`/v1/vouchers?game=${q(project)}&round=${q(round)}&wallet=${q(wallet.address)}`)).vouchers;
|
|
1832
|
+
} catch (e) {
|
|
1833
|
+
if (e.code !== 'R-SIGN-IN' || !token) throw e;
|
|
1834
|
+
const { vouchers } = await ask('/v1/claims', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ game: project, round, token }) });
|
|
1835
|
+
return vouchers.filter((v) => v.wallet === wallet.address);
|
|
1836
|
+
}
|
|
1837
|
+
};
|
|
1838
|
+
const claimed = async (grantId, incarnation, round) => {
|
|
1839
|
+
const grant = loot.pda.grant(project, grantId);
|
|
1840
|
+
incarnation ??= (await read('Grant', grant)).incarnation;
|
|
1841
|
+
return loot.readReceiptAccount(await client.account(loot.pda.grantReceipt(grant, incarnation, wallet.address)))?.claimed(round) ?? false;
|
|
1842
|
+
};
|
|
1843
|
+
const out = [];
|
|
1844
|
+
const take = async (round, grant, claim) => {
|
|
1845
|
+
try {
|
|
1846
|
+
out.push({ round, grant, signature: await claim() });
|
|
1847
|
+
} catch (e) {
|
|
1848
|
+
out.push({ round, grant, error: e.message });
|
|
1849
|
+
}
|
|
1850
|
+
};
|
|
1851
|
+
// Page by page, until a page names no next one (null, or left out) or one already read: a server that leaves it
|
|
1852
|
+
// out, or names the same page again, must not have it read again and again.
|
|
1853
|
+
let page = { next: '' };
|
|
1854
|
+
const pagesRead = new Set();
|
|
1855
|
+
for (let first = true; first || (page.next && !pagesRead.has(page.next)); first = false) {
|
|
1856
|
+
pagesRead.add(page.next);
|
|
1857
|
+
page = await get(`/v1/wins?game=${q(project)}&wallet=${q(wallet.address)}${page.next ? `&before=${q(page.next)}` : ''}`);
|
|
1858
|
+
for (const win of page.wins.filter((x) => x.grant !== null && x.grant !== undefined)) {
|
|
1859
|
+
// one win that fails (its vouchers refused, a sign-in run out, a list not there) is its own error line, and
|
|
1860
|
+
// the rest go on (a refused POST /v1/claims must not stop every later win)
|
|
1861
|
+
try {
|
|
1862
|
+
for (const v of await vouchersOf(win.round)) {
|
|
1863
|
+
const grantKey = (spec.grants ?? []).find((x) => x.id === v.grant)?.key;
|
|
1864
|
+
if (!grantKey) continue;
|
|
1865
|
+
if (v.list) {
|
|
1866
|
+
// Paid by a list the owner wrote from the draw: its proof names the round.
|
|
1867
|
+
const list = await get(proofAt(v.proof));
|
|
1868
|
+
const mine = list.winners.find((w) => w.wallet === wallet.address);
|
|
1869
|
+
if (!mine || (await claimed(v.grant, null, list.round))) continue;
|
|
1870
|
+
await take(win.round, v.grant, () => this.claimGrant(grantKey, { proof: mine.proof, personHash: mine.personHash, round: list.round, amount: mine.amount ?? 0 }));
|
|
1871
|
+
} else if (v.signature && !(await claimed(v.grant, v.incarnation, v.round))) {
|
|
1872
|
+
await take(win.round, v.grant, () => this.claimGrant(grantKey, { voucher: v }));
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
} catch (e) {
|
|
1876
|
+
out.push({ round: win.round, grant: win.grant, error: e.message });
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
return out;
|
|
1881
|
+
},
|
|
1882
|
+
|
|
1883
|
+
/**
|
|
1884
|
+
* What this player can finish now, themselves: a box to settle or refund, a contest entry to claim, a held
|
|
1885
|
+
* collectible to take home, an auction they won (or one off by the clock: their bid back), a lottery place they
|
|
1886
|
+
* won, a draw that is due (anyone may reveal it, or ask again when the oracle never answered; it takes 0.0005
|
|
1887
|
+
* SOL), and a drawn lottery's unpaid places, which anyone may pay so the next round can open. [{ kind, says, … }];
|
|
1888
|
+
* pass one to `claim`.
|
|
1889
|
+
*/
|
|
1890
|
+
async claims() {
|
|
1891
|
+
const me = wallet.address;
|
|
1892
|
+
const out = [];
|
|
1893
|
+
const tag = (t) => ({ memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS[t])) } });
|
|
1894
|
+
const at = (offset, bytes) => ({ memcmp: { offset, bytes } });
|
|
1895
|
+
const slot = await slotNow();
|
|
1896
|
+
const time = BigInt(now());
|
|
1897
|
+
for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Order'), at(1, project), at(33, me)])) {
|
|
1898
|
+
const o = loot.decode('Order', account.data);
|
|
1899
|
+
const opened = BigInt(o.requested_slot);
|
|
1900
|
+
const refundAt = loot.orderRefundSlot(o);
|
|
1901
|
+
if (refundAt !== null && slot >= refundAt) out.push({ kind: 'refund', order: pubkey, says: 'Your payment back for an order that never finished.' });
|
|
1902
|
+
else if (o.status === 'Rolled' && slot > opened) out.push({ kind: 'settle', order: pubkey, says: 'What your box rolled.' });
|
|
1903
|
+
else if (loot.rollAskable(o, slot)) out.push({ kind: 'settle', order: pubkey, says: 'A roll you won (a mission’s, a recipe’s, a prize’s): ask for it and see what it gives (one whose box has nothing left to give waits until it has).' });
|
|
1904
|
+
}
|
|
1905
|
+
for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Entry'), at(33, me)])) {
|
|
1906
|
+
const entry = loot.decode('Entry', account.data);
|
|
1907
|
+
const c = await client.account(entry.contest);
|
|
1908
|
+
if (!c) continue;
|
|
1909
|
+
const contest = loot.decode('Contest', c.data);
|
|
1910
|
+
const off = loot.contestOff(contest, time);
|
|
1911
|
+
if (contest.project !== project || !(off || contest.verdict === 'Won')) continue;
|
|
1912
|
+
const won = contest.winner === entry.side;
|
|
1913
|
+
// A loser's NFT waits for the winners: it goes to the opener once they have all claimed.
|
|
1914
|
+
if (contest.nft && !off && !won && entry.asset !== NOBODY && BigInt(contest.unclaimed) > 0n) continue;
|
|
1915
|
+
const says = off ? 'Your entry back: the contest is off.' : won ? (contest.nft ? 'Your NFT back, and one of the losers’.' : contest.prize != null ? 'Your share of the pot, and your prize.' : 'Your share of the pot.') : 'Close your entry (your side lost).';
|
|
1916
|
+
out.push({ kind: 'contest', entry: pubkey, id: contest.id, side: entry.side, says });
|
|
1917
|
+
}
|
|
1918
|
+
for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Hold'), at(1, project), at(65, me)])) {
|
|
1919
|
+
const hold = loot.decode('Hold', account.data);
|
|
1920
|
+
// A repaid loan's NFT goes home whenever; an unpaid one's is the lender's once due.
|
|
1921
|
+
if (hold.debt?.paid) out.push({ kind: 'release', asset: hold.asset, hold: pubkey, says: 'Your collectible back: your loan is repaid.' });
|
|
1922
|
+
else if (!hold.debt && time >= BigInt(hold.free_at)) out.push({ kind: 'release', asset: hold.asset, hold: pubkey, says: 'Your collectible back: its hold is over.' });
|
|
1923
|
+
}
|
|
1924
|
+
const auctions = spec.auctions ?? [];
|
|
1925
|
+
const found = await client.accounts(auctions.map((a) => auctionAt(a)));
|
|
1926
|
+
auctions.forEach((a, i) => {
|
|
1927
|
+
const x = found[i] && loot.decode('Auction', found[i].data);
|
|
1928
|
+
if (!x || x.high_bidder !== me || time < loot.auctionEnds(x)) return;
|
|
1929
|
+
out.push({ kind: 'auction', key: a.key, says: `You won “${a.name}”: take it.` });
|
|
1930
|
+
});
|
|
1931
|
+
for (const l of spec.lotteries ?? []) {
|
|
1932
|
+
// A lottery the page names but nobody has written yet (a raffle still to open) has nothing to finish.
|
|
1933
|
+
if (!(await client.account(loot.pda.lottery(project, l.id)))) continue;
|
|
1934
|
+
const { lottery, contributions, winners } = await roundOf(l);
|
|
1935
|
+
if (loot.lotteryCancellable(lottery, slot)) out.push({ kind: 'cancel-draw', key: l.key, says: `The oracle never answered the draw of “${l.name}”: make ticket payments refundable.` });
|
|
1936
|
+
else if (lottery.status === 'Open' && time >= BigInt(lottery.draws_at) && BigInt(lottery.sold) > 0n) out.push({ kind: 'reveal', key: l.key, says: `The draw of “${l.name}” is due: reveal the winners.` });
|
|
1937
|
+
else if (loot.lotteryDrawStuck(lottery, slot)) out.push({ kind: 'reveal', key: l.key, says: `The oracle never answered the draw of “${l.name}”: ask it again.` });
|
|
1938
|
+
const open = contributions.filter(({ value }) => !value.done);
|
|
1939
|
+
// A drawn round gives every place's prize first; its money moves after.
|
|
1940
|
+
const owed = contributions.filter(({ value }) => loot.lotteryStep(lottery, value) === 'prizes');
|
|
1941
|
+
if (owed.length) {
|
|
1942
|
+
const mine = owed.find(({ value }) => value.player === me);
|
|
1943
|
+
out.push({ kind: 'lottery-prize', key: l.key, contribution: (mine ?? owed[0]).address,
|
|
1944
|
+
says: mine ? `Take your prize in “${l.name}”.` : `Give the winners of “${l.name}” their prizes, so its draw can complete.` });
|
|
1945
|
+
} else if (lottery.status === 'Drawn' && open.length) {
|
|
1946
|
+
out.push({ kind: 'complete-draw', key: l.key, contribution: open[0].address, says: `Complete the draw of “${l.name}” so its winners can collect.` });
|
|
1947
|
+
}
|
|
1948
|
+
if (lottery.status === 'Cancelled' && open.length) {
|
|
1949
|
+
const mine = open.find(({ value }) => value.player === me) ?? open[0];
|
|
1950
|
+
out.push({ kind: 'ticket-refund', key: l.key, contribution: mine.address,
|
|
1951
|
+
says: mine.value.player === me ? `Recover your ticket payment for “${l.name}”.` : `Return a ticket payment for “${l.name}” so its next round can open.` });
|
|
1952
|
+
}
|
|
1953
|
+
if (winners.some((w) => w.winner === me)) out.push({ kind: 'prize', key: l.key, says: `You won a place in “${l.name}”.` });
|
|
1954
|
+
else if (winners.length) {
|
|
1955
|
+
// A place that rolls makes its winner's roll, whose order the payer funds (its rent and SOL go to the winner).
|
|
1956
|
+
const rolls = lottery.places.some((p) => p.prize?.Roll) ? ', and about 0.005 SOL (0.011 when its box can give an item) for each place won that gives a roll, which goes to the winner' : '';
|
|
1957
|
+
out.push({ kind: 'payout', key: l.key, says: `Pay the winners of “${l.name}” so its next round can open (about 0.002 SOL of account rent for each winner who has no account for the prize yet${rolls}; it stays with the winner).` });
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
// Tokens whose unbonding wait is over, in a farm of tokens.
|
|
1961
|
+
const chainTime = await chainNow();
|
|
1962
|
+
for (const f of (spec.farms ?? []).filter((x) => x.stakes !== 'collectibles')) {
|
|
1963
|
+
const seat = await readMaybe('Seat', loot.pda.seat(loot.pda.farm(project, f.id), me));
|
|
1964
|
+
const free = seat ? loot.withdrawable(seat, chainTime) : 0n;
|
|
1965
|
+
if (free > 0n) out.push({ kind: 'withdraw', key: f.key, says: `Your tokens leaving “${f.name ?? f.key}” are free: take them out.` });
|
|
1966
|
+
}
|
|
1967
|
+
// A sealed schedule that pays this wallet: its runs that are due (vesting nobody can take back).
|
|
1968
|
+
for (const sc of spec.schedules ?? []) {
|
|
1969
|
+
const schedule = await readMaybe('Schedule', loot.pda.schedule(project, sc.id));
|
|
1970
|
+
if (!schedule?.action.from.Vest || schedule.action.to.Wallet?.[0] !== me) continue;
|
|
1971
|
+
const vault = await client.account(loot.pda.vest(loot.pda.schedule(project, sc.id)));
|
|
1972
|
+
const held = vault ? vault.data.subarray(64, 72).reduce((n, b, i) => n | (BigInt(b) << BigInt(8 * i)), 0n) : 0n;
|
|
1973
|
+
if (loot.runsDue(schedule, chainTime, held) > 0) out.push({ kind: 'vest', key: sc.key, says: `What has vested of “${sc.name ?? sc.key}”: claim it.` });
|
|
1974
|
+
}
|
|
1975
|
+
// The player's listings whose rental is over: their NFT can go home (or wait for the next renter).
|
|
1976
|
+
const mineListed = await client.programAccounts(loot.LOOT_PROGRAM, [tag('Listing'), at(1, project), at(33, me)]);
|
|
1977
|
+
for (const { account } of mineListed) {
|
|
1978
|
+
const listing = loot.decode('Listing', account.data);
|
|
1979
|
+
if (listing.deal.Rent && listing.user !== NOBODY && !loot.renterOf(listing, chainTime)) out.push({ kind: 'unlist', asset: listing.asset, says: listing.stopped ? 'The rental of your stopped listing is over: take your NFT home.' : 'A rental of yours is over: take your NFT home, or leave it listed for the next renter.' });
|
|
1980
|
+
}
|
|
1981
|
+
// The lookup tables a transaction of this player's too big for a legacy one made (`send`): each is deactivated by
|
|
1982
|
+
// the transaction it was made for, and its rent comes back once it has cooled down (about 513 slots). One of this
|
|
1983
|
+
// project's whose transaction was never sent (the wallet declined it) is retired first. A table that names no
|
|
1984
|
+
// account of this project is another app's: left alone.
|
|
1985
|
+
const mineTables = await client.programAccounts(LOOKUP_TABLE_PROGRAM, tablesOf(me)).catch(() => []);
|
|
1986
|
+
// A table closes once its deactivation slot has left the recent slot hashes (skipped slots keep it there longer).
|
|
1987
|
+
const recent = mineTables.length ? await recentSlots() : [];
|
|
1988
|
+
for (const { pubkey, account } of mineTables) {
|
|
1989
|
+
const table = readTable({ ...account, owner: account.owner ?? LOOKUP_TABLE_PROGRAM });
|
|
1990
|
+
if (!table || table.authority !== me) continue;
|
|
1991
|
+
const step = tableStep(table, slot, recent);
|
|
1992
|
+
const rent = show(BigInt(account.lamports ?? 0), 9);
|
|
1993
|
+
if (step === 'close') out.push({ kind: 'lookup-table', table: pubkey, says: `${rent} SOL of rent back from a lookup table a large transaction of yours used.` });
|
|
1994
|
+
else if (step === 'active' && table.addresses[0] === project && slot > table.lastExtendedSlot + TABLE_UNSENT_SLOTS)
|
|
1995
|
+
out.push({ kind: 'lookup-table', table: pubkey, says: `A lookup table made for a large transaction of yours that was never sent: retire it, and its ${rent} SOL of rent comes back about two minutes later.` });
|
|
1996
|
+
}
|
|
1997
|
+
return out;
|
|
1998
|
+
},
|
|
1999
|
+
|
|
2000
|
+
/**
|
|
2001
|
+
* Finishes one of `claims()`, signed by this player. Returns the signature. A winner of a contest of NFTs picks
|
|
2002
|
+
* which loser's NFT they take (`{ take: asset }`, the NFT of a losing entry that still holds one; the first found by default):
|
|
2003
|
+
* only the winner can, while any are left.
|
|
2004
|
+
*/
|
|
2005
|
+
async claim(c, { take, ...others } = NO_OPTIONS) {
|
|
2006
|
+
only(others, ['take'], 'claim');
|
|
2007
|
+
const me = wallet.address;
|
|
2008
|
+
const g = await projectNow();
|
|
2009
|
+
if (c.kind === 'settle') return (await collect(c.order)).settled;
|
|
2010
|
+
if (c.kind === 'refund') return this.refund(c.order);
|
|
2011
|
+
if (c.kind === 'contest') {
|
|
2012
|
+
const contestAddress = loot.pda.contest(project, c.id);
|
|
2013
|
+
const contest = await read('Contest', contestAddress);
|
|
2014
|
+
const won = contest.verdict === 'Won' && contest.winner === c.side;
|
|
2015
|
+
const given = await prizeInputs([won ? contest.prize : null]);
|
|
2016
|
+
// A contest of NFTs: the entry's own NFT, and for a winner while losers' NFTs are left, a losing entry's.
|
|
2017
|
+
const nfts = {};
|
|
2018
|
+
if (contest.nft) {
|
|
2019
|
+
const entry = await read('Entry', loot.pda.entry(contestAddress, me));
|
|
2020
|
+
nfts.entry = entry;
|
|
2021
|
+
const kindOf = async (asset, to = me) => {
|
|
2022
|
+
const { before, hasAuthority: _, ...kind } = await nftOf(asset, me, { to });
|
|
2023
|
+
given.before.push(...before);
|
|
2024
|
+
return kind;
|
|
2025
|
+
};
|
|
2026
|
+
// A losing entry's own NFT goes to the contest's opener once winners have taken theirs.
|
|
2027
|
+
const lost = contest.verdict === 'Won' && !won;
|
|
2028
|
+
if (entry.asset !== NOBODY) nfts.own = await kindOf(entry.asset, lost ? contest.opener : me);
|
|
2029
|
+
if (won && Number(contest.losers) > 0) {
|
|
2030
|
+
const found = await client.programAccounts(loot.LOOT_PROGRAM, [{ memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS.Entry)) } }, { memcmp: { offset: 1, bytes: contestAddress } }]);
|
|
2031
|
+
const left = found.map(({ account }) => loot.decode('Entry', account.data)).filter((e) => e.side !== contest.winner && e.asset !== NOBODY);
|
|
2032
|
+
const losing = take ? left.find((e) => e.asset === take) : left[0];
|
|
2033
|
+
if (take && !losing) throw new Error(`${take} is not a loser’s NFT left in this contest.`);
|
|
2034
|
+
if (losing) nfts.taking = { player: losing.player, side: losing.side, asset: losing.asset, kind: await kindOf(losing.asset) };
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
return send([computeUnitLimit(800_000), ...given.before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), loot.claimIx({ payer: me, project, id: c.id, contest, player: me, side: c.side, collection: given.collection, poolMints: given.poolMints, items: given.items, nft: given.nft, ...nfts, programs })]);
|
|
2038
|
+
}
|
|
2039
|
+
if (c.kind === 'release') {
|
|
2040
|
+
const hold = await read('Hold', c.hold);
|
|
2041
|
+
const { before, hasAuthority: _, ...nft } = await nftOf(c.asset, me, { to: me });
|
|
2042
|
+
return send([...before, loot.releaseIx({ signer: me, project, asset: c.asset, ...nft, collection: (await collectionOf(c.asset)) ?? MPL_CORE, hold })]);
|
|
2043
|
+
}
|
|
2044
|
+
if (c.kind === 'auction') return (await this.endAuction(c.key)).signature;
|
|
2045
|
+
if (c.kind === 'withdraw') return this.withdraw(c.key);
|
|
2046
|
+
if (c.kind === 'vest') return this.runSchedule(c.key);
|
|
2047
|
+
if (c.kind === 'unlist') return this.unlist(c.asset);
|
|
2048
|
+
if (c.kind === 'lookup-table') {
|
|
2049
|
+
const table = readTable(await client.account(c.table));
|
|
2050
|
+
const step = table && tableStep(table, await slotNow(), await recentSlots());
|
|
2051
|
+
if (!step || step === 'cooling') return null;
|
|
2052
|
+
return send([step === 'close' ? closeTableIx({ table: c.table, authority: me }) : deactivateTableIx({ table: c.table, authority: me })], [], { keepWrapped: true });
|
|
2053
|
+
}
|
|
2054
|
+
const l = part('lotteries', c.key);
|
|
2055
|
+
if (c.kind === 'reveal') return send([loot.drawLotteryIx({ payer: me, project, id: l.id })]);
|
|
2056
|
+
if (c.kind === 'cancel-draw') return send([loot.cancelLotteryIx({ project, id: l.id })]);
|
|
2057
|
+
if (c.kind === 'lottery-prize') {
|
|
2058
|
+
const { lottery, contributions } = await roundOf(l);
|
|
2059
|
+
const found = contributions.find(({ address }) => address === c.contribution);
|
|
2060
|
+
if (!found || loot.lotteryStep(lottery, found.value) !== 'prizes') return null;
|
|
2061
|
+
const given = await prizeInputs(lottery.places.map((p) => p.prize), me, found.value.player);
|
|
2062
|
+
return send([computeUnitLimit(600_000), ...given.before.filter((x) => x.programId !== COMPUTE_BUDGET_PROGRAM), loot.finishLotteryContributionIx({ payer: me, project, id: l.id, lottery, contributionAddress: found.address, contribution: found.value, collection: given.collection, poolMints: given.poolMints, items: given.items, nfts: given.nfts, programs })]);
|
|
2063
|
+
}
|
|
2064
|
+
if (c.kind === 'complete-draw' || c.kind === 'ticket-refund') {
|
|
2065
|
+
const { lottery, contributions } = await roundOf(l);
|
|
2066
|
+
const found = contributions.find(({ address }) => address === c.contribution);
|
|
2067
|
+
if (!found || found.value.done || lottery.status !== (c.kind === 'ticket-refund' ? 'Cancelled' : 'Drawn') || !loot.lotteryStep(lottery, found.value)) return null;
|
|
2068
|
+
// A ticket an NFT paid for passes that NFT, which the project holds: back to its buyer when the round is
|
|
2069
|
+
// cancelled or gives tickets back, to the wallet its price names when it is given on.
|
|
2070
|
+
const then = lottery.status === 'Drawn' ? lottery.nft_ticket?.then : null;
|
|
2071
|
+
// Given to a wallet that is an account of the loot program, it goes back to its buyer.
|
|
2072
|
+
const handBack = Boolean(then?.Give) && loot.handsBack(await client.account(then.Give[0]));
|
|
2073
|
+
const to = !then || handBack ? found.value.player : then.Give ? then.Give[0] : undefined;
|
|
2074
|
+
const { before = [], hasAuthority: _, ...ticket } = found.value.asset !== NOBODY ? await nftOf(found.value.asset, me, { to }) : {};
|
|
2075
|
+
return send([...before, loot.finishLotteryContributionIx({ payer: me, project, id: l.id, lottery, contributionAddress: found.address, contribution: found.value, treasury: g.treasury, ticket, handBack, programs })]);
|
|
2076
|
+
}
|
|
2077
|
+
if (c.kind === 'prize' || c.kind === 'payout') {
|
|
2078
|
+
// Every unpaid winner, three a transaction, each paid every place's share of the pot they won (their prizes went
|
|
2079
|
+
// first): the round ends once all are paid, and the next opens. Each winner's account of the pot's mint is
|
|
2080
|
+
// opened when they have none, paid by whoever sends it.
|
|
2081
|
+
const { lottery, winners } = await roundOf(l);
|
|
2082
|
+
const mine = winners.filter((w) => w.winner === me);
|
|
2083
|
+
const chosen = [...mine, ...winners.filter((w) => w.winner !== me)].slice(0, 3);
|
|
2084
|
+
if (!chosen.length) return null;
|
|
2085
|
+
const pay = (w) => loot.finishLotteryContributionIx({ payer: me, project, id: l.id, lottery, contributionAddress: w.contribution.address, contribution: w.contribution.value, treasury: g.treasury, programs });
|
|
2086
|
+
return send(chosen.map(pay));
|
|
2087
|
+
}
|
|
2088
|
+
throw new Error(`Nothing to do for a claim of kind ${c.kind}.`);
|
|
2089
|
+
},
|
|
2090
|
+
|
|
2091
|
+
/** The player's collectibles of this project: [{ asset, item, itemId, name, uri, attributes, frozen }]. `item` is its key in the spec. */
|
|
2092
|
+
collectibles: owned,
|
|
2093
|
+
|
|
2094
|
+
/** What the player holds of each of the economy's tokens, in whole units. */
|
|
2095
|
+
async balances(owner = wallet?.address) {
|
|
2096
|
+
const out = {};
|
|
2097
|
+
for (const t of tokens.values()) {
|
|
2098
|
+
const mint = mintOf(t.key);
|
|
2099
|
+
if (t.source === 'sol') out[t.key] = show(await client.balance(owner), 9);
|
|
2100
|
+
else if (mint) {
|
|
2101
|
+
const a = await client.account(loot.ata(owner, mint, programOf(mint)));
|
|
2102
|
+
out[t.key] = a ? show(new DataView(a.data.buffer, a.data.byteOffset).getBigUint64(64, true), t.decimals ?? 0) : '0';
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
return out;
|
|
2106
|
+
},
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
|