@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.
Files changed (44) hide show
  1. package/README.md +8 -8
  2. package/js/approve/details.mjs +52 -2
  3. package/js/approve/live-check.mjs +0 -15
  4. package/js/approve/server.mjs +5 -4
  5. package/js/chain/token2022.js +3 -2
  6. package/js/client/accounts.js +67 -25
  7. package/js/client/grant.js +7 -2
  8. package/js/client/idl/loot.js +1 -1
  9. package/js/client/idl/loot.json +716 -183
  10. package/js/client/ix.js +617 -113
  11. package/js/client/sizes.js +210 -64
  12. package/js/economy/check.mjs +691 -261
  13. package/js/economy/cost.mjs +2 -4
  14. package/js/economy/explain.mjs +16 -2
  15. package/js/economy/guide.mjs +12 -12
  16. package/js/economy/model.mjs +133 -14
  17. package/js/economy/plan.mjs +68 -19
  18. package/js/economy/review.mjs +50 -8
  19. package/js/economy/schema.json +73 -29
  20. package/js/economy/simulate.mjs +116 -13
  21. package/js/mcp/tools.mjs +2 -2
  22. package/js/play/index.js +855 -296
  23. package/package.json +1 -1
  24. package/release.json +5 -5
  25. package/skills/xsolana-economy/SKILL.md +12 -4
  26. package/skills/xsolana-economy/references/after-launch.md +1 -1
  27. package/skills/xsolana-economy/references/apps.md +11 -2
  28. package/skills/xsolana-economy/references/sentence-to-spec.md +3 -3
  29. package/skills/xsolana-economy/references/telling-the-person.md +22 -4
  30. package/skills/xsolana-economy/references/trust-and-cheating.md +15 -5
  31. package/types/chain/token2022.d.ts +3 -2
  32. package/types/client/accounts.d.ts +34 -11
  33. package/types/client/idl/loot.d.ts +25 -4
  34. package/types/client/ix.d.ts +217 -45
  35. package/types/client/sizes.d.ts +42 -8
  36. package/types/economy/check.d.mts +11 -2
  37. package/types/economy/cost.d.mts +1 -0
  38. package/types/economy/live.d.mts +2 -0
  39. package/types/economy/model.d.mts +75 -12
  40. package/types/economy/plan.d.mts +31 -2
  41. package/types/economy/simulate.d.mts +12 -1
  42. package/types/mcp/server.d.mts +12 -1
  43. package/types/mcp/tools.d.mts +12 -1
  44. 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 { MEMO_PROGRAM, mintPowers, resolveTransfers } from '../chain/token2022.js';
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 signs that settle.
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
- { timeoutMs = 60_000, burner = null, putBack = [], opened = false, landed = null, keepBurner = false } = {},
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 && !putBack.length && order.settler === burner.address ? burner.address : wallet.address;
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 assets = wonAssets(order).map(a => (putBack.includes(a) ? null : a));
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
- async function asksFor(payer, orderAddress, order, settler) {
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
- async function askRoll(orderAddress) {
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 = ['shown', 'listProof', 'maxPrice', 'listing', 'chosenRecipient', 'on', 'oneSignature', 'count'];
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
- rolls.push(...loot.pullRolls({ project, round: r, entry: i, part, nonce: more.nonce }));
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 (anyone may; the asker pays the oracle). On a box
2106
- * with nothing in stock only the order's player may ask, a slot after it was made or last asked, and that ends the
2107
- * roll there with nothing, no oracle (a lot its winner paid for fails, for its refund). */
2108
- askRoll: orderAddress => askRoll(orderAddress),
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), never their own. `holders`: asset → holder, for a holder's share (read
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 && Number(lottery.every) === 0 && lottery.round > 0;
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
- { shown, chosenRecipient = null, listProof, nft: paidWith, listing, maxPrice, ...others } = NO_OPTIONS,
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(others, ['shown', 'chosenRecipient', 'listProof', 'nft', 'listing', 'maxPrice'], 'buyTickets');
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 (Number(lottery.every) === 0 && lottery.round > 0) throw new Error(`“${l.name}” has drawn and ended.`);
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
- * Returns the signature, or { signature, roll } when it makes a roll (a mission's result on the NFT, or a chest),
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(farmKey, asset, { forfeit = false, extra, transfers, ...others } = NO_OPTIONS) {
2493
- only(others, ['forfeit', 'extra', 'transfers'], 'unstakeNft');
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 { signature, rolls } = await sendRolling(
2545
- await walking(f, farm, [...before, ...given.before, ...token.before, ix]),
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. The program
2906
- * takes such tokens, and a lender who holds one can stop every repayment and take the NFT once it is due: show the
2907
- * risks before the player borrows.
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 (`loanOffer`'s `risks`) is refused unless
2948
- * `acceptRisks` is true: the page shows the player who holds that power first. `extra`: a transfer hook's accounts.
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 = [], ...others } = NO_OPTIONS) {
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, then sends the NFT home to its
3000
- * borrower. `repay`: the debt the payer was shown (`loan()`'s, in base units); the program refuses any other
3001
- * (`TermsChanged`). The repay goes in the wallet's transaction alone, with what it opens (the borrower's Token
3002
- * Metadata account), so a release that fails never undoes a repay that must land before the loan is due; the same
3003
- * transaction funds a throwaway key that sends the release (anyone may), a pNFT's or a compressed NFT's too: the key
3004
- * is funded for what that release opens (a pNFT's token account and record, a compressed NFT's proof record, which
3005
- * comes back in the same transaction). Returns { signature, released }.
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, extra = [], ...others } = NO_OPTIONS) {
3008
- only(others, ['repay', 'extra'], 'repay');
3009
- const hold = await read('Hold', loot.pda.hold(asset, due));
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 collection = nft.collection ?? (await collectionOf(asset)) ?? MPL_CORE;
3021
- // A pNFT's or a compressed NFT's release opens something (a token account and record, a proof record): its key
3022
- // is funded for it, and makes its own proof and accounts as it sends the release.
3023
- const heavy = before.some(isProveLeaf) || Boolean(nft.tokenMetadata?.programmable);
3024
- const opens = heavy ? [] : before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM);
3025
- const at = loot.pda.hold(asset, due);
3026
- const { key, fund } = await keyFor(
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.releaseIx({
3044
- signer,
3628
+ loot.repayIx({
3629
+ payer: wallet.address,
3045
3630
  project,
3046
- asset,
3047
- ...kind,
3048
- collection,
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 = accounts[i] && loot.decode('Auction', accounts[i].data);
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
- const memo =
3215
- programOf(mint) === TOKEN_2022_PROGRAM ? [{ pubkey: MEMO_PROGRAM, isSigner: false, isWritable: false }] : [];
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, a new
3234
- * collectible minted for them at the address its item and serial name, however long after the end it is sent. A
3235
- * roll lot makes the winner's roll of its box (`order`), which `collect` asks for and settles. An end too big for one
3236
- * transaction makes each NFT a hold and sends each home with a release after it (`releases`).
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
- const a = part('auctions', auctionKey);
3241
- const auction = await read('Auction', auctionAt(a));
3242
- const g = await projectNow();
3243
- // The lot went at an earlier end: this one pays what its seller is owed and closes the auction.
3244
- if (BigInt(auction.proceeds_owed) > 0n) {
3245
- const memo =
3246
- programOf(auction.mint) === TOKEN_2022_PROGRAM
3247
- ? [{ pubkey: MEMO_PROGRAM, isSigner: false, isWritable: false }]
3248
- : [];
3249
- return {
3250
- signature: await send([
3251
- loot.endAuctionIx({
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(asset, { price, lasts, maxPeriods = 1, currency, shares = [], ...others } = NO_OPTIONS) {
3466
- only(others, ['price', 'lasts', 'maxPeriods', 'currency', 'shares'], 'listForRent');
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
- kind: 'release',
4003
- asset: hold.asset,
4004
- hold: pubkey,
4005
- says:
4006
- hold.player === project
4007
- ? 'An NFT owed to the treasury: send it there.'
4008
- : 'Your collectible back: its hold is over.',
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
- kind: 'release',
4020
- asset: hold.asset,
4021
- hold: pubkey,
4022
- says: 'A loan you made is overdue and unpaid: take its NFT.',
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 at its end, and its seller's account couldn't take the money then: only the money is left to pay.
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 your account couldn’t take the money then: end it again once it can, and it is paid to you.`,
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
- given.before.push(...before);
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') return (await this.endAuction(c.key)).signature;
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 || projectKey || r.entries[i].buyer === me) return false;
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
- const close = [loot.pullIx({ payer: me, project, roundAddress: key, round: r, projectState: g, programs })];
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, { closes: closes && k === batch.length - 1 });
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
  /**