@xgamesfun/solana 0.25.4 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) 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 +714 -182
  10. package/js/client/ix.js +617 -113
  11. package/js/client/sizes.js +35 -3
  12. package/js/economy/check.mjs +655 -242
  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 +36 -11
  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 +847 -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 +21 -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/economy/check.d.mts +5 -0
  36. package/types/economy/model.d.mts +75 -12
  37. package/types/economy/plan.d.mts +22 -1
  38. package/types/economy/simulate.d.mts +12 -1
  39. package/types/mcp/server.d.mts +12 -1
  40. package/types/mcp/tools.d.mts +12 -1
  41. 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
@@ -1455,6 +1575,7 @@ export function solanaApp({
1455
1575
  chosenRecipient = null,
1456
1576
  listProof,
1457
1577
  count = 1,
1578
+ acceptLeftover,
1458
1579
  } = {},
1459
1580
  ) {
1460
1581
  const g = await projectNow();
@@ -1468,6 +1589,8 @@ export function solanaApp({
1468
1589
  const items = await itemsOf(box);
1469
1590
  // A box that rolls on an NFT takes the one the player brings (`on`), whatever its kind.
1470
1591
  if (box.subject && !on) throw new Error('This box rolls on an NFT: say which (on).');
1592
+ // A box that burns it, gives it away or moves its numbers takes it into the project's hands until the roll settles.
1593
+ if (box.subject && loot.boxHolds(box)) await noLeftovers(on, acceptLeftover);
1471
1594
  const brought = box.subject ? await nftOf(on) : { before: [] };
1472
1595
  const { before: proving, hasAuthority, ...kind } = brought;
1473
1596
  const subject = box.subject
@@ -1706,6 +1829,339 @@ export function solanaApp({
1706
1829
  );
1707
1830
  }
1708
1831
 
1832
+ /**
1833
+ * An auction's end, as `endAuction` and `callOffSale` send it (`a`: the auction's part of the spec). Each step is
1834
+ * checked before the wallet signs: a dry run when it is one legacy transaction, the size otherwise.
1835
+ */
1836
+ async function endSale(a, { holders, callOff = false } = {}) {
1837
+ const at = auctionAt(a);
1838
+ let auction = await read('Auction', at);
1839
+ const g = await projectNow();
1840
+ // What a transfer of the bids' coin carries: the Memo program for Token-2022, and a transfer hook's accounts.
1841
+ const memo = await hookTail([auction.mint]);
1842
+ const sold = auction.high > 0n;
1843
+ const lot = auction.lot.Asset?.[0] ?? null;
1844
+ const owner = lot ? auction.seller : g.treasury;
1845
+ // The bids' coin's token program, read from the mint when the app wasn't told it.
1846
+ const coin = programs[auction.mint] ?? (await client.account(auction.mint))?.owner ?? TOKEN_PROGRAM;
1847
+ const base = {
1848
+ signer: wallet.address,
1849
+ project,
1850
+ projectState: g,
1851
+ id: a.id,
1852
+ programs: { ...programs, [auction.mint]: coin },
1853
+ extra: memo,
1854
+ };
1855
+ if (callOff && (!sold || !lot))
1856
+ throw new Error('Only the sale of an NFT lot that has a bid is called off: end this auction with endAuction.');
1857
+ // The lot went at an earlier end: this one pays what the bid still owes and closes the auction.
1858
+ if (BigInt(auction.proceeds_owed) > 0n) {
1859
+ if (callOff)
1860
+ throw new Error(
1861
+ 'The lot went to its winner already: the sale stands (CantCallOff). End it to pay what it owes.',
1862
+ );
1863
+ return { ...(await payOwed(a, auction, base, holders)), owedPaid: auction.proceeds_owed };
1864
+ }
1865
+ const item = auction.lot.Item ? await read('Item', loot.pda.item(project, auction.lot.Item[0])) : null;
1866
+ const made =
1867
+ sold && item?.kind === 'Collectible'
1868
+ ? loot.pda.itemAsset(loot.pda.item(project, auction.lot.Item[0]), auction.pin)
1869
+ : null;
1870
+ // Gone (taken back or burned by whoever controls its collection): the lot, or an NFT of the winning bid.
1871
+ const lotNow = lot ? await heldNow(lot) : { held: null };
1872
+ const bidNow = [];
1873
+ if (sold && auction.nft_bids) for (const nft of auction.held) bidNow.push(await heldNow(nft));
1874
+ const gone = lotNow.held === false || bidNow.some(n => n.held === false);
1875
+ const ending = sold && lot && (callOff || gone) ? 'CallOff' : 'Deliver';
1876
+ const home = !sold || ending === 'CallOff';
1877
+ // The lot to its winner, or home to its seller; a bid's NFTs to whoever owned the lot, or back to their bidder.
1878
+ const before = [];
1879
+ const lotKind = lot ? await kindFor(lot, home ? auction.seller : auction.high_bidder, lotNow.evidence, before) : {};
1880
+ const bidTo = ending === 'CallOff' ? auction.high_bidder : owner;
1881
+ const held = [];
1882
+ if (sold && auction.nft_bids)
1883
+ for (const [i, nft] of auction.held.entries()) held.push(await kindFor(nft, bidTo, bidNow[i].evidence, before));
1884
+ if (sold && !home) holders ??= await holdersOf(auction.shares);
1885
+ const end = (ending, hold = false) =>
1886
+ loot.endAuctionIx({ ...base, auction, item, ...lotKind, held, holders, ending, hold });
1887
+ const steps = ix => [
1888
+ computeUnitLimit(1_000_000),
1889
+ ...before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
1890
+ ix,
1891
+ ];
1892
+ const fits = ix => Boolean(sendPlan({ payer: wallet.address, instructions: steps(ix), alone: isProveLeaf }));
1893
+ const result = {
1894
+ ...(sold && ending === 'Deliver' ? { winner: auction.high_bidder } : {}),
1895
+ ...(made ? { asset: made } : {}),
1896
+ };
1897
+ if (ending === 'CallOff') Object.assign(result, { calledOff: true, gone });
1898
+
1899
+ // 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.
1900
+ const outs = [
1901
+ ...(lot && home && lotNow.held !== false ? [[lot, auction.seller]] : []),
1902
+ ...(sold && auction.nft_bids && (home || !lot)
1903
+ ? auction.held.filter((_, i) => bidNow[i].held !== false).map(x => [x, bidTo])
1904
+ : []),
1905
+ ];
1906
+ const holding = async () => ({
1907
+ ...(await heldThenReleased(
1908
+ at,
1909
+ steps(
1910
+ end(
1911
+ ending,
1912
+ outs.map(([x]) => x),
1913
+ ),
1914
+ ),
1915
+ outs,
1916
+ )),
1917
+ ...result,
1918
+ });
1919
+ if (fits(end(ending))) {
1920
+ if (await stuck(steps(end(ending)))) {
1921
+ // What can't move now goes home or back as a hold, sent on once it can; a sold lot waits instead.
1922
+ if (outs.length) return holding();
1923
+ return {
1924
+ waiting: true,
1925
+ 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.',
1926
+ };
1927
+ }
1928
+ // 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
1929
+ // (`collect`); the winner ending it asks in the same transaction and a key settles it.
1930
+ const roll =
1931
+ sold && auction.lot.Roll
1932
+ ? loot.pda.order(
1933
+ project,
1934
+ auction.high_bidder,
1935
+ loot.prizeRollNonce(loot.prizeNonce(at, BigInt(auction.last_bid_at))),
1936
+ )
1937
+ : null;
1938
+ const { signature, rolls } =
1939
+ roll && auction.high_bidder === wallet.address
1940
+ ? await sendRolling(steps(end(ending)))
1941
+ : { signature: await send(steps(end(ending))), rolls: [] };
1942
+ return {
1943
+ signature,
1944
+ ...result,
1945
+ ...(roll ? { order: roll } : {}),
1946
+ ...(rolls.length ? { rolled: rolls[0] } : {}),
1947
+ };
1948
+ }
1949
+ // Too wide for one transaction. A sold NFT lot goes first, and a second end pays the bid.
1950
+ if (sold && lot && ending === 'Deliver') {
1951
+ const first = steps(end('LotFirst'));
1952
+ if (await stuck(first))
1953
+ return {
1954
+ waiting: true,
1955
+ 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).',
1956
+ };
1957
+ const signature = await send(first);
1958
+ auction = await read('Auction', at);
1959
+ return { signature, ...result, lotFirst: true, ...(await payOwed(a, auction, base, holders)) };
1960
+ }
1961
+ // Otherwise each NFT that may be a hold becomes one, and is sent on with a release after.
1962
+ return holding();
1963
+ }
1964
+
1965
+ /** An NFT's kind on an auction's end, going to `to`: what `nftOf` reads, its proof put in `before`; `evidence` for a Token
1966
+ * Metadata NFT that left the project (the account that holds it now, or its mint). */
1967
+ async function kindFor(asset, to, evidence, before) {
1968
+ const { before: proof, hasAuthority: _, ...kind } = await nftOf(asset, wallet.address, { to });
1969
+ before.push(...proof);
1970
+ return {
1971
+ ...kind,
1972
+ collection: kind.compressed || kind.tokenMetadata ? undefined : ((await collectionOf(asset)) ?? MPL_CORE),
1973
+ ...(evidence && kind.tokenMetadata ? { evidence } : {}),
1974
+ };
1975
+ }
1976
+
1977
+ /**
1978
+ * Whether an end can't move an NFT now: dry-run as the wallet would send it (one legacy transaction; a bigger one is
1979
+ * 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
1980
+ * (it is frozen, or its collection refuses the move) is one that is stuck: true, and nothing is sent.
1981
+ */
1982
+ async function stuck(instructions) {
1983
+ const plan = sendPlan({ payer: wallet.address, instructions, alone: isProveLeaf });
1984
+ if (!plan?.legacy) return false;
1985
+ const tried = await dryRun(wallet.address, instructions);
1986
+ if (tried.ok) return false;
1987
+ if (tried.error) throw Object.assign(new Error(`${tried.error.msg} (${tried.error.name}).`), tried.error);
1988
+ return true;
1989
+ }
1990
+
1991
+ /**
1992
+ * After an NFT lot went first: the end that pays what the bid still owes and closes the auction. The accounts its
1993
+ * money goes into (each share's wallet and the seller's) are opened first, in a transaction of their own when any is
1994
+ * missing: the end itself can make up to 64 calls. A bid's NFTs go to the seller, made holds and released after when
1995
+ * they don't all fit. Returns { paid } (its signature), and `releases`.
1996
+ */
1997
+ async function payOwed(a, auction, base, holders) {
1998
+ const at = auctionAt(a);
1999
+ holders ??= await holdersOf(auction.shares);
2000
+ const opens = [];
2001
+ if (!auction.nft_bids) {
2002
+ const payees = [
2003
+ auction.seller,
2004
+ ...auction.shares.flatMap(sh =>
2005
+ sh.to.Wallet
2006
+ ? [sh.to.Wallet[0]]
2007
+ : sh.to.Holder
2008
+ ? [holders[sh.to.Holder[0]]]
2009
+ : sh.to === 'Treasury'
2010
+ ? [base.projectState.treasury]
2011
+ : [],
2012
+ ),
2013
+ ].filter(Boolean);
2014
+ const program = base.programs[auction.mint];
2015
+ const accounts = await client.accounts(payees.map(w => loot.ata(w, auction.mint, program)));
2016
+ for (const [i, w] of payees.entries())
2017
+ if (!accounts[i]) opens.push(createAssociatedTokenAccountIdempotent(wallet.address, w, auction.mint, program));
2018
+ }
2019
+ if (opens.length) await send([...new Map(opens.map(x => [x.keys[1].pubkey, x])).values()]);
2020
+ const before = [];
2021
+ const held = [];
2022
+ if (auction.nft_bids) for (const nft of auction.held) held.push(await kindFor(nft, auction.seller, null, before));
2023
+ const end = (hold = false) => loot.endAuctionIx({ ...base, auction, held, holders, hold });
2024
+ const steps = ix => [
2025
+ computeUnitLimit(1_000_000),
2026
+ ...before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
2027
+ ix,
2028
+ ];
2029
+ if (sendPlan({ payer: wallet.address, instructions: steps(end()), alone: isProveLeaf }))
2030
+ try {
2031
+ return { paid: await send(steps(end())) };
2032
+ } catch (e) {
2033
+ // The lot is the winner's already: what is owed waits until the account it goes into can take it.
2034
+ return {
2035
+ paid: null,
2036
+ says: `The lot is the winner’s; the money waits (${e.message}): end the auction again to pay it.`,
2037
+ };
2038
+ }
2039
+ const outs = auction.held.map(x => [x, auction.seller]);
2040
+ const { signature, releases } = await heldThenReleased(at, steps(end(true)), outs);
2041
+ return { paid: signature, releases };
2042
+ }
2043
+
2044
+ /**
2045
+ * An end that makes NFTs holds (`outs`: [asset, to]), sent by the wallet with a transfer that funds a throwaway key; the
2046
+ * key then sends each on with a release (anyone may), funded for what each may open (a pNFT's token account and record,
2047
+ * a compressed NFT's proof record). One the key can't send, the wallet does. Returns { signature, releases }.
2048
+ */
2049
+ async function heldThenReleased(at, instructions, outs) {
2050
+ const { key, fund } = await keyFor(
2051
+ BigInt(outs.length) * (2n * SIGNATURE_FEE + loot.SETTLE_RENT_UNIT),
2052
+ await jobKey(at),
2053
+ );
2054
+ const signature = await send([...instructions, fund]);
2055
+ const releases = [];
2056
+ for (const [asset, to] of outs) {
2057
+ const release = async signer => {
2058
+ const hold = await read('Hold', loot.pda.hold(asset, 0));
2059
+ const { before: proof, hasAuthority: _h, ...kind } = await nftOf(asset, signer, { to });
2060
+ return [
2061
+ ...proof,
2062
+ loot.releaseIx({
2063
+ signer,
2064
+ project,
2065
+ asset,
2066
+ ...kind,
2067
+ collection: kind.compressed || kind.tokenMetadata ? undefined : ((await collectionOf(asset)) ?? MPL_CORE),
2068
+ hold,
2069
+ to,
2070
+ }),
2071
+ ];
2072
+ };
2073
+ const steps = await release(key.address);
2074
+ const byKey = (await dryRun(key.address, steps)).ok ? await sendAs(key, steps).catch(() => null) : null;
2075
+ // One that still can't move (frozen for now) stays a hold: claims() lists it for whoever it is owed to.
2076
+ releases.push(byKey ?? (await send(await release(wallet.address)).catch(e => ({ asset, waits: e.message }))));
2077
+ }
2078
+ await emptyKey(key);
2079
+ return { signature, releases };
2080
+ }
2081
+
2082
+ /**
2083
+ * A hold's claim, or, when the project no longer holds its NFT (whoever controls its collection took it back or
2084
+ * burned it while it was held), the same claim flagged `gone`: the hold's record still says the NFT is held, and it
2085
+ * is not, so nothing can be sent.
2086
+ */
2087
+ async function goneOr(asset, claim) {
2088
+ if ((await heldNow(asset)).held !== false) return claim;
2089
+ return {
2090
+ ...claim,
2091
+ gone: true,
2092
+ 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.',
2093
+ };
2094
+ }
2095
+
2096
+ /**
2097
+ * The records of this project an earlier owner of `asset` left behind: its listing at
2098
+ * `["listing", project, asset]` (one read) and its holds (one search, by the asset's address), each that names a wallet
2099
+ * other than the player's. Each is { kind, address, says }. Such a record stays when whoever controls the NFT's
2100
+ * collection moved it out of the project's hands without the program, and the program hands out whatever the project
2101
+ * holds at that address: once this player's deposit puts the NFT back in the project's hands, the record can send it
2102
+ * to the wallet it names. NFTs of the project's own collections are not read: those collections refuse the permanent
2103
+ * delegates that take an NFT out.
2104
+ */
2105
+ async function leftovers(asset) {
2106
+ if (ours(await collectionOf(asset))) return [];
2107
+ const me = wallet.address;
2108
+ const out = [];
2109
+ const listingAt = loot.pda.listing(project, asset);
2110
+ const listing = await readMaybe('Listing', listingAt);
2111
+ if (listing && listing.maker !== me) {
2112
+ const sale = Boolean(listing.deal.Sell);
2113
+ out.push({
2114
+ kind: 'listing',
2115
+ address: listingAt,
2116
+ 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}` : ''}.`,
2117
+ });
2118
+ }
2119
+ const tag = { memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS.Hold)) } };
2120
+ const holds = await client.programAccounts(loot.LOOT_PROGRAM, [
2121
+ tag,
2122
+ { memcmp: { offset: 1, bytes: project } },
2123
+ { memcmp: { offset: 33, bytes: asset } },
2124
+ ]);
2125
+ const time = BigInt(await chainNow());
2126
+ for (const { pubkey, account } of holds) {
2127
+ const hold = loot.decode('Hold', account.data);
2128
+ const to = hold.player === project ? 'the project’s treasury' : hold.player;
2129
+ const names = [hold.player, hold.debt?.lender].filter(Boolean);
2130
+ if (names.every(x => x === me)) continue;
2131
+ const due = time >= BigInt(hold.free_at);
2132
+ const what = hold.debt
2133
+ ? due
2134
+ ? `a loan's hold whose loan is overdue: anyone can send the NFT to its lender, ${hold.debt.lender}`
2135
+ : `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`
2136
+ : due
2137
+ ? `a hold that is over: anyone can send the NFT to ${to} at once`
2138
+ : `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`;
2139
+ out.push({
2140
+ kind: 'hold',
2141
+ address: pubkey,
2142
+ 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.`,
2143
+ });
2144
+ }
2145
+ return out;
2146
+ }
2147
+
2148
+ /**
2149
+ * Refuses, in words, to hand `asset` to the project while a record an earlier owner left can take it (`leftovers`),
2150
+ * unless the caller says the player accepts that (`accept` true). `listing`: the route is a listing, which the program
2151
+ * refuses outright while a listing of the NFT is open (its address is taken), whatever the option says.
2152
+ */
2153
+ async function noLeftovers(asset, accept, { listing = false } = {}) {
2154
+ const found = await leftovers(asset);
2155
+ if (listing && found.some(x => x.kind === 'listing'))
2156
+ throw new Error(
2157
+ `${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.`,
2158
+ );
2159
+ if (!found.length || accept === true) return;
2160
+ throw new Error(
2161
+ `${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.`,
2162
+ );
2163
+ }
2164
+
1709
2165
  /** Who holds each collectible a split's holder shares name now: { asset: holder }. */
1710
2166
  async function holdersOf(shares) {
1711
2167
  const holders = {};
@@ -1846,7 +2302,9 @@ export function solanaApp({
1846
2302
  return out;
1847
2303
  }
1848
2304
 
1849
- /** The nonce a pull of entry `i` of the round at `key` names: a place's roll is made at `prizeRollNonce(nonce + place)`. */
2305
+ /** The nonce a pull of entry `i` of the round at `key` names: a place's roll is made at `prizeRollNonce(nonce + place)`.
2306
+ * 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
2307
+ * this and no other, so no later pull lands on an order an earlier one made. */
1850
2308
  const pullNonce = (key, i) => loot.prizeNonce(key, BigInt(i));
1851
2309
 
1852
2310
  /** How many of a round's owed parts a pull of `part` (of entry `i`) settles. */
@@ -1863,10 +2321,14 @@ export function solanaApp({
1863
2321
  * `signer`, and the rolls they make ([{ order, box }], `loot.pullRolls`). With `closes`, the last pull carries the
1864
2322
  * round's close (when the parts it pays are the last owed).
1865
2323
  */
1866
- async function pulls(key, r, i, parts, signer, { closes = false } = {}) {
2324
+ async function pulls(key, r, i, parts, signer, { closes = false, lotteryClosed = false } = {}) {
1867
2325
  const e = i == null ? null : r.entries[i];
1868
2326
  const out = [];
1869
2327
  const rolls = [];
2328
+ // Whether a pull here wants the largest heap (a run of rolls), and whether one stops short of the part (a raffle's
2329
+ // NFTs past what one pull takes out, a run of rolls past what one transaction carries): a next pull carries on.
2330
+ let heap = false;
2331
+ let partial = false;
1870
2332
  const g = await projectNow();
1871
2333
  // A run's token prize is in its own mint: the prizes the round recorded name each.
1872
2334
  const extra = await hookTail([...roundMints(r), ...r.prizes.map(p => p.mint)]);
@@ -1890,6 +2352,10 @@ export function solanaApp({
1890
2352
  ? loot.decodeStash((await client.account(loot.pda.lotteryStash(r.lottery)))?.data ?? null)
1891
2353
  : null;
1892
2354
  if (stash) {
2355
+ // One pull takes out at most so many of the stash's NFTs (`loot.pullAssigns`): the places past them wait.
2356
+ const owedPlaces = loot.prizePlacesOwed(r, i, run);
2357
+ const last = loot.placesIn(r, run, e.start, e.end).at(-1) ?? 0;
2358
+ if (owedPlaces.some(p => loot.nftSlot(r, p) >= loot.pullAssigns(r, stash, last))) partial = true;
1893
2359
  const predicted = loot.stashNfts(r, stash);
1894
2360
  for (const place of loot.prizePlacesOwed(r, i, run)) {
1895
2361
  const nft = predicted[loot.nftSlot(r, place)];
@@ -1916,7 +2382,38 @@ export function solanaApp({
1916
2382
  projectMint: mintOf(),
1917
2383
  };
1918
2384
  more.nonce = pullNonce(key, i);
1919
- rolls.push(...loot.pullRolls({ project, round: r, entry: i, part, nonce: more.nonce }));
2385
+ // A run of places that each give a roll: two accounts a place, as many as one transaction carries, on the
2386
+ // largest heap. The pull is sized from a dry run, largest first; the next pull carries on, and only the last
2387
+ // carries the close.
2388
+ if (given.Roll) {
2389
+ const owed = loot.prizePlacesOwed(r, i, run).length;
2390
+ const sized = async most => {
2391
+ const ix = loot.pullIx({
2392
+ payer: signer,
2393
+ project,
2394
+ roundAddress: key,
2395
+ round: { ...r, owed: Number.MAX_SAFE_INTEGER },
2396
+ entry: i,
2397
+ part,
2398
+ projectState: g,
2399
+ programs,
2400
+ extra,
2401
+ ...more,
2402
+ prize: { ...more.prize, most },
2403
+ });
2404
+ const tx = [computeUnitLimit(1_400_000), requestHeapFrame(loot.LARGE_SETTLE_HEAP), ix];
2405
+ return (
2406
+ Boolean(
2407
+ compileFitting({ payer: signer, blockhash: NOBODY, instructions: tx, tables: await standing() }),
2408
+ ) && (await dryRun(signer, tx)).ok
2409
+ );
2410
+ };
2411
+ let most = owed;
2412
+ while (most > 1 && !(await sized(most))) most = Math.max(1, Math.floor(most * 0.75));
2413
+ if (most < owed) [more.prize.most, partial] = [most, true];
2414
+ heap = true;
2415
+ }
2416
+ rolls.push(...loot.pullRolls({ project, round: r, entry: i, part, nonce: more.nonce, most: more.prize.most }));
1920
2417
  } else if (part.Common) {
1921
2418
  // Where a part owed in common goes: a share's payee (a collectible's holder now), what the places leave (the
1922
2419
  // 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 +2444,14 @@ export function solanaApp({
1947
2444
  entry: i ?? 0,
1948
2445
  part,
1949
2446
  projectState: g,
2447
+ lotteryClosed,
1950
2448
  programs,
1951
2449
  extra,
1952
2450
  ...more,
1953
2451
  }),
1954
2452
  );
1955
2453
  }
1956
- return { instructions: out, rolls };
2454
+ return { instructions: out, rolls, heap, partial };
1957
2455
  }
1958
2456
 
1959
2457
  return {
@@ -2102,15 +2600,20 @@ export function solanaApp({
2102
2600
  return send([...before, loot.refundIx({ signer: wallet.address, orderAddress, order, subject, programs })]);
2103
2601
  },
2104
2602
 
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),
2603
+ /** Asks the oracle for a roll a mission, a recipe or a prize made (its player or the project's keys may; the asker
2604
+ * pays the oracle). Its first ask fixes its odds: the player's profile, and an NFT they show (`shown`; by default
2605
+ * their collectible that raises the box's odds the most), count then. On a box with nothing in stock only the order's
2606
+ * player may ask, a slot after it was made or last asked, and that ends the roll there with nothing, no oracle (a lot
2607
+ * its winner paid for fails, for its refund). */
2608
+ askRoll: (orderAddress, options = {}) => {
2609
+ only(options, ['shown'], 'askRoll');
2610
+ return askRoll(orderAddress, options);
2611
+ },
2109
2612
 
2110
2613
  /**
2111
2614
  * Pays a payment of the economy (an entry, an attempt, a tip). `amount`: what the player chose, in whole units, for
2112
2615
  * 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
2616
+ * 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
2617
  * now when not given).
2115
2618
  */
2116
2619
  async pay(paymentKey, { amount: chosen, holders, chosenRecipient = null, ...others } = NO_OPTIONS) {
@@ -2125,8 +2628,6 @@ export function solanaApp({
2125
2628
  const pools = new Map((spec.pools ?? []).map(x => [x.key, x.index]));
2126
2629
  // `pay` takes its shares from the payer each time: a "chosen" share pays the wallet they name, and with none named
2127
2630
  // 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
2631
  const shares = (p.split ?? []).flatMap(s => {
2131
2632
  const [kind, key] = s.to.split(':');
2132
2633
  const bps = Math.round(s.percent * 100);
@@ -2203,7 +2704,10 @@ export function solanaApp({
2203
2704
  const copy = current ?? lottery;
2204
2705
  const terms = copy?.terms;
2205
2706
  const currency = lottery ? ticketCurrencyOf(l, lottery) : (l.ticketCurrency ?? currencyKey);
2206
- const ended = lottery && Number(lottery.every) === 0 && lottery.round > 0;
2707
+ const ended = lottery && loot.lotteryEnded(lottery);
2708
+ // A lottery that weighs tickets by time: what one ticket bought now weighs, and its share of the round's weight
2709
+ // (the same ticket bought later weighs less: show it on the buy screen).
2710
+ const odds = terms && !ended ? loot.purchaseOdds(current, terms, 1n, await chainNow()) : null;
2207
2711
  out.push({
2208
2712
  key: l.key,
2209
2713
  name: l.name,
@@ -2214,6 +2718,11 @@ export function solanaApp({
2214
2718
  round: lottery?.round ?? null,
2215
2719
  sold: lottery ? (current?.sold ?? 0n) : null,
2216
2720
  drawsAt: terms ? Number(terms.draws_at) : null,
2721
+ salesCloseAt: terms ? Number(loot.salesCloseAt(terms)) : null,
2722
+ timeWeighted: Boolean(terms?.time_weighted),
2723
+ weight: current ? loot.roundTotal(current) : null,
2724
+ ticketWeight: odds?.weight ?? null,
2725
+ ticketShare: odds?.share ?? null,
2217
2726
  nftTicket: Boolean(lottery?.terms.nft_ticket),
2218
2727
  incarnation: lottery?.incarnation ?? null,
2219
2728
  revision: copy?.revision ?? null,
@@ -2222,6 +2731,25 @@ export function solanaApp({
2222
2731
  return out;
2223
2732
  },
2224
2733
 
2734
+ /**
2735
+ * What `count` tickets of a lottery bought now would weigh in its current round's draw, before they are bought:
2736
+ * { tickets, weight, total, share, salesCloseAt, timeWeighted }. In a lottery that weighs tickets by time, each
2737
+ * ticket weighs the seconds from its purchase to the close of sales, so the same tickets bought later weigh less;
2738
+ * `share` is the purchase's part of the round's weight with it (0 to 1), its chance at each place as things stand.
2739
+ */
2740
+ async ticketWeight(lotteryKey, count = 1) {
2741
+ const l = part('lotteries', lotteryKey);
2742
+ const { lottery, current } = await roundOf(l);
2743
+ const terms = (current ?? lottery).terms;
2744
+ const odds = loot.purchaseOdds(current, terms, BigInt(count), await chainNow());
2745
+ return {
2746
+ tickets: BigInt(count),
2747
+ ...odds,
2748
+ salesCloseAt: Number(loot.salesCloseAt(terms)),
2749
+ timeWeighted: Boolean(terms.time_weighted),
2750
+ };
2751
+ },
2752
+
2225
2753
  /**
2226
2754
  * Buys `count` tickets of a lottery, on the terms the player was shown: `listing`, the lottery as `lotteries()`
2227
2755
  * showed it (a lottery changed since, its price or anything else, is refused rather than paid), or `maxPrice`, the
@@ -2234,9 +2762,22 @@ export function solanaApp({
2234
2762
  async buyTickets(
2235
2763
  lotteryKey,
2236
2764
  count = 1,
2237
- { shown, chosenRecipient = null, listProof, nft: paidWith, listing, maxPrice, ...others } = NO_OPTIONS,
2765
+ {
2766
+ shown,
2767
+ chosenRecipient = null,
2768
+ listProof,
2769
+ nft: paidWith,
2770
+ listing,
2771
+ maxPrice,
2772
+ acceptLeftover,
2773
+ ...others
2774
+ } = NO_OPTIONS,
2238
2775
  ) {
2239
- only(others, ['shown', 'chosenRecipient', 'listProof', 'nft', 'listing', 'maxPrice'], 'buyTickets');
2776
+ only(
2777
+ others,
2778
+ ['shown', 'chosenRecipient', 'listProof', 'nft', 'listing', 'maxPrice', 'acceptLeftover'],
2779
+ 'buyTickets',
2780
+ );
2240
2781
  const l = part('lotteries', lotteryKey);
2241
2782
  const name = l.name ?? lotteryKey;
2242
2783
  if (listing && listing.key !== undefined && listing.key !== l.key)
@@ -2279,7 +2820,14 @@ export function solanaApp({
2279
2820
  throw new Error(
2280
2821
  `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
2822
  );
2282
- if (Number(lottery.every) === 0 && lottery.round > 0) throw new Error(`“${l.name}” has drawn and ended.`);
2823
+ if (loot.lotteryEnded(lottery)) throw new Error(`“${l.name}” has drawn and ended.`);
2824
+ // Sales close `closes_before` seconds before the draw (SalesClosed after).
2825
+ // (A repeating lottery's current round nobody bought into moves to its next date as this purchase opens it.)
2826
+ const movesOn = !round && Number(lottery.every) > 0 && BigInt(now()) >= BigInt(terms.draws_at);
2827
+ if (!due && !movesOn && BigInt(now()) >= loot.salesCloseAt(terms))
2828
+ throw new Error(
2829
+ `Sales of “${name}” closed at ${new Date(Number(loot.salesCloseAt(terms)) * 1000).toISOString()}, before its draw: wait for the next round.`,
2830
+ );
2283
2831
  shown ??= await passing(lottery.gate, 'buy a ticket');
2284
2832
  listProof = listProofOf(lottery.gate, l.gate?.list, listProof);
2285
2833
  let nft;
@@ -2287,6 +2835,8 @@ export function solanaApp({
2287
2835
  if (terms.nft_ticket) {
2288
2836
  if (!paidWith) throw new Error(`A ticket of “${l.name}” costs an NFT: say which (nft).`);
2289
2837
  if (BigInt(count) !== 1n) throw new Error('An NFT pays for one ticket at a time.');
2838
+ // The project holds an NFT ticket until the round is drawn.
2839
+ await noLeftovers(paidWith, acceptLeftover);
2290
2840
  const { before: proof, ...kind } = await nftOf(paidWith);
2291
2841
  nft = { asset: paidWith, ...kind };
2292
2842
  before.push(...proof);
@@ -2450,10 +3000,13 @@ export function solanaApp({
2450
3000
  * Core asset, a Token Metadata NFT's mint (legacy or pNFT), or a compressed NFT's asset id. In a farm that watches,
2451
3001
  * 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
3002
  */
2453
- async stakeNft(farmKey, asset, tier = 0, { extra, transfers, ...others } = NO_OPTIONS) {
2454
- only(others, ['extra', 'transfers'], 'stakeNft');
3003
+ async stakeNft(farmKey, asset, tier = 0, { extra, transfers, acceptLeftover, ...others } = NO_OPTIONS) {
3004
+ only(others, ['extra', 'transfers', 'acceptLeftover'], 'stakeNft');
2455
3005
  const f = part('farms', farmKey);
2456
3006
  const farm = await read('Farm', loot.pda.farm(project, f.id));
3007
+ // A farm that keeps its NFTs in escrow hands this one to the project; one that freezes or watches leaves it in the
3008
+ // wallet, where no record of the project's can move it.
3009
+ if (farm.custody === 'Escrow') await noLeftovers(asset, acceptLeftover);
2457
3010
  await openFor(f, farm, tier);
2458
3011
  const token = await farmTokens([farm.rewards_mint], [], { extra, transfers });
2459
3012
  const { before, ...nft } = await nftOf(asset);
@@ -2485,12 +3038,17 @@ export function solanaApp({
2485
3038
 
2486
3039
  /**
2487
3040
  * 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),
3041
+ * takes its tier's prize (a mission: `prize.rs`). `forfeit` gives up rewards, and the prize, that cannot move. A
3042
+ * prize that can't be given now refuses the leave and the NFT stays (`needPrize`, true by default); `needPrize: false`
3043
+ * 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
3044
  * which `collect` then finishes.
2491
3045
  */
2492
- async unstakeNft(farmKey, asset, { forfeit = false, extra, transfers, ...others } = NO_OPTIONS) {
2493
- only(others, ['forfeit', 'extra', 'transfers'], 'unstakeNft');
3046
+ async unstakeNft(
3047
+ farmKey,
3048
+ asset,
3049
+ { forfeit = false, needPrize = true, extra, transfers, acceptLeftover, ...others } = NO_OPTIONS,
3050
+ ) {
3051
+ only(others, ['forfeit', 'needPrize', 'extra', 'transfers', 'acceptLeftover'], 'unstakeNft');
2494
3052
  const f = part('farms', farmKey);
2495
3053
  const farm = await read('Farm', loot.pda.farm(project, f.id));
2496
3054
  const token = await farmTokens(
@@ -2513,6 +3071,10 @@ export function solanaApp({
2513
3071
  // `prizeRollNonce` of the one named (`namedRollNonce`).
2514
3072
  const nonce = roll != null ? loot.nonceOn(asset) : freshNonce();
2515
3073
  const boxes = roll != null ? { [roll]: await read('LootBox', loot.pda.box(project, roll)) } : {};
3074
+ // 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
3075
+ // left it in the wallet.
3076
+ if (roll != null && farm.custody !== 'Escrow' && loot.boxHolds(boxes[roll]))
3077
+ await noLeftovers(asset, acceptLeftover);
2516
3078
  // A roll still at the NFT's order address blocks the leave, unless it was asked and came back with nothing to give
2517
3079
  // (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
3080
  if (roll != null) {
@@ -2531,6 +3093,7 @@ export function solanaApp({
2531
3093
  asset,
2532
3094
  ...nft,
2533
3095
  forfeit,
3096
+ needPrize,
2534
3097
  roll,
2535
3098
  nonce,
2536
3099
  tier: seat.tier,
@@ -2541,9 +3104,20 @@ export function solanaApp({
2541
3104
  boxes,
2542
3105
  ...token,
2543
3106
  });
2544
- const { signature, rolls } = await sendRolling(
2545
- await walking(f, farm, [...before, ...given.before, ...token.before, ix]),
2546
- );
3107
+ const steps = await walking(f, farm, [...before, ...given.before, ...token.before, ix]);
3108
+ // A mission prize that can't be given now (its pool short, an item sold out) refuses the leave and the NFT stays
3109
+ // (PrizeNotCovered): say so, and offer to leave without it (`needPrize: false`).
3110
+ if (prize != null && !forfeit && needPrize) {
3111
+ const tried = await dryRun(wallet.address, steps);
3112
+ if (!tried.ok && tried.error?.name === 'PrizeNotCovered')
3113
+ throw Object.assign(
3114
+ new Error(
3115
+ `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.`,
3116
+ ),
3117
+ { code: tried.error.code, name: 'PrizeNotCovered' },
3118
+ );
3119
+ }
3120
+ const { signature, rolls } = await sendRolling(steps);
2547
3121
  const rolled = rolls.length ? { rolled: rolls[0] } : {};
2548
3122
  if (roll != null)
2549
3123
  return {
@@ -2712,6 +3286,9 @@ export function solanaApp({
2712
3286
  freeAt: farm.lock_key ? null : Number(BigInt(seat.since) + lock),
2713
3287
  ...(farm.lock_key ? { lockKey: farm.lock_key } : {}),
2714
3288
  boostPercent: Number(seat.boost_bps) / 100,
3289
+ // Its weight in the farm, as the seat holds it: near the farm's total cap a seat weighs only what is left, so
3290
+ // it is read, never worked out from the tier and the boost.
3291
+ weight: BigInt(seat.weight),
2715
3292
  rewards: rewards?.key ?? null,
2716
3293
  waiting: show(seat.owed, rewards ? decimalsOf(rewards.key) : 0),
2717
3294
  owedUnits: BigInt(seat.owed),
@@ -2882,7 +3459,9 @@ export function solanaApp({
2882
3459
  * Hands an NFT of any kind to the project until a time (a hold): `seconds` from now, or `{ until }` (Unix seconds).
2883
3460
  * The hold's address names its end. Returns { signature, freeAt }.
2884
3461
  */
2885
- async hold(asset, seconds) {
3462
+ async hold(asset, seconds, { acceptLeftover, ...others } = NO_OPTIONS) {
3463
+ only(others, ['acceptLeftover'], 'hold');
3464
+ await noLeftovers(asset, acceptLeftover);
2886
3465
  const freeAt = BigInt(typeof seconds === 'object' ? seconds.until : now() + Number(seconds));
2887
3466
  const { before, hasAuthority: _, ...nft } = await nftOf(asset);
2888
3467
  const signature = await send([
@@ -2902,9 +3481,10 @@ export function solanaApp({
2902
3481
  /**
2903
3482
  * A lender's offer to borrow against: `maker`'s offer number `id` in this project. Returns { address, offer, mint,
2904
3483
  * 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.
3484
+ * (its freeze authority, a Token-2022 pause, a transfer hook, a permanent delegate) and who holds it, and each setting
3485
+ * of the token that lets the lender turn the repayment away (accounts that start frozen or can be made to,
3486
+ * confidential transfers) or blocks it (a token that can't be transferred). The program takes such tokens, and a
3487
+ * lender who can stop every repayment takes the NFT once it is due: show the risks before the player borrows.
2908
3488
  */
2909
3489
  async loanOffer(maker, id) {
2910
3490
  const address = loot.pda.offer(project, maker, id);
@@ -2937,6 +3517,8 @@ export function solanaApp({
2937
3517
  says: `${p.holder} can move anyone's tokens of the loan's token, the ones you would repay with too.${lender(p.holder)}`,
2938
3518
  };
2939
3519
  });
3520
+ // What the token's own settings let a lender do: the program no longer refuses these, so the kit says them.
3521
+ risks.push(...tokenSettings(account, 'repaying', 'the lender', 'and take your NFT when the loan is due'));
2940
3522
  const { amount, repay, lasts } = offer.deal.Lend;
2941
3523
  return { address, offer, mint: offer.mint, program: account.owner, amount, repay, lasts, risks };
2942
3524
  },
@@ -2944,12 +3526,15 @@ export function solanaApp({
2944
3526
  /**
2945
3527
  * Borrows against the player's NFT `asset` from `maker`'s offer `id`: the NFT is held until the loan is repaid, or
2946
3528
  * 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.
3529
+ * A loan whose token someone could freeze, pause, hook or move, or whose own settings let the lender turn the
3530
+ * repayment away (`loanOffer`'s `risks`), is refused unless `acceptRisks` is true: the page shows the player each
3531
+ * risk first. `extra`: a transfer hook's accounts.
2949
3532
  */
2950
- async borrow(maker, id, asset, { due, acceptRisks = false, extra = [], ...others } = NO_OPTIONS) {
2951
- only(others, ['due', 'acceptRisks', 'extra'], 'borrow');
3533
+ async borrow(maker, id, asset, { due, acceptRisks = false, acceptLeftover, extra, ...others } = NO_OPTIONS) {
3534
+ only(others, ['due', 'acceptRisks', 'acceptLeftover', 'extra'], 'borrow');
3535
+ await noLeftovers(asset, acceptLeftover);
2952
3536
  const loan = await this.loanOffer(maker, id);
3537
+ extra ??= await hookTail([loan.mint]);
2953
3538
  if (loan.risks.length && acceptRisks !== true)
2954
3539
  throw new Error(
2955
3540
  `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 +3581,21 @@ export function solanaApp({
2996
3581
  },
2997
3582
 
2998
3583
  /**
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 }.
3584
+ * Repays the loan on `asset` (held until `due`), the player's own or a friend's, and brings the NFT home to its
3585
+ * borrower in the same instruction: one prompt. `repay`: the debt the payer was shown (`loan()`'s, in base units);
3586
+ * the program refuses any other (`TermsChanged`). An NFT its collection took back or burned while the project held
3587
+ * it is refused here, before anything is signed: the program takes no repayment for it, and the borrower keeps what
3588
+ * they borrowed. When the NFT can't move now (frozen for now, or its collection refuses the move), or `home` is
3589
+ * false, the money goes alone, so the loan does not fall due: the hold is then over, and anyone sends the NFT home
3590
+ * once it can move (`claims()` lists it). A compressed NFT is proven in the same transaction; the money alone for one
3591
+ * whose proof keeps going out of date goes without it. Returns { signature, home }, and `says` when the NFT waits.
3006
3592
  */
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));
3593
+ async repay(asset, due, { repay, home = true, extra, ...others } = NO_OPTIONS) {
3594
+ only(others, ['repay', 'home', 'extra'], 'repay');
3595
+ const at = loot.pda.hold(asset, due);
3596
+ const hold = await read('Hold', at);
3010
3597
  if (!hold.debt) throw new Error('This NFT is held for no loan left to repay.');
3598
+ extra ??= await hookTail([hold.debt.mint]);
3011
3599
  if (repay === undefined || repay === null)
3012
3600
  throw new Error(
3013
3601
  'Say the debt the payer was shown (repay, from loan()): the amount read now is never paid in its place.',
@@ -3016,43 +3604,52 @@ export function solanaApp({
3016
3604
  throw new Error(
3017
3605
  `This loan now asks ${hold.debt.repay}, not the ${repay} shown: show the payer the loan as it is now first.`,
3018
3606
  );
3607
+ if ((await heldNow(asset)).held === false)
3608
+ throw new Error(
3609
+ `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.`,
3610
+ );
3019
3611
  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 [
3612
+ const kind = nft.compressed || nft.tokenMetadata ? nft : { collection: (await collectionOf(asset)) ?? MPL_CORE };
3613
+ // A Token Metadata NFT's move and a compressed NFT's proof take more than the default compute.
3614
+ const budget = nft.compressed || nft.tokenMetadata ? [computeUnitLimit(1_000_000)] : [];
3615
+ const opens = before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM);
3616
+ const repaying = (goesHome, first = []) =>
3617
+ withSolWrap(wallet.address, hold.debt.mint, BigInt(hold.debt.repay), [
3618
+ ...budget,
3042
3619
  ...first,
3043
- loot.releaseIx({
3044
- signer,
3620
+ loot.repayIx({
3621
+ payer: wallet.address,
3045
3622
  project,
3046
- asset,
3047
- ...kind,
3048
- collection,
3049
- hold: { ...hold, debt: null, free_at: 0 },
3623
+ hold,
3624
+ repay,
3625
+ home: goesHome,
3050
3626
  address: at,
3627
+ ...kind,
3628
+ programs,
3629
+ extra,
3051
3630
  }),
3052
- ];
3631
+ ]);
3632
+ if (home) {
3633
+ const all = repaying(true, opens);
3634
+ // Too big for a dry run as one legacy transaction (a compressed NFT's long proof): sent as it is, the wallet's
3635
+ // one transaction made to fit.
3636
+ const plan = sendPlan({ payer: wallet.address, instructions: all, alone: isProveLeaf });
3637
+ if (plan && !plan.legacy) return { signature: await send(all), home: true };
3638
+ const tried = await dryRun(wallet.address, all);
3639
+ if (tried.ok) return { signature: await send(all), home: true };
3640
+ // What would refuse the money alone too is said now.
3641
+ if (['TermsChanged', 'LoanDue', 'NotALoan', 'NotTheNft'].includes(tried.error?.name))
3642
+ throw Object.assign(new Error(`${tried.error.msg} (${tried.error.name}).`), tried.error);
3643
+ }
3644
+ // The money alone: a compressed NFT proven in the same transaction when that lands, so one that is gone is refused.
3645
+ const proven = nft.compressed ? repaying(false, opens.filter(isProveLeaf)) : null;
3646
+ const money = proven && (await dryRun(wallet.address, proven)).ok ? proven : repaying(false);
3647
+ const signature = await send(money);
3648
+ return {
3649
+ signature,
3650
+ home: false,
3651
+ 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
3652
  };
3054
- const released = await keyRun(key, release);
3055
- return { signature, released };
3056
3653
  },
3057
3654
 
3058
3655
  /**
@@ -3060,13 +3657,14 @@ export function solanaApp({
3060
3657
  * of NFTs takes one of the player's NFTs that passes its rule instead (`nft`, its address), held until it is
3061
3658
  * claimed. `listProof`: for a list gate.
3062
3659
  */
3063
- async enter(id, side, amount, { shown, listProof, nft: staked, ...others } = NO_OPTIONS) {
3064
- only(others, ['shown', 'listProof', 'nft'], 'enter');
3660
+ async enter(id, side, amount, { shown, listProof, nft: staked, acceptLeftover, ...others } = NO_OPTIONS) {
3661
+ only(others, ['shown', 'listProof', 'nft', 'acceptLeftover'], 'enter');
3065
3662
  const contest = await read('Contest', loot.pda.contest(project, id));
3066
3663
  shown ??= await passing(contest.gate, 'enter');
3067
3664
  listProof = listProofOf(contest.gate, null, listProof);
3068
3665
  if (contest.nft) {
3069
3666
  if (!staked) throw new Error('This contest takes an NFT: say which (nft).');
3667
+ await noLeftovers(staked, acceptLeftover);
3070
3668
  const { before, ...kind } = await nftOf(staked);
3071
3669
  return send([
3072
3670
  ...before,
@@ -3106,7 +3704,8 @@ export function solanaApp({
3106
3704
  /**
3107
3705
  * 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
3706
  * 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).
3707
+ * wrapped-SOL account from a bid that was topped (`unwrap` turns it into plain SOL). `warnings`: what to show every
3708
+ * bidder before a bid (a token whose accounts can be frozen, a lot of another collection).
3110
3709
  */
3111
3710
  async auctions() {
3112
3711
  const list = spec.auctions ?? [];
@@ -3118,8 +3717,28 @@ export function solanaApp({
3118
3717
  const waiting = wrapped
3119
3718
  ? show(new DataView(wrapped.data.buffer, wrapped.data.byteOffset).getBigUint64(64, true), 9)
3120
3719
  : '0';
3720
+ const auctions = accounts.map(x => x && loot.decode('Auction', x.data));
3721
+ // What every bidder is told before a bid: what the bids' token lets the high bidder do, and what whoever
3722
+ // controls an NFT lot of another collection can do to it.
3723
+ const mints = [...new Set(auctions.filter(Boolean).map(x => x.mint))];
3724
+ const mintAccounts = Object.fromEntries((await client.accounts(mints)).map((m, k) => [mints[k], m]));
3725
+ const warningsOf = async x => {
3726
+ const out = tokenSettings(
3727
+ mintAccounts[x.mint],
3728
+ 'the refund of the bid a higher one tops',
3729
+ 'the high bidder',
3730
+ 'so every higher bid fails and they win at their price',
3731
+ ).map(r => r.says);
3732
+ const lot = x.lot.Asset?.[0];
3733
+ if (lot && !ours(await collectionOf(lot)))
3734
+ out.push(
3735
+ '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.',
3736
+ );
3737
+ return out;
3738
+ };
3739
+ const warned = await Promise.all(auctions.map(x => (x ? warningsOf(x) : [])));
3121
3740
  return list.map((a, i) => {
3122
- const x = accounts[i] && loot.decode('Auction', accounts[i].data);
3741
+ const x = auctions[i];
3123
3742
  const base = {
3124
3743
  key: a.key,
3125
3744
  name: a.name,
@@ -3151,6 +3770,7 @@ export function solanaApp({
3151
3770
  // A bid of NFTs: a picker of 1 to `maxNfts` (and at least `nextBid`) of the player's NFTs.
3152
3771
  ...(x.nft_bids ? { nftBids: true, held: x.held, maxNfts: loot.MAX_BID_NFTS } : {}),
3153
3772
  ...(tokens.get(a.currency ?? currencyKey)?.source === 'sol' ? { waiting } : {}),
3773
+ ...(warned[i].length ? { warnings: warned[i] } : {}),
3154
3774
  };
3155
3775
  });
3156
3776
  },
@@ -3161,8 +3781,8 @@ export function solanaApp({
3161
3781
  * wrapped-SOL account does not hold already is wrapped, and the account stays open: if this bid is topped, it comes
3162
3782
  * back there.
3163
3783
  */
3164
- async bid(auctionKey, amount, { shown, listProof, nfts, ...others } = NO_OPTIONS) {
3165
- only(others, ['shown', 'listProof', 'nfts'], 'bid');
3784
+ async bid(auctionKey, amount, { shown, listProof, nfts, acceptLeftover, ...others } = NO_OPTIONS) {
3785
+ only(others, ['shown', 'listProof', 'nfts', 'acceptLeftover'], 'bid');
3166
3786
  const a = part('auctions', auctionKey);
3167
3787
  const auction = await read('Auction', auctionAt(a));
3168
3788
  shown ??= await passing(auction.gate, 'bid');
@@ -3175,6 +3795,7 @@ export function solanaApp({
3175
3795
  if (bundle.length > loot.MAX_BID_NFTS) throw new Error(`A bid holds at most ${loot.MAX_BID_NFTS} NFTs.`);
3176
3796
  if (auction.held.length >= loot.MAX_BID_NFTS)
3177
3797
  throw new Error(`The high bid holds ${loot.MAX_BID_NFTS} NFTs, the most a bid holds: it can't be topped.`);
3798
+ for (const asset of bundle) await noLeftovers(asset, acceptLeftover);
3178
3799
  const before = [];
3179
3800
  const kindOf = async (asset, to) => {
3180
3801
  const { before: proof, ...kind } = await nftOf(asset, wallet.address, { to });
@@ -3210,9 +3831,11 @@ export function solanaApp({
3210
3831
  if (value > held)
3211
3832
  instructions.push(transferLamports(wallet.address, wrapped, value - held), syncNative(wrapped));
3212
3833
  }
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 }] : [];
3834
+ // A Token-2022 account can ask for a memo on every transfer into it: the Memo program rides along, and a transfer
3835
+ // hook's accounts.
3836
+ const memo = await hookTail([mint]);
3837
+ // The coin's token program, read from the mint when the app wasn't told it.
3838
+ const coin = programs[mint] ?? (await client.account(mint))?.owner ?? TOKEN_PROGRAM;
3216
3839
  instructions.push(
3217
3840
  loot.bidIx({
3218
3841
  bidder: wallet.address,
@@ -3222,7 +3845,7 @@ export function solanaApp({
3222
3845
  amount: value,
3223
3846
  shown,
3224
3847
  listProof,
3225
- programs,
3848
+ programs: { ...programs, [mint]: coin },
3226
3849
  extra: memo,
3227
3850
  }),
3228
3851
  );
@@ -3230,167 +3853,32 @@ export function solanaApp({
3230
3853
  },
3231
3854
 
3232
3855
  /**
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`).
3856
+ * Ends an auction that is over (anyone can; the winner usually does): the lot to the highest bidder and the winning
3857
+ * bid to whoever owned it, in one transaction (a new collectible minted for the winner at the address its item and
3858
+ * serial name), however long after the end it is sent. A roll lot makes the winner's roll of its box (`order`), which
3859
+ * `collect` asks for and settles. An NFT lot whose end can't fit one transaction (a compressed lot's long proof, many
3860
+ * shares, a hooked coin) goes first, and the bid is paid by a second end, the accounts it pays into opened before it.
3861
+ * When the lot can't move now (its collection froze it or refuses the move), nothing is sent: the sale waits, the
3862
+ * winning bid in the project's vault (`{ waiting: true, says }`); end it again later, or the project's keys call it
3863
+ * off (`callOffSale`). When the lot or an NFT of the winning bid is gone (its collection took it back or burned it),
3864
+ * the sale is called off on that evidence, which anyone may: the bid goes back to its bidder (`{ calledOff: true }`).
3865
+ * With no bid, the lot goes home to its seller. An end too big for one transaction otherwise makes each NFT that may
3866
+ * be a hold one, and sends each on with a release after it (`releases`).
3237
3867
  */
3238
3868
  async endAuction(auctionKey, { holders, ...others } = NO_OPTIONS) {
3239
3869
  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
- };
3870
+ return endSale(part('auctions', auctionKey), { holders });
3871
+ },
3872
+
3873
+ /**
3874
+ * The project's owner or authority calls off the sale of an NFT lot whose auction is over and whose lot has not moved
3875
+ * (one that never thaws, say), for the project fee: the winning bid goes back to its bidder and the lot home to its
3876
+ * seller, each moved, or made a hold that anyone sends on once it can move (`releases`). Anyone may when the lot or
3877
+ * an NFT of the winning bid is gone (`endAuction` does it then). Refused once the lot went to its winner (`CantCallOff`).
3878
+ */
3879
+ async callOffSale(auctionKey, { ...others } = NO_OPTIONS) {
3880
+ only(others, [], 'callOffSale');
3881
+ return endSale(part('auctions', auctionKey), { callOff: true });
3394
3882
  },
3395
3883
 
3396
3884
  /**
@@ -3440,8 +3928,9 @@ export function solanaApp({
3440
3928
  * Lists one of the player's NFTs (any kind) for sale at `price` (whole units of `currency`, the economy's by
3441
3929
  * default), `shares` taken first (a royalty, a platform's cut). The project holds it until it sells or is unlisted.
3442
3930
  */
3443
- async listForSale(asset, { price, currency, shares = [], ...others } = NO_OPTIONS) {
3444
- only(others, ['price', 'currency', 'shares'], 'listForSale');
3931
+ async listForSale(asset, { price, currency, shares = [], acceptLeftover, ...others } = NO_OPTIONS) {
3932
+ only(others, ['price', 'currency', 'shares', 'acceptLeftover'], 'listForSale');
3933
+ await noLeftovers(asset, acceptLeftover, { listing: true });
3445
3934
  const mint = mintOf(currency);
3446
3935
  const { before, ...nft } = await nftOf(asset);
3447
3936
  return send([
@@ -3462,8 +3951,12 @@ export function solanaApp({
3462
3951
  * Lists one of the player's NFTs for rent: `price` (whole units) a period of `lasts` seconds, a renter taking 1 to
3463
3952
  * `maxPeriods` at once. Nobody can end a rental early; the next renter takes it once one is over, until it is unlisted.
3464
3953
  */
3465
- async listForRent(asset, { price, lasts, maxPeriods = 1, currency, shares = [], ...others } = NO_OPTIONS) {
3466
- only(others, ['price', 'lasts', 'maxPeriods', 'currency', 'shares'], 'listForRent');
3954
+ async listForRent(
3955
+ asset,
3956
+ { price, lasts, maxPeriods = 1, currency, shares = [], acceptLeftover, ...others } = NO_OPTIONS,
3957
+ ) {
3958
+ only(others, ['price', 'lasts', 'maxPeriods', 'currency', 'shares', 'acceptLeftover'], 'listForRent');
3959
+ await noLeftovers(asset, acceptLeftover, { listing: true });
3467
3960
  const mint = mintOf(currency);
3468
3961
  const { before, ...nft } = await nftOf(asset);
3469
3962
  return send([
@@ -3998,15 +4491,17 @@ export function solanaApp({
3998
4491
  // 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
4492
  // is the lender's once due.
4000
4493
  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
- });
4494
+ out.push(
4495
+ await goneOr(hold.asset, {
4496
+ kind: 'release',
4497
+ asset: hold.asset,
4498
+ hold: pubkey,
4499
+ says:
4500
+ hold.player === project
4501
+ ? 'An NFT owed to the treasury: send it there.'
4502
+ : 'Your collectible back: its hold is over.',
4503
+ }),
4504
+ );
4010
4505
  }
4011
4506
  // A loan this wallet made that is overdue and unpaid: its NFT is the lender's to take.
4012
4507
  for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [
@@ -4015,12 +4510,14 @@ export function solanaApp({
4015
4510
  ])) {
4016
4511
  const hold = loot.decode('Hold', account.data);
4017
4512
  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
- });
4513
+ out.push(
4514
+ await goneOr(hold.asset, {
4515
+ kind: 'release',
4516
+ asset: hold.asset,
4517
+ hold: pubkey,
4518
+ says: 'A loan you made is overdue and unpaid: take its NFT.',
4519
+ }),
4520
+ );
4024
4521
  }
4025
4522
  // A compressed NFT's proof record this wallet paid for and left open: closing it brings its rent back.
4026
4523
  for (const { account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Proven')])) {
@@ -4044,13 +4541,14 @@ export function solanaApp({
4044
4541
  auctions.forEach((a, i) => {
4045
4542
  const x = found[i] && loot.decode('Auction', found[i].data);
4046
4543
  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.
4544
+ // The lot went first (an end too wide for one transaction, or a seller's account that couldn't take the money
4545
+ // then): only the money is left to pay.
4048
4546
  if (BigInt(x.proceeds_owed) > 0n) {
4049
4547
  if (x.seller === me)
4050
4548
  out.push({
4051
4549
  kind: 'auction',
4052
4550
  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.`,
4551
+ 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
4552
  });
4055
4553
  return;
4056
4554
  }
@@ -4250,9 +4748,10 @@ export function solanaApp({
4250
4748
  const nfts = [];
4251
4749
  const kinds = {};
4252
4750
  if (contest.nft) {
4751
+ const proofs = {};
4253
4752
  const kindOf = async (asset, to) => {
4254
4753
  const { before, hasAuthority: _, ...kind } = await nftOf(asset, me, { to });
4255
- given.before.push(...before);
4754
+ proofs[asset] = before;
4256
4755
  kinds[asset] = kind;
4257
4756
  };
4258
4757
  if (Number(own.amount) === 1) {
@@ -4274,6 +4773,10 @@ export function solanaApp({
4274
4773
  nfts.push(j);
4275
4774
  await kindOf(e.nft, me);
4276
4775
  }
4776
+ // A compressed NFT's proof is long (up to 17 nodes): such a claim moves one NFT, as a v0 transaction with a
4777
+ // lookup table (`send`), and the next claim takes the next.
4778
+ if (nfts.length > 1 && nfts.some(j => kinds[entries[j].nft]?.compressed)) nfts.splice(1);
4779
+ for (const j of nfts) given.before.push(...proofs[entries[j].nft]);
4277
4780
  }
4278
4781
  const claimed = [
4279
4782
  computeUnitLimit(800_000),
@@ -4299,6 +4802,7 @@ export function solanaApp({
4299
4802
  return contest.prize?.Roll && !build ? (await sendRolling(claimed)).signature : send(claimed);
4300
4803
  }
4301
4804
  if (c.kind === 'release') {
4805
+ if (c.gone) throw new Error(c.says);
4302
4806
  const hold = await read('Hold', c.hold);
4303
4807
  const { before, hasAuthority: _, ...nft } = await nftOf(c.asset, me, { to: me });
4304
4808
  // An overdue loan's NFT goes to its lender.
@@ -4317,7 +4821,12 @@ export function solanaApp({
4317
4821
  }),
4318
4822
  ]);
4319
4823
  }
4320
- if (c.kind === 'auction') return (await this.endAuction(c.key)).signature;
4824
+ if (c.kind === 'auction') {
4825
+ const ended = await this.endAuction(c.key);
4826
+ // A lot that can't move now: the sale waits, and nothing was sent.
4827
+ if (ended.waiting) throw new Error(ended.says);
4828
+ return ended.signature;
4829
+ }
4321
4830
  if (c.kind === 'close-leaf') return send([loot.closeLeafIx({ asset: c.asset, tree: c.tree, payer: me })]);
4322
4831
  if (c.kind === 'walk') return this.walkFarm(c.key);
4323
4832
  if (c.kind === 'vest' || c.kind === 'schedule') return this.runSchedule(c.key);
@@ -4361,13 +4870,24 @@ export function solanaApp({
4361
4870
  return build ? steps : send(steps);
4362
4871
  }
4363
4872
  if (!r) return null;
4873
+ // A round whose lottery was closed since: its lent rent is the owner's, on the owner's own close.
4874
+ const lotteryClosed = !lottery;
4364
4875
  // Pulls: this wallet's own parts, or (`payout`) everyone's and the parts owed in common, a few to a transaction;
4365
4876
  // when they are the last owed, the last pull closes the round. A round that owes nothing is only closed.
4366
4877
  // A named stash NFT that is gone refuses a stranger's pull: only its winner or a project key passes it over.
4367
4878
  const projectKey = me === g.owner || me === g.authority;
4368
4879
  const stranger = async (i, p) => {
4880
+ if (projectKey || r.entries[i].buyer === me) return false;
4881
+ // A minted prize the project can't mint now (not the mint's authority): a stranger's pull fails, so it is left
4882
+ // for later, when the project can; its winner or the project's keys may pass it over for nothing.
4883
+ const prized = p.Prize ? r.prizes.find(x => Number(x.run) === Number(p.Prize[0])) : null;
4884
+ if (prized?.minted) {
4885
+ const data = (await client.account(prized.mint))?.data;
4886
+ const authority = data && data[0] === 1 ? encodeBase58(data.subarray(4, 36)) : null;
4887
+ if (authority !== project) return true;
4888
+ }
4369
4889
  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;
4890
+ if (!named) return false;
4371
4891
  const stash = loot.decodeStash((await client.account(loot.pda.stash(project, named.stash)))?.data ?? null);
4372
4892
  return !stash?.nfts.includes(named.nft);
4373
4893
  };
@@ -4382,7 +4902,10 @@ export function solanaApp({
4382
4902
  c.kind === 'pull' ? c.pulls : [...loot.commonOwed(r).map(p => ({ entry: null, parts: [p] })), ...theirs];
4383
4903
  if (!owed.length) {
4384
4904
  if (!loot.roundFinished(r)) return null;
4385
- const close = [loot.pullIx({ payer: me, project, roundAddress: key, round: r, projectState: g, programs })];
4905
+ // Rent the lottery lent goes to the owner once it is closed, and only on the owner's own close.
4906
+ const close = [
4907
+ loot.pullIx({ payer: me, project, roundAddress: key, round: r, projectState: g, lotteryClosed, programs }),
4908
+ ];
4386
4909
  return build ? close : send(close);
4387
4910
  }
4388
4911
  const batch = owed.slice(0, 4);
@@ -4393,21 +4916,49 @@ export function solanaApp({
4393
4916
  const closes = settles >= Number(r.owed);
4394
4917
  const instructions = [computeUnitLimit(1_400_000)];
4395
4918
  const rolls = [];
4919
+ let heap = false;
4920
+ let partial = false;
4396
4921
  for (const [k, { entry, parts }] of batch.entries()) {
4397
- const built = await pulls(key, r, entry, parts, me, { closes: closes && k === batch.length - 1 });
4922
+ const built = await pulls(key, r, entry, parts, me, {
4923
+ closes: closes && k === batch.length - 1,
4924
+ lotteryClosed,
4925
+ });
4398
4926
  instructions.push(...built.instructions.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM));
4399
4927
  rolls.push(...built.rolls);
4928
+ heap ||= built.heap;
4929
+ partial ||= built.partial;
4400
4930
  }
4931
+ // A run of rolls asks for the largest heap.
4932
+ if (heap) instructions.splice(1, 0, requestHeapFrame(loot.LARGE_SETTLE_HEAP));
4933
+ // A pull that stopped short of its part (a raffle's NFTs, 64 a pull; a run of rolls): sent, then the next carries
4934
+ // on while the round moves (its NFTs out, its places paid) and still owes it.
4935
+ const carryOn = async signature => {
4936
+ if (build || !partial) return signature;
4937
+ const after = await client.account(key);
4938
+ const r2 = after?.owner === loot.LOOT_PROGRAM ? loot.decodeRound(after.data) : null;
4939
+ const moved = r2 && (Number(r2.assigned) > Number(r.assigned) || Number(r2.owed) < Number(r.owed));
4940
+ if (!moved) return signature;
4941
+ const left =
4942
+ c.kind === 'pull'
4943
+ ? r2.entries.flatMap((e, i) => {
4944
+ const parts = e.buyer === me ? loot.partsOwed(r2, i) : [];
4945
+ return parts.length ? [{ entry: i, parts }] : [];
4946
+ })
4947
+ : null;
4948
+ if (left && !left.length) return signature;
4949
+ await this.claim(left ? { ...c, pulls: left } : c);
4950
+ return signature;
4951
+ };
4401
4952
  // 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
4953
  // 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;
4954
+ if (!rolls.length) return build ? instructions : carryOn(await send(instructions));
4955
+ if (c.kind === 'pull' && !build) return carryOn((await sendRolling(instructions)).signature);
4405
4956
  const asks = [];
4406
4957
  for (const { order, box } of rolls)
4407
4958
  asks.push(...(await asksFor(me, order, { project, loot_box: loot.pda.box(project, box) })));
4408
4959
  const all = [...instructions, ...asks];
4409
4960
  const asked = (await dryRun(me, all)).ok ? all : instructions;
4410
- return build ? asked : send(asked);
4961
+ return build ? asked : carryOn(await send(asked));
4411
4962
  },
4412
4963
 
4413
4964
  /**