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