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