@xgamesfun/solana 0.25.4 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/js/approve/details.mjs +52 -2
- package/js/approve/live-check.mjs +0 -15
- package/js/approve/server.mjs +5 -4
- package/js/chain/token2022.js +3 -2
- package/js/client/accounts.js +67 -25
- package/js/client/grant.js +7 -2
- package/js/client/idl/loot.js +1 -1
- package/js/client/idl/loot.json +716 -183
- package/js/client/ix.js +617 -113
- package/js/client/sizes.js +210 -64
- package/js/economy/check.mjs +691 -261
- package/js/economy/cost.mjs +2 -4
- package/js/economy/explain.mjs +16 -2
- package/js/economy/guide.mjs +12 -12
- package/js/economy/model.mjs +133 -14
- package/js/economy/plan.mjs +68 -19
- package/js/economy/review.mjs +50 -8
- package/js/economy/schema.json +73 -29
- package/js/economy/simulate.mjs +116 -13
- package/js/mcp/tools.mjs +2 -2
- package/js/play/index.js +855 -296
- package/package.json +1 -1
- package/release.json +5 -5
- package/skills/xsolana-economy/SKILL.md +12 -4
- package/skills/xsolana-economy/references/after-launch.md +1 -1
- package/skills/xsolana-economy/references/apps.md +11 -2
- package/skills/xsolana-economy/references/sentence-to-spec.md +3 -3
- package/skills/xsolana-economy/references/telling-the-person.md +22 -4
- package/skills/xsolana-economy/references/trust-and-cheating.md +15 -5
- package/types/chain/token2022.d.ts +3 -2
- package/types/client/accounts.d.ts +34 -11
- package/types/client/idl/loot.d.ts +25 -4
- package/types/client/ix.d.ts +217 -45
- package/types/client/sizes.d.ts +42 -8
- package/types/economy/check.d.mts +11 -2
- package/types/economy/cost.d.mts +1 -0
- package/types/economy/live.d.mts +2 -0
- package/types/economy/model.d.mts +75 -12
- package/types/economy/plan.d.mts +31 -2
- package/types/economy/simulate.d.mts +12 -1
- package/types/mcp/server.d.mts +12 -1
- package/types/mcp/tools.d.mts +12 -1
- package/types/play/index.d.ts +210 -144
package/js/play/index.js
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
withSolWrap,
|
|
41
41
|
} from '../chain/programs.js';
|
|
42
42
|
import { checkCluster, notMainnet, rpcClient } from '../chain/rpc.js';
|
|
43
|
-
import {
|
|
43
|
+
import { extension, mayStartFrozen, mintPowers, resolveTransfers } from '../chain/token2022.js';
|
|
44
44
|
import {
|
|
45
45
|
compileFitting,
|
|
46
46
|
closeTableIx,
|
|
@@ -371,6 +371,62 @@ export function solanaApp({
|
|
|
371
371
|
before: tokenMetadata ? [computeUnitLimit(400_000), ...before] : [],
|
|
372
372
|
};
|
|
373
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* Whether the project still holds `asset`, as the program reads it (`piece::held`): { held, evidence }. A Core asset by
|
|
376
|
+
* its owner (one burned is gone); a Token Metadata NFT by the project's token account of it, and when that holds none,
|
|
377
|
+
* by the account that holds it now or by its mint when it was burned (`evidence`, what a call-off passes to show it
|
|
378
|
+
* gone); a compressed NFT by the RPC's DAS record of it (proven again by `nftOf`). `held` is null when nothing shows
|
|
379
|
+
* either way.
|
|
380
|
+
*/
|
|
381
|
+
async function heldNow(asset) {
|
|
382
|
+
const account = await client.account(asset);
|
|
383
|
+
if (account?.owner === MPL_CORE)
|
|
384
|
+
return { held: account.data[0] === 1 && parseCoreAsset(account.data).owner === project };
|
|
385
|
+
if (account?.owner === TOKEN_PROGRAM) {
|
|
386
|
+
const u64 = (data, at) => new DataView(data.buffer, data.byteOffset).getBigUint64(at, true);
|
|
387
|
+
const amount = data => (data?.length >= 72 ? u64(data, 64) : 0n);
|
|
388
|
+
if (amount((await client.account(loot.ata(project, asset)))?.data) === 1n) return { held: true };
|
|
389
|
+
// Burned: its mint holds none.
|
|
390
|
+
if (account.data.length >= 44 && u64(account.data, 36) === 0n) return { held: false, evidence: asset };
|
|
391
|
+
const found = await client.programAccounts(TOKEN_PROGRAM, [
|
|
392
|
+
{ dataSize: 165 },
|
|
393
|
+
{ memcmp: { offset: 0, bytes: asset } },
|
|
394
|
+
]);
|
|
395
|
+
const now = found.find(x => amount(x.account.data) === 1n);
|
|
396
|
+
return now ? { held: false, evidence: now.pubkey } : { held: null };
|
|
397
|
+
}
|
|
398
|
+
if (account) return { held: null };
|
|
399
|
+
const das = await client.call('getAsset', { id: asset }).catch(() => null);
|
|
400
|
+
if (!das?.compression?.compressed) return { held: null };
|
|
401
|
+
return { held: !das.compression.burnt && das.ownership?.owner === project };
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* A token's own settings that let whoever is to be paid turn a payment away, for a page to say before a player puts
|
|
405
|
+
* money in: accounts that start frozen or can be switched to it (a payee closes their own account, and the one
|
|
406
|
+
* opened again in its place is frozen), confidential transfers (a payee's account can refuse plain ones), and a token
|
|
407
|
+
* that can't be transferred at all. `what`: the payment ("repaying", "a higher bid"); `who` and `then`: who could and
|
|
408
|
+
* what they gain. Each is { power, says }.
|
|
409
|
+
*/
|
|
410
|
+
function tokenSettings(account, what, who, then) {
|
|
411
|
+
if (account?.owner !== TOKEN_2022_PROGRAM || account.data.length <= 166 || account.data[165] !== 1) return [];
|
|
412
|
+
const out = [];
|
|
413
|
+
if (mayStartFrozen(account))
|
|
414
|
+
out.push({
|
|
415
|
+
power: 'startsFrozen',
|
|
416
|
+
says: `New accounts of this token start frozen, or can be switched to start frozen: ${who} can close their own account so that ${what} cannot arrive, ${then}.`,
|
|
417
|
+
});
|
|
418
|
+
if (extension(account.data, 4))
|
|
419
|
+
out.push({
|
|
420
|
+
power: 'confidential',
|
|
421
|
+
says: `This token has confidential transfers: ${who} can turn plain transfers into their account off so that ${what} cannot arrive, ${then}.`,
|
|
422
|
+
});
|
|
423
|
+
if (extension(account.data, 9))
|
|
424
|
+
out.push({
|
|
425
|
+
power: 'nonTransferable',
|
|
426
|
+
says: `This token can't be transferred: nothing can be paid in it, ${what} included.`,
|
|
427
|
+
});
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
374
430
|
/**
|
|
375
431
|
* What `loot.prizeMetas` needs to give these prizes (`null`s are skipped): each pool's mint, each item (its kind, its
|
|
376
432
|
* collection and its next serial; null for one never written), each NFT's kind (`nfts`, and `nft` for one), the
|
|
@@ -521,6 +577,21 @@ export function solanaApp({
|
|
|
521
577
|
return true;
|
|
522
578
|
};
|
|
523
579
|
const pass = c => rules.every(r => passes(r, c));
|
|
580
|
+
// A rule on the player's profile: the client passes the profile itself (`loot.shownFor`); here it is only read, to
|
|
581
|
+
// say plainly when it falls short.
|
|
582
|
+
if (loot.readsProfile(rule)) {
|
|
583
|
+
const found = await client.account(loot.pda.profile(project, wallet.address));
|
|
584
|
+
const attributes = Object.fromEntries(
|
|
585
|
+
(loot.decodeProfile(found?.owner === loot.LOOT_PROGRAM ? found.data : null)?.attributes ?? []).map(a => [
|
|
586
|
+
a.key,
|
|
587
|
+
a.value,
|
|
588
|
+
]),
|
|
589
|
+
);
|
|
590
|
+
if (passes(rule, { attributes })) return null;
|
|
591
|
+
throw new Error(
|
|
592
|
+
`You need ${rule.key} ${atLeast !== undefined ? `${atLeast} or more` : equals !== undefined ? equals : 'set'} on your profile to ${what}.`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
524
595
|
if (other) {
|
|
525
596
|
const found = await client.programAccounts(MPL_CORE, ownedAssetFilters(wallet.address, other));
|
|
526
597
|
for (const { pubkey, account } of found) {
|
|
@@ -916,16 +987,6 @@ export function solanaApp({
|
|
|
916
987
|
if (key.name && !(BigInt(await client.balance(key.address).catch(() => 1n)) > 0n)) keys?.forget(key.name);
|
|
917
988
|
}
|
|
918
989
|
|
|
919
|
-
/** A key's job: its step (`build(signer)`), simulated as the key sends it, then sent by the key, or by the wallet when
|
|
920
|
-
* the simulation fails (one more prompt); then the key empties itself back to the wallet. Returns the signature. */
|
|
921
|
-
async function keyRun(key, build) {
|
|
922
|
-
const steps = await build(key.address);
|
|
923
|
-
const signature = (await dryRun(key.address, steps)).ok ? await sendAs(key, steps).catch(() => null) : null;
|
|
924
|
-
const sent = signature ?? (await send(await build(wallet.address)));
|
|
925
|
-
await emptyKey(key);
|
|
926
|
-
return sent;
|
|
927
|
-
}
|
|
928
|
-
|
|
929
990
|
/**
|
|
930
991
|
* Rolls in one prompt: a step that makes orders waiting for their roll (a craft, a buy, a mission left, a grant or a
|
|
931
992
|
* contest prize, a lot won) asks for each in the same transaction, and a throwaway key it funds then settles each (the
|
|
@@ -1007,10 +1068,13 @@ export function solanaApp({
|
|
|
1007
1068
|
* throwaway key the open funded), the burner signs and pays the settle, and gives the rest back: no wallet prompt.
|
|
1008
1069
|
* The order names it (`settler`), so it may settle; when the player or the project did first, the burner only gives its lamports back. `putBack`: NFTs
|
|
1009
1070
|
* won that the player puts back into their stash in place of taking them (one whose collection now refuses to let it
|
|
1010
|
-
* move); the player
|
|
1071
|
+
* move); `giveUp`: the places in the order's `won` of tokens or collectibles the player gives up because they can't
|
|
1072
|
+
* receive them (an account the token's issuer froze, a collection that refuses the mint): tokens given up stay in the
|
|
1073
|
+
* project's vault, a collectible is never made. The player signs either settle. A settle that fails because the player
|
|
1074
|
+
* can't receive a prize says which (`error.giveUp`), so a page can offer to give them up and take the rest.
|
|
1011
1075
|
*/
|
|
1012
1076
|
async function collect(orderAddress, options = {}) {
|
|
1013
|
-
only(options, ['timeoutMs', 'burner', 'putBack', 'opened', 'landed', 'keepBurner'], 'collect');
|
|
1077
|
+
only(options, ['timeoutMs', 'burner', 'putBack', 'giveUp', 'opened', 'landed', 'keepBurner'], 'collect');
|
|
1014
1078
|
try {
|
|
1015
1079
|
return await collectOnce(orderAddress, options);
|
|
1016
1080
|
} catch (e) {
|
|
@@ -1028,8 +1092,18 @@ export function solanaApp({
|
|
|
1028
1092
|
|
|
1029
1093
|
async function collectOnce(
|
|
1030
1094
|
orderAddress,
|
|
1031
|
-
{
|
|
1095
|
+
{
|
|
1096
|
+
timeoutMs = 60_000,
|
|
1097
|
+
burner = null,
|
|
1098
|
+
putBack = [],
|
|
1099
|
+
giveUp = [],
|
|
1100
|
+
opened = false,
|
|
1101
|
+
landed = null,
|
|
1102
|
+
keepBurner = false,
|
|
1103
|
+
} = {},
|
|
1032
1104
|
) {
|
|
1105
|
+
// The player's own choices (an NFT put back, a prize given up) are theirs to sign: no burner settles them.
|
|
1106
|
+
const chooses = putBack.length > 0 || giveUp.length > 0;
|
|
1033
1107
|
const started = Date.now();
|
|
1034
1108
|
// `landed`: the slot one's own open landed in; the order is read from a node that has seen it.
|
|
1035
1109
|
const current = async () => {
|
|
@@ -1150,6 +1224,7 @@ export function solanaApp({
|
|
|
1150
1224
|
items,
|
|
1151
1225
|
nfts,
|
|
1152
1226
|
putBack,
|
|
1227
|
+
giveUp,
|
|
1153
1228
|
subject,
|
|
1154
1229
|
handBack,
|
|
1155
1230
|
holders,
|
|
@@ -1167,7 +1242,7 @@ export function solanaApp({
|
|
|
1167
1242
|
// with; the player's wallet, a part it can't.
|
|
1168
1243
|
const signatures = [];
|
|
1169
1244
|
// A key the order doesn't name can't settle it (`Order::settler`): then the player signs.
|
|
1170
|
-
const by = burner && !
|
|
1245
|
+
const by = burner && !chooses && order.settler === burner.address ? burner.address : wallet.address;
|
|
1171
1246
|
const fits = async instructions =>
|
|
1172
1247
|
Boolean(compileFitting({ payer: by, blockhash: NOBODY, instructions, tables: await standing() })) &&
|
|
1173
1248
|
transactionLocks(by, instructions) <= 64;
|
|
@@ -1196,8 +1271,7 @@ export function solanaApp({
|
|
|
1196
1271
|
// player's own choice: they sign.
|
|
1197
1272
|
let signature = null;
|
|
1198
1273
|
let emptied = false;
|
|
1199
|
-
const byBurner =
|
|
1200
|
-
burner && !putBack.length && order.settler === burner.address ? await settleBy(burner.address) : null;
|
|
1274
|
+
const byBurner = burner && !chooses && order.settler === burner.address ? await settleBy(burner.address) : null;
|
|
1201
1275
|
const alone = byBurner && !byBurner.before.length ? [...budget, byBurner.ix] : null;
|
|
1202
1276
|
const legacy = instructions => Boolean(compileFitting({ payer: burner.address, blockhash: NOBODY, instructions }));
|
|
1203
1277
|
const tried = alone && legacy(alone) ? await dryRun(burner.address, alone, [burner.address]) : null;
|
|
@@ -1214,12 +1288,15 @@ export function solanaApp({
|
|
|
1214
1288
|
if (!signature) {
|
|
1215
1289
|
// The wallet's settle: as a v0 transaction with a lookup table when it doesn't fit a legacy one (`send`).
|
|
1216
1290
|
const { ix, before } = await settleBy(wallet.address);
|
|
1217
|
-
signature = await send([...budget, ...before, ix])
|
|
1291
|
+
signature = await send([...budget, ...before, ix]).catch(async e => {
|
|
1292
|
+
throw await cannotReceive(order, e);
|
|
1293
|
+
});
|
|
1218
1294
|
}
|
|
1219
1295
|
// The burner gives back what it holds after its last settle (a purchase of several orders: after the last order's).
|
|
1220
1296
|
if (burner && !keepBurner && !emptied) await emptyKey(burner);
|
|
1221
1297
|
else if (burner && emptied) keys?.forget(burner.name);
|
|
1222
|
-
const
|
|
1298
|
+
const given = new Set(giveUp.map(Number));
|
|
1299
|
+
const assets = wonAssets(order).map((a, k) => (putBack.includes(a) || given.has(k) ? null : a));
|
|
1223
1300
|
return {
|
|
1224
1301
|
order: orderAddress,
|
|
1225
1302
|
won: order.won,
|
|
@@ -1230,6 +1307,32 @@ export function solanaApp({
|
|
|
1230
1307
|
};
|
|
1231
1308
|
}
|
|
1232
1309
|
|
|
1310
|
+
/**
|
|
1311
|
+
* A settle of `order` that failed (`e`): when a prize can't reach the player (their token account of its mint frozen by
|
|
1312
|
+
* the token's issuer; a collectible whose collection refuses the mint), an error that says so in words and names the
|
|
1313
|
+
* places in `won` they may give up (`giveUp`, for `collect`); otherwise `e` itself.
|
|
1314
|
+
*/
|
|
1315
|
+
async function cannotReceive(order, e) {
|
|
1316
|
+
const from = Number(order.settled ?? 0);
|
|
1317
|
+
const stuck = [];
|
|
1318
|
+
for (const [k, won] of order.won.entries()) {
|
|
1319
|
+
if (k < from || !won.Tokens) continue;
|
|
1320
|
+
const account = await client.account(loot.ata(order.player, won.Tokens.mint, programOf(won.Tokens.mint)));
|
|
1321
|
+
// A token account's state is its byte 108: 2 is frozen.
|
|
1322
|
+
if (account && account.data?.[108] === 2) stuck.push(k);
|
|
1323
|
+
}
|
|
1324
|
+
// Core refusing the mint (a collection's plugin): the collectibles of the order are what it would not take.
|
|
1325
|
+
const core = (e?.logs ?? []).some(line => line.startsWith(`Program ${MPL_CORE} failed`));
|
|
1326
|
+
if (!stuck.length && core)
|
|
1327
|
+
for (const [k, won] of order.won.entries()) if (k >= from && won.Item?.serial != null) stuck.push(k);
|
|
1328
|
+
if (!stuck.length) return e;
|
|
1329
|
+
const err = new Error(
|
|
1330
|
+
`This order can't be settled as it is: ${stuck.length === 1 ? 'one prize' : `${stuck.length} prizes`} can't reach your wallet (a token account its issuer froze, or a collection that refuses the mint). Give ${stuck.length === 1 ? 'it' : 'them'} up to take the rest (collect again with giveUp: [${stuck.join(', ')}]): tokens given up stay in the project's vault, and a collectible given up is never made.`,
|
|
1331
|
+
);
|
|
1332
|
+
Object.assign(err, { giveUp: stuck, cause: e, logs: e?.logs });
|
|
1333
|
+
return err;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1233
1336
|
/**
|
|
1234
1337
|
* For each prize an order won, what it is now: the collectible minted (`["asset", item, serial]`), the NFT handed
|
|
1235
1338
|
* over; null for tokens. A normal item is the project's backend's to deliver.
|
|
@@ -1282,9 +1385,14 @@ export function solanaApp({
|
|
|
1282
1385
|
|
|
1283
1386
|
/** The instructions that ask the oracle for the roll of `order` (at `orderAddress`), `payer` paying it. Each prize
|
|
1284
1387
|
* mint's token program is read from the mint when the app wasn't given it (`programs`): the ask opens the project's
|
|
1285
|
-
* vault of it.
|
|
1286
|
-
|
|
1388
|
+
* vault of it. Its first ask fixes its odds (`loot.askReads`): it passes the order's player's profile when the box's
|
|
1389
|
+
* odds read one, and for a roll on no NFT, when they read an NFT, `shown` (an NFT the player holds, as `openBox` takes
|
|
1390
|
+
* it), or else the player's collectible of this project that raises them the most (`loot.bestShown`). */
|
|
1391
|
+
async function asksFor(payer, orderAddress, order, settler, shown = null) {
|
|
1287
1392
|
const box = await read('LootBox', order.loot_box);
|
|
1393
|
+
const player = order.player ?? payer;
|
|
1394
|
+
if (!shown && loot.firstAsk(order, box) && loot.askReads(box, order).nft && player === wallet?.address)
|
|
1395
|
+
shown = loot.bestShown(box, await owned(player)).best;
|
|
1288
1396
|
const poolMints = await poolMintsOf(box);
|
|
1289
1397
|
const mints = [
|
|
1290
1398
|
...Object.values(poolMints),
|
|
@@ -1295,20 +1403,22 @@ export function solanaApp({
|
|
|
1295
1403
|
return loot.askRollIx({
|
|
1296
1404
|
payer,
|
|
1297
1405
|
orderAddress,
|
|
1298
|
-
order,
|
|
1406
|
+
order: { ...order, player },
|
|
1299
1407
|
box,
|
|
1300
1408
|
settler,
|
|
1409
|
+
shown,
|
|
1301
1410
|
poolMints,
|
|
1302
1411
|
items: await itemsOf(box),
|
|
1303
1412
|
programs: known,
|
|
1304
1413
|
});
|
|
1305
1414
|
}
|
|
1306
1415
|
|
|
1307
|
-
/** Asks the oracle for a roll a mission, a recipe or a prize made (`Waiting`): the player pays the oracle.
|
|
1308
|
-
|
|
1416
|
+
/** Asks the oracle for a roll a mission, a recipe or a prize made (`Waiting`): the player pays the oracle. `shown`: an
|
|
1417
|
+
* NFT the player shows for the box's odds at its first ask (by default the one of theirs that raises them most). */
|
|
1418
|
+
async function askRoll(orderAddress, { shown = null } = {}) {
|
|
1309
1419
|
return send([
|
|
1310
1420
|
computeUnitLimit(400_000),
|
|
1311
|
-
...(await asksFor(wallet.address, orderAddress, await read('Order', orderAddress))),
|
|
1421
|
+
...(await asksFor(wallet.address, orderAddress, await read('Order', orderAddress), undefined, shown)),
|
|
1312
1422
|
]);
|
|
1313
1423
|
}
|
|
1314
1424
|
|
|
@@ -1349,7 +1459,17 @@ export function solanaApp({
|
|
|
1349
1459
|
);
|
|
1350
1460
|
}
|
|
1351
1461
|
/** What `buy` and `openBox` take: a box bought at once (`buyBox`) or opened (`open`). */
|
|
1352
|
-
const BOX_OPTIONS = [
|
|
1462
|
+
const BOX_OPTIONS = [
|
|
1463
|
+
'shown',
|
|
1464
|
+
'listProof',
|
|
1465
|
+
'maxPrice',
|
|
1466
|
+
'listing',
|
|
1467
|
+
'chosenRecipient',
|
|
1468
|
+
'on',
|
|
1469
|
+
'oneSignature',
|
|
1470
|
+
'count',
|
|
1471
|
+
'acceptLeftover',
|
|
1472
|
+
];
|
|
1353
1473
|
|
|
1354
1474
|
/**
|
|
1355
1475
|
* Buys `count` of a box that needs no roll (a shop item, a bundle, a fixed pack: one entry, nothing random, no NFT
|
|
@@ -1369,6 +1489,11 @@ export function solanaApp({
|
|
|
1369
1489
|
const g = await projectNow();
|
|
1370
1490
|
const box = await read('LootBox', loot.pda.box(project, boxId));
|
|
1371
1491
|
if (!Number.isInteger(count) || count < 1) throw new Error('Buy a whole number, one at least (count).');
|
|
1492
|
+
// One `buy` prices at most so many sales (`PackTooLarge` past it): more is several purchases.
|
|
1493
|
+
if (count > loot.MOST_SALES_A_BUY)
|
|
1494
|
+
throw new Error(
|
|
1495
|
+
`One purchase buys at most ${loot.MOST_SALES_A_BUY.toLocaleString('en-US')} (count): buy the rest in another.`,
|
|
1496
|
+
);
|
|
1372
1497
|
const bound =
|
|
1373
1498
|
boundOf({ ...box, currencyKey: part?.currency }, part?.name ?? `box ${boxId}`, { maxPrice, listing }, part?.key) *
|
|
1374
1499
|
BigInt(count);
|
|
@@ -1455,6 +1580,7 @@ export function solanaApp({
|
|
|
1455
1580
|
chosenRecipient = null,
|
|
1456
1581
|
listProof,
|
|
1457
1582
|
count = 1,
|
|
1583
|
+
acceptLeftover,
|
|
1458
1584
|
} = {},
|
|
1459
1585
|
) {
|
|
1460
1586
|
const g = await projectNow();
|
|
@@ -1468,6 +1594,8 @@ export function solanaApp({
|
|
|
1468
1594
|
const items = await itemsOf(box);
|
|
1469
1595
|
// A box that rolls on an NFT takes the one the player brings (`on`), whatever its kind.
|
|
1470
1596
|
if (box.subject && !on) throw new Error('This box rolls on an NFT: say which (on).');
|
|
1597
|
+
// A box that burns it, gives it away or moves its numbers takes it into the project's hands until the roll settles.
|
|
1598
|
+
if (box.subject && loot.boxHolds(box)) await noLeftovers(on, acceptLeftover);
|
|
1471
1599
|
const brought = box.subject ? await nftOf(on) : { before: [] };
|
|
1472
1600
|
const { before: proving, hasAuthority, ...kind } = brought;
|
|
1473
1601
|
const subject = box.subject
|
|
@@ -1706,6 +1834,339 @@ export function solanaApp({
|
|
|
1706
1834
|
);
|
|
1707
1835
|
}
|
|
1708
1836
|
|
|
1837
|
+
/**
|
|
1838
|
+
* An auction's end, as `endAuction` and `callOffSale` send it (`a`: the auction's part of the spec). Each step is
|
|
1839
|
+
* checked before the wallet signs: a dry run when it is one legacy transaction, the size otherwise.
|
|
1840
|
+
*/
|
|
1841
|
+
async function endSale(a, { holders, callOff = false } = {}) {
|
|
1842
|
+
const at = auctionAt(a);
|
|
1843
|
+
let auction = await read('Auction', at);
|
|
1844
|
+
const g = await projectNow();
|
|
1845
|
+
// What a transfer of the bids' coin carries: the Memo program for Token-2022, and a transfer hook's accounts.
|
|
1846
|
+
const memo = await hookTail([auction.mint]);
|
|
1847
|
+
const sold = auction.high > 0n;
|
|
1848
|
+
const lot = auction.lot.Asset?.[0] ?? null;
|
|
1849
|
+
const owner = lot ? auction.seller : g.treasury;
|
|
1850
|
+
// The bids' coin's token program, read from the mint when the app wasn't told it.
|
|
1851
|
+
const coin = programs[auction.mint] ?? (await client.account(auction.mint))?.owner ?? TOKEN_PROGRAM;
|
|
1852
|
+
const base = {
|
|
1853
|
+
signer: wallet.address,
|
|
1854
|
+
project,
|
|
1855
|
+
projectState: g,
|
|
1856
|
+
id: a.id,
|
|
1857
|
+
programs: { ...programs, [auction.mint]: coin },
|
|
1858
|
+
extra: memo,
|
|
1859
|
+
};
|
|
1860
|
+
if (callOff && (!sold || !lot))
|
|
1861
|
+
throw new Error('Only the sale of an NFT lot that has a bid is called off: end this auction with endAuction.');
|
|
1862
|
+
// The lot went at an earlier end: this one pays what the bid still owes and closes the auction.
|
|
1863
|
+
if (BigInt(auction.proceeds_owed) > 0n) {
|
|
1864
|
+
if (callOff)
|
|
1865
|
+
throw new Error(
|
|
1866
|
+
'The lot went to its winner already: the sale stands (CantCallOff). End it to pay what it owes.',
|
|
1867
|
+
);
|
|
1868
|
+
return { ...(await payOwed(a, auction, base, holders)), owedPaid: auction.proceeds_owed };
|
|
1869
|
+
}
|
|
1870
|
+
const item = auction.lot.Item ? await read('Item', loot.pda.item(project, auction.lot.Item[0])) : null;
|
|
1871
|
+
const made =
|
|
1872
|
+
sold && item?.kind === 'Collectible'
|
|
1873
|
+
? loot.pda.itemAsset(loot.pda.item(project, auction.lot.Item[0]), auction.pin)
|
|
1874
|
+
: null;
|
|
1875
|
+
// Gone (taken back or burned by whoever controls its collection): the lot, or an NFT of the winning bid.
|
|
1876
|
+
const lotNow = lot ? await heldNow(lot) : { held: null };
|
|
1877
|
+
const bidNow = [];
|
|
1878
|
+
if (sold && auction.nft_bids) for (const nft of auction.held) bidNow.push(await heldNow(nft));
|
|
1879
|
+
const gone = lotNow.held === false || bidNow.some(n => n.held === false);
|
|
1880
|
+
const ending = sold && lot && (callOff || gone) ? 'CallOff' : 'Deliver';
|
|
1881
|
+
const home = !sold || ending === 'CallOff';
|
|
1882
|
+
// The lot to its winner, or home to its seller; a bid's NFTs to whoever owned the lot, or back to their bidder.
|
|
1883
|
+
const before = [];
|
|
1884
|
+
const lotKind = lot ? await kindFor(lot, home ? auction.seller : auction.high_bidder, lotNow.evidence, before) : {};
|
|
1885
|
+
const bidTo = ending === 'CallOff' ? auction.high_bidder : owner;
|
|
1886
|
+
const held = [];
|
|
1887
|
+
if (sold && auction.nft_bids)
|
|
1888
|
+
for (const [i, nft] of auction.held.entries()) held.push(await kindFor(nft, bidTo, bidNow[i].evidence, before));
|
|
1889
|
+
if (sold && !home) holders ??= await holdersOf(auction.shares);
|
|
1890
|
+
const end = (ending, hold = false) =>
|
|
1891
|
+
loot.endAuctionIx({ ...base, auction, item, ...lotKind, held, holders, ending, hold });
|
|
1892
|
+
const steps = ix => [
|
|
1893
|
+
computeUnitLimit(1_000_000),
|
|
1894
|
+
...before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
|
|
1895
|
+
ix,
|
|
1896
|
+
];
|
|
1897
|
+
const fits = ix => Boolean(sendPlan({ payer: wallet.address, instructions: steps(ix), alone: isProveLeaf }));
|
|
1898
|
+
const result = {
|
|
1899
|
+
...(sold && ending === 'Deliver' ? { winner: auction.high_bidder } : {}),
|
|
1900
|
+
...(made ? { asset: made } : {}),
|
|
1901
|
+
};
|
|
1902
|
+
if (ending === 'CallOff') Object.assign(result, { calledOff: true, gone });
|
|
1903
|
+
|
|
1904
|
+
// An end that may make holds: a lot going home, a bid's NFTs going back or to the treasury for the project's lot.
|
|
1905
|
+
const outs = [
|
|
1906
|
+
...(lot && home && lotNow.held !== false ? [[lot, auction.seller]] : []),
|
|
1907
|
+
...(sold && auction.nft_bids && (home || !lot)
|
|
1908
|
+
? auction.held.filter((_, i) => bidNow[i].held !== false).map(x => [x, bidTo])
|
|
1909
|
+
: []),
|
|
1910
|
+
];
|
|
1911
|
+
const holding = async () => ({
|
|
1912
|
+
...(await heldThenReleased(
|
|
1913
|
+
at,
|
|
1914
|
+
steps(
|
|
1915
|
+
end(
|
|
1916
|
+
ending,
|
|
1917
|
+
outs.map(([x]) => x),
|
|
1918
|
+
),
|
|
1919
|
+
),
|
|
1920
|
+
outs,
|
|
1921
|
+
)),
|
|
1922
|
+
...result,
|
|
1923
|
+
});
|
|
1924
|
+
if (fits(end(ending))) {
|
|
1925
|
+
if (await stuck(steps(end(ending)))) {
|
|
1926
|
+
// What can't move now goes home or back as a hold, sent on once it can; a sold lot waits instead.
|
|
1927
|
+
if (outs.length) return holding();
|
|
1928
|
+
return {
|
|
1929
|
+
waiting: true,
|
|
1930
|
+
says: 'The lot can’t move to its winner now (its collection froze it, or refuses the move): the sale waits, the winning bid in the project’s vault. End it again later; the project’s keys can call it off (callOffSale), and if the lot is taken back or burned anyone can.',
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
// 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
|
|
1934
|
+
// (`collect`); the winner ending it asks in the same transaction and a key settles it.
|
|
1935
|
+
const roll =
|
|
1936
|
+
sold && auction.lot.Roll
|
|
1937
|
+
? loot.pda.order(
|
|
1938
|
+
project,
|
|
1939
|
+
auction.high_bidder,
|
|
1940
|
+
loot.prizeRollNonce(loot.prizeNonce(at, BigInt(auction.last_bid_at))),
|
|
1941
|
+
)
|
|
1942
|
+
: null;
|
|
1943
|
+
const { signature, rolls } =
|
|
1944
|
+
roll && auction.high_bidder === wallet.address
|
|
1945
|
+
? await sendRolling(steps(end(ending)))
|
|
1946
|
+
: { signature: await send(steps(end(ending))), rolls: [] };
|
|
1947
|
+
return {
|
|
1948
|
+
signature,
|
|
1949
|
+
...result,
|
|
1950
|
+
...(roll ? { order: roll } : {}),
|
|
1951
|
+
...(rolls.length ? { rolled: rolls[0] } : {}),
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
// Too wide for one transaction. A sold NFT lot goes first, and a second end pays the bid.
|
|
1955
|
+
if (sold && lot && ending === 'Deliver') {
|
|
1956
|
+
const first = steps(end('LotFirst'));
|
|
1957
|
+
if (await stuck(first))
|
|
1958
|
+
return {
|
|
1959
|
+
waiting: true,
|
|
1960
|
+
says: 'The lot can’t move to its winner now (its collection froze it, or refuses the move): the sale waits, the winning bid in the project’s vault. End it again later; the project’s keys can call it off (callOffSale).',
|
|
1961
|
+
};
|
|
1962
|
+
const signature = await send(first);
|
|
1963
|
+
auction = await read('Auction', at);
|
|
1964
|
+
return { signature, ...result, lotFirst: true, ...(await payOwed(a, auction, base, holders)) };
|
|
1965
|
+
}
|
|
1966
|
+
// Otherwise each NFT that may be a hold becomes one, and is sent on with a release after.
|
|
1967
|
+
return holding();
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
/** An NFT's kind on an auction's end, going to `to`: what `nftOf` reads, its proof put in `before`; `evidence` for a Token
|
|
1971
|
+
* Metadata NFT that left the project (the account that holds it now, or its mint). */
|
|
1972
|
+
async function kindFor(asset, to, evidence, before) {
|
|
1973
|
+
const { before: proof, hasAuthority: _, ...kind } = await nftOf(asset, wallet.address, { to });
|
|
1974
|
+
before.push(...proof);
|
|
1975
|
+
return {
|
|
1976
|
+
...kind,
|
|
1977
|
+
collection: kind.compressed || kind.tokenMetadata ? undefined : ((await collectionOf(asset)) ?? MPL_CORE),
|
|
1978
|
+
...(evidence && kind.tokenMetadata ? { evidence } : {}),
|
|
1979
|
+
};
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
/**
|
|
1983
|
+
* Whether an end can't move an NFT now: dry-run as the wallet would send it (one legacy transaction; a bigger one is
|
|
1984
|
+
* sent as it is, and counts as one that can). A refusal of loot's is said in words; a failure in the NFT's own program
|
|
1985
|
+
* (it is frozen, or its collection refuses the move) is one that is stuck: true, and nothing is sent.
|
|
1986
|
+
*/
|
|
1987
|
+
async function stuck(instructions) {
|
|
1988
|
+
const plan = sendPlan({ payer: wallet.address, instructions, alone: isProveLeaf });
|
|
1989
|
+
if (!plan?.legacy) return false;
|
|
1990
|
+
const tried = await dryRun(wallet.address, instructions);
|
|
1991
|
+
if (tried.ok) return false;
|
|
1992
|
+
if (tried.error) throw Object.assign(new Error(`${tried.error.msg} (${tried.error.name}).`), tried.error);
|
|
1993
|
+
return true;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
/**
|
|
1997
|
+
* After an NFT lot went first: the end that pays what the bid still owes and closes the auction. The accounts its
|
|
1998
|
+
* money goes into (each share's wallet and the seller's) are opened first, in a transaction of their own when any is
|
|
1999
|
+
* missing: the end itself can make up to 64 calls. A bid's NFTs go to the seller, made holds and released after when
|
|
2000
|
+
* they don't all fit. Returns { paid } (its signature), and `releases`.
|
|
2001
|
+
*/
|
|
2002
|
+
async function payOwed(a, auction, base, holders) {
|
|
2003
|
+
const at = auctionAt(a);
|
|
2004
|
+
holders ??= await holdersOf(auction.shares);
|
|
2005
|
+
const opens = [];
|
|
2006
|
+
if (!auction.nft_bids) {
|
|
2007
|
+
const payees = [
|
|
2008
|
+
auction.seller,
|
|
2009
|
+
...auction.shares.flatMap(sh =>
|
|
2010
|
+
sh.to.Wallet
|
|
2011
|
+
? [sh.to.Wallet[0]]
|
|
2012
|
+
: sh.to.Holder
|
|
2013
|
+
? [holders[sh.to.Holder[0]]]
|
|
2014
|
+
: sh.to === 'Treasury'
|
|
2015
|
+
? [base.projectState.treasury]
|
|
2016
|
+
: [],
|
|
2017
|
+
),
|
|
2018
|
+
].filter(Boolean);
|
|
2019
|
+
const program = base.programs[auction.mint];
|
|
2020
|
+
const accounts = await client.accounts(payees.map(w => loot.ata(w, auction.mint, program)));
|
|
2021
|
+
for (const [i, w] of payees.entries())
|
|
2022
|
+
if (!accounts[i]) opens.push(createAssociatedTokenAccountIdempotent(wallet.address, w, auction.mint, program));
|
|
2023
|
+
}
|
|
2024
|
+
if (opens.length) await send([...new Map(opens.map(x => [x.keys[1].pubkey, x])).values()]);
|
|
2025
|
+
const before = [];
|
|
2026
|
+
const held = [];
|
|
2027
|
+
if (auction.nft_bids) for (const nft of auction.held) held.push(await kindFor(nft, auction.seller, null, before));
|
|
2028
|
+
const end = (hold = false) => loot.endAuctionIx({ ...base, auction, held, holders, hold });
|
|
2029
|
+
const steps = ix => [
|
|
2030
|
+
computeUnitLimit(1_000_000),
|
|
2031
|
+
...before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
|
|
2032
|
+
ix,
|
|
2033
|
+
];
|
|
2034
|
+
if (sendPlan({ payer: wallet.address, instructions: steps(end()), alone: isProveLeaf }))
|
|
2035
|
+
try {
|
|
2036
|
+
return { paid: await send(steps(end())) };
|
|
2037
|
+
} catch (e) {
|
|
2038
|
+
// The lot is the winner's already: what is owed waits until the account it goes into can take it.
|
|
2039
|
+
return {
|
|
2040
|
+
paid: null,
|
|
2041
|
+
says: `The lot is the winner’s; the money waits (${e.message}): end the auction again to pay it.`,
|
|
2042
|
+
};
|
|
2043
|
+
}
|
|
2044
|
+
const outs = auction.held.map(x => [x, auction.seller]);
|
|
2045
|
+
const { signature, releases } = await heldThenReleased(at, steps(end(true)), outs);
|
|
2046
|
+
return { paid: signature, releases };
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
/**
|
|
2050
|
+
* An end that makes NFTs holds (`outs`: [asset, to]), sent by the wallet with a transfer that funds a throwaway key; the
|
|
2051
|
+
* key then sends each on with a release (anyone may), funded for what each may open (a pNFT's token account and record,
|
|
2052
|
+
* a compressed NFT's proof record). One the key can't send, the wallet does. Returns { signature, releases }.
|
|
2053
|
+
*/
|
|
2054
|
+
async function heldThenReleased(at, instructions, outs) {
|
|
2055
|
+
const { key, fund } = await keyFor(
|
|
2056
|
+
BigInt(outs.length) * (2n * SIGNATURE_FEE + loot.SETTLE_RENT_UNIT),
|
|
2057
|
+
await jobKey(at),
|
|
2058
|
+
);
|
|
2059
|
+
const signature = await send([...instructions, fund]);
|
|
2060
|
+
const releases = [];
|
|
2061
|
+
for (const [asset, to] of outs) {
|
|
2062
|
+
const release = async signer => {
|
|
2063
|
+
const hold = await read('Hold', loot.pda.hold(asset, 0));
|
|
2064
|
+
const { before: proof, hasAuthority: _h, ...kind } = await nftOf(asset, signer, { to });
|
|
2065
|
+
return [
|
|
2066
|
+
...proof,
|
|
2067
|
+
loot.releaseIx({
|
|
2068
|
+
signer,
|
|
2069
|
+
project,
|
|
2070
|
+
asset,
|
|
2071
|
+
...kind,
|
|
2072
|
+
collection: kind.compressed || kind.tokenMetadata ? undefined : ((await collectionOf(asset)) ?? MPL_CORE),
|
|
2073
|
+
hold,
|
|
2074
|
+
to,
|
|
2075
|
+
}),
|
|
2076
|
+
];
|
|
2077
|
+
};
|
|
2078
|
+
const steps = await release(key.address);
|
|
2079
|
+
const byKey = (await dryRun(key.address, steps)).ok ? await sendAs(key, steps).catch(() => null) : null;
|
|
2080
|
+
// One that still can't move (frozen for now) stays a hold: claims() lists it for whoever it is owed to.
|
|
2081
|
+
releases.push(byKey ?? (await send(await release(wallet.address)).catch(e => ({ asset, waits: e.message }))));
|
|
2082
|
+
}
|
|
2083
|
+
await emptyKey(key);
|
|
2084
|
+
return { signature, releases };
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/**
|
|
2088
|
+
* A hold's claim, or, when the project no longer holds its NFT (whoever controls its collection took it back or
|
|
2089
|
+
* burned it while it was held), the same claim flagged `gone`: the hold's record still says the NFT is held, and it
|
|
2090
|
+
* is not, so nothing can be sent.
|
|
2091
|
+
*/
|
|
2092
|
+
async function goneOr(asset, claim) {
|
|
2093
|
+
if ((await heldNow(asset)).held !== false) return claim;
|
|
2094
|
+
return {
|
|
2095
|
+
...claim,
|
|
2096
|
+
gone: true,
|
|
2097
|
+
says: 'The project no longer holds this NFT: whoever controls its collection took it back or burned it while it was held. Its hold says otherwise, but nothing can be sent, and nothing was paid against it.',
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
/**
|
|
2102
|
+
* The records of this project an earlier owner of `asset` left behind: its listing at
|
|
2103
|
+
* `["listing", project, asset]` (one read) and its holds (one search, by the asset's address), each that names a wallet
|
|
2104
|
+
* other than the player's. Each is { kind, address, says }. Such a record stays when whoever controls the NFT's
|
|
2105
|
+
* collection moved it out of the project's hands without the program, and the program hands out whatever the project
|
|
2106
|
+
* holds at that address: once this player's deposit puts the NFT back in the project's hands, the record can send it
|
|
2107
|
+
* to the wallet it names. NFTs of the project's own collections are not read: those collections refuse the permanent
|
|
2108
|
+
* delegates that take an NFT out.
|
|
2109
|
+
*/
|
|
2110
|
+
async function leftovers(asset) {
|
|
2111
|
+
if (ours(await collectionOf(asset))) return [];
|
|
2112
|
+
const me = wallet.address;
|
|
2113
|
+
const out = [];
|
|
2114
|
+
const listingAt = loot.pda.listing(project, asset);
|
|
2115
|
+
const listing = await readMaybe('Listing', listingAt);
|
|
2116
|
+
if (listing && listing.maker !== me) {
|
|
2117
|
+
const sale = Boolean(listing.deal.Sell);
|
|
2118
|
+
out.push({
|
|
2119
|
+
kind: 'listing',
|
|
2120
|
+
address: listingAt,
|
|
2121
|
+
says: `A listing of this NFT (${listingAt}) is still open in this project, left by an earlier owner, ${listing.maker}. Once the project holds the NFT again, ${listing.maker} can take it home by closing that listing${sale ? `, and anyone can buy it at the listing's price, which is paid to ${listing.maker}` : ''}.`,
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
const tag = { memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS.Hold)) } };
|
|
2125
|
+
const holds = await client.programAccounts(loot.LOOT_PROGRAM, [
|
|
2126
|
+
tag,
|
|
2127
|
+
{ memcmp: { offset: 1, bytes: project } },
|
|
2128
|
+
{ memcmp: { offset: 33, bytes: asset } },
|
|
2129
|
+
]);
|
|
2130
|
+
const time = BigInt(await chainNow());
|
|
2131
|
+
for (const { pubkey, account } of holds) {
|
|
2132
|
+
const hold = loot.decode('Hold', account.data);
|
|
2133
|
+
const to = hold.player === project ? 'the project’s treasury' : hold.player;
|
|
2134
|
+
const names = [hold.player, hold.debt?.lender].filter(Boolean);
|
|
2135
|
+
if (names.every(x => x === me)) continue;
|
|
2136
|
+
const due = time >= BigInt(hold.free_at);
|
|
2137
|
+
const what = hold.debt
|
|
2138
|
+
? due
|
|
2139
|
+
? `a loan's hold whose loan is overdue: anyone can send the NFT to its lender, ${hold.debt.lender}`
|
|
2140
|
+
: `a loan's hold: anyone who repays it (${hold.debt.repay} to ${hold.debt.lender}) sends the NFT to ${to}, and once it is due anyone sends it to the lender`
|
|
2141
|
+
: due
|
|
2142
|
+
? `a hold that is over: anyone can send the NFT to ${to} at once`
|
|
2143
|
+
: `a hold that runs until ${new Date(Number(hold.free_at) * 1000).toISOString()}: the project's authority decides where the NFT goes until then, and anyone sends it to ${to} after`;
|
|
2144
|
+
out.push({
|
|
2145
|
+
kind: 'hold',
|
|
2146
|
+
address: pubkey,
|
|
2147
|
+
says: `This project still has ${what} (${pubkey}), left from an earlier owner of this NFT. Once the project holds the NFT again, it can be sent there.`,
|
|
2148
|
+
});
|
|
2149
|
+
}
|
|
2150
|
+
return out;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/**
|
|
2154
|
+
* Refuses, in words, to hand `asset` to the project while a record an earlier owner left can take it (`leftovers`),
|
|
2155
|
+
* unless the caller says the player accepts that (`accept` true). `listing`: the route is a listing, which the program
|
|
2156
|
+
* refuses outright while a listing of the NFT is open (its address is taken), whatever the option says.
|
|
2157
|
+
*/
|
|
2158
|
+
async function noLeftovers(asset, accept, { listing = false } = {}) {
|
|
2159
|
+
const found = await leftovers(asset);
|
|
2160
|
+
if (listing && found.some(x => x.kind === 'listing'))
|
|
2161
|
+
throw new Error(
|
|
2162
|
+
`${found.find(x => x.kind === 'listing').says} The program refuses another listing of this NFT while that one is open (its address is taken): nothing was sent.`,
|
|
2163
|
+
);
|
|
2164
|
+
if (!found.length || accept === true) return;
|
|
2165
|
+
throw new Error(
|
|
2166
|
+
`${found.map(x => x.says).join(' ')} This deposit would put the NFT where that record can take it: it would be lost to you. Nothing was sent. Go ahead only if the player accepts this: pass acceptLeftover: true.`,
|
|
2167
|
+
);
|
|
2168
|
+
}
|
|
2169
|
+
|
|
1709
2170
|
/** Who holds each collectible a split's holder shares name now: { asset: holder }. */
|
|
1710
2171
|
async function holdersOf(shares) {
|
|
1711
2172
|
const holders = {};
|
|
@@ -1846,7 +2307,9 @@ export function solanaApp({
|
|
|
1846
2307
|
return out;
|
|
1847
2308
|
}
|
|
1848
2309
|
|
|
1849
|
-
/** The nonce a pull of entry `i` of the round at `key` names: a place's roll is made at `prizeRollNonce(nonce + place)`.
|
|
2310
|
+
/** The nonce a pull of entry `i` of the round at `key` names: a place's roll is made at `prizeRollNonce(nonce + place)`.
|
|
2311
|
+
* The one nonce of that entry: every pull of it (the winner's, a payout's, a cancel's, a run of rolls in parts) names
|
|
2312
|
+
* this and no other, so no later pull lands on an order an earlier one made. */
|
|
1850
2313
|
const pullNonce = (key, i) => loot.prizeNonce(key, BigInt(i));
|
|
1851
2314
|
|
|
1852
2315
|
/** How many of a round's owed parts a pull of `part` (of entry `i`) settles. */
|
|
@@ -1863,10 +2326,15 @@ export function solanaApp({
|
|
|
1863
2326
|
* `signer`, and the rolls they make ([{ order, box }], `loot.pullRolls`). With `closes`, the last pull carries the
|
|
1864
2327
|
* round's close (when the parts it pays are the last owed).
|
|
1865
2328
|
*/
|
|
1866
|
-
async function pulls(key, r, i, parts, signer, { closes = false } = {}) {
|
|
2329
|
+
async function pulls(key, r, i, parts, signer, { closes = false, lotteryClosed = false } = {}) {
|
|
1867
2330
|
const e = i == null ? null : r.entries[i];
|
|
1868
2331
|
const out = [];
|
|
1869
2332
|
const rolls = [];
|
|
2333
|
+
// Whether a pull here wants the largest heap (a run of rolls, or several NFTs out of the round's own stash), and
|
|
2334
|
+
// whether one stops short of the part (a raffle's NFTs past what one pull takes out or one transaction carries, a
|
|
2335
|
+
// run of rolls past what one transaction carries): a next pull carries on.
|
|
2336
|
+
let heap = false;
|
|
2337
|
+
let partial = false;
|
|
1870
2338
|
const g = await projectNow();
|
|
1871
2339
|
// A run's token prize is in its own mint: the prizes the round recorded name each.
|
|
1872
2340
|
const extra = await hookTail([...roundMints(r), ...r.prizes.map(p => p.mint)]);
|
|
@@ -1890,6 +2358,10 @@ export function solanaApp({
|
|
|
1890
2358
|
? loot.decodeStash((await client.account(loot.pda.lotteryStash(r.lottery)))?.data ?? null)
|
|
1891
2359
|
: null;
|
|
1892
2360
|
if (stash) {
|
|
2361
|
+
// One pull takes out at most so many of the stash's NFTs (`loot.pullAssigns`): the places past them wait.
|
|
2362
|
+
const owedPlaces = loot.prizePlacesOwed(r, i, run);
|
|
2363
|
+
const last = loot.placesIn(r, run, e.start, e.end).at(-1) ?? 0;
|
|
2364
|
+
if (owedPlaces.some(p => loot.nftSlot(r, p) >= loot.pullAssigns(r, stash, last))) partial = true;
|
|
1893
2365
|
const predicted = loot.stashNfts(r, stash);
|
|
1894
2366
|
for (const place of loot.prizePlacesOwed(r, i, run)) {
|
|
1895
2367
|
const nft = predicted[loot.nftSlot(r, place)];
|
|
@@ -1916,7 +2388,40 @@ export function solanaApp({
|
|
|
1916
2388
|
projectMint: mintOf(),
|
|
1917
2389
|
};
|
|
1918
2390
|
more.nonce = pullNonce(key, i);
|
|
1919
|
-
|
|
2391
|
+
// A run of places that each give a roll (two accounts a place), or several NFTs out of the round's own stash
|
|
2392
|
+
// (each NFT's accounts, and heap the program does not give back: 8 Core moves in a round of some 125 places or
|
|
2393
|
+
// more run past the default 32 KiB): as many places as one transaction carries, on the largest heap. The pull
|
|
2394
|
+
// is sized from a dry run, largest first; the next pull carries on while the round still owes the entry, and
|
|
2395
|
+
// only the last carries the close.
|
|
2396
|
+
if (given.Roll || (stash && loot.prizePlacesOwed(r, i, run).length > 1)) {
|
|
2397
|
+
const owed = loot.prizePlacesOwed(r, i, run).length;
|
|
2398
|
+
const sized = async most => {
|
|
2399
|
+
const ix = loot.pullIx({
|
|
2400
|
+
payer: signer,
|
|
2401
|
+
project,
|
|
2402
|
+
roundAddress: key,
|
|
2403
|
+
round: { ...r, owed: Number.MAX_SAFE_INTEGER },
|
|
2404
|
+
entry: i,
|
|
2405
|
+
part,
|
|
2406
|
+
projectState: g,
|
|
2407
|
+
programs,
|
|
2408
|
+
extra,
|
|
2409
|
+
...more,
|
|
2410
|
+
prize: { ...more.prize, most },
|
|
2411
|
+
});
|
|
2412
|
+
const tx = [computeUnitLimit(1_400_000), requestHeapFrame(loot.LARGE_SETTLE_HEAP), ix];
|
|
2413
|
+
return (
|
|
2414
|
+
Boolean(
|
|
2415
|
+
compileFitting({ payer: signer, blockhash: NOBODY, instructions: tx, tables: await standing() }),
|
|
2416
|
+
) && (await dryRun(signer, tx)).ok
|
|
2417
|
+
);
|
|
2418
|
+
};
|
|
2419
|
+
let most = owed;
|
|
2420
|
+
while (most > 1 && !(await sized(most))) most = Math.max(1, Math.floor(most * 0.75));
|
|
2421
|
+
if (most < owed) [more.prize.most, partial] = [most, true];
|
|
2422
|
+
heap = true;
|
|
2423
|
+
}
|
|
2424
|
+
rolls.push(...loot.pullRolls({ project, round: r, entry: i, part, nonce: more.nonce, most: more.prize.most }));
|
|
1920
2425
|
} else if (part.Common) {
|
|
1921
2426
|
// Where a part owed in common goes: a share's payee (a collectible's holder now), what the places leave (the
|
|
1922
2427
|
// pot, or the `rest` a round paid from its own money names), a pool; one that can't take it (a pool never
|
|
@@ -1947,13 +2452,14 @@ export function solanaApp({
|
|
|
1947
2452
|
entry: i ?? 0,
|
|
1948
2453
|
part,
|
|
1949
2454
|
projectState: g,
|
|
2455
|
+
lotteryClosed,
|
|
1950
2456
|
programs,
|
|
1951
2457
|
extra,
|
|
1952
2458
|
...more,
|
|
1953
2459
|
}),
|
|
1954
2460
|
);
|
|
1955
2461
|
}
|
|
1956
|
-
return { instructions: out, rolls };
|
|
2462
|
+
return { instructions: out, rolls, heap, partial };
|
|
1957
2463
|
}
|
|
1958
2464
|
|
|
1959
2465
|
return {
|
|
@@ -2102,15 +2608,20 @@ export function solanaApp({
|
|
|
2102
2608
|
return send([...before, loot.refundIx({ signer: wallet.address, orderAddress, order, subject, programs })]);
|
|
2103
2609
|
},
|
|
2104
2610
|
|
|
2105
|
-
/** Asks the oracle for a roll a mission, a recipe or a prize made (
|
|
2106
|
-
*
|
|
2107
|
-
*
|
|
2108
|
-
|
|
2611
|
+
/** Asks the oracle for a roll a mission, a recipe or a prize made (its player or the project's keys may; the asker
|
|
2612
|
+
* pays the oracle). Its first ask fixes its odds: the player's profile, and an NFT they show (`shown`; by default
|
|
2613
|
+
* their collectible that raises the box's odds the most), count then. On a box with nothing in stock only the order's
|
|
2614
|
+
* player may ask, a slot after it was made or last asked, and that ends the roll there with nothing, no oracle (a lot
|
|
2615
|
+
* its winner paid for fails, for its refund). */
|
|
2616
|
+
askRoll: (orderAddress, options = {}) => {
|
|
2617
|
+
only(options, ['shown'], 'askRoll');
|
|
2618
|
+
return askRoll(orderAddress, options);
|
|
2619
|
+
},
|
|
2109
2620
|
|
|
2110
2621
|
/**
|
|
2111
2622
|
* Pays a payment of the economy (an entry, an attempt, a tip). `amount`: what the player chose, in whole units, for
|
|
2112
2623
|
* a payment with `any` (at least its `amount`). `chosenRecipient`: the wallet the player names for a share the
|
|
2113
|
-
* payment lets them choose (a referrer
|
|
2624
|
+
* payment lets them choose (a referrer; their own wallet too, which then pays them back their share). `holders`: asset → holder, for a holder's share (read
|
|
2114
2625
|
* now when not given).
|
|
2115
2626
|
*/
|
|
2116
2627
|
async pay(paymentKey, { amount: chosen, holders, chosenRecipient = null, ...others } = NO_OPTIONS) {
|
|
@@ -2125,8 +2636,6 @@ export function solanaApp({
|
|
|
2125
2636
|
const pools = new Map((spec.pools ?? []).map(x => [x.key, x.index]));
|
|
2126
2637
|
// `pay` takes its shares from the payer each time: a "chosen" share pays the wallet they name, and with none named
|
|
2127
2638
|
// it is left out, so what it would pay goes to the treasury with the rest (as a purchase's does).
|
|
2128
|
-
if (chosenRecipient && chosenRecipient === wallet.address)
|
|
2129
|
-
throw new Error('A payer can’t name their own wallet for a share they choose.');
|
|
2130
2639
|
const shares = (p.split ?? []).flatMap(s => {
|
|
2131
2640
|
const [kind, key] = s.to.split(':');
|
|
2132
2641
|
const bps = Math.round(s.percent * 100);
|
|
@@ -2203,7 +2712,10 @@ export function solanaApp({
|
|
|
2203
2712
|
const copy = current ?? lottery;
|
|
2204
2713
|
const terms = copy?.terms;
|
|
2205
2714
|
const currency = lottery ? ticketCurrencyOf(l, lottery) : (l.ticketCurrency ?? currencyKey);
|
|
2206
|
-
const ended = lottery &&
|
|
2715
|
+
const ended = lottery && loot.lotteryEnded(lottery);
|
|
2716
|
+
// A lottery that weighs tickets by time: what one ticket bought now weighs, and its share of the round's weight
|
|
2717
|
+
// (the same ticket bought later weighs less: show it on the buy screen).
|
|
2718
|
+
const odds = terms && !ended ? loot.purchaseOdds(current, terms, 1n, await chainNow()) : null;
|
|
2207
2719
|
out.push({
|
|
2208
2720
|
key: l.key,
|
|
2209
2721
|
name: l.name,
|
|
@@ -2214,6 +2726,11 @@ export function solanaApp({
|
|
|
2214
2726
|
round: lottery?.round ?? null,
|
|
2215
2727
|
sold: lottery ? (current?.sold ?? 0n) : null,
|
|
2216
2728
|
drawsAt: terms ? Number(terms.draws_at) : null,
|
|
2729
|
+
salesCloseAt: terms ? Number(loot.salesCloseAt(terms)) : null,
|
|
2730
|
+
timeWeighted: Boolean(terms?.time_weighted),
|
|
2731
|
+
weight: current ? loot.roundTotal(current) : null,
|
|
2732
|
+
ticketWeight: odds?.weight ?? null,
|
|
2733
|
+
ticketShare: odds?.share ?? null,
|
|
2217
2734
|
nftTicket: Boolean(lottery?.terms.nft_ticket),
|
|
2218
2735
|
incarnation: lottery?.incarnation ?? null,
|
|
2219
2736
|
revision: copy?.revision ?? null,
|
|
@@ -2222,6 +2739,25 @@ export function solanaApp({
|
|
|
2222
2739
|
return out;
|
|
2223
2740
|
},
|
|
2224
2741
|
|
|
2742
|
+
/**
|
|
2743
|
+
* What `count` tickets of a lottery bought now would weigh in its current round's draw, before they are bought:
|
|
2744
|
+
* { tickets, weight, total, share, salesCloseAt, timeWeighted }. In a lottery that weighs tickets by time, each
|
|
2745
|
+
* ticket weighs the seconds from its purchase to the close of sales, so the same tickets bought later weigh less;
|
|
2746
|
+
* `share` is the purchase's part of the round's weight with it (0 to 1), its chance at each place as things stand.
|
|
2747
|
+
*/
|
|
2748
|
+
async ticketWeight(lotteryKey, count = 1) {
|
|
2749
|
+
const l = part('lotteries', lotteryKey);
|
|
2750
|
+
const { lottery, current } = await roundOf(l);
|
|
2751
|
+
const terms = (current ?? lottery).terms;
|
|
2752
|
+
const odds = loot.purchaseOdds(current, terms, BigInt(count), await chainNow());
|
|
2753
|
+
return {
|
|
2754
|
+
tickets: BigInt(count),
|
|
2755
|
+
...odds,
|
|
2756
|
+
salesCloseAt: Number(loot.salesCloseAt(terms)),
|
|
2757
|
+
timeWeighted: Boolean(terms.time_weighted),
|
|
2758
|
+
};
|
|
2759
|
+
},
|
|
2760
|
+
|
|
2225
2761
|
/**
|
|
2226
2762
|
* Buys `count` tickets of a lottery, on the terms the player was shown: `listing`, the lottery as `lotteries()`
|
|
2227
2763
|
* showed it (a lottery changed since, its price or anything else, is refused rather than paid), or `maxPrice`, the
|
|
@@ -2234,9 +2770,22 @@ export function solanaApp({
|
|
|
2234
2770
|
async buyTickets(
|
|
2235
2771
|
lotteryKey,
|
|
2236
2772
|
count = 1,
|
|
2237
|
-
{
|
|
2773
|
+
{
|
|
2774
|
+
shown,
|
|
2775
|
+
chosenRecipient = null,
|
|
2776
|
+
listProof,
|
|
2777
|
+
nft: paidWith,
|
|
2778
|
+
listing,
|
|
2779
|
+
maxPrice,
|
|
2780
|
+
acceptLeftover,
|
|
2781
|
+
...others
|
|
2782
|
+
} = NO_OPTIONS,
|
|
2238
2783
|
) {
|
|
2239
|
-
only(
|
|
2784
|
+
only(
|
|
2785
|
+
others,
|
|
2786
|
+
['shown', 'chosenRecipient', 'listProof', 'nft', 'listing', 'maxPrice', 'acceptLeftover'],
|
|
2787
|
+
'buyTickets',
|
|
2788
|
+
);
|
|
2240
2789
|
const l = part('lotteries', lotteryKey);
|
|
2241
2790
|
const name = l.name ?? lotteryKey;
|
|
2242
2791
|
if (listing && listing.key !== undefined && listing.key !== l.key)
|
|
@@ -2279,7 +2828,14 @@ export function solanaApp({
|
|
|
2279
2828
|
throw new Error(
|
|
2280
2829
|
`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.`,
|
|
2281
2830
|
);
|
|
2282
|
-
if (
|
|
2831
|
+
if (loot.lotteryEnded(lottery)) throw new Error(`“${l.name}” has drawn and ended.`);
|
|
2832
|
+
// Sales close `closes_before` seconds before the draw (SalesClosed after).
|
|
2833
|
+
// (A repeating lottery's current round nobody bought into moves to its next date as this purchase opens it.)
|
|
2834
|
+
const movesOn = !round && Number(lottery.every) > 0 && BigInt(now()) >= BigInt(terms.draws_at);
|
|
2835
|
+
if (!due && !movesOn && BigInt(now()) >= loot.salesCloseAt(terms))
|
|
2836
|
+
throw new Error(
|
|
2837
|
+
`Sales of “${name}” closed at ${new Date(Number(loot.salesCloseAt(terms)) * 1000).toISOString()}, before its draw: wait for the next round.`,
|
|
2838
|
+
);
|
|
2283
2839
|
shown ??= await passing(lottery.gate, 'buy a ticket');
|
|
2284
2840
|
listProof = listProofOf(lottery.gate, l.gate?.list, listProof);
|
|
2285
2841
|
let nft;
|
|
@@ -2287,6 +2843,8 @@ export function solanaApp({
|
|
|
2287
2843
|
if (terms.nft_ticket) {
|
|
2288
2844
|
if (!paidWith) throw new Error(`A ticket of “${l.name}” costs an NFT: say which (nft).`);
|
|
2289
2845
|
if (BigInt(count) !== 1n) throw new Error('An NFT pays for one ticket at a time.');
|
|
2846
|
+
// The project holds an NFT ticket until the round is drawn.
|
|
2847
|
+
await noLeftovers(paidWith, acceptLeftover);
|
|
2290
2848
|
const { before: proof, ...kind } = await nftOf(paidWith);
|
|
2291
2849
|
nft = { asset: paidWith, ...kind };
|
|
2292
2850
|
before.push(...proof);
|
|
@@ -2450,10 +3008,13 @@ export function solanaApp({
|
|
|
2450
3008
|
* Core asset, a Token Metadata NFT's mint (legacy or pNFT), or a compressed NFT's asset id. In a farm that watches,
|
|
2451
3009
|
* an NFT whose last staker sold it takes their seat over. A farm with an end takes none at its end or after it.
|
|
2452
3010
|
*/
|
|
2453
|
-
async stakeNft(farmKey, asset, tier = 0, { extra, transfers, ...others } = NO_OPTIONS) {
|
|
2454
|
-
only(others, ['extra', 'transfers'], 'stakeNft');
|
|
3011
|
+
async stakeNft(farmKey, asset, tier = 0, { extra, transfers, acceptLeftover, ...others } = NO_OPTIONS) {
|
|
3012
|
+
only(others, ['extra', 'transfers', 'acceptLeftover'], 'stakeNft');
|
|
2455
3013
|
const f = part('farms', farmKey);
|
|
2456
3014
|
const farm = await read('Farm', loot.pda.farm(project, f.id));
|
|
3015
|
+
// A farm that keeps its NFTs in escrow hands this one to the project; one that freezes or watches leaves it in the
|
|
3016
|
+
// wallet, where no record of the project's can move it.
|
|
3017
|
+
if (farm.custody === 'Escrow') await noLeftovers(asset, acceptLeftover);
|
|
2457
3018
|
await openFor(f, farm, tier);
|
|
2458
3019
|
const token = await farmTokens([farm.rewards_mint], [], { extra, transfers });
|
|
2459
3020
|
const { before, ...nft } = await nftOf(asset);
|
|
@@ -2485,12 +3046,17 @@ export function solanaApp({
|
|
|
2485
3046
|
|
|
2486
3047
|
/**
|
|
2487
3048
|
* Releases the collectible after its lock: it grows on its way out (a farm with `growth`), and from its own seat
|
|
2488
|
-
* takes its tier's prize (a mission: `prize.rs`). `forfeit` gives up rewards, and the prize, that cannot move.
|
|
2489
|
-
*
|
|
3049
|
+
* takes its tier's prize (a mission: `prize.rs`). `forfeit` gives up rewards, and the prize, that cannot move. A
|
|
3050
|
+
* prize that can't be given now refuses the leave and the NFT stays (`needPrize`, true by default); `needPrize: false`
|
|
3051
|
+
* leaves without it (the receipt says `prize_missed`). Returns the signature, or { signature, roll } when it makes a roll (a mission's result on the NFT, or a chest),
|
|
2490
3052
|
* which `collect` then finishes.
|
|
2491
3053
|
*/
|
|
2492
|
-
async unstakeNft(
|
|
2493
|
-
|
|
3054
|
+
async unstakeNft(
|
|
3055
|
+
farmKey,
|
|
3056
|
+
asset,
|
|
3057
|
+
{ forfeit = false, needPrize = true, extra, transfers, acceptLeftover, ...others } = NO_OPTIONS,
|
|
3058
|
+
) {
|
|
3059
|
+
only(others, ['forfeit', 'needPrize', 'extra', 'transfers', 'acceptLeftover'], 'unstakeNft');
|
|
2494
3060
|
const f = part('farms', farmKey);
|
|
2495
3061
|
const farm = await read('Farm', loot.pda.farm(project, f.id));
|
|
2496
3062
|
const token = await farmTokens(
|
|
@@ -2513,6 +3079,10 @@ export function solanaApp({
|
|
|
2513
3079
|
// `prizeRollNonce` of the one named (`namedRollNonce`).
|
|
2514
3080
|
const nonce = roll != null ? loot.nonceOn(asset) : freshNonce();
|
|
2515
3081
|
const boxes = roll != null ? { [roll]: await read('LootBox', loot.pda.box(project, roll)) } : {};
|
|
3082
|
+
// A mission whose box holds the NFT it rolls on takes it into the project's hands on the way out, from a farm that
|
|
3083
|
+
// left it in the wallet.
|
|
3084
|
+
if (roll != null && farm.custody !== 'Escrow' && loot.boxHolds(boxes[roll]))
|
|
3085
|
+
await noLeftovers(asset, acceptLeftover);
|
|
2516
3086
|
// A roll still at the NFT's order address blocks the leave, unless it was asked and came back with nothing to give
|
|
2517
3087
|
// (Waiting, asked once): that one stands in for this. Any other is asked (its player or the project's keys), answered and settled first.
|
|
2518
3088
|
if (roll != null) {
|
|
@@ -2531,6 +3101,7 @@ export function solanaApp({
|
|
|
2531
3101
|
asset,
|
|
2532
3102
|
...nft,
|
|
2533
3103
|
forfeit,
|
|
3104
|
+
needPrize,
|
|
2534
3105
|
roll,
|
|
2535
3106
|
nonce,
|
|
2536
3107
|
tier: seat.tier,
|
|
@@ -2541,9 +3112,20 @@ export function solanaApp({
|
|
|
2541
3112
|
boxes,
|
|
2542
3113
|
...token,
|
|
2543
3114
|
});
|
|
2544
|
-
const
|
|
2545
|
-
|
|
2546
|
-
)
|
|
3115
|
+
const steps = await walking(f, farm, [...before, ...given.before, ...token.before, ix]);
|
|
3116
|
+
// A mission prize that can't be given now (its pool short, an item sold out) refuses the leave and the NFT stays
|
|
3117
|
+
// (PrizeNotCovered): say so, and offer to leave without it (`needPrize: false`).
|
|
3118
|
+
if (prize != null && !forfeit && needPrize) {
|
|
3119
|
+
const tried = await dryRun(wallet.address, steps);
|
|
3120
|
+
if (!tried.ok && tried.error?.name === 'PrizeNotCovered')
|
|
3121
|
+
throw Object.assign(
|
|
3122
|
+
new Error(
|
|
3123
|
+
`This NFT's mission prize can't be given now, so it stays staked. Leave without the prize (needPrize: false) to take the NFT home now; the prize is then not given.`,
|
|
3124
|
+
),
|
|
3125
|
+
{ code: tried.error.code, name: 'PrizeNotCovered' },
|
|
3126
|
+
);
|
|
3127
|
+
}
|
|
3128
|
+
const { signature, rolls } = await sendRolling(steps);
|
|
2547
3129
|
const rolled = rolls.length ? { rolled: rolls[0] } : {};
|
|
2548
3130
|
if (roll != null)
|
|
2549
3131
|
return {
|
|
@@ -2712,6 +3294,9 @@ export function solanaApp({
|
|
|
2712
3294
|
freeAt: farm.lock_key ? null : Number(BigInt(seat.since) + lock),
|
|
2713
3295
|
...(farm.lock_key ? { lockKey: farm.lock_key } : {}),
|
|
2714
3296
|
boostPercent: Number(seat.boost_bps) / 100,
|
|
3297
|
+
// Its weight in the farm, as the seat holds it: near the farm's total cap a seat weighs only what is left, so
|
|
3298
|
+
// it is read, never worked out from the tier and the boost.
|
|
3299
|
+
weight: BigInt(seat.weight),
|
|
2715
3300
|
rewards: rewards?.key ?? null,
|
|
2716
3301
|
waiting: show(seat.owed, rewards ? decimalsOf(rewards.key) : 0),
|
|
2717
3302
|
owedUnits: BigInt(seat.owed),
|
|
@@ -2882,7 +3467,9 @@ export function solanaApp({
|
|
|
2882
3467
|
* Hands an NFT of any kind to the project until a time (a hold): `seconds` from now, or `{ until }` (Unix seconds).
|
|
2883
3468
|
* The hold's address names its end. Returns { signature, freeAt }.
|
|
2884
3469
|
*/
|
|
2885
|
-
async hold(asset, seconds) {
|
|
3470
|
+
async hold(asset, seconds, { acceptLeftover, ...others } = NO_OPTIONS) {
|
|
3471
|
+
only(others, ['acceptLeftover'], 'hold');
|
|
3472
|
+
await noLeftovers(asset, acceptLeftover);
|
|
2886
3473
|
const freeAt = BigInt(typeof seconds === 'object' ? seconds.until : now() + Number(seconds));
|
|
2887
3474
|
const { before, hasAuthority: _, ...nft } = await nftOf(asset);
|
|
2888
3475
|
const signature = await send([
|
|
@@ -2902,9 +3489,10 @@ export function solanaApp({
|
|
|
2902
3489
|
/**
|
|
2903
3490
|
* A lender's offer to borrow against: `maker`'s offer number `id` in this project. Returns { address, offer, mint,
|
|
2904
3491
|
* amount, repay, lasts, risks }: `risks`, in words, each power over the loan's token that could make repaying fail
|
|
2905
|
-
* (its freeze authority, a Token-2022 pause, a transfer hook, a permanent delegate) and who holds it
|
|
2906
|
-
*
|
|
2907
|
-
*
|
|
3492
|
+
* (its freeze authority, a Token-2022 pause, a transfer hook, a permanent delegate) and who holds it, and each setting
|
|
3493
|
+
* of the token that lets the lender turn the repayment away (accounts that start frozen or can be made to,
|
|
3494
|
+
* confidential transfers) or blocks it (a token that can't be transferred). The program takes such tokens, and a
|
|
3495
|
+
* lender who can stop every repayment takes the NFT once it is due: show the risks before the player borrows.
|
|
2908
3496
|
*/
|
|
2909
3497
|
async loanOffer(maker, id) {
|
|
2910
3498
|
const address = loot.pda.offer(project, maker, id);
|
|
@@ -2937,6 +3525,8 @@ export function solanaApp({
|
|
|
2937
3525
|
says: `${p.holder} can move anyone's tokens of the loan's token, the ones you would repay with too.${lender(p.holder)}`,
|
|
2938
3526
|
};
|
|
2939
3527
|
});
|
|
3528
|
+
// What the token's own settings let a lender do: the program no longer refuses these, so the kit says them.
|
|
3529
|
+
risks.push(...tokenSettings(account, 'repaying', 'the lender', 'and take your NFT when the loan is due'));
|
|
2940
3530
|
const { amount, repay, lasts } = offer.deal.Lend;
|
|
2941
3531
|
return { address, offer, mint: offer.mint, program: account.owner, amount, repay, lasts, risks };
|
|
2942
3532
|
},
|
|
@@ -2944,12 +3534,15 @@ export function solanaApp({
|
|
|
2944
3534
|
/**
|
|
2945
3535
|
* Borrows against the player's NFT `asset` from `maker`'s offer `id`: the NFT is held until the loan is repaid, or
|
|
2946
3536
|
* goes to the lender once it is due (`due`, Unix seconds; by default the longest the offer allows, less a minute).
|
|
2947
|
-
* A loan whose token someone could freeze, pause, hook or move
|
|
2948
|
-
* `acceptRisks` is true: the page shows the player
|
|
3537
|
+
* A loan whose token someone could freeze, pause, hook or move, or whose own settings let the lender turn the
|
|
3538
|
+
* repayment away (`loanOffer`'s `risks`), is refused unless `acceptRisks` is true: the page shows the player each
|
|
3539
|
+
* risk first. `extra`: a transfer hook's accounts.
|
|
2949
3540
|
*/
|
|
2950
|
-
async borrow(maker, id, asset, { due, acceptRisks = false, extra
|
|
2951
|
-
only(others, ['due', 'acceptRisks', 'extra'], 'borrow');
|
|
3541
|
+
async borrow(maker, id, asset, { due, acceptRisks = false, acceptLeftover, extra, ...others } = NO_OPTIONS) {
|
|
3542
|
+
only(others, ['due', 'acceptRisks', 'acceptLeftover', 'extra'], 'borrow');
|
|
3543
|
+
await noLeftovers(asset, acceptLeftover);
|
|
2952
3544
|
const loan = await this.loanOffer(maker, id);
|
|
3545
|
+
extra ??= await hookTail([loan.mint]);
|
|
2953
3546
|
if (loan.risks.length && acceptRisks !== true)
|
|
2954
3547
|
throw new Error(
|
|
2955
3548
|
`Repaying this loan can be blocked. ${loan.risks.map(r => r.says).join(' ')} Borrow only if the player accepts this: pass acceptRisks: true.`,
|
|
@@ -2996,18 +3589,21 @@ export function solanaApp({
|
|
|
2996
3589
|
},
|
|
2997
3590
|
|
|
2998
3591
|
/**
|
|
2999
|
-
* Repays the loan on `asset` (held until `due`), the player's own or a friend's,
|
|
3000
|
-
* borrower. `repay`: the debt the payer was shown (`loan()`'s, in base units);
|
|
3001
|
-
* (`TermsChanged`).
|
|
3002
|
-
*
|
|
3003
|
-
*
|
|
3004
|
-
*
|
|
3005
|
-
*
|
|
3592
|
+
* Repays the loan on `asset` (held until `due`), the player's own or a friend's, and brings the NFT home to its
|
|
3593
|
+
* borrower in the same instruction: one prompt. `repay`: the debt the payer was shown (`loan()`'s, in base units);
|
|
3594
|
+
* the program refuses any other (`TermsChanged`). An NFT its collection took back or burned while the project held
|
|
3595
|
+
* it is refused here, before anything is signed: the program takes no repayment for it, and the borrower keeps what
|
|
3596
|
+
* they borrowed. When the NFT can't move now (frozen for now, or its collection refuses the move), or `home` is
|
|
3597
|
+
* false, the money goes alone, so the loan does not fall due: the hold is then over, and anyone sends the NFT home
|
|
3598
|
+
* once it can move (`claims()` lists it). A compressed NFT is proven in the same transaction; the money alone for one
|
|
3599
|
+
* whose proof keeps going out of date goes without it. Returns { signature, home }, and `says` when the NFT waits.
|
|
3006
3600
|
*/
|
|
3007
|
-
async repay(asset, due, { repay,
|
|
3008
|
-
only(others, ['repay', 'extra'], 'repay');
|
|
3009
|
-
const
|
|
3601
|
+
async repay(asset, due, { repay, home = true, extra, ...others } = NO_OPTIONS) {
|
|
3602
|
+
only(others, ['repay', 'home', 'extra'], 'repay');
|
|
3603
|
+
const at = loot.pda.hold(asset, due);
|
|
3604
|
+
const hold = await read('Hold', at);
|
|
3010
3605
|
if (!hold.debt) throw new Error('This NFT is held for no loan left to repay.');
|
|
3606
|
+
extra ??= await hookTail([hold.debt.mint]);
|
|
3011
3607
|
if (repay === undefined || repay === null)
|
|
3012
3608
|
throw new Error(
|
|
3013
3609
|
'Say the debt the payer was shown (repay, from loan()): the amount read now is never paid in its place.',
|
|
@@ -3016,43 +3612,52 @@ export function solanaApp({
|
|
|
3016
3612
|
throw new Error(
|
|
3017
3613
|
`This loan now asks ${hold.debt.repay}, not the ${repay} shown: show the payer the loan as it is now first.`,
|
|
3018
3614
|
);
|
|
3615
|
+
if ((await heldNow(asset)).held === false)
|
|
3616
|
+
throw new Error(
|
|
3617
|
+
`The project no longer holds the NFT ${asset}: whoever controls its collection took it back or burned it while it was held. The program takes no repayment for it (NotTheNft): the borrower keeps what they borrowed.`,
|
|
3618
|
+
);
|
|
3019
3619
|
const { before, hasAuthority: _, ...nft } = await nftOf(asset, wallet.address, { to: hold.player });
|
|
3020
|
-
const
|
|
3021
|
-
// A
|
|
3022
|
-
|
|
3023
|
-
const
|
|
3024
|
-
const
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
heavy ? loot.SETTLE_RENT_UNIT + SIGNATURE_FEE : SIGNATURE_FEE,
|
|
3028
|
-
await jobKey(at),
|
|
3029
|
-
);
|
|
3030
|
-
const repaid = loot.repayIx({ payer: wallet.address, project, hold, repay, programs, extra });
|
|
3031
|
-
const signature = await send(
|
|
3032
|
-
withSolWrap(wallet.address, hold.debt.mint, BigInt(hold.debt.repay), [...opens, repaid, fund]),
|
|
3033
|
-
);
|
|
3034
|
-
// Repaid, the hold is a plain one that is over (no debt, `free_at` 0) at the address its loan named.
|
|
3035
|
-
const release = async signer => {
|
|
3036
|
-
const {
|
|
3037
|
-
before: first,
|
|
3038
|
-
hasAuthority: _h,
|
|
3039
|
-
...kind
|
|
3040
|
-
} = heavy ? await nftOf(asset, signer, { to: hold.player }) : { before: [], ...nft };
|
|
3041
|
-
return [
|
|
3620
|
+
const kind = nft.compressed || nft.tokenMetadata ? nft : { collection: (await collectionOf(asset)) ?? MPL_CORE };
|
|
3621
|
+
// A Token Metadata NFT's move and a compressed NFT's proof take more than the default compute.
|
|
3622
|
+
const budget = nft.compressed || nft.tokenMetadata ? [computeUnitLimit(1_000_000)] : [];
|
|
3623
|
+
const opens = before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM);
|
|
3624
|
+
const repaying = (goesHome, first = []) =>
|
|
3625
|
+
withSolWrap(wallet.address, hold.debt.mint, BigInt(hold.debt.repay), [
|
|
3626
|
+
...budget,
|
|
3042
3627
|
...first,
|
|
3043
|
-
loot.
|
|
3044
|
-
|
|
3628
|
+
loot.repayIx({
|
|
3629
|
+
payer: wallet.address,
|
|
3045
3630
|
project,
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
hold: { ...hold, debt: null, free_at: 0 },
|
|
3631
|
+
hold,
|
|
3632
|
+
repay,
|
|
3633
|
+
home: goesHome,
|
|
3050
3634
|
address: at,
|
|
3635
|
+
...kind,
|
|
3636
|
+
programs,
|
|
3637
|
+
extra,
|
|
3051
3638
|
}),
|
|
3052
|
-
];
|
|
3639
|
+
]);
|
|
3640
|
+
if (home) {
|
|
3641
|
+
const all = repaying(true, opens);
|
|
3642
|
+
// Too big for a dry run as one legacy transaction (a compressed NFT's long proof): sent as it is, the wallet's
|
|
3643
|
+
// one transaction made to fit.
|
|
3644
|
+
const plan = sendPlan({ payer: wallet.address, instructions: all, alone: isProveLeaf });
|
|
3645
|
+
if (plan && !plan.legacy) return { signature: await send(all), home: true };
|
|
3646
|
+
const tried = await dryRun(wallet.address, all);
|
|
3647
|
+
if (tried.ok) return { signature: await send(all), home: true };
|
|
3648
|
+
// What would refuse the money alone too is said now.
|
|
3649
|
+
if (['TermsChanged', 'LoanDue', 'NotALoan', 'NotTheNft'].includes(tried.error?.name))
|
|
3650
|
+
throw Object.assign(new Error(`${tried.error.msg} (${tried.error.name}).`), tried.error);
|
|
3651
|
+
}
|
|
3652
|
+
// The money alone: a compressed NFT proven in the same transaction when that lands, so one that is gone is refused.
|
|
3653
|
+
const proven = nft.compressed ? repaying(false, opens.filter(isProveLeaf)) : null;
|
|
3654
|
+
const money = proven && (await dryRun(wallet.address, proven)).ok ? proven : repaying(false);
|
|
3655
|
+
const signature = await send(money);
|
|
3656
|
+
return {
|
|
3657
|
+
signature,
|
|
3658
|
+
home: false,
|
|
3659
|
+
says: 'The loan is repaid. The NFT stays with the project until it can move (frozen for now, or its collection refuses the move): anyone sends it home to its borrower then, and claims() lists it.',
|
|
3053
3660
|
};
|
|
3054
|
-
const released = await keyRun(key, release);
|
|
3055
|
-
return { signature, released };
|
|
3056
3661
|
},
|
|
3057
3662
|
|
|
3058
3663
|
/**
|
|
@@ -3060,13 +3665,14 @@ export function solanaApp({
|
|
|
3060
3665
|
* of NFTs takes one of the player's NFTs that passes its rule instead (`nft`, its address), held until it is
|
|
3061
3666
|
* claimed. `listProof`: for a list gate.
|
|
3062
3667
|
*/
|
|
3063
|
-
async enter(id, side, amount, { shown, listProof, nft: staked, ...others } = NO_OPTIONS) {
|
|
3064
|
-
only(others, ['shown', 'listProof', 'nft'], 'enter');
|
|
3668
|
+
async enter(id, side, amount, { shown, listProof, nft: staked, acceptLeftover, ...others } = NO_OPTIONS) {
|
|
3669
|
+
only(others, ['shown', 'listProof', 'nft', 'acceptLeftover'], 'enter');
|
|
3065
3670
|
const contest = await read('Contest', loot.pda.contest(project, id));
|
|
3066
3671
|
shown ??= await passing(contest.gate, 'enter');
|
|
3067
3672
|
listProof = listProofOf(contest.gate, null, listProof);
|
|
3068
3673
|
if (contest.nft) {
|
|
3069
3674
|
if (!staked) throw new Error('This contest takes an NFT: say which (nft).');
|
|
3675
|
+
await noLeftovers(staked, acceptLeftover);
|
|
3070
3676
|
const { before, ...kind } = await nftOf(staked);
|
|
3071
3677
|
return send([
|
|
3072
3678
|
...before,
|
|
@@ -3106,7 +3712,8 @@ export function solanaApp({
|
|
|
3106
3712
|
/**
|
|
3107
3713
|
* The auctions: each one's lot, high bid, the least the next bid must be, when it ends and how long is left, and
|
|
3108
3714
|
* whether the player leads. One no longer on chain has ended. `waiting`: SOL that came back to the player's
|
|
3109
|
-
* wrapped-SOL account from a bid that was topped (`unwrap` turns it into plain SOL).
|
|
3715
|
+
* wrapped-SOL account from a bid that was topped (`unwrap` turns it into plain SOL). `warnings`: what to show every
|
|
3716
|
+
* bidder before a bid (a token whose accounts can be frozen, a lot of another collection).
|
|
3110
3717
|
*/
|
|
3111
3718
|
async auctions() {
|
|
3112
3719
|
const list = spec.auctions ?? [];
|
|
@@ -3118,8 +3725,28 @@ export function solanaApp({
|
|
|
3118
3725
|
const waiting = wrapped
|
|
3119
3726
|
? show(new DataView(wrapped.data.buffer, wrapped.data.byteOffset).getBigUint64(64, true), 9)
|
|
3120
3727
|
: '0';
|
|
3728
|
+
const auctions = accounts.map(x => x && loot.decode('Auction', x.data));
|
|
3729
|
+
// What every bidder is told before a bid: what the bids' token lets the high bidder do, and what whoever
|
|
3730
|
+
// controls an NFT lot of another collection can do to it.
|
|
3731
|
+
const mints = [...new Set(auctions.filter(Boolean).map(x => x.mint))];
|
|
3732
|
+
const mintAccounts = Object.fromEntries((await client.accounts(mints)).map((m, k) => [mints[k], m]));
|
|
3733
|
+
const warningsOf = async x => {
|
|
3734
|
+
const out = tokenSettings(
|
|
3735
|
+
mintAccounts[x.mint],
|
|
3736
|
+
'the refund of the bid a higher one tops',
|
|
3737
|
+
'the high bidder',
|
|
3738
|
+
'so every higher bid fails and they win at their price',
|
|
3739
|
+
).map(r => r.says);
|
|
3740
|
+
const lot = x.lot.Asset?.[0];
|
|
3741
|
+
if (lot && !ours(await collectionOf(lot)))
|
|
3742
|
+
out.push(
|
|
3743
|
+
'This lot is an NFT outside the project’s collections: whoever controls its collection can stop it moving or take it back while the project holds it. The seller is paid only when the lot reaches the winner; while it can’t move, the winning bid waits in the project’s vault, until it can or the project calls the sale off, and a lot taken back or burned sends the bid back. A bid can be tied up, not lost.',
|
|
3744
|
+
);
|
|
3745
|
+
return out;
|
|
3746
|
+
};
|
|
3747
|
+
const warned = await Promise.all(auctions.map(x => (x ? warningsOf(x) : [])));
|
|
3121
3748
|
return list.map((a, i) => {
|
|
3122
|
-
const x =
|
|
3749
|
+
const x = auctions[i];
|
|
3123
3750
|
const base = {
|
|
3124
3751
|
key: a.key,
|
|
3125
3752
|
name: a.name,
|
|
@@ -3151,6 +3778,7 @@ export function solanaApp({
|
|
|
3151
3778
|
// A bid of NFTs: a picker of 1 to `maxNfts` (and at least `nextBid`) of the player's NFTs.
|
|
3152
3779
|
...(x.nft_bids ? { nftBids: true, held: x.held, maxNfts: loot.MAX_BID_NFTS } : {}),
|
|
3153
3780
|
...(tokens.get(a.currency ?? currencyKey)?.source === 'sol' ? { waiting } : {}),
|
|
3781
|
+
...(warned[i].length ? { warnings: warned[i] } : {}),
|
|
3154
3782
|
};
|
|
3155
3783
|
});
|
|
3156
3784
|
},
|
|
@@ -3161,8 +3789,8 @@ export function solanaApp({
|
|
|
3161
3789
|
* wrapped-SOL account does not hold already is wrapped, and the account stays open: if this bid is topped, it comes
|
|
3162
3790
|
* back there.
|
|
3163
3791
|
*/
|
|
3164
|
-
async bid(auctionKey, amount, { shown, listProof, nfts, ...others } = NO_OPTIONS) {
|
|
3165
|
-
only(others, ['shown', 'listProof', 'nfts'], 'bid');
|
|
3792
|
+
async bid(auctionKey, amount, { shown, listProof, nfts, acceptLeftover, ...others } = NO_OPTIONS) {
|
|
3793
|
+
only(others, ['shown', 'listProof', 'nfts', 'acceptLeftover'], 'bid');
|
|
3166
3794
|
const a = part('auctions', auctionKey);
|
|
3167
3795
|
const auction = await read('Auction', auctionAt(a));
|
|
3168
3796
|
shown ??= await passing(auction.gate, 'bid');
|
|
@@ -3175,6 +3803,7 @@ export function solanaApp({
|
|
|
3175
3803
|
if (bundle.length > loot.MAX_BID_NFTS) throw new Error(`A bid holds at most ${loot.MAX_BID_NFTS} NFTs.`);
|
|
3176
3804
|
if (auction.held.length >= loot.MAX_BID_NFTS)
|
|
3177
3805
|
throw new Error(`The high bid holds ${loot.MAX_BID_NFTS} NFTs, the most a bid holds: it can't be topped.`);
|
|
3806
|
+
for (const asset of bundle) await noLeftovers(asset, acceptLeftover);
|
|
3178
3807
|
const before = [];
|
|
3179
3808
|
const kindOf = async (asset, to) => {
|
|
3180
3809
|
const { before: proof, ...kind } = await nftOf(asset, wallet.address, { to });
|
|
@@ -3210,9 +3839,11 @@ export function solanaApp({
|
|
|
3210
3839
|
if (value > held)
|
|
3211
3840
|
instructions.push(transferLamports(wallet.address, wrapped, value - held), syncNative(wrapped));
|
|
3212
3841
|
}
|
|
3213
|
-
// A Token-2022 account can ask for a memo on every transfer into it: the Memo program rides along
|
|
3214
|
-
|
|
3215
|
-
|
|
3842
|
+
// A Token-2022 account can ask for a memo on every transfer into it: the Memo program rides along, and a transfer
|
|
3843
|
+
// hook's accounts.
|
|
3844
|
+
const memo = await hookTail([mint]);
|
|
3845
|
+
// The coin's token program, read from the mint when the app wasn't told it.
|
|
3846
|
+
const coin = programs[mint] ?? (await client.account(mint))?.owner ?? TOKEN_PROGRAM;
|
|
3216
3847
|
instructions.push(
|
|
3217
3848
|
loot.bidIx({
|
|
3218
3849
|
bidder: wallet.address,
|
|
@@ -3222,7 +3853,7 @@ export function solanaApp({
|
|
|
3222
3853
|
amount: value,
|
|
3223
3854
|
shown,
|
|
3224
3855
|
listProof,
|
|
3225
|
-
programs,
|
|
3856
|
+
programs: { ...programs, [mint]: coin },
|
|
3226
3857
|
extra: memo,
|
|
3227
3858
|
}),
|
|
3228
3859
|
);
|
|
@@ -3230,167 +3861,32 @@ export function solanaApp({
|
|
|
3230
3861
|
},
|
|
3231
3862
|
|
|
3232
3863
|
/**
|
|
3233
|
-
* Ends an auction that is over (anyone can; the winner usually does): the lot to the highest bidder
|
|
3234
|
-
*
|
|
3235
|
-
* roll lot makes the winner's roll of its box (`order`), which
|
|
3236
|
-
*
|
|
3864
|
+
* Ends an auction that is over (anyone can; the winner usually does): the lot to the highest bidder and the winning
|
|
3865
|
+
* bid to whoever owned it, in one transaction (a new collectible minted for the winner at the address its item and
|
|
3866
|
+
* serial name), however long after the end it is sent. A roll lot makes the winner's roll of its box (`order`), which
|
|
3867
|
+
* `collect` asks for and settles. An NFT lot whose end can't fit one transaction (a compressed lot's long proof, many
|
|
3868
|
+
* shares, a hooked coin) goes first, and the bid is paid by a second end, the accounts it pays into opened before it.
|
|
3869
|
+
* When the lot can't move now (its collection froze it or refuses the move), nothing is sent: the sale waits, the
|
|
3870
|
+
* winning bid in the project's vault (`{ waiting: true, says }`); end it again later, or the project's keys call it
|
|
3871
|
+
* off (`callOffSale`). When the lot or an NFT of the winning bid is gone (its collection took it back or burned it),
|
|
3872
|
+
* the sale is called off on that evidence, which anyone may: the bid goes back to its bidder (`{ calledOff: true }`).
|
|
3873
|
+
* With no bid, the lot goes home to its seller. An end too big for one transaction otherwise makes each NFT that may
|
|
3874
|
+
* be a hold one, and sends each on with a release after it (`releases`).
|
|
3237
3875
|
*/
|
|
3238
3876
|
async endAuction(auctionKey, { holders, ...others } = NO_OPTIONS) {
|
|
3239
3877
|
only(others, ['holders'], 'endAuction');
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
signer: wallet.address,
|
|
3253
|
-
project,
|
|
3254
|
-
projectState: g,
|
|
3255
|
-
id: a.id,
|
|
3256
|
-
auction,
|
|
3257
|
-
programs,
|
|
3258
|
-
extra: memo,
|
|
3259
|
-
}),
|
|
3260
|
-
]),
|
|
3261
|
-
owedPaid: auction.proceeds_owed,
|
|
3262
|
-
};
|
|
3263
|
-
}
|
|
3264
|
-
const sold = auction.high > 0n;
|
|
3265
|
-
const item = auction.lot.Item ? await read('Item', loot.pda.item(project, auction.lot.Item[0])) : null;
|
|
3266
|
-
const made =
|
|
3267
|
-
sold && item?.kind === 'Collectible'
|
|
3268
|
-
? loot.pda.itemAsset(loot.pda.item(project, auction.lot.Item[0]), auction.pin)
|
|
3269
|
-
: null;
|
|
3270
|
-
const memo =
|
|
3271
|
-
programOf(auction.mint) === TOKEN_2022_PROGRAM
|
|
3272
|
-
? [{ pubkey: MEMO_PROGRAM, isSigner: false, isWritable: false }]
|
|
3273
|
-
: [];
|
|
3274
|
-
holders ??= sold ? await holdersOf(auction.shares) : {};
|
|
3275
|
-
const lot = auction.lot.Asset?.[0];
|
|
3276
|
-
// The lot goes to its winner (back to its seller, unsold); a winning bid of NFTs to whoever the rest of a bid in
|
|
3277
|
-
// tokens would go to: the seller of an NFT, the treasury for a new item.
|
|
3278
|
-
const {
|
|
3279
|
-
before,
|
|
3280
|
-
hasAuthority: _,
|
|
3281
|
-
...nft
|
|
3282
|
-
} = lot ? await nftOf(lot, wallet.address, { to: sold ? auction.high_bidder : auction.seller }) : { before: [] };
|
|
3283
|
-
const collection =
|
|
3284
|
-
lot && !nft.compressed && !nft.tokenMetadata ? ((await collectionOf(lot)) ?? MPL_CORE) : undefined;
|
|
3285
|
-
const proceeds = auction.lot.Asset ? auction.seller : g.treasury;
|
|
3286
|
-
const held = [];
|
|
3287
|
-
if (sold && auction.nft_bids) {
|
|
3288
|
-
for (const asset of auction.held) {
|
|
3289
|
-
const { before: proof, ...kind } = await nftOf(asset, wallet.address, { to: proceeds });
|
|
3290
|
-
before.push(...proof);
|
|
3291
|
-
held.push(kind);
|
|
3292
|
-
}
|
|
3293
|
-
}
|
|
3294
|
-
const ix = loot.endAuctionIx({
|
|
3295
|
-
signer: wallet.address,
|
|
3296
|
-
project,
|
|
3297
|
-
projectState: g,
|
|
3298
|
-
id: a.id,
|
|
3299
|
-
auction,
|
|
3300
|
-
item,
|
|
3301
|
-
...nft,
|
|
3302
|
-
collection,
|
|
3303
|
-
held,
|
|
3304
|
-
holders,
|
|
3305
|
-
programs,
|
|
3306
|
-
extra: memo,
|
|
3307
|
-
});
|
|
3308
|
-
let all = [computeUnitLimit(800_000), ...before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM), ix];
|
|
3309
|
-
// An end that can't fit one transaction (a compressed lot's long proof, a hooked mint, many shares) makes each NFT
|
|
3310
|
-
// a hold for whoever it goes to, then sends each home with its own release: anyone may, and the rent comes back.
|
|
3311
|
-
const holds =
|
|
3312
|
-
lot || (sold && auction.nft_bids)
|
|
3313
|
-
? !sendPlan({ payer: wallet.address, instructions: all, alone: isProveLeaf })
|
|
3314
|
-
: false;
|
|
3315
|
-
if (holds) {
|
|
3316
|
-
// The end's own transaction funds a throwaway key that sends each release after it (anyone may): each one's
|
|
3317
|
-
// fees, and what it may open (a pNFT's token account and record, a compressed NFT's proof record). One the key
|
|
3318
|
-
// can't send, the wallet does.
|
|
3319
|
-
const outs = [
|
|
3320
|
-
...(lot ? [[lot, sold ? auction.high_bidder : auction.seller]] : []),
|
|
3321
|
-
...(sold && auction.nft_bids ? auction.held.map(x => [x, proceeds]) : []),
|
|
3322
|
-
];
|
|
3323
|
-
const { key, fund } = await keyFor(
|
|
3324
|
-
BigInt(outs.length) * (2n * SIGNATURE_FEE + loot.SETTLE_RENT_UNIT),
|
|
3325
|
-
await jobKey(auctionAt(a)),
|
|
3326
|
-
);
|
|
3327
|
-
all = [
|
|
3328
|
-
computeUnitLimit(800_000),
|
|
3329
|
-
loot.endAuctionIx({
|
|
3330
|
-
signer: wallet.address,
|
|
3331
|
-
project,
|
|
3332
|
-
projectState: g,
|
|
3333
|
-
id: a.id,
|
|
3334
|
-
auction,
|
|
3335
|
-
item,
|
|
3336
|
-
...nft,
|
|
3337
|
-
collection,
|
|
3338
|
-
held,
|
|
3339
|
-
hold: true,
|
|
3340
|
-
holders,
|
|
3341
|
-
programs,
|
|
3342
|
-
extra: memo,
|
|
3343
|
-
}),
|
|
3344
|
-
fund,
|
|
3345
|
-
];
|
|
3346
|
-
const signature = await send(all);
|
|
3347
|
-
const releases = [];
|
|
3348
|
-
for (const [asset, to] of outs) {
|
|
3349
|
-
const release = async signer => {
|
|
3350
|
-
const hold = await read('Hold', loot.pda.hold(asset, 0));
|
|
3351
|
-
const { before: proof, hasAuthority: _h, ...kind } = await nftOf(asset, signer, { to });
|
|
3352
|
-
return [
|
|
3353
|
-
...proof,
|
|
3354
|
-
loot.releaseIx({
|
|
3355
|
-
signer,
|
|
3356
|
-
project,
|
|
3357
|
-
asset,
|
|
3358
|
-
...kind,
|
|
3359
|
-
collection:
|
|
3360
|
-
kind.compressed || kind.tokenMetadata ? undefined : ((await collectionOf(asset)) ?? MPL_CORE),
|
|
3361
|
-
hold,
|
|
3362
|
-
to,
|
|
3363
|
-
}),
|
|
3364
|
-
];
|
|
3365
|
-
};
|
|
3366
|
-
const steps = await release(key.address);
|
|
3367
|
-
const byKey = (await dryRun(key.address, steps)).ok ? await sendAs(key, steps).catch(() => null) : null;
|
|
3368
|
-
releases.push(byKey ?? (await send(await release(wallet.address))));
|
|
3369
|
-
}
|
|
3370
|
-
await emptyKey(key);
|
|
3371
|
-
return { signature, releases, ...(sold ? { winner: auction.high_bidder } : {}) };
|
|
3372
|
-
}
|
|
3373
|
-
// 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
|
|
3374
|
-
// ending it asks in the same transaction and a key settles it.
|
|
3375
|
-
const roll =
|
|
3376
|
-
sold && auction.lot.Roll
|
|
3377
|
-
? loot.pda.order(
|
|
3378
|
-
project,
|
|
3379
|
-
auction.high_bidder,
|
|
3380
|
-
loot.prizeRollNonce(loot.prizeNonce(auctionAt(a), BigInt(auction.last_bid_at))),
|
|
3381
|
-
)
|
|
3382
|
-
: null;
|
|
3383
|
-
const { signature, rolls } =
|
|
3384
|
-
roll && auction.high_bidder === wallet.address
|
|
3385
|
-
? await sendRolling(all)
|
|
3386
|
-
: { signature: await send(all), rolls: [] };
|
|
3387
|
-
return {
|
|
3388
|
-
signature,
|
|
3389
|
-
...(sold ? { winner: auction.high_bidder } : {}),
|
|
3390
|
-
...(made ? { asset: made } : {}),
|
|
3391
|
-
...(roll ? { order: roll } : {}),
|
|
3392
|
-
...(rolls.length ? { rolled: rolls[0] } : {}),
|
|
3393
|
-
};
|
|
3878
|
+
return endSale(part('auctions', auctionKey), { holders });
|
|
3879
|
+
},
|
|
3880
|
+
|
|
3881
|
+
/**
|
|
3882
|
+
* The project's owner or authority calls off the sale of an NFT lot whose auction is over and whose lot has not moved
|
|
3883
|
+
* (one that never thaws, say), for the project fee: the winning bid goes back to its bidder and the lot home to its
|
|
3884
|
+
* seller, each moved, or made a hold that anyone sends on once it can move (`releases`). Anyone may when the lot or
|
|
3885
|
+
* an NFT of the winning bid is gone (`endAuction` does it then). Refused once the lot went to its winner (`CantCallOff`).
|
|
3886
|
+
*/
|
|
3887
|
+
async callOffSale(auctionKey, { ...others } = NO_OPTIONS) {
|
|
3888
|
+
only(others, [], 'callOffSale');
|
|
3889
|
+
return endSale(part('auctions', auctionKey), { callOff: true });
|
|
3394
3890
|
},
|
|
3395
3891
|
|
|
3396
3892
|
/**
|
|
@@ -3440,8 +3936,9 @@ export function solanaApp({
|
|
|
3440
3936
|
* Lists one of the player's NFTs (any kind) for sale at `price` (whole units of `currency`, the economy's by
|
|
3441
3937
|
* default), `shares` taken first (a royalty, a platform's cut). The project holds it until it sells or is unlisted.
|
|
3442
3938
|
*/
|
|
3443
|
-
async listForSale(asset, { price, currency, shares = [], ...others } = NO_OPTIONS) {
|
|
3444
|
-
only(others, ['price', 'currency', 'shares'], 'listForSale');
|
|
3939
|
+
async listForSale(asset, { price, currency, shares = [], acceptLeftover, ...others } = NO_OPTIONS) {
|
|
3940
|
+
only(others, ['price', 'currency', 'shares', 'acceptLeftover'], 'listForSale');
|
|
3941
|
+
await noLeftovers(asset, acceptLeftover, { listing: true });
|
|
3445
3942
|
const mint = mintOf(currency);
|
|
3446
3943
|
const { before, ...nft } = await nftOf(asset);
|
|
3447
3944
|
return send([
|
|
@@ -3462,8 +3959,12 @@ export function solanaApp({
|
|
|
3462
3959
|
* Lists one of the player's NFTs for rent: `price` (whole units) a period of `lasts` seconds, a renter taking 1 to
|
|
3463
3960
|
* `maxPeriods` at once. Nobody can end a rental early; the next renter takes it once one is over, until it is unlisted.
|
|
3464
3961
|
*/
|
|
3465
|
-
async listForRent(
|
|
3466
|
-
|
|
3962
|
+
async listForRent(
|
|
3963
|
+
asset,
|
|
3964
|
+
{ price, lasts, maxPeriods = 1, currency, shares = [], acceptLeftover, ...others } = NO_OPTIONS,
|
|
3965
|
+
) {
|
|
3966
|
+
only(others, ['price', 'lasts', 'maxPeriods', 'currency', 'shares', 'acceptLeftover'], 'listForRent');
|
|
3967
|
+
await noLeftovers(asset, acceptLeftover, { listing: true });
|
|
3467
3968
|
const mint = mintOf(currency);
|
|
3468
3969
|
const { before, ...nft } = await nftOf(asset);
|
|
3469
3970
|
return send([
|
|
@@ -3998,15 +4499,17 @@ export function solanaApp({
|
|
|
3998
4499
|
// A repaid loan is a plain hold that is over (no debt, `free_at` 0): its NFT goes home whenever. An unpaid one's
|
|
3999
4500
|
// is the lender's once due.
|
|
4000
4501
|
if (!hold.debt && time >= BigInt(hold.free_at))
|
|
4001
|
-
out.push(
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4502
|
+
out.push(
|
|
4503
|
+
await goneOr(hold.asset, {
|
|
4504
|
+
kind: 'release',
|
|
4505
|
+
asset: hold.asset,
|
|
4506
|
+
hold: pubkey,
|
|
4507
|
+
says:
|
|
4508
|
+
hold.player === project
|
|
4509
|
+
? 'An NFT owed to the treasury: send it there.'
|
|
4510
|
+
: 'Your collectible back: its hold is over.',
|
|
4511
|
+
}),
|
|
4512
|
+
);
|
|
4010
4513
|
}
|
|
4011
4514
|
// A loan this wallet made that is overdue and unpaid: its NFT is the lender's to take.
|
|
4012
4515
|
for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [
|
|
@@ -4015,12 +4518,14 @@ export function solanaApp({
|
|
|
4015
4518
|
])) {
|
|
4016
4519
|
const hold = loot.decode('Hold', account.data);
|
|
4017
4520
|
if (hold.debt?.lender === me && time >= BigInt(hold.free_at))
|
|
4018
|
-
out.push(
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4521
|
+
out.push(
|
|
4522
|
+
await goneOr(hold.asset, {
|
|
4523
|
+
kind: 'release',
|
|
4524
|
+
asset: hold.asset,
|
|
4525
|
+
hold: pubkey,
|
|
4526
|
+
says: 'A loan you made is overdue and unpaid: take its NFT.',
|
|
4527
|
+
}),
|
|
4528
|
+
);
|
|
4024
4529
|
}
|
|
4025
4530
|
// A compressed NFT's proof record this wallet paid for and left open: closing it brings its rent back.
|
|
4026
4531
|
for (const { account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Proven')])) {
|
|
@@ -4044,13 +4549,14 @@ export function solanaApp({
|
|
|
4044
4549
|
auctions.forEach((a, i) => {
|
|
4045
4550
|
const x = found[i] && loot.decode('Auction', found[i].data);
|
|
4046
4551
|
if (!x || time < loot.auctionEnds(x)) return;
|
|
4047
|
-
// The lot went
|
|
4552
|
+
// The lot went first (an end too wide for one transaction, or a seller's account that couldn't take the money
|
|
4553
|
+
// then): only the money is left to pay.
|
|
4048
4554
|
if (BigInt(x.proceeds_owed) > 0n) {
|
|
4049
4555
|
if (x.seller === me)
|
|
4050
4556
|
out.push({
|
|
4051
4557
|
kind: 'auction',
|
|
4052
4558
|
key: a.key,
|
|
4053
|
-
says: `The winner of “${a.name}” has the lot, and
|
|
4559
|
+
says: `The winner of “${a.name}” has the lot, and the winning bid is still to be paid to you: end it again, and it is paid (once your account can take it).`,
|
|
4054
4560
|
});
|
|
4055
4561
|
return;
|
|
4056
4562
|
}
|
|
@@ -4250,9 +4756,10 @@ export function solanaApp({
|
|
|
4250
4756
|
const nfts = [];
|
|
4251
4757
|
const kinds = {};
|
|
4252
4758
|
if (contest.nft) {
|
|
4759
|
+
const proofs = {};
|
|
4253
4760
|
const kindOf = async (asset, to) => {
|
|
4254
4761
|
const { before, hasAuthority: _, ...kind } = await nftOf(asset, me, { to });
|
|
4255
|
-
|
|
4762
|
+
proofs[asset] = before;
|
|
4256
4763
|
kinds[asset] = kind;
|
|
4257
4764
|
};
|
|
4258
4765
|
if (Number(own.amount) === 1) {
|
|
@@ -4274,6 +4781,10 @@ export function solanaApp({
|
|
|
4274
4781
|
nfts.push(j);
|
|
4275
4782
|
await kindOf(e.nft, me);
|
|
4276
4783
|
}
|
|
4784
|
+
// A compressed NFT's proof is long (up to 17 nodes): such a claim moves one NFT, as a v0 transaction with a
|
|
4785
|
+
// lookup table (`send`), and the next claim takes the next.
|
|
4786
|
+
if (nfts.length > 1 && nfts.some(j => kinds[entries[j].nft]?.compressed)) nfts.splice(1);
|
|
4787
|
+
for (const j of nfts) given.before.push(...proofs[entries[j].nft]);
|
|
4277
4788
|
}
|
|
4278
4789
|
const claimed = [
|
|
4279
4790
|
computeUnitLimit(800_000),
|
|
@@ -4299,6 +4810,7 @@ export function solanaApp({
|
|
|
4299
4810
|
return contest.prize?.Roll && !build ? (await sendRolling(claimed)).signature : send(claimed);
|
|
4300
4811
|
}
|
|
4301
4812
|
if (c.kind === 'release') {
|
|
4813
|
+
if (c.gone) throw new Error(c.says);
|
|
4302
4814
|
const hold = await read('Hold', c.hold);
|
|
4303
4815
|
const { before, hasAuthority: _, ...nft } = await nftOf(c.asset, me, { to: me });
|
|
4304
4816
|
// An overdue loan's NFT goes to its lender.
|
|
@@ -4317,7 +4829,12 @@ export function solanaApp({
|
|
|
4317
4829
|
}),
|
|
4318
4830
|
]);
|
|
4319
4831
|
}
|
|
4320
|
-
if (c.kind === 'auction')
|
|
4832
|
+
if (c.kind === 'auction') {
|
|
4833
|
+
const ended = await this.endAuction(c.key);
|
|
4834
|
+
// A lot that can't move now: the sale waits, and nothing was sent.
|
|
4835
|
+
if (ended.waiting) throw new Error(ended.says);
|
|
4836
|
+
return ended.signature;
|
|
4837
|
+
}
|
|
4321
4838
|
if (c.kind === 'close-leaf') return send([loot.closeLeafIx({ asset: c.asset, tree: c.tree, payer: me })]);
|
|
4322
4839
|
if (c.kind === 'walk') return this.walkFarm(c.key);
|
|
4323
4840
|
if (c.kind === 'vest' || c.kind === 'schedule') return this.runSchedule(c.key);
|
|
@@ -4361,13 +4878,24 @@ export function solanaApp({
|
|
|
4361
4878
|
return build ? steps : send(steps);
|
|
4362
4879
|
}
|
|
4363
4880
|
if (!r) return null;
|
|
4881
|
+
// A round whose lottery was closed since: its lent rent is the owner's, on the owner's own close.
|
|
4882
|
+
const lotteryClosed = !lottery;
|
|
4364
4883
|
// Pulls: this wallet's own parts, or (`payout`) everyone's and the parts owed in common, a few to a transaction;
|
|
4365
4884
|
// when they are the last owed, the last pull closes the round. A round that owes nothing is only closed.
|
|
4366
4885
|
// A named stash NFT that is gone refuses a stranger's pull: only its winner or a project key passes it over.
|
|
4367
4886
|
const projectKey = me === g.owner || me === g.authority;
|
|
4368
4887
|
const stranger = async (i, p) => {
|
|
4888
|
+
if (projectKey || r.entries[i].buyer === me) return false;
|
|
4889
|
+
// A minted prize the project can't mint now (not the mint's authority): a stranger's pull fails, so it is left
|
|
4890
|
+
// for later, when the project can; its winner or the project's keys may pass it over for nothing.
|
|
4891
|
+
const prized = p.Prize ? r.prizes.find(x => Number(x.run) === Number(p.Prize[0])) : null;
|
|
4892
|
+
if (prized?.minted) {
|
|
4893
|
+
const data = (await client.account(prized.mint))?.data;
|
|
4894
|
+
const authority = data && data[0] === 1 ? encodeBase58(data.subarray(4, 36)) : null;
|
|
4895
|
+
if (authority !== project) return true;
|
|
4896
|
+
}
|
|
4369
4897
|
const named = p.Prize ? r.terms.places[Number(p.Prize[0])].prize?.Nft : null;
|
|
4370
|
-
if (!named
|
|
4898
|
+
if (!named) return false;
|
|
4371
4899
|
const stash = loot.decodeStash((await client.account(loot.pda.stash(project, named.stash)))?.data ?? null);
|
|
4372
4900
|
return !stash?.nfts.includes(named.nft);
|
|
4373
4901
|
};
|
|
@@ -4382,7 +4910,10 @@ export function solanaApp({
|
|
|
4382
4910
|
c.kind === 'pull' ? c.pulls : [...loot.commonOwed(r).map(p => ({ entry: null, parts: [p] })), ...theirs];
|
|
4383
4911
|
if (!owed.length) {
|
|
4384
4912
|
if (!loot.roundFinished(r)) return null;
|
|
4385
|
-
|
|
4913
|
+
// Rent the lottery lent goes to the owner once it is closed, and only on the owner's own close.
|
|
4914
|
+
const close = [
|
|
4915
|
+
loot.pullIx({ payer: me, project, roundAddress: key, round: r, projectState: g, lotteryClosed, programs }),
|
|
4916
|
+
];
|
|
4386
4917
|
return build ? close : send(close);
|
|
4387
4918
|
}
|
|
4388
4919
|
const batch = owed.slice(0, 4);
|
|
@@ -4393,21 +4924,49 @@ export function solanaApp({
|
|
|
4393
4924
|
const closes = settles >= Number(r.owed);
|
|
4394
4925
|
const instructions = [computeUnitLimit(1_400_000)];
|
|
4395
4926
|
const rolls = [];
|
|
4927
|
+
let heap = false;
|
|
4928
|
+
let partial = false;
|
|
4396
4929
|
for (const [k, { entry, parts }] of batch.entries()) {
|
|
4397
|
-
const built = await pulls(key, r, entry, parts, me, {
|
|
4930
|
+
const built = await pulls(key, r, entry, parts, me, {
|
|
4931
|
+
closes: closes && k === batch.length - 1,
|
|
4932
|
+
lotteryClosed,
|
|
4933
|
+
});
|
|
4398
4934
|
instructions.push(...built.instructions.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM));
|
|
4399
4935
|
rolls.push(...built.rolls);
|
|
4936
|
+
heap ||= built.heap;
|
|
4937
|
+
partial ||= built.partial;
|
|
4400
4938
|
}
|
|
4939
|
+
// A run of rolls, or several NFTs out of the round's own stash, asks for the largest heap.
|
|
4940
|
+
if (heap) instructions.splice(1, 0, requestHeapFrame(loot.LARGE_SETTLE_HEAP));
|
|
4941
|
+
// A pull that stopped short of its part (a raffle's NFTs, 64 a pull; a run of rolls): sent, then the next carries
|
|
4942
|
+
// on while the round moves (its NFTs out, its places paid) and still owes it.
|
|
4943
|
+
const carryOn = async signature => {
|
|
4944
|
+
if (build || !partial) return signature;
|
|
4945
|
+
const after = await client.account(key);
|
|
4946
|
+
const r2 = after?.owner === loot.LOOT_PROGRAM ? loot.decodeRound(after.data) : null;
|
|
4947
|
+
const moved = r2 && (Number(r2.assigned) > Number(r.assigned) || Number(r2.owed) < Number(r.owed));
|
|
4948
|
+
if (!moved) return signature;
|
|
4949
|
+
const left =
|
|
4950
|
+
c.kind === 'pull'
|
|
4951
|
+
? r2.entries.flatMap((e, i) => {
|
|
4952
|
+
const parts = e.buyer === me ? loot.partsOwed(r2, i) : [];
|
|
4953
|
+
return parts.length ? [{ entry: i, parts }] : [];
|
|
4954
|
+
})
|
|
4955
|
+
: null;
|
|
4956
|
+
if (left && !left.length) return signature;
|
|
4957
|
+
await this.claim(left ? { ...c, pulls: left } : c);
|
|
4958
|
+
return signature;
|
|
4959
|
+
};
|
|
4401
4960
|
// A place's roll is asked for in the pull's own transaction (catalog P1): the winner's own, with a key that settles
|
|
4402
4961
|
// it after (one prompt); someone else's, asked for when the box can give, else left for its winner to ask.
|
|
4403
|
-
if (!rolls.length) return build ? instructions : send(instructions);
|
|
4404
|
-
if (c.kind === 'pull' && !build) return (await sendRolling(instructions)).signature;
|
|
4962
|
+
if (!rolls.length) return build ? instructions : carryOn(await send(instructions));
|
|
4963
|
+
if (c.kind === 'pull' && !build) return carryOn((await sendRolling(instructions)).signature);
|
|
4405
4964
|
const asks = [];
|
|
4406
4965
|
for (const { order, box } of rolls)
|
|
4407
4966
|
asks.push(...(await asksFor(me, order, { project, loot_box: loot.pda.box(project, box) })));
|
|
4408
4967
|
const all = [...instructions, ...asks];
|
|
4409
4968
|
const asked = (await dryRun(me, all)).ok ? all : instructions;
|
|
4410
|
-
return build ? asked : send(asked);
|
|
4969
|
+
return build ? asked : carryOn(await send(asked));
|
|
4411
4970
|
},
|
|
4412
4971
|
|
|
4413
4972
|
/**
|