@xgamesfun/solana 0.26.4 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/js/play/index.js CHANGED
@@ -371,23 +371,27 @@ export function solanaApp({
371
371
  before: tokenMetadata ? [computeUnitLimit(400_000), ...before] : [],
372
372
  };
373
373
  }
374
+ /** Whether a Core asset's account shows the project holds it: held by another, or burned (one byte left), it is gone. */
375
+ const coreHeld = account => account.data[0] === 1 && parseCoreAsset(account.data).owner === project;
374
376
  /**
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.
377
+ * Whether the project still holds `asset`, as the program reads it (`piece::held`): { held, evidence, das }. A Core
378
+ * asset by its owner (one burned is gone); a Token Metadata NFT by the project's token account of it, and when that
379
+ * holds none, by the account that holds it now or by its mint when it was burned (`evidence`, what shows it gone); a
380
+ * compressed NFT by the RPC's DAS record of it (`das`, proven again on chain before anything relies on it). `held` is
381
+ * null when nothing shows either way. `holder`: a wallet thought to hold it now (a player handing it in), whose own
382
+ * token account is read before any search.
380
383
  */
381
- async function heldNow(asset) {
384
+ async function heldNow(asset, { holder = null } = {}) {
382
385
  const account = await client.account(asset);
383
- if (account?.owner === MPL_CORE)
384
- return { held: account.data[0] === 1 && parseCoreAsset(account.data).owner === project };
386
+ if (account?.owner === MPL_CORE) return { held: coreHeld(account) };
385
387
  if (account?.owner === TOKEN_PROGRAM) {
386
388
  const u64 = (data, at) => new DataView(data.buffer, data.byteOffset).getBigUint64(at, true);
387
389
  const amount = data => (data?.length >= 72 ? u64(data, 64) : 0n);
388
390
  if (amount((await client.account(loot.ata(project, asset)))?.data) === 1n) return { held: true };
389
391
  // Burned: its mint holds none.
390
392
  if (account.data.length >= 44 && u64(account.data, 36) === 0n) return { held: false, evidence: asset };
393
+ const own = holder ? loot.ata(holder, asset) : null;
394
+ if (own && amount((await client.account(own))?.data) === 1n) return { held: false, evidence: own };
391
395
  const found = await client.programAccounts(TOKEN_PROGRAM, [
392
396
  { dataSize: 165 },
393
397
  { memcmp: { offset: 0, bytes: asset } },
@@ -398,8 +402,70 @@ export function solanaApp({
398
402
  if (account) return { held: null };
399
403
  const das = await client.call('getAsset', { id: asset }).catch(() => null);
400
404
  if (!das?.compression?.compressed) return { held: null };
401
- return { held: !das.compression.burnt && das.ownership?.owner === project };
405
+ // DAS says a burned NFT at the asset's top level (`burnt`).
406
+ const burnt = das.burnt === true || das.compression.burnt === true;
407
+ return { held: !burnt && das.ownership?.owner === project, das };
402
408
  }
409
+ /** `heldNow` of several NFTs: the Core assets from one read of them all, any other kind one by one. By asset. */
410
+ async function heldEach(assets) {
411
+ const unique = [...new Set(assets)];
412
+ const found = unique.length ? await client.accounts(unique) : [];
413
+ const out = new Map();
414
+ for (const [i, asset] of unique.entries())
415
+ out.set(asset, found[i]?.owner === MPL_CORE ? { held: coreHeld(found[i]) } : await heldNow(asset));
416
+ return out;
417
+ }
418
+ /**
419
+ * What proves `asset` gone from the project, as the instruction that lets go of it takes it (loot 0.27: a record
420
+ * whose NFT is proven gone closes): { before, kind }, or null when the project still holds it or nothing shows either
421
+ * way. Whoever controls a collection the project does not can take an NFT out of the project's hands or burn it. A
422
+ * Core asset shows it by itself (`kind`: its collection now); a Token Metadata NFT by the token account that holds it
423
+ * now, or its mint once burned (`kind.tokenMetadata.evidence`); a compressed NFT by its record, proven again as its
424
+ * leaf is now (`before`, by `signer`). `holder`: as `heldNow` takes it. `now`: what `heldNow` read of it already.
425
+ */
426
+ async function goneProof(asset, signer = wallet.address, { holder = null, now = null } = {}) {
427
+ now ??= await heldNow(asset, { holder });
428
+ if (now.held !== false) return null;
429
+ if (now.das) {
430
+ const proof = await client.call('getAssetProof', { id: asset });
431
+ const tree = await client.account(now.das.compression.tree);
432
+ const compressed = loot.compressedFromDas({ asset: now.das, proof, treeData: tree.data });
433
+ return { before: [computeUnitLimit(1_000_000), loot.proveLeafIx({ signer, compressed })], kind: { compressed } };
434
+ }
435
+ if (now.evidence)
436
+ return { before: [], kind: { tokenMetadata: { programmable: false, ruleSet: null, evidence: now.evidence } } };
437
+ return { before: [], kind: { collection: (await collectionOf(asset)) ?? MPL_CORE } };
438
+ }
439
+ /**
440
+ * An NFT a record of the project's is to send on (`nftOf`, to `to`), or, when it is proven gone from the project
441
+ * (`goneProof`), what shows that, with `gone` true: the instruction then finishes without it and nothing is sent.
442
+ * `now`: what `heldNow` read of it already.
443
+ */
444
+ async function leaving(asset, signer = wallet.address, { to, payer = signer, now = null } = {}) {
445
+ const proof = await goneProof(asset, signer, { now });
446
+ if (!proof) return { ...(await nftOf(asset, signer, { to, payer })), gone: false };
447
+ // A Core asset shows it by itself, with the accounts it always took.
448
+ if (!proof.kind.tokenMetadata && !proof.kind.compressed) return { ...(await nftOf(asset, signer)), gone: true };
449
+ return { before: proof.before, hasAuthority: false, ...proof.kind, gone: true };
450
+ }
451
+ /**
452
+ * The release of a hold a route just made of `asset`, an NFT it owed and could only hand out (`kind`: from
453
+ * `leaving`). The program makes such a hold at `["hold", asset, 0]` for `player`, the route's `signer` paying its
454
+ * rent and `rentTo` named for its NFT's account's (`hold::owe`). The release right after it, by the same signer,
455
+ * ends it: for an NFT proven gone from the project it closes the hold on the proof and nothing is sent (`NftGone`);
456
+ * for any other it sends the NFT on to `player` (`Released`). Either way the hold's rent comes back to the signer.
457
+ * `g`: the project, for a hold owed to the treasury.
458
+ */
459
+ const releaseOwed = (asset, kind, { player, rentTo }, signer, g) =>
460
+ loot.releaseIx({
461
+ signer,
462
+ project,
463
+ asset,
464
+ ...kind,
465
+ hold: { player, payer: signer, rent_to: rentTo, free_at: 0n },
466
+ address: loot.pda.hold(asset, 0),
467
+ treasury: g.treasury,
468
+ });
403
469
  /**
404
470
  * 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
471
  * money in: accounts that start frozen or can be switched to it (a payee closes their own account, and the one
@@ -1066,7 +1132,10 @@ export function solanaApp({
1066
1132
  /**
1067
1133
  * Waits for an order's roll (the oracle answers a moment after the open), then settles it. With `burner` (the
1068
1134
  * throwaway key the open funded), the burner signs and pays the settle, and gives the rest back: no wallet prompt.
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
1135
+ * The order names it (`settler`), so it may settle; when the player or the project did first, the burner only gives its lamports back.
1136
+ * An NFT of the order the project no longer holds (its collection took it back or burned it) is passed over: a
1137
+ * stash's NFT won is not given (its place in `assets` is null) and the NFT rolled on is not sent back; `gone` lists
1138
+ * them, with `says`. `putBack`: NFTs
1070
1139
  * won that the player puts back into their stash in place of taking them (one whose collection now refuses to let it
1071
1140
  * move); `giveUp`: the places in the order's `won` of tokens or collectibles the player gives up because they can't
1072
1141
  * receive them (an account the token's issuer froze, a collection that refuses the mint): tokens given up stay in the
@@ -1191,6 +1260,9 @@ export function solanaApp({
1191
1260
  };
1192
1261
  };
1193
1262
  const holders = await holdersOf(order.shares);
1263
+ // NFTs of this order the project no longer holds (their collection took them back or burned them): the settle
1264
+ // passes each over on that proof. A stash's NFT won is then not given; the NFT rolled on is not sent back.
1265
+ const lost = new Set();
1194
1266
  // Given by its fate to a wallet that is an account of the loot program, the NFT goes back to the player.
1195
1267
  const gives = order.held ? order.fate?.then?.Give?.[0] : undefined;
1196
1268
  const handBack = gives !== undefined && loot.handsBack(await client.account(gives));
@@ -1207,15 +1279,25 @@ export function solanaApp({
1207
1279
  const {
1208
1280
  before,
1209
1281
  hasAuthority: _,
1282
+ gone,
1210
1283
  ...kind
1211
- } = await nftOf(won.Nft.nft, signer, { to: order.player, payer: wallet.address });
1284
+ } = await leaving(won.Nft.nft, signer, { to: order.player, payer: wallet.address });
1285
+ if (gone) lost.add(won.Nft.nft);
1212
1286
  nfts[won.Nft.nft] = kind;
1213
1287
  proving.push(...before);
1214
1288
  }
1215
1289
  }
1216
- // The NFT the roll was on: its kind, and for a compressed one a proof first.
1217
- const { before: subjectProof, ...subject } =
1218
- order.subject && order.subject !== NOBODY ? await nftOf(order.subject, signer) : { before: [] };
1290
+ // The NFT the roll was on: its kind, and for a compressed one a proof first. One the order holds may be gone.
1291
+ const {
1292
+ before: subjectProof,
1293
+ gone: subjectGone,
1294
+ ...subject
1295
+ } = order.subject && order.subject !== NOBODY
1296
+ ? order.held
1297
+ ? await leaving(order.subject, signer)
1298
+ : await nftOf(order.subject, signer)
1299
+ : { before: [] };
1300
+ if (subjectGone) lost.add(order.subject);
1219
1301
  const ix = loot.settleIx({
1220
1302
  signer,
1221
1303
  orderAddress,
@@ -1296,7 +1378,7 @@ export function solanaApp({
1296
1378
  if (burner && !keepBurner && !emptied) await emptyKey(burner);
1297
1379
  else if (burner && emptied) keys?.forget(burner.name);
1298
1380
  const given = new Set(giveUp.map(Number));
1299
- const assets = wonAssets(order).map((a, k) => (putBack.includes(a) || given.has(k) ? null : a));
1381
+ const assets = wonAssets(order).map((a, k) => (putBack.includes(a) || given.has(k) || lost.has(a) ? null : a));
1300
1382
  return {
1301
1383
  order: orderAddress,
1302
1384
  won: order.won,
@@ -1304,6 +1386,13 @@ export function solanaApp({
1304
1386
  settled: signature,
1305
1387
  ...(signatures.length ? { parts: [...signatures, signature] } : {}),
1306
1388
  ...(assets.find(Boolean) ? { asset: assets.find(Boolean) } : {}),
1389
+ // What the settle passed over: not given (a stash's NFT won), not sent back (the NFT rolled on).
1390
+ ...(lost.size
1391
+ ? {
1392
+ gone: [...lost],
1393
+ says: 'The project no longer holds an NFT of this order (whoever controls its collection took it back or burned it): it was not given or sent back. Everything else settled as rolled.',
1394
+ }
1395
+ : {}),
1307
1396
  };
1308
1397
  }
1309
1398
 
@@ -1595,7 +1684,8 @@ export function solanaApp({
1595
1684
  // A box that rolls on an NFT takes the one the player brings (`on`), whatever its kind.
1596
1685
  if (box.subject && !on) throw new Error('This box rolls on an NFT: say which (on).');
1597
1686
  // A box that burns it, gives it away or moves its numbers takes it into the project's hands until the roll settles.
1598
- if (box.subject && loot.boxHolds(box)) await noLeftovers(on, acceptLeftover);
1687
+ // What an earlier owner of it left in the project is cleared first (`clearLeftovers`).
1688
+ const clears = box.subject && loot.boxHolds(box) ? await clearLeftovers(on, acceptLeftover) : [];
1599
1689
  const brought = box.subject ? await nftOf(on) : { before: [] };
1600
1690
  const { before: proving, hasAuthority, ...kind } = brought;
1601
1691
  const subject = box.subject
@@ -1713,7 +1803,7 @@ export function solanaApp({
1713
1803
  x.keys[0]?.pubkey === associatedTokenAddress(wallet.address, NATIVE_MINT)
1714
1804
  ),
1715
1805
  );
1716
- const signature = await send(instructions, [], { keepWrapped: solPrize });
1806
+ const signature = await send(await closesFirst(clears, instructions), [], { keepWrapped: solPrize });
1717
1807
  // The slot it landed in: the order is read from a node that has seen it (a load-balanced RPC's node lagging
1718
1808
  // behind answers nothing for an order just opened).
1719
1809
  const landed = (await client.signatureStatus?.(signature).catch(() => null))?.slot ?? null;
@@ -1751,6 +1841,17 @@ export function solanaApp({
1751
1841
  );
1752
1842
  }
1753
1843
 
1844
+ /**
1845
+ * Refuses, in words, to buy or rent the listing of an NFT the project no longer holds (whoever controls its
1846
+ * collection took it back or burned it): nothing is there. Sent all the same, the take would only close the listing.
1847
+ */
1848
+ async function listedStill(asset) {
1849
+ if ((await heldNow(asset)).held === false)
1850
+ throw new Error(
1851
+ `The project no longer holds the NFT ${asset}: whoever controls its collection took it back or burned it while it was listed. There is nothing to buy or rent, and nothing was sent. Anyone can close this listing for nothing (closeGone): its rent goes back to its maker.`,
1852
+ );
1853
+ }
1854
+
1754
1855
  /** A box bought at once when it needs no roll (`buyBox`: a shop item, a bundle, a fixed pack of any count), or
1755
1856
  * opened and collected: one that rolls, gives a fresh item (its traits are rolled), or rolls on an NFT. A fixed pack
1756
1857
  * too big for one transaction is opened instead (the oracle answers it like any box). */
@@ -1876,16 +1977,18 @@ export function solanaApp({
1876
1977
  const lotNow = lot ? await heldNow(lot) : { held: null };
1877
1978
  const bidNow = [];
1878
1979
  if (sold && auction.nft_bids) for (const nft of auction.held) bidNow.push(await heldNow(nft));
1879
- const gone = lotNow.held === false || bidNow.some(n => n.held === false);
1980
+ const lostBids = sold && auction.nft_bids ? auction.held.filter((_, i) => bidNow[i].held === false) : [];
1981
+ const gone = lotNow.held === false || lostBids.length > 0;
1880
1982
  const ending = sold && lot && (callOff || gone) ? 'CallOff' : 'Deliver';
1881
1983
  const home = !sold || ending === 'CallOff';
1882
1984
  // The lot to its winner, or home to its seller; a bid's NFTs to whoever owned the lot, or back to their bidder.
1985
+ // One that is gone is not sent: its accounts only show the end that the project no longer holds it.
1883
1986
  const before = [];
1884
- const lotKind = lot ? await kindFor(lot, home ? auction.seller : auction.high_bidder, lotNow.evidence, before) : {};
1987
+ const lotKind = lot ? await kindFor(lot, home ? auction.seller : auction.high_bidder, lotNow, before) : {};
1885
1988
  const bidTo = ending === 'CallOff' ? auction.high_bidder : owner;
1886
1989
  const held = [];
1887
1990
  if (sold && auction.nft_bids)
1888
- for (const [i, nft] of auction.held.entries()) held.push(await kindFor(nft, bidTo, bidNow[i].evidence, before));
1991
+ for (const [i, nft] of auction.held.entries()) held.push(await kindFor(nft, bidTo, bidNow[i], before));
1889
1992
  if (sold && !home) holders ??= await holdersOf(auction.shares);
1890
1993
  const end = (ending, hold = false) =>
1891
1994
  loot.endAuctionIx({ ...base, auction, item, ...lotKind, held, holders, ending, hold });
@@ -1900,6 +2003,24 @@ export function solanaApp({
1900
2003
  ...(made ? { asset: made } : {}),
1901
2004
  };
1902
2005
  if (ending === 'CallOff') Object.assign(result, { calledOff: true, gone });
2006
+ // What the end did about an NFT that is gone, in words: nothing here says it moved.
2007
+ if (gone) {
2008
+ const which =
2009
+ lotNow.held === false
2010
+ ? `the lot (${lot})`
2011
+ : `${lostBids.length === 1 ? 'an NFT' : 'NFTs'} of the winning bid (${lostBids.join(', ')})`;
2012
+ const did = !sold
2013
+ ? 'Nothing went home: the auction is closed, and its rent went back to its seller.'
2014
+ : ending === 'Deliver'
2015
+ ? 'Nothing was paid for it: the lot went to its winner, and the rest of the bid was paid.'
2016
+ : lotNow.held === false
2017
+ ? `The sale was called off: ${auction.nft_bids ? 'the NFTs of the winning bid the project still held' : 'the winning bid'} went back to its bidder, and nothing went home to the seller.`
2018
+ : 'The sale was called off: the lot went home to its seller, and the rest of the bid went back to its bidder.';
2019
+ Object.assign(result, {
2020
+ gone: true,
2021
+ says: `The project no longer holds ${which}: whoever controls its collection took it back or burned it while the auction held it. ${did}`,
2022
+ });
2023
+ }
1903
2024
 
1904
2025
  // An end that may make holds: a lot going home, a bid's NFTs going back or to the treasury for the project's lot.
1905
2026
  const outs = [
@@ -1967,15 +2088,15 @@ export function solanaApp({
1967
2088
  return holding();
1968
2089
  }
1969
2090
 
1970
- /** An NFT's kind on an auction's end, going to `to`: what `nftOf` reads, its proof put in `before`; `evidence` for a Token
1971
- * Metadata NFT that left the project (the account that holds it now, or its mint). */
1972
- async function kindFor(asset, to, evidence, before) {
1973
- const { before: proof, hasAuthority: _, ...kind } = await nftOf(asset, wallet.address, { to });
2091
+ /** An NFT's kind on an auction's end, going to `to`: what `nftOf` reads, its proof put in `before`. `now`: what
2092
+ * `heldNow` read of it; one that is gone carries what shows it instead (a Token Metadata NFT's account that holds it
2093
+ * now, or its mint; a compressed NFT's leaf as it is now), and is sent nowhere. */
2094
+ async function kindFor(asset, to, now, before) {
2095
+ const { before: proof, hasAuthority: _h, gone: _g, ...kind } = await leaving(asset, wallet.address, { to, now });
1974
2096
  before.push(...proof);
1975
2097
  return {
1976
2098
  ...kind,
1977
2099
  collection: kind.compressed || kind.tokenMetadata ? undefined : ((await collectionOf(asset)) ?? MPL_CORE),
1978
- ...(evidence && kind.tokenMetadata ? { evidence } : {}),
1979
2100
  };
1980
2101
  }
1981
2102
 
@@ -2024,16 +2145,30 @@ export function solanaApp({
2024
2145
  if (opens.length) await send([...new Map(opens.map(x => [x.keys[1].pubkey, x])).values()]);
2025
2146
  const before = [];
2026
2147
  const held = [];
2027
- if (auction.nft_bids) for (const nft of auction.held) held.push(await kindFor(nft, auction.seller, null, before));
2148
+ // An NFT of the bid that is gone (its collection took it back or burned it) is not paid: its accounts show the
2149
+ // end that the project no longer holds it, and the end passes it over.
2150
+ const lost = [];
2151
+ if (auction.nft_bids)
2152
+ for (const nft of auction.held) {
2153
+ const now = await heldNow(nft);
2154
+ if (now.held === false) lost.push(nft);
2155
+ held.push(await kindFor(nft, auction.seller, now, before));
2156
+ }
2028
2157
  const end = (hold = false) => loot.endAuctionIx({ ...base, auction, held, holders, hold });
2029
2158
  const steps = ix => [
2030
2159
  computeUnitLimit(1_000_000),
2031
2160
  ...before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
2032
2161
  ix,
2033
2162
  ];
2163
+ const passed = lost.length
2164
+ ? {
2165
+ gone: true,
2166
+ says: `The project no longer holds ${lost.length === 1 ? 'an NFT' : 'NFTs'} of the winning bid (${lost.join(', ')}): whoever controls its collection took it back or burned it while the auction held it. Nothing was paid for it: the rest of the bid was paid to the seller.`,
2167
+ }
2168
+ : {};
2034
2169
  if (sendPlan({ payer: wallet.address, instructions: steps(end()), alone: isProveLeaf }))
2035
2170
  try {
2036
- return { paid: await send(steps(end())) };
2171
+ return { paid: await send(steps(end())), ...passed };
2037
2172
  } catch (e) {
2038
2173
  // The lot is the winner's already: what is owed waits until the account it goes into can take it.
2039
2174
  return {
@@ -2041,9 +2176,9 @@ export function solanaApp({
2041
2176
  says: `The lot is the winner’s; the money waits (${e.message}): end the auction again to pay it.`,
2042
2177
  };
2043
2178
  }
2044
- const outs = auction.held.map(x => [x, auction.seller]);
2045
- const { signature, releases } = await heldThenReleased(at, steps(end(true)), outs);
2046
- return { paid: signature, releases };
2179
+ const outs = auction.held.filter(x => !lost.includes(x)).map(x => [x, auction.seller]);
2180
+ const { signature, releases } = await heldThenReleased(at, steps(end(outs.map(([x]) => x))), outs);
2181
+ return { paid: signature, releases, ...passed };
2047
2182
  }
2048
2183
 
2049
2184
  /**
@@ -2059,9 +2194,12 @@ export function solanaApp({
2059
2194
  const signature = await send([...instructions, fund]);
2060
2195
  const releases = [];
2061
2196
  for (const [asset, to] of outs) {
2197
+ // Taken back or burned since the end made it a hold: its release closes the hold on that proof, and sends nothing.
2198
+ let lost = false;
2062
2199
  const release = async signer => {
2063
2200
  const hold = await read('Hold', loot.pda.hold(asset, 0));
2064
- const { before: proof, hasAuthority: _h, ...kind } = await nftOf(asset, signer, { to });
2201
+ const { before: proof, hasAuthority: _h, gone, ...kind } = await leaving(asset, signer, { to });
2202
+ lost = gone;
2065
2203
  return [
2066
2204
  ...proof,
2067
2205
  loot.releaseIx({
@@ -2078,58 +2216,143 @@ export function solanaApp({
2078
2216
  const steps = await release(key.address);
2079
2217
  const byKey = (await dryRun(key.address, steps)).ok ? await sendAs(key, steps).catch(() => null) : null;
2080
2218
  // One that still can't move (frozen for now) stays a hold: claims() lists it for whoever it is owed to.
2081
- releases.push(byKey ?? (await send(await release(wallet.address)).catch(e => ({ asset, waits: e.message }))));
2219
+ const sent = byKey ?? (await send(await release(wallet.address)).catch(e => ({ asset, waits: e.message })));
2220
+ releases.push(lost && typeof sent === 'string' ? { asset, gone: true, closed: sent } : sent);
2082
2221
  }
2083
2222
  await emptyKey(key);
2084
2223
  return { signature, releases };
2085
2224
  }
2086
2225
 
2226
+ /** What closing a hold whose NFT is gone does, in words, for `claims()`. */
2227
+ const goneHoldSays = hold =>
2228
+ `The project no longer holds this NFT: whoever controls its collection took it back or burned it while it was held. Its hold can be closed now, by anyone: closing it sends nothing, and the hold's rent goes back to whoever paid it (${hold.payer}).${hold.debt ? ' It was a loan: nothing is repaid, the borrower keeps what they borrowed, and the lender gets neither the NFT nor the repayment.' : ''}`;
2229
+ /** The same for a listing. */
2230
+ const GONE_LISTING =
2231
+ 'The project no longer holds this NFT: whoever controls its collection took it back or burned it while it was listed. There is nothing to buy or rent. The listing can be closed for nothing, by its maker (unlist) or by anyone (closeGone), once no rental runs: its rent goes back to its maker.';
2232
+
2087
2233
  /**
2088
- * A hold's claim, or, when the project no longer holds its NFT (whoever controls its collection took it back or
2089
- * burned it while it was held), the same claim flagged `gone`: the hold's record still says the NFT is held, and it
2090
- * is not, so nothing can be sent.
2234
+ * What ending an auction does when its lot or an NFT of its winning bid is gone, in words, for `claims()`: `x` the
2235
+ * auction, `me` the wallet told (its seller, or its high bidder), `lot` the lot when it is gone, `bids` the NFTs of
2236
+ * the winning bid that are gone.
2091
2237
  */
2092
- async function goneOr(asset, claim) {
2093
- if ((await heldNow(asset)).held !== false) return claim;
2094
- return {
2095
- ...claim,
2096
- gone: true,
2097
- says: 'The project no longer holds this NFT: whoever controls its collection took it back or burned it while it was held. Its hold says otherwise, but nothing can be sent, and nothing was paid against it.',
2098
- };
2238
+ function goneSaleSays(name, x, me, lot, bids) {
2239
+ const [sold, owed] = [BigInt(x.high) > 0n, BigInt(x.proceeds_owed) > 0n];
2240
+ const mine = sold && x.high_bidder === me;
2241
+ const why = 'whoever controls its collection took it back or burned it while the auction held it';
2242
+ const lostBids = `${bids.length === 1 ? 'an NFT' : 'NFTs'} of ${mine ? 'your' : 'the winning'} bid (${bids.join(', ')})`;
2243
+ if (lot && !sold)
2244
+ return `The project no longer holds its lot (${lot}): ${why}. “${name}” ended with no bid, and nothing comes back to you: ending it closes the auction, and its rent comes back to you.`;
2245
+ if (lot) {
2246
+ const back = x.nft_bids
2247
+ ? `the NFTs of ${mine ? 'your' : 'the winning'} bid that the project still holds go back to ${mine ? 'you' : 'their bidder'}`
2248
+ : mine
2249
+ ? 'your bid comes back to you'
2250
+ : 'the winning bid goes back to its bidder';
2251
+ return `The project no longer holds its lot (${lot}): ${why}. The sale of “${name}” is called off by its end, which anyone may send: ${back}, and ${mine ? 'the lot is not yours' : 'you are paid nothing'}.${bids.length ? ` It no longer holds ${lostBids} either: nothing comes back for it.` : ''}`;
2252
+ }
2253
+ const first = `The project no longer holds ${lostBids}: ${why}.`;
2254
+ // A player's lot that has not moved: the two sides move together or not at all, so the sale is called off.
2255
+ if (x.lot.Asset && !owed)
2256
+ return `${first} The sale of “${name}” is called off by its end, which anyone may send: the lot goes home to ${mine ? 'its seller' : 'you'}, and the rest of the bid back to ${mine ? 'you' : 'its bidder'}. Nothing comes back for the one gone.`;
2257
+ // The lot went first, or it is the project's own: the end passes that NFT over and pays the rest.
2258
+ return owed
2259
+ ? `${first} The winner of “${name}” has the lot: ending it again pays you the rest of the bid, and nothing for the one gone.`
2260
+ : `${first} Ending “${name}” gives the lot to ${mine ? 'you' : 'its winner'} and pays the rest of the bid: nothing is paid for the one gone.`;
2099
2261
  }
2262
+ /** What claiming does about the NFTs of a round's parts that are gone, in words, for `claims()`: `tickets` the NFTs
2263
+ * that paid for the wallet's tickets, `prizes` the NFTs its places won; `back` when its tickets go back to their
2264
+ * buyers (a round called off, a lottery that returns its tickets). */
2265
+ const goneRoundSays = (tickets, prizes, back) =>
2266
+ [
2267
+ tickets.length
2268
+ ? `The project no longer holds the NFT that paid for your ticket (${tickets.join(', ')}): whoever controls its collection took it back or burned it while the lottery held it. ${back ? 'It can’t come back to you' : 'It goes nowhere'}: claiming settles that part and sends nothing.`
2269
+ : '',
2270
+ prizes.length
2271
+ ? `The project no longer holds an NFT you won (${prizes.join(', ')}): whoever controls its collection took it back or burned it. It can’t be given: claiming closes that prize and sends nothing.`
2272
+ : '',
2273
+ ]
2274
+ .filter(Boolean)
2275
+ .join(' ');
2100
2276
 
2101
2277
  /**
2102
- * The records of this project an earlier owner of `asset` left behind: its listing at
2103
- * `["listing", project, asset]` (one read) and its holds (one search, by the asset's address), each that names a wallet
2104
- * other than the player's. Each is { kind, address, says }. Such a record stays when whoever controls the NFT's
2105
- * collection moved it out of the project's hands without the program, and the program hands out whatever the project
2106
- * holds at that address: once this player's deposit puts the NFT back in the project's hands, the record can send it
2107
- * to the wallet it names. NFTs of the project's own collections are not read: those collections refuse the permanent
2108
- * delegates that take an NFT out.
2278
+ * This project's records of `asset`: its listing at `["listing", project, asset]` (one read) and its holds (one
2279
+ * search, by the asset's address). Each is { kind: 'listing' | 'hold', address, record }.
2109
2280
  */
2110
- async function leftovers(asset) {
2111
- if (ours(await collectionOf(asset))) return [];
2112
- const me = wallet.address;
2281
+ async function recordsOf(asset) {
2113
2282
  const out = [];
2114
2283
  const listingAt = loot.pda.listing(project, asset);
2115
2284
  const listing = await readMaybe('Listing', listingAt);
2116
- if (listing && listing.maker !== me) {
2117
- const sale = Boolean(listing.deal.Sell);
2118
- out.push({
2119
- kind: 'listing',
2120
- address: listingAt,
2121
- says: `A listing of this NFT (${listingAt}) is still open in this project, left by an earlier owner, ${listing.maker}. Once the project holds the NFT again, ${listing.maker} can take it home by closing that listing${sale ? `, and anyone can buy it at the listing's price, which is paid to ${listing.maker}` : ''}.`,
2122
- });
2123
- }
2285
+ if (listing) out.push({ kind: 'listing', address: listingAt, record: listing });
2124
2286
  const tag = { memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS.Hold)) } };
2125
2287
  const holds = await client.programAccounts(loot.LOOT_PROGRAM, [
2126
2288
  tag,
2127
2289
  { memcmp: { offset: 1, bytes: project } },
2128
2290
  { memcmp: { offset: 33, bytes: asset } },
2129
2291
  ]);
2292
+ for (const { pubkey, account } of holds)
2293
+ out.push({ kind: 'hold', address: pubkey, record: loot.decode('Hold', account.data) });
2294
+ return out;
2295
+ }
2296
+
2297
+ /**
2298
+ * The stashes of this project that list `asset`: a project's stash (`kind` 'stash') or a lottery's own
2299
+ * ('lottery-stash'), each { kind, address, id } (`id`: the stash's, or the lottery's). A stash keeps its NFTs in
2300
+ * rows of no fixed place, so no filter finds one NFT's row: this is one search of the project's stashes
2301
+ * (`getProgramAccounts` by tag, project and payload), which returns every stash of the project whole, 36 bytes for
2302
+ * each NFT a stash lists, and reads them here.
2303
+ */
2304
+ async function stashesOf(asset) {
2305
+ const at = (offset, bytes) => ({ memcmp: { offset, bytes } });
2306
+ const found = await client.programAccounts(loot.LOOT_PROGRAM, [
2307
+ at(0, encodeBase58(Uint8Array.of(loot.TAGS.Table))),
2308
+ at(1, project),
2309
+ at(37, encodeBase58(Uint8Array.of(loot.PAYLOAD.Nft))),
2310
+ ]);
2311
+ const out = [];
2312
+ for (const { pubkey, account } of found) {
2313
+ const stash = loot.decodeStash(account.data);
2314
+ if (!stash?.nfts.includes(asset)) continue;
2315
+ const own = pubkey === loot.pda.stash(project, stash.id);
2316
+ out.push({ kind: own ? 'stash' : 'lottery-stash', address: pubkey, id: stash.id });
2317
+ }
2318
+ return out;
2319
+ }
2320
+
2321
+ /**
2322
+ * The records of this project an earlier owner of `asset` left behind (`recordsOf`), each that names a wallet other
2323
+ * than the player's: { kind, address, record, says }. Such a record is left when whoever controls the NFT's
2324
+ * collection moved it out of the project's hands without the program. Until it is closed, the program hands out
2325
+ * whatever the project holds at that address: once this player's deposit puts the NFT back in the project's hands, the
2326
+ * record can send it to the wallet it names. `own`: the player's own listing of it counts too (a route that lists:
2327
+ * the program takes no second listing of an NFT while one is open). NFTs of the project's own collections are not
2328
+ * read: those collections refuse the permanent delegates that take an NFT out.
2329
+ */
2330
+ async function leftovers(asset, { own = false } = {}) {
2331
+ if (ours(await collectionOf(asset))) return [];
2332
+ const me = wallet.address;
2333
+ const out = [];
2130
2334
  const time = BigInt(await chainNow());
2131
- for (const { pubkey, account } of holds) {
2132
- const hold = loot.decode('Hold', account.data);
2335
+ const iso = at => new Date(Number(at) * 1000).toISOString();
2336
+ for (const r of await recordsOf(asset)) {
2337
+ if (r.kind === 'listing') {
2338
+ const listing = r.record;
2339
+ const mine = listing.maker === me;
2340
+ if (mine && !own) continue;
2341
+ const sale = Boolean(listing.deal.Sell);
2342
+ // A running rental is kept: nobody but its maker can even stop the listing until it has run.
2343
+ const rented =
2344
+ time < BigInt(listing.until)
2345
+ ? ` It is rented until ${iso(listing.until)}: the program keeps a running rental, so nobody can close this listing before then (anyone can after, for nothing).`
2346
+ : '';
2347
+ out.push({
2348
+ ...r,
2349
+ says: mine
2350
+ ? `Your own earlier listing of this NFT (${r.address}) is still open in this project.${rented}`
2351
+ : `A listing of this NFT (${r.address}) 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}` : ''}.${rented}`,
2352
+ });
2353
+ continue;
2354
+ }
2355
+ const hold = r.record;
2133
2356
  const to = hold.player === project ? 'the project’s treasury' : hold.player;
2134
2357
  const names = [hold.player, hold.debt?.lender].filter(Boolean);
2135
2358
  if (names.every(x => x === me)) continue;
@@ -2140,31 +2363,137 @@ export function solanaApp({
2140
2363
  : `a loan's hold: anyone who repays it (${hold.debt.repay} to ${hold.debt.lender}) sends the NFT to ${to}, and once it is due anyone sends it to the lender`
2141
2364
  : due
2142
2365
  ? `a hold that is over: anyone can send the NFT to ${to} at once`
2143
- : `a hold that runs until ${new Date(Number(hold.free_at) * 1000).toISOString()}: the project's authority decides where the NFT goes until then, and anyone sends it to ${to} after`;
2366
+ : `a hold that runs until ${iso(hold.free_at)}: the project's authority decides where the NFT goes until then, and anyone sends it to ${to} after`;
2144
2367
  out.push({
2145
- kind: 'hold',
2146
- address: pubkey,
2147
- says: `This project still has ${what} (${pubkey}), left from an earlier owner of this NFT. Once the project holds the NFT again, it can be sent there.`,
2368
+ ...r,
2369
+ says: `This project still has ${what} (${r.address}), left from an earlier owner of this NFT. Once the project holds the NFT again, it can be sent there.`,
2148
2370
  });
2149
2371
  }
2150
2372
  return out;
2151
2373
  }
2152
2374
 
2153
2375
  /**
2154
- * Refuses, in words, to hand `asset` to the project while a record an earlier owner left can take it (`leftovers`),
2155
- * unless the caller says the player accepts that (`accept` true). `listing`: the route is a listing, which the program
2156
- * refuses outright while a listing of the NFT is open (its address is taken), whatever the option says.
2376
+ * The instructions that close `records` of `asset` (`recordsOf`) on the proof that the project no longer holds it
2377
+ * (`proof`, from `goneProof`), as `signer` sends them: a hold by `release`, a listing by `take_listing`. Anyone may
2378
+ * send either, at any time; nothing is sent or paid, and no project fee is taken. A listing whose rental still runs
2379
+ * can't be closed (`Rented`): the program keeps a running rental, and the listing closes once it has run.
2380
+ * Returns { closes, closed: [{ kind, address }], waits: the records left }.
2381
+ */
2382
+ async function closesOf(asset, records, proof, signer = wallet.address) {
2383
+ const g = await projectNow();
2384
+ const time = BigInt(await chainNow());
2385
+ const closes = [];
2386
+ const closed = [];
2387
+ const waits = [];
2388
+ for (const r of records) {
2389
+ if (r.kind === 'listing') {
2390
+ if (time < BigInt(r.record.until)) {
2391
+ waits.push(r);
2392
+ continue;
2393
+ }
2394
+ closes.push(
2395
+ loot.takeListingIx({
2396
+ taker: signer,
2397
+ project,
2398
+ projectState: g,
2399
+ listing: r.record,
2400
+ holders: await holdersOf(r.record.shares ?? []),
2401
+ programs,
2402
+ ...proof.kind,
2403
+ }),
2404
+ );
2405
+ } else {
2406
+ const hold = r.record;
2407
+ closes.push(
2408
+ loot.releaseIx({
2409
+ signer,
2410
+ project,
2411
+ asset,
2412
+ ...proof.kind,
2413
+ hold,
2414
+ address: r.address,
2415
+ treasury: g.treasury,
2416
+ ...(hold.debt ? { to: hold.debt.lender } : {}),
2417
+ }),
2418
+ );
2419
+ }
2420
+ closed.push({ kind: r.kind, address: r.address });
2421
+ }
2422
+ return { closes: closes.length ? [...proof.before, ...closes] : [], closed, waits };
2423
+ }
2424
+
2425
+ /**
2426
+ * The deposit guard. Before the player hands `asset` to the project, what an earlier owner left of it there
2427
+ * (`leftovers`) is cleared: the player holds the NFT, so it is provably gone from the project, and each leftover hold
2428
+ * closes by `release` on that proof and a leftover listing by `take_listing` on it (anyone may, at any time, and
2429
+ * nothing is paid). Returns those instructions, tried in a dry run, to go in front of the deposit (`closesFirst`);
2430
+ * none when nothing was left. The closes cost the player their transaction fee only: each record's rent goes to
2431
+ * whoever that record names (a hold's payer, a listing's maker), never to the player.
2432
+ * What can't be closed still refuses the deposit, in words, with nothing sent: a listing whose rental still runs
2433
+ * (the program keeps it until the rental has run), or a record of an NFT nothing shows gone. `accept` true goes
2434
+ * ahead all the same (the closes that can be made still go first). `listing`: the route is a listing, which the
2435
+ * program refuses outright while a listing of the NFT is open (its address is taken), whatever the option says.
2157
2436
  */
2158
- async function noLeftovers(asset, accept, { listing = false } = {}) {
2159
- const found = await leftovers(asset);
2160
- if (listing && found.some(x => x.kind === 'listing'))
2437
+ async function clearLeftovers(asset, accept, { listing = false } = {}) {
2438
+ const found = await leftovers(asset, { own: listing });
2439
+ if (!found.length) return [];
2440
+ const proof = await goneProof(asset, wallet.address, { holder: wallet.address });
2441
+ let { closes, waits } = proof ? await closesOf(asset, found, proof) : { closes: [], waits: found };
2442
+ let why = '';
2443
+ if (closes.length) {
2444
+ const trial = [computeUnitLimit(1_400_000), ...closes.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM)];
2445
+ // Tried in a dry run when they are one transaction as they stand; a longer list is made to fit as it is sent.
2446
+ const one = compileFitting({
2447
+ payer: wallet.address,
2448
+ blockhash: NOBODY,
2449
+ instructions: trial,
2450
+ tables: await standing(),
2451
+ });
2452
+ const tried = one ? await dryRun(wallet.address, trial) : { ok: true };
2453
+ if (!tried.ok) {
2454
+ why = tried.error ? ` Closing it was refused in a dry run: ${tried.error.msg} (${tried.error.name}).` : '';
2455
+ [closes, waits] = [[], found];
2456
+ }
2457
+ }
2458
+ const open = waits.find(x => x.kind === 'listing');
2459
+ if (listing && open)
2161
2460
  throw new Error(
2162
- `${found.find(x => x.kind === 'listing').says} The program refuses another listing of this NFT while that one is open (its address is taken): nothing was sent.`,
2461
+ `${open.says}${why} The program refuses another listing of this NFT while that one is open (its address is taken): nothing was sent.`,
2163
2462
  );
2164
- if (!found.length || accept === true) return;
2165
- throw new Error(
2166
- `${found.map(x => x.says).join(' ')} This deposit would put the NFT where that record can take it: it would be lost to you. Nothing was sent. Go ahead only if the player accepts this: pass acceptLeftover: true.`,
2167
- );
2463
+ if (waits.length && accept !== true)
2464
+ throw new Error(
2465
+ `${waits.map(x => x.says).join(' ')}${why} 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.`,
2466
+ );
2467
+ return closes;
2468
+ }
2469
+
2470
+ /**
2471
+ * A deposit's `instructions` with `closes` (`clearLeftovers`) in front of them: in the same transaction when both
2472
+ * fit one as the wallet sends it (a legacy one, or one with the project's standing tables); when they do not, the
2473
+ * closes are sent first in a transaction of their own, and the deposit's instructions come back alone.
2474
+ */
2475
+ async function closesFirst(closes, instructions) {
2476
+ if (!closes.length) return instructions;
2477
+ const isLimit = ix => ix.programId === COMPUTE_BUDGET_PROGRAM && ix.data[0] === 2;
2478
+ // One proof of a compressed NFT serves both: the closes read its record first, then the deposit moves it.
2479
+ const proven = new Set(closes.filter(isProveLeaf).map(ix => ix.keys[1].pubkey));
2480
+ const together = [
2481
+ computeUnitLimit(1_400_000),
2482
+ ...closes.filter(ix => !isLimit(ix)),
2483
+ ...instructions.filter(ix => !isLimit(ix) && !(isProveLeaf(ix) && proven.has(ix.keys[1].pubkey))),
2484
+ ];
2485
+ const fits =
2486
+ Boolean(
2487
+ compileFitting({
2488
+ payer: wallet.address,
2489
+ blockhash: NOBODY,
2490
+ instructions: loot.withLeafCloses(together),
2491
+ tables: await standing(),
2492
+ }),
2493
+ ) && transactionLocks(wallet.address, together) <= 64;
2494
+ if (fits) return together;
2495
+ await send(closes);
2496
+ return instructions;
2168
2497
  }
2169
2498
 
2170
2499
  /** Who holds each collectible a split's holder shares name now: { asset: holder }. */
@@ -2266,6 +2595,77 @@ export function solanaApp({
2266
2595
  ];
2267
2596
  }
2268
2597
 
2598
+ /** How many of the losers' NFTs left a winner's claim reads to choose its picks among. */
2599
+ const PICKS_READ = 8;
2600
+ /**
2601
+ * The NFTs a claim of entry `i` of a contest of NFTs moves, as `claim` sends it: the entry's own while the contest
2602
+ * holds it, and for a winner the losers' NFTs its share still lets it pick, at most 4 a claim. `take` names the
2603
+ * picks (one asset, or several). With none named, the first of the losers' NFTs left that the project still holds
2604
+ * are picked (the first `PICKS_READ` are read), and one that is gone only when no other is left there: a pick of an
2605
+ * NFT gone takes nothing. Each is { j, nft, gone }: its entry's place in the list, and whether the project no longer
2606
+ * holds it (its collection took it back or burned it).
2607
+ */
2608
+ async function contestNfts(contest, entries, i, take) {
2609
+ const own = entries[i];
2610
+ const won = contest.verdict === 'Won' && Number(contest.winner) === Number(own.side);
2611
+ const out = Number(own.amount) === 1 ? [{ j: i, nft: own.nft }] : [];
2612
+ const share = won && !(own.bits & loot.ENDED) ? loot.shareOf(contest, own) - Number(own.taken) : 0;
2613
+ const left = entries
2614
+ .map((e, j) => ({ j, nft: e.nft, e }))
2615
+ .filter(({ e }) => Number(e.side) !== Number(contest.winner) && Number(e.amount) === 1);
2616
+ const wanted = take ? [take].flat() : [];
2617
+ const among =
2618
+ share <= 0 ? [] : wanted.length ? left.filter(x => wanted.includes(x.nft)) : left.slice(0, PICKS_READ);
2619
+ if (wanted.length && among.length < Math.min(wanted.length, share))
2620
+ throw new Error(`${wanted.join(', ')} is not a loser’s NFT left in this contest for you to pick.`);
2621
+ const heldOf = await heldEach([...out, ...among].map(x => x.nft));
2622
+ const isGone = x => heldOf.get(x.nft)?.held === false;
2623
+ const picks = wanted.length ? among : [...among.filter(x => !isGone(x)), ...among.filter(isGone)];
2624
+ out.push(...picks.slice(0, Math.min(share, 4)));
2625
+ return out.map(x => ({ j: x.j, nft: x.nft, gone: isGone(x) }));
2626
+ }
2627
+ /** What a contest's claim does about its NFTs that are gone, in words, for `claims()`: `own` the wallet's own NFT
2628
+ * when it is gone, `picks` the losers' NFTs gone that its picks land on. */
2629
+ const goneContestSays = (own, picks) =>
2630
+ [
2631
+ own
2632
+ ? `The project no longer holds the NFT you entered (${own}): whoever controls its collection took it back or burned it while the contest held it. It can’t come back to you: claiming closes your entry and sends nothing.`
2633
+ : '',
2634
+ picks.length
2635
+ ? `The project no longer holds ${picks.length === 1 ? 'the loser’s NFT' : 'the losers’ NFTs'} left for you to pick (${picks.join(', ')}): whoever controls its collection took it back or burned it. A pick of it takes nothing: claiming closes it and sends nothing.`
2636
+ : '',
2637
+ ]
2638
+ .filter(Boolean)
2639
+ .join(' ');
2640
+
2641
+ /**
2642
+ * The NFTs the pulls of a wallet's parts of round `r` (`mine`: [{ entry, parts }]) are to hand out: each entry's NFT
2643
+ * ticket ('Own'), and each NFT its places are still owed (`Prize`: the lottery's own stash's, as the pulls take them
2644
+ * out; a project stash's named one). Returns { tickets, prizes }, their addresses.
2645
+ */
2646
+ async function roundNfts(r, mine) {
2647
+ const [tickets, prizes] = [[], []];
2648
+ let own;
2649
+ for (const { entry, parts } of mine) {
2650
+ const e = r.entries[entry];
2651
+ for (const part of parts) {
2652
+ if (part === 'Own' && e.nft !== NOBODY) tickets.push(e.nft);
2653
+ if (!part.Prize || loot.tokenRun(r, Number(part.Prize[0]))) continue;
2654
+ const run = Number(part.Prize[0]);
2655
+ const place = r.terms.places[run];
2656
+ if (place.prize?.Nft) prizes.push(place.prize.Nft.nft);
2657
+ if (!loot.ownStash(place)) continue;
2658
+ own ??= { stash: loot.decodeStash((await client.account(loot.pda.lotteryStash(r.lottery)))?.data ?? null) };
2659
+ const predicted = loot.stashNfts(r, own.stash);
2660
+ for (const p of loot.prizePlacesOwed(r, entry, run)) {
2661
+ const nft = predicted[loot.nftSlot(r, p)];
2662
+ if (nft) prizes.push(nft);
2663
+ }
2664
+ }
2665
+ }
2666
+ return { tickets, prizes };
2667
+ }
2668
+
2269
2669
  /**
2270
2670
  * When a draw of round `r` (at `key`) would cancel it (an open round short of its least tickets; a waiting one past
2271
2671
  * the timeout), the pulls of `who`'s own parts as the cancelled round owes them (a few entries' worth), to go right
@@ -2325,6 +2725,11 @@ export function solanaApp({
2325
2725
  * The pulls of `parts` of entry `i` of a round (`partsOwed`; `i` null for parts owed in common, `commonOwed`), signed by
2326
2726
  * `signer`, and the rolls they make ([{ order, box }], `loot.pullRolls`). With `closes`, the last pull carries the
2327
2727
  * round's close (when the parts it pays are the last owed).
2728
+ * An NFT of a part that is proven gone from the project is not sent anywhere. A ticket whose price sends it on
2729
+ * passes what proves it gone, and its part settles (`NftGone`, the round its record). A ticket going back to its
2730
+ * buyer and a place's NFT are only handed out by the program: the pull passes each as its hold, and a release on
2731
+ * the proof follows it here, which closes the hold (`NftGone`, the hold its record). A pull makes holds of all its
2732
+ * prize NFTs or of none, so with one of them gone the others are made holds too and released on to their winner.
2328
2733
  */
2329
2734
  async function pulls(key, r, i, parts, signer, { closes = false, lotteryClosed = false } = {}) {
2330
2735
  const e = i == null ? null : r.entries[i];
@@ -2340,20 +2745,38 @@ export function solanaApp({
2340
2745
  const extra = await hookTail([...roundMints(r), ...r.prizes.map(p => p.mint)]);
2341
2746
  for (const [k, part] of parts.entries()) {
2342
2747
  const more = {};
2748
+ // The NFTs this pull makes holds in place of handing them out (`hold::owe`: for whom, and whose its NFT's
2749
+ // account's rent is), each released right after it. The round only hands an NFT out and reads nothing, so one
2750
+ // proven gone from the project is made a hold, and that hold closes on the proof.
2751
+ const holdsMade = new Map();
2343
2752
  if (part === 'Own' && e.nft !== NOBODY) {
2344
2753
  const then = r.status === 'Cancelled' || r.terms.returns ? null : r.terms.nft_ticket?.then;
2345
2754
  more.handBack = Boolean(then?.Give) && loot.handsBack(await client.account(then.Give[0]));
2346
2755
  const to = !then || more.handBack ? e.buyer : then.Give ? then.Give[0] : undefined;
2347
- const { before = [], hasAuthority: _, ...kind } = await nftOf(e.nft, signer, { to });
2756
+ const { before = [], hasAuthority: _, gone, ...kind } = await leaving(e.nft, signer, { to });
2348
2757
  out.push(...before);
2349
- more.ticket = kind;
2758
+ // Where its price sends it on (burned, given, stashed) the round reads the ticket itself: proven gone, it goes
2759
+ // nowhere and the part settles. Going back to its buyer it is only handed out: made a hold, closed below.
2760
+ if (gone && !then) {
2761
+ more.hold = true;
2762
+ holdsMade.set(e.nft, { kind, player: e.buyer, rentTo: e.buyer });
2763
+ } else more.ticket = kind;
2350
2764
  } else if (part.Prize && !loot.tokenRun(r, Number(part.Prize[0]))) {
2351
2765
  // Each of the entry's places in the run: an NFT of the lottery's own stash (which one, as the pull takes them
2352
2766
  // out), a project stash's NFT, an item, or a roll (asked in the same transaction).
2353
2767
  const run = Number(part.Prize[0]);
2354
2768
  const given = r.terms.places[run].prize;
2355
- const inputs = await prizeInputs([given], signer, e.buyer);
2769
+ // An NFT's kind is read below, with whether the project still holds it.
2770
+ const inputs = await prizeInputs([given.Nft || given.Held ? null : given], signer, e.buyer);
2356
2771
  out.push(...inputs.before);
2772
+ const handed = new Map();
2773
+ const hand = async nft => {
2774
+ if (inputs.nfts[nft]) return;
2775
+ const { before, hasAuthority: _, gone, ...kind } = await leaving(nft, signer, { to: e.buyer });
2776
+ inputs.nfts[nft] = kind;
2777
+ out.push(...before);
2778
+ handed.set(nft, { kind, gone, player: e.buyer, rentTo: signer });
2779
+ };
2357
2780
  const stash = loot.ownStash(r.terms.places[run])
2358
2781
  ? loot.decodeStash((await client.account(loot.pda.lotteryStash(r.lottery)))?.data ?? null)
2359
2782
  : null;
@@ -2365,10 +2788,7 @@ export function solanaApp({
2365
2788
  const predicted = loot.stashNfts(r, stash);
2366
2789
  for (const place of loot.prizePlacesOwed(r, i, run)) {
2367
2790
  const nft = predicted[loot.nftSlot(r, place)];
2368
- if (!nft || inputs.nfts[nft]) continue;
2369
- const { before, hasAuthority: _, ...kind } = await nftOf(nft, signer, { to: e.buyer });
2370
- inputs.nfts[nft] = kind;
2371
- out.push(...before);
2791
+ if (nft) await hand(nft);
2372
2792
  }
2373
2793
  }
2374
2794
  const stashes = given.Nft
@@ -2378,10 +2798,17 @@ export function solanaApp({
2378
2798
  ) ?? { nfts: [] },
2379
2799
  }
2380
2800
  : {};
2801
+ // A project stash's named NFT is handed out only while that stash lists it.
2802
+ if (given.Nft && stashes[given.Nft.stash].nfts.includes(given.Nft.nft)) await hand(given.Nft.nft);
2803
+ // One of them gone: a pull makes holds of all its NFTs or of none, so each becomes a hold, and its release
2804
+ // below closes the one that is gone and sends any other on to its winner.
2805
+ const holds = [...handed.values()].some(x => x.gone);
2806
+ if (holds) for (const [nft, x] of handed) holdsMade.set(nft, x);
2381
2807
  more.prize = {
2382
2808
  stash,
2383
2809
  stashes,
2384
2810
  nfts: inputs.nfts,
2811
+ hold: holds,
2385
2812
  collection: inputs.collection,
2386
2813
  poolMints: inputs.poolMints,
2387
2814
  items: inputs.items,
@@ -2416,7 +2843,8 @@ export function solanaApp({
2416
2843
  ) && (await dryRun(signer, tx)).ok
2417
2844
  );
2418
2845
  };
2419
- let most = owed;
2846
+ // (Made holds, each NFT's release follows the pull in its transaction: a few places a pull then.)
2847
+ let most = holds ? Math.min(owed, 4) : owed;
2420
2848
  while (most > 1 && !(await sized(most))) most = Math.max(1, Math.floor(most * 0.75));
2421
2849
  if (most < owed) [more.prize.most, partial] = [most, true];
2422
2850
  heap = true;
@@ -2443,21 +2871,26 @@ export function solanaApp({
2443
2871
  }
2444
2872
  const last = k === parts.length - 1;
2445
2873
  const round = closes && last ? { ...r, owed: pullSettles(r, i, part) } : { ...r, owed: Number.MAX_SAFE_INTEGER };
2446
- out.push(
2447
- loot.pullIx({
2448
- payer: signer,
2449
- project,
2450
- roundAddress: key,
2451
- round,
2452
- entry: i ?? 0,
2453
- part,
2454
- projectState: g,
2455
- lotteryClosed,
2456
- programs,
2457
- extra,
2458
- ...more,
2459
- }),
2460
- );
2874
+ const pull = loot.pullIx({
2875
+ payer: signer,
2876
+ project,
2877
+ roundAddress: key,
2878
+ round,
2879
+ entry: i ?? 0,
2880
+ part,
2881
+ projectState: g,
2882
+ lotteryClosed,
2883
+ programs,
2884
+ extra,
2885
+ ...more,
2886
+ });
2887
+ out.push(pull);
2888
+ // Each hold this pull makes (a pull that stops short carries only some) is released at once: one whose NFT is
2889
+ // gone closes on the proof and nothing is sent, any other goes on to its winner, and the hold's rent comes back
2890
+ // to the signer, who paid it.
2891
+ for (const [nft, { kind, player, rentTo }] of holdsMade)
2892
+ if (pull.keys.some(x => x.pubkey === loot.pda.hold(nft, 0)))
2893
+ out.push(releaseOwed(nft, kind, { player, rentTo }, signer, g));
2461
2894
  }
2462
2895
  return { instructions: out, rolls, heap, partial };
2463
2896
  }
@@ -2596,15 +3029,18 @@ export function solanaApp({
2596
3029
  return out;
2597
3030
  },
2598
3031
 
2599
- /** Gives an order's payment back once its time has come (a roll that failed or never came). */
3032
+ /** Gives an order's payment back once its time has come (a roll that failed or never came). An NFT the project
3033
+ * held for the roll goes back with it; one it no longer holds (its collection took it back or burned it) is not
3034
+ * looked for, and the whole payment goes back all the same. */
2600
3035
  async refund(orderAddress) {
2601
3036
  const order = await read('Order', orderAddress);
2602
3037
  // An NFT the project held for the roll goes back.
2603
3038
  const {
2604
3039
  before = [],
2605
3040
  hasAuthority: _,
3041
+ gone: _gone,
2606
3042
  ...subject
2607
- } = order.held ? await nftOf(order.subject, wallet.address, { to: order.player }) : {};
3043
+ } = order.held ? await leaving(order.subject, wallet.address, { to: order.player }) : {};
2608
3044
  return send([...before, loot.refundIx({ signer: wallet.address, orderAddress, order, subject, programs })]);
2609
3045
  },
2610
3046
 
@@ -2839,12 +3275,13 @@ export function solanaApp({
2839
3275
  shown ??= await passing(lottery.gate, 'buy a ticket');
2840
3276
  listProof = listProofOf(lottery.gate, l.gate?.list, listProof);
2841
3277
  let nft;
3278
+ let clears = [];
2842
3279
  const before = [];
2843
3280
  if (terms.nft_ticket) {
2844
3281
  if (!paidWith) throw new Error(`A ticket of “${l.name}” costs an NFT: say which (nft).`);
2845
3282
  if (BigInt(count) !== 1n) throw new Error('An NFT pays for one ticket at a time.');
2846
- // The project holds an NFT ticket until the round is drawn.
2847
- await noLeftovers(paidWith, acceptLeftover);
3283
+ // The project holds an NFT ticket until the round is drawn: what an earlier owner left of it goes first.
3284
+ clears = await clearLeftovers(paidWith, acceptLeftover);
2848
3285
  const { before: proof, ...kind } = await nftOf(paidWith);
2849
3286
  nft = { asset: paidWith, ...kind };
2850
3287
  before.push(...proof);
@@ -2914,9 +3351,12 @@ export function solanaApp({
2914
3351
  }
2915
3352
  const buy = [...before, ...withSolWrap(wallet.address, mint, BigInt(terms.price) * BigInt(count), [ix])];
2916
3353
  return send(
2917
- draw.length
2918
- ? [computeUnitLimit(800_000), ...draw, ...buy.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM)]
2919
- : buy,
3354
+ await closesFirst(
3355
+ clears,
3356
+ draw.length
3357
+ ? [computeUnitLimit(800_000), ...draw, ...buy.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM)]
3358
+ : buy,
3359
+ ),
2920
3360
  );
2921
3361
  },
2922
3362
 
@@ -3012,9 +3452,9 @@ export function solanaApp({
3012
3452
  only(others, ['extra', 'transfers', 'acceptLeftover'], 'stakeNft');
3013
3453
  const f = part('farms', farmKey);
3014
3454
  const farm = await read('Farm', loot.pda.farm(project, f.id));
3015
- // A farm that keeps its NFTs in escrow hands this one to the project; one that freezes or watches leaves it in the
3016
- // wallet, where no record of the project's can move it.
3017
- if (farm.custody === 'Escrow') await noLeftovers(asset, acceptLeftover);
3455
+ // A farm that keeps its NFTs in escrow hands this one to the project (what an earlier owner left of it there is
3456
+ // cleared first); one that freezes or watches leaves it in the wallet, where no record of the project's can move it.
3457
+ const clears = farm.custody === 'Escrow' ? await clearLeftovers(asset, acceptLeftover) : [];
3018
3458
  await openFor(f, farm, tier);
3019
3459
  const token = await farmTokens([farm.rewards_mint], [], { extra, transfers });
3020
3460
  const { before, ...nft } = await nftOf(asset);
@@ -3036,11 +3476,24 @@ export function solanaApp({
3036
3476
  // prize and as a forfeit, is sized before it goes in, and an NFT that could never come back out isn't staked.
3037
3477
  if (!farm.farmed.Tokens) await exitFits(f, farm, asset, nft, before, tier, token);
3038
3478
  return send(
3039
- await walking(f, farm, [
3040
- ...refresh,
3041
- ...before,
3042
- loot.stakeNftIx({ player: wallet.address, project, id: f.id, farm, asset, ...nft, previous, tier, ...token }),
3043
- ]),
3479
+ await closesFirst(
3480
+ clears,
3481
+ await walking(f, farm, [
3482
+ ...refresh,
3483
+ ...before,
3484
+ loot.stakeNftIx({
3485
+ player: wallet.address,
3486
+ project,
3487
+ id: f.id,
3488
+ farm,
3489
+ asset,
3490
+ ...nft,
3491
+ previous,
3492
+ tier,
3493
+ ...token,
3494
+ }),
3495
+ ]),
3496
+ ),
3044
3497
  );
3045
3498
  },
3046
3499
 
@@ -3081,8 +3534,10 @@ export function solanaApp({
3081
3534
  const boxes = roll != null ? { [roll]: await read('LootBox', loot.pda.box(project, roll)) } : {};
3082
3535
  // A mission whose box holds the NFT it rolls on takes it into the project's hands on the way out, from a farm that
3083
3536
  // left it in the wallet.
3084
- if (roll != null && farm.custody !== 'Escrow' && loot.boxHolds(boxes[roll]))
3085
- await noLeftovers(asset, acceptLeftover);
3537
+ const clears =
3538
+ roll != null && farm.custody !== 'Escrow' && loot.boxHolds(boxes[roll])
3539
+ ? await clearLeftovers(asset, acceptLeftover)
3540
+ : [];
3086
3541
  // A roll still at the NFT's order address blocks the leave, unless it was asked and came back with nothing to give
3087
3542
  // (Waiting, asked once): that one stands in for this. Any other is asked (its player or the project's keys), answered and settled first.
3088
3543
  if (roll != null) {
@@ -3125,7 +3580,7 @@ export function solanaApp({
3125
3580
  { code: tried.error.code, name: 'PrizeNotCovered' },
3126
3581
  );
3127
3582
  }
3128
- const { signature, rolls } = await sendRolling(steps);
3583
+ const { signature, rolls } = await sendRolling(await closesFirst(clears, steps));
3129
3584
  const rolled = rolls.length ? { rolled: rolls[0] } : {};
3130
3585
  if (roll != null)
3131
3586
  return {
@@ -3466,23 +3921,37 @@ export function solanaApp({
3466
3921
  /**
3467
3922
  * Hands an NFT of any kind to the project until a time (a hold): `seconds` from now, or `{ until }` (Unix seconds).
3468
3923
  * The hold's address names its end. Returns { signature, freeAt }.
3924
+ *
3925
+ * Like every route that hands an NFT in (`stakeNft` in escrow, `borrow`, `enter`, `bid`, `listForSale`,
3926
+ * `listForRent`, a ticket paid with an NFT, a box that rolls on one), it first clears what an earlier owner of the
3927
+ * NFT left in this project: a hold or a listing that stayed open when whoever controls the NFT's collection took it
3928
+ * out of the project's hands. Left open, such a record could take the NFT once the project holds it again. The
3929
+ * player holds the NFT now, so the program closes each on that proof (a hold by `release`, a listing by
3930
+ * `take_listing`, nothing sent or paid), in front of the deposit in the same transaction, or in a transaction of
3931
+ * their own before it when the two do not fit one. The closes cost the player their transaction fee only: each
3932
+ * record's rent goes to whoever that record names (a hold's payer, a listing's maker), never to the player. Only
3933
+ * what can't be closed still refuses the deposit, in words (a listing whose rental still runs: the program keeps
3934
+ * it until the rental has run); `acceptLeftover: true` goes ahead all the same. NFTs of the project's own
3935
+ * collections are not read.
3469
3936
  */
3470
3937
  async hold(asset, seconds, { acceptLeftover, ...others } = NO_OPTIONS) {
3471
3938
  only(others, ['acceptLeftover'], 'hold');
3472
- await noLeftovers(asset, acceptLeftover);
3939
+ const clears = await clearLeftovers(asset, acceptLeftover);
3473
3940
  const freeAt = BigInt(typeof seconds === 'object' ? seconds.until : now() + Number(seconds));
3474
3941
  const { before, hasAuthority: _, ...nft } = await nftOf(asset);
3475
- const signature = await send([
3476
- ...before,
3477
- loot.holdIx({
3478
- player: wallet.address,
3479
- project,
3480
- asset,
3481
- ...nft,
3482
- collection: nft.collection ?? (await collectionOf(asset)) ?? MPL_CORE,
3483
- freeAt,
3484
- }),
3485
- ]);
3942
+ const signature = await send(
3943
+ await closesFirst(clears, [
3944
+ ...before,
3945
+ loot.holdIx({
3946
+ player: wallet.address,
3947
+ project,
3948
+ asset,
3949
+ ...nft,
3950
+ collection: nft.collection ?? (await collectionOf(asset)) ?? MPL_CORE,
3951
+ freeAt,
3952
+ }),
3953
+ ]),
3954
+ );
3486
3955
  return { signature, freeAt };
3487
3956
  },
3488
3957
 
@@ -3540,7 +4009,7 @@ export function solanaApp({
3540
4009
  */
3541
4010
  async borrow(maker, id, asset, { due, acceptRisks = false, acceptLeftover, extra, ...others } = NO_OPTIONS) {
3542
4011
  only(others, ['due', 'acceptRisks', 'acceptLeftover', 'extra'], 'borrow');
3543
- await noLeftovers(asset, acceptLeftover);
4012
+ const clears = await clearLeftovers(asset, acceptLeftover);
3544
4013
  const loan = await this.loanOffer(maker, id);
3545
4014
  extra ??= await hookTail([loan.mint]);
3546
4015
  if (loan.risks.length && acceptRisks !== true)
@@ -3564,11 +4033,13 @@ export function solanaApp({
3564
4033
  programs: loanPrograms,
3565
4034
  extra,
3566
4035
  });
3567
- const signature = await send([
3568
- ...before,
3569
- createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, loan.mint, loan.program),
3570
- ix,
3571
- ]);
4036
+ const signature = await send(
4037
+ await closesFirst(clears, [
4038
+ ...before,
4039
+ createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, loan.mint, loan.program),
4040
+ ix,
4041
+ ]),
4042
+ );
3572
4043
  return { signature, due, hold: loot.pda.hold(asset, due) };
3573
4044
  },
3574
4045
 
@@ -3593,7 +4064,9 @@ export function solanaApp({
3593
4064
  * borrower in the same instruction: one prompt. `repay`: the debt the payer was shown (`loan()`'s, in base units);
3594
4065
  * the program refuses any other (`TermsChanged`). An NFT its collection took back or burned while the project held
3595
4066
  * it is refused here, before anything is signed: the program takes no repayment for it, and the borrower keeps what
3596
- * they borrowed. When the NFT can't move now (frozen for now, or its collection refuses the move), or `home` is
4067
+ * they borrowed. Such a loan is closed for nothing instead: `claims()` lists it for its borrower and its lender, and
4068
+ * `claim` of it closes the hold (nothing is repaid, and the hold's rent goes back to whoever paid it). When the NFT
4069
+ * can't move now (frozen for now, or its collection refuses the move), or `home` is
3597
4070
  * false, the money goes alone, so the loan does not fall due: the hold is then over, and anyone sends the NFT home
3598
4071
  * once it can move (`claims()` lists it). A compressed NFT is proven in the same transaction; the money alone for one
3599
4072
  * whose proof keeps going out of date goes without it. Returns { signature, home }, and `says` when the NFT waits.
@@ -3614,7 +4087,7 @@ export function solanaApp({
3614
4087
  );
3615
4088
  if ((await heldNow(asset)).held === false)
3616
4089
  throw new Error(
3617
- `The project no longer holds the NFT ${asset}: whoever controls its collection took it back or burned it while it was held. The program takes no repayment for it (NotTheNft): the borrower keeps what they borrowed.`,
4090
+ `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), so nothing was sent: the borrower keeps what they borrowed. This loan can be closed for nothing: claims() lists it for its borrower and for its lender, and claim() of it closes the hold (nothing is repaid, and the hold's rent goes back to whoever paid it).`,
3618
4091
  );
3619
4092
  const { before, hasAuthority: _, ...nft } = await nftOf(asset, wallet.address, { to: hold.player });
3620
4093
  const kind = nft.compressed || nft.tokenMetadata ? nft : { collection: (await collectionOf(asset)) ?? MPL_CORE };
@@ -3672,23 +4145,25 @@ export function solanaApp({
3672
4145
  listProof = listProofOf(contest.gate, null, listProof);
3673
4146
  if (contest.nft) {
3674
4147
  if (!staked) throw new Error('This contest takes an NFT: say which (nft).');
3675
- await noLeftovers(staked, acceptLeftover);
4148
+ const clears = await clearLeftovers(staked, acceptLeftover);
3676
4149
  const { before, ...kind } = await nftOf(staked);
3677
- return send([
3678
- ...before,
3679
- loot.enterIx({
3680
- player: wallet.address,
3681
- project,
3682
- id,
3683
- contest,
3684
- side,
3685
- amount: 1n,
3686
- shown,
3687
- listProof,
3688
- nft: { asset: staked, ...kind },
3689
- programs,
3690
- }),
3691
- ]);
4150
+ return send(
4151
+ await closesFirst(clears, [
4152
+ ...before,
4153
+ loot.enterIx({
4154
+ player: wallet.address,
4155
+ project,
4156
+ id,
4157
+ contest,
4158
+ side,
4159
+ amount: 1n,
4160
+ shown,
4161
+ listProof,
4162
+ nft: { asset: staked, ...kind },
4163
+ programs,
4164
+ }),
4165
+ ]),
4166
+ );
3692
4167
  }
3693
4168
  const t = [...tokens.values()].find(x => mintOf(x.key) === contest.mint);
3694
4169
  const value = units(amount, t?.source === 'sol' ? 9 : (t?.decimals ?? 0));
@@ -3787,7 +4262,9 @@ export function solanaApp({
3787
4262
  * Bids `amount` (whole units) on an auction. The bid it tops goes back to its bidder in the same transaction
3788
4263
  * (the program opens their account again if they closed it, paid by this bidder). In SOL, only what the player's
3789
4264
  * wrapped-SOL account does not hold already is wrapped, and the account stays open: if this bid is topped, it comes
3790
- * back there.
4265
+ * back there. In an auction of NFT bids, an NFT of the topped bid that the project no longer holds (its collection
4266
+ * took it back or burned it) can't go back: the bid makes it a hold for its bidder and closes that hold on the
4267
+ * proof in the same transaction, so a high bid with an NFT gone can still be topped.
3791
4268
  */
3792
4269
  async bid(auctionKey, amount, { shown, listProof, nfts, acceptLeftover, ...others } = NO_OPTIONS) {
3793
4270
  only(others, ['shown', 'listProof', 'nfts', 'acceptLeftover'], 'bid');
@@ -3803,7 +4280,8 @@ export function solanaApp({
3803
4280
  if (bundle.length > loot.MAX_BID_NFTS) throw new Error(`A bid holds at most ${loot.MAX_BID_NFTS} NFTs.`);
3804
4281
  if (auction.held.length >= loot.MAX_BID_NFTS)
3805
4282
  throw new Error(`The high bid holds ${loot.MAX_BID_NFTS} NFTs, the most a bid holds: it can't be topped.`);
3806
- for (const asset of bundle) await noLeftovers(asset, acceptLeftover);
4283
+ const clears = [];
4284
+ for (const asset of bundle) clears.push(...(await clearLeftovers(asset, acceptLeftover)));
3807
4285
  const before = [];
3808
4286
  const kindOf = async (asset, to) => {
3809
4287
  const { before: proof, ...kind } = await nftOf(asset, wallet.address, { to });
@@ -3812,9 +4290,17 @@ export function solanaApp({
3812
4290
  };
3813
4291
  const bid = [];
3814
4292
  for (const asset of bundle) bid.push(await kindOf(asset));
3815
- // The bid it tops goes back to its bidder: into a fresh account of theirs when their own can't take it.
4293
+ // The bid it tops goes back to its bidder: into a fresh account of theirs when their own can't take it. An
4294
+ // NFT of it that is gone (its collection took it back or burned it) can't go back: the auction only hands it
4295
+ // out, so it is made a hold for its bidder, and that hold closes on the proof right after the bid.
3816
4296
  const held = [];
3817
- for (const asset of auction.held) held.push(await kindOf(asset, auction.high_bidder));
4297
+ const lost = new Map();
4298
+ for (const asset of auction.held) {
4299
+ const { before: proof, gone, ...kind } = await leaving(asset, wallet.address, { to: auction.high_bidder });
4300
+ before.push(...proof);
4301
+ held.push({ asset, ...kind });
4302
+ if (gone) lost.set(asset, kind);
4303
+ }
3818
4304
  const ix = loot.bidIx({
3819
4305
  bidder: wallet.address,
3820
4306
  project,
@@ -3824,9 +4310,21 @@ export function solanaApp({
3824
4310
  listProof,
3825
4311
  nfts: bid,
3826
4312
  held,
4313
+ hold: [...lost.keys()],
3827
4314
  programs,
3828
4315
  });
3829
- return send([computeUnitLimit(800_000), ...before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM), ix]);
4316
+ const g = await projectNow();
4317
+ const closes = [...lost].map(([asset, { hasAuthority: _, ...kind }]) =>
4318
+ releaseOwed(asset, kind, { player: auction.high_bidder, rentTo: auction.high_bidder }, wallet.address, g),
4319
+ );
4320
+ return send(
4321
+ await closesFirst(clears, [
4322
+ computeUnitLimit(800_000),
4323
+ ...before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
4324
+ ix,
4325
+ ...closes,
4326
+ ]),
4327
+ );
3830
4328
  }
3831
4329
  const mint = auction.mint;
3832
4330
  const value = units(amount, decimalsOf(a.currency));
@@ -3872,6 +4370,12 @@ export function solanaApp({
3872
4370
  * the sale is called off on that evidence, which anyone may: the bid goes back to its bidder (`{ calledOff: true }`).
3873
4371
  * With no bid, the lot goes home to its seller. An end too big for one transaction otherwise makes each NFT that may
3874
4372
  * be a hold one, and sends each on with a release after it (`releases`).
4373
+ * An NFT that is gone is never sent, whatever its kind, and the result says so (`gone: true`, and `says`, what the
4374
+ * end did in words): a lot gone with no bid stays where it is and the auction closes, its rent to its seller; a
4375
+ * sale with the lot or an NFT of the bid gone is called off, and what the project still holds goes back; for the
4376
+ * project's own lot, or a lot that went first, the end passes the bid's NFT that is gone over and pays the rest.
4377
+ * A hold made at the end whose NFT goes before its release is closed on the proof: its entry in `releases` is
4378
+ * then `{ asset, gone: true, closed }`, `closed` the signature.
3875
4379
  */
3876
4380
  async endAuction(auctionKey, { holders, ...others } = NO_OPTIONS) {
3877
4381
  only(others, ['holders'], 'endAuction');
@@ -3938,21 +4442,23 @@ export function solanaApp({
3938
4442
  */
3939
4443
  async listForSale(asset, { price, currency, shares = [], acceptLeftover, ...others } = NO_OPTIONS) {
3940
4444
  only(others, ['price', 'currency', 'shares', 'acceptLeftover'], 'listForSale');
3941
- await noLeftovers(asset, acceptLeftover, { listing: true });
4445
+ const clears = await clearLeftovers(asset, acceptLeftover, { listing: true });
3942
4446
  const mint = mintOf(currency);
3943
4447
  const { before, ...nft } = await nftOf(asset);
3944
- return send([
3945
- ...before,
3946
- loot.openListingIx({
3947
- maker: wallet.address,
3948
- project,
3949
- asset,
3950
- mint,
3951
- deal: { Sell: { price: units(price, decimalsOf(currency)) } },
3952
- shares,
3953
- ...nft,
3954
- }),
3955
- ]);
4448
+ return send(
4449
+ await closesFirst(clears, [
4450
+ ...before,
4451
+ loot.openListingIx({
4452
+ maker: wallet.address,
4453
+ project,
4454
+ asset,
4455
+ mint,
4456
+ deal: { Sell: { price: units(price, decimalsOf(currency)) } },
4457
+ shares,
4458
+ ...nft,
4459
+ }),
4460
+ ]),
4461
+ );
3956
4462
  },
3957
4463
 
3958
4464
  /**
@@ -3964,31 +4470,40 @@ export function solanaApp({
3964
4470
  { price, lasts, maxPeriods = 1, currency, shares = [], acceptLeftover, ...others } = NO_OPTIONS,
3965
4471
  ) {
3966
4472
  only(others, ['price', 'lasts', 'maxPeriods', 'currency', 'shares', 'acceptLeftover'], 'listForRent');
3967
- await noLeftovers(asset, acceptLeftover, { listing: true });
4473
+ const clears = await clearLeftovers(asset, acceptLeftover, { listing: true });
3968
4474
  const mint = mintOf(currency);
3969
4475
  const { before, ...nft } = await nftOf(asset);
3970
- return send([
3971
- ...before,
3972
- loot.openListingIx({
3973
- maker: wallet.address,
3974
- project,
3975
- asset,
3976
- mint,
3977
- deal: { Rent: { price: units(price, decimalsOf(currency)), lasts, max_periods: maxPeriods } },
3978
- shares,
3979
- ...nft,
3980
- }),
3981
- ]);
4476
+ return send(
4477
+ await closesFirst(clears, [
4478
+ ...before,
4479
+ loot.openListingIx({
4480
+ maker: wallet.address,
4481
+ project,
4482
+ asset,
4483
+ mint,
4484
+ deal: { Rent: { price: units(price, decimalsOf(currency)), lasts, max_periods: maxPeriods } },
4485
+ shares,
4486
+ ...nft,
4487
+ }),
4488
+ ]),
4489
+ );
3982
4490
  },
3983
4491
 
3984
- /** The listing of `asset` (one account read), or null: { listing, asset, maker, mint, deal, renter, until }. */
4492
+ /**
4493
+ * The listing of `asset` (one account read), or null: { listing, asset, maker, mint, deal, renter, until }. One more
4494
+ * read says whether the project still holds the NFT: when whoever controls its collection took it back or burned
4495
+ * it, the listing is flagged `gone` with `says`, what that means in words. There is then nothing to buy or rent,
4496
+ * and the listing can be closed for nothing (`unlist` by its maker, `closeGone` by anyone).
4497
+ */
3985
4498
  async listing(asset) {
3986
4499
  const listing = await readMaybe('Listing', loot.pda.listing(project, asset));
3987
4500
  if (!listing) return null;
4501
+ const gone = (await heldNow(asset)).held === false;
3988
4502
  return {
3989
4503
  listing: loot.pda.listing(project, asset),
3990
4504
  ...listing,
3991
4505
  renter: loot.renterOf(listing, await chainNow()),
4506
+ ...(gone ? { gone: true, says: GONE_LISTING } : {}),
3992
4507
  };
3993
4508
  },
3994
4509
 
@@ -4002,7 +4517,12 @@ export function solanaApp({
4002
4517
  return holds ? renter : null;
4003
4518
  },
4004
4519
 
4005
- /** The project's listings, a maker's (`maker`) or all: one search of the program's accounts. */
4520
+ /**
4521
+ * The project's listings, a maker's (`maker`) or all: one search of the program's accounts, then one read of the
4522
+ * listed NFTs together, which flags a listing `gone` (with `says`) when the project no longer holds its NFT: a
4523
+ * Core asset held by another or burned, a Token Metadata NFT whose token left the project's account. A compressed
4524
+ * NFT is not read here (an indexer's answer for each): `listing(asset)` says it for one.
4525
+ */
4006
4526
  async listings({ maker, ...others } = NO_OPTIONS) {
4007
4527
  only(others, ['maker'], 'listings');
4008
4528
  const filters = [
@@ -4011,19 +4531,36 @@ export function solanaApp({
4011
4531
  ...(maker ? [{ memcmp: { offset: 33, bytes: maker } }] : []),
4012
4532
  ];
4013
4533
  const time = await chainNow();
4014
- return (await client.programAccounts(loot.LOOT_PROGRAM, filters)).map(({ pubkey, account }) => {
4534
+ const found = (await client.programAccounts(loot.LOOT_PROGRAM, filters)).map(({ pubkey, account }) => {
4015
4535
  const listing = loot.decode('Listing', account.data);
4016
4536
  return { listing: pubkey, ...listing, renter: loot.renterOf(listing, time) };
4017
4537
  });
4538
+ // Each NFT and the project's token account of it, all in one read.
4539
+ const read = found.length ? await client.accounts(found.flatMap(l => [l.asset, loot.ata(project, l.asset)])) : [];
4540
+ return found.map((l, i) => {
4541
+ const [nft, held] = [read[2 * i], read[2 * i + 1]];
4542
+ const gone =
4543
+ nft?.owner === MPL_CORE
4544
+ ? !coreHeld(nft)
4545
+ : nft?.owner === TOKEN_PROGRAM &&
4546
+ !(
4547
+ held?.data?.length >= 72 &&
4548
+ new DataView(held.data.buffer, held.data.byteOffset).getBigUint64(64, true) === 1n
4549
+ );
4550
+ return gone ? { ...l, gone: true, says: GONE_LISTING } : l;
4551
+ });
4018
4552
  },
4019
4553
 
4020
4554
  /**
4021
4555
  * Buys a listed NFT at the terms the player saw: `listing`, as `listing()` or `listings()` showed it. It is
4022
4556
  * required: the terms signed are those, never the listing read now, so a price or a share changed since is refused.
4557
+ * A listing whose NFT the project no longer holds (its collection took it back or burned it) is refused in words
4558
+ * before anything is signed: nothing is there to buy, and anyone can close that listing for nothing (`closeGone`).
4023
4559
  */
4024
4560
  async buyListing(asset, { listing, ...others } = NO_OPTIONS) {
4025
4561
  only(others, ['listing'], 'buyListing');
4026
4562
  await shownListing(asset, listing, 'Sell');
4563
+ await listedStill(asset);
4027
4564
  const { before, ...nft } = await nftOf(asset, wallet.address, { to: wallet.address });
4028
4565
  const holders = await holdersOf(listing.shares ?? []);
4029
4566
  return send([
@@ -4044,11 +4581,13 @@ export function solanaApp({
4044
4581
 
4045
4582
  /**
4046
4583
  * Rents a listed NFT for `periods` periods, at the terms the player saw: `listing`, as `listing()` or `listings()`
4047
- * showed it (required, as for `buyListing`).
4584
+ * showed it (required, as for `buyListing`). One whose NFT the project no longer holds is refused in words, as
4585
+ * `buyListing` refuses it.
4048
4586
  */
4049
4587
  async rentListing(asset, { periods = 1, listing, ...others } = NO_OPTIONS) {
4050
4588
  only(others, ['periods', 'listing'], 'rentListing');
4051
4589
  await shownListing(asset, listing, 'Rent');
4590
+ await listedStill(asset);
4052
4591
  const holders = await holdersOf(listing.shares ?? []);
4053
4592
  const total = BigInt(listing.deal.Rent.price) * BigInt(periods);
4054
4593
  // A compressed NFT is read by its record, proven first in the same transaction.
@@ -4070,13 +4609,66 @@ export function solanaApp({
4070
4609
  ]);
4071
4610
  },
4072
4611
 
4073
- /** Takes a listed NFT home (its maker). While a rental runs it only stops the listing; call again once it is over. */
4612
+ /**
4613
+ * Takes a listed NFT home (its maker). While a rental runs it only stops the listing; call again once it is over.
4614
+ * A listing whose NFT the project no longer holds (its collection took it back or burned it) closes without it, on
4615
+ * that proof: nothing is sent, and the listing's rent comes back to its maker. While a rental of it still runs,
4616
+ * that close too only stops the listing, and closes it once the rental has run.
4617
+ */
4074
4618
  async unlist(asset) {
4075
4619
  const listing = await read('Listing', loot.pda.listing(project, asset));
4620
+ const proof = await goneProof(asset);
4621
+ if (proof)
4622
+ return send([...proof.before, loot.closeListingIx({ maker: wallet.address, project, listing, ...proof.kind })]);
4076
4623
  const { before, ...nft } = await nftOf(asset, wallet.address, { to: wallet.address });
4077
4624
  return send([...before, loot.closeListingIx({ maker: wallet.address, project, listing, ...nft })]);
4078
4625
  },
4079
4626
 
4627
+ /**
4628
+ * Closes what this project still records of an NFT it no longer holds (`asset`: whoever controls its collection
4629
+ * took it out of the project's hands or burned it): each hold of it (`release`), its listing (`take_listing`),
4630
+ * and its row on each of the project's stashes that lists it (`stash`, out), on the proof that it is gone. Anyone
4631
+ * may, at any time, also while a hold runs and for a loan before it is due: nothing is sent or paid, and a loan's
4632
+ * debt ends unpaid. The sender pays the transaction's fee only; each record's rent goes to whoever that record
4633
+ * names (a hold's payer, a listing's maker), never to the sender, and a stash keeps the rent its row no longer
4634
+ * needs (it goes to the sender only when that is one of the project's keys). With its row off a stash, no roll
4635
+ * draws the NFT, and a prize that names it is passed over in place of failing on an NFT that can't move.
4636
+ * What is left is in `waits`: a listing whose rental still runs (the program keeps a running rental, and the
4637
+ * listing closes once it has run), and a lottery's own stash that lists the NFT (`kind` 'lottery-stash', with its
4638
+ * `lottery`, the lottery's id): only the project's keys take a row out of that one (`unraffle`), gone or not.
4639
+ * Refused in words, with nothing sent, when the project still holds the NFT or nothing shows it gone.
4640
+ * Besides one search of the NFT's holds, this makes one search of the project's stashes, which returns every
4641
+ * stash of the project whole (36 bytes for each NFT a stash lists): a stash's rows have no fixed place to look in.
4642
+ * Returns { signature, closed: [{ kind, address }], unstashed: [{ address, id }], waits: [{ kind, address }] }:
4643
+ * `closed` the holds and the listing it closed, `unstashed` the project's stashes it took the row off;
4644
+ * `signature` is null when there was nothing to close.
4645
+ */
4646
+ async closeGone(asset) {
4647
+ const [records, stashes] = [await recordsOf(asset), await stashesOf(asset)];
4648
+ if (!records.length && !stashes.length) return { signature: null, closed: [], unstashed: [], waits: [] };
4649
+ const proof = await goneProof(asset);
4650
+ if (!proof)
4651
+ throw new Error(
4652
+ `Nothing shows that the project no longer holds the NFT ${asset}: its records close this way only once it is proven gone (its collection took it back or burned it). Nothing was sent.`,
4653
+ );
4654
+ const { closes, closed, waits } = await closesOf(asset, records, proof);
4655
+ const listed = stashes.filter(x => x.kind === 'stash');
4656
+ // The row comes off each of the project's stashes: free, nothing moves, and where it would go is not read.
4657
+ const offs = listed.map(({ id }) =>
4658
+ loot.stashIx({ signer: wallet.address, project, id, asset, out: wallet.address, ...proof.kind }),
4659
+ );
4660
+ const left = [
4661
+ ...waits.map(({ kind, address }) => ({ kind, address })),
4662
+ ...stashes
4663
+ .filter(x => x.kind === 'lottery-stash')
4664
+ .map(({ kind, address, id }) => ({ kind, address, lottery: id })),
4665
+ ];
4666
+ const unstashed = listed.map(({ address, id }) => ({ address, id }));
4667
+ const instructions = [...(closes.length ? closes : offs.length ? proof.before : []), ...offs];
4668
+ if (!instructions.length) return { signature: null, closed, unstashed, waits: left };
4669
+ return { signature: await send(instructions), closed, unstashed, waits: left };
4670
+ },
4671
+
4080
4672
  // ---------------------------------------------------------------- raffles
4081
4673
 
4082
4674
  /**
@@ -4125,9 +4717,20 @@ export function solanaApp({
4125
4717
  return send([loot.upsertIx({ owner: wallet.address, project, def: { Lottery: { id, args } }, lotteryState })]);
4126
4718
  },
4127
4719
 
4128
- /** The raffle's owner takes the NFT back out of lottery `id`'s own stash once nothing promises it (the lottery ended, or no place names it). */
4720
+ /**
4721
+ * The raffle's owner takes the NFT back out of lottery `id`'s own stash once nothing promises it (the lottery
4722
+ * ended, or no place names it). When the project no longer holds the NFT (its collection took it back or burned
4723
+ * it), only its row comes off the stash, on that proof: nothing is sent, and the owner gets no NFT back. Only the
4724
+ * project's owner or authority may, gone or not: a lottery's own stash is theirs alone (`NotAuthority` for anyone
4725
+ * else; `closeGone` leaves it and says so). Returns the signature.
4726
+ */
4129
4727
  async unraffle(id, nft) {
4130
- const { before, hasAuthority: _, ...piece } = await nftOf(nft, wallet.address, { to: wallet.address });
4728
+ const {
4729
+ before,
4730
+ hasAuthority: _,
4731
+ gone: _g,
4732
+ ...piece
4733
+ } = await leaving(nft, wallet.address, { to: wallet.address });
4131
4734
  const lotteryState = await readMaybe('Lottery', loot.pda.lottery(project, id));
4132
4735
  return send([
4133
4736
  ...before,
@@ -4413,11 +5016,17 @@ export function solanaApp({
4413
5016
 
4414
5017
  /**
4415
5018
  * What this player can finish now, themselves: a box to settle or refund, a contest entry to claim, a held
4416
- * collectible to take home, an auction they won (or one off by the clock: their bid back), a lottery place they
5019
+ * collectible to take home, a hold of theirs or a loan they made or took whose NFT the project no longer holds (its
5020
+ * collection took it back or burned it: listed at any time, also while the hold runs, as a `release` flagged
5021
+ * `gone`, whose claim closes the hold and sends nothing), an auction they won (or one off by the clock: their bid back), a lottery place they
4417
5022
  * won, a draw that is due (anyone may reveal it, or ask again when the oracle never answered; it takes 0.0008
4418
5023
  * SOL), and a drawn lottery's unpaid places, which anyone may pay so the next round can open. [{ kind, says, … }];
4419
5024
  * a winner's first claim of a contest of tokens carries `potUnits` and `prizeUnits`, its part as the decision wrote
4420
5025
  * it (base units, as text). Pass one to `claim`.
5026
+ * A `pull`, a `contest` or an `auction` whose claim would hand out an NFT the project no longer holds is flagged
5027
+ * `gone` too, with `assets` (those NFTs) and `says`, what the claim does about each in words: a ticket's NFT, a
5028
+ * place's NFT, a contest's entry or a loser's NFT picked is not sent anywhere and its part closes; an auction with
5029
+ * its lot or an NFT of its winning bid gone is called off, or ended without it. Show `says` before the player signs.
4421
5030
  */
4422
5031
  async claims() {
4423
5032
  const me = wallet.address;
@@ -4453,8 +5062,8 @@ export function solanaApp({
4453
5062
  const off = loot.contestOff(contest, time);
4454
5063
  if (!(off || contest.verdict === 'Won')) continue;
4455
5064
  const list = await client.account(loot.contestEntries(pubkey, contest));
4456
- const entry =
4457
- list?.owner === loot.LOOT_PROGRAM ? loot.decodeEntries(list.data).find(e => e.player === me) : null;
5065
+ const entries = list?.owner === loot.LOOT_PROGRAM ? loot.decodeEntries(list.data) : [];
5066
+ const entry = entries.find(e => e.player === me);
4458
5067
  if (!entry) continue;
4459
5068
  const won = !off && Number(contest.winner) === Number(entry.side);
4460
5069
  const first = won && !(entry.bits & loot.CLAIMED);
@@ -4477,55 +5086,61 @@ export function solanaApp({
4477
5086
  : 'Your share of the pot.';
4478
5087
  // A winner's part of a contest of tokens is what the decision wrote, whoever claims first (`contestWinnings`).
4479
5088
  const winnings = first && !contest.nft ? loot.contestWinnings(contest, entry.amount) : null;
5089
+ // A contest of NFTs: what its claim moves, and which of those the project no longer holds (its collection
5090
+ // took it back or burned it). Such an NFT is not sent anywhere: the claim closes it, and says so here first.
5091
+ const moves = contest.nft ? await contestNfts(contest, entries, entries.indexOf(entry), undefined) : [];
5092
+ const lost = moves.filter(m => m.gone);
5093
+ const mine = lost.find(m => m.nft === entry.nft && entries[m.j] === entry);
5094
+ const there = [
5095
+ moves.some(m => entries[m.j] === entry && !m.gone) ? 'your NFT back' : '',
5096
+ moves.some(m => entries[m.j] !== entry && !m.gone) ? 'your pick of the losers’' : '',
5097
+ ]
5098
+ .filter(Boolean)
5099
+ .join(', and ');
5100
+ const goneSays = [
5101
+ off ? 'The contest is off.' : '',
5102
+ there ? `${there[0].toUpperCase()}${there.slice(1)}.` : '',
5103
+ goneContestSays(
5104
+ mine?.nft,
5105
+ lost.filter(m => m !== mine).map(m => m.nft),
5106
+ ),
5107
+ ]
5108
+ .filter(Boolean)
5109
+ .join(' ');
4480
5110
  out.push({
4481
5111
  kind: 'contest',
4482
5112
  id: contest.id,
4483
5113
  side: entry.side,
4484
- says,
5114
+ ...(lost.length ? { gone: true, assets: lost.map(m => m.nft), says: goneSays } : { says }),
4485
5115
  ...(winnings ? { potUnits: String(winnings.pot), prizeUnits: String(winnings.prize) } : {}),
4486
5116
  });
4487
5117
  }
4488
5118
  // The player's holds; the project's treasury wallet's also those owed to the treasury (a hold whose player is the
4489
- // project's own address: a dropped stash NFT, a lottery's or an auction's NFT owed to it).
5119
+ // project's own address: a dropped stash NFT, a lottery's or an auction's NFT owed to it). And the loans this
5120
+ // wallet made: one search of the project's holds finds them all.
4490
5121
  const g = await projectNow();
4491
5122
  const holdsOf = [me, ...(g.treasury === me ? [project] : [])];
4492
- const holds = (
4493
- await Promise.all(
4494
- holdsOf.map(who => client.programAccounts(loot.LOOT_PROGRAM, [tag('Hold'), at(1, project), at(65, who)])),
4495
- )
4496
- ).flat();
4497
- for (const { pubkey, account } of holds) {
4498
- const hold = loot.decode('Hold', account.data);
4499
- // A repaid loan is a plain hold that is over (no debt, `free_at` 0): its NFT goes home whenever. An unpaid one's
4500
- // is the lender's once due.
4501
- if (!hold.debt && time >= BigInt(hold.free_at))
4502
- out.push(
4503
- await goneOr(hold.asset, {
4504
- kind: 'release',
4505
- asset: hold.asset,
4506
- hold: pubkey,
4507
- says:
4508
- hold.player === project
4509
- ? 'An NFT owed to the treasury: send it there.'
4510
- : 'Your collectible back: its hold is over.',
4511
- }),
4512
- );
4513
- }
4514
- // A loan this wallet made that is overdue and unpaid: its NFT is the lender's to take.
4515
- for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [
4516
- tag('Hold'),
4517
- at(1, project),
4518
- ])) {
4519
- const hold = loot.decode('Hold', account.data);
4520
- if (hold.debt?.lender === me && time >= BigInt(hold.free_at))
4521
- out.push(
4522
- await goneOr(hold.asset, {
4523
- kind: 'release',
4524
- asset: hold.asset,
4525
- hold: pubkey,
4526
- says: 'A loan you made is overdue and unpaid: take its NFT.',
4527
- }),
4528
- );
5123
+ const holds = (await client.programAccounts(loot.LOOT_PROGRAM, [tag('Hold'), at(1, project)]))
5124
+ .map(({ pubkey, account }) => ({ pubkey, hold: loot.decode('Hold', account.data) }))
5125
+ .filter(({ hold }) => holdsOf.includes(hold.player) || hold.debt?.lender === me);
5126
+ // Whether the project still holds each one's NFT: whoever controls its collection can take it back or burn it.
5127
+ const heldOf = await heldEach(holds.map(({ hold }) => hold.asset));
5128
+ for (const { pubkey, hold } of holds) {
5129
+ const claim = { kind: 'release', asset: hold.asset, hold: pubkey };
5130
+ // An NFT proven gone: its hold closes at any time, also while it runs and for a loan before it is due.
5131
+ if (heldOf.get(hold.asset)?.held === false) out.push({ ...claim, gone: true, says: goneHoldSays(hold) });
5132
+ // A repaid loan is a plain hold that is over (no debt, `free_at` 0): its NFT goes home whenever.
5133
+ else if (!hold.debt && holdsOf.includes(hold.player) && time >= BigInt(hold.free_at))
5134
+ out.push({
5135
+ ...claim,
5136
+ says:
5137
+ hold.player === project
5138
+ ? 'An NFT owed to the treasury: send it there.'
5139
+ : 'Your collectible back: its hold is over.',
5140
+ });
5141
+ // A loan this wallet made that is overdue and unpaid: its NFT is the lender's to take.
5142
+ else if (hold.debt?.lender === me && time >= BigInt(hold.free_at))
5143
+ out.push({ ...claim, says: 'A loan you made is overdue and unpaid: take its NFT.' });
4529
5144
  }
4530
5145
  // A compressed NFT's proof record this wallet paid for and left open: closing it brings its rent back.
4531
5146
  for (const { account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Proven')])) {
@@ -4546,32 +5161,47 @@ export function solanaApp({
4546
5161
  }
4547
5162
  const auctions = spec.auctions ?? [];
4548
5163
  const found = await client.accounts(auctions.map(a => auctionAt(a)));
4549
- auctions.forEach((a, i) => {
5164
+ for (const [i, a] of auctions.entries()) {
4550
5165
  const x = found[i] && loot.decode('Auction', found[i].data);
4551
- if (!x || time < loot.auctionEnds(x)) return;
5166
+ if (!x || time < loot.auctionEnds(x)) continue;
5167
+ const [sold, owed] = [BigInt(x.high) > 0n, BigInt(x.proceeds_owed) > 0n];
5168
+ if (!(x.seller === me || (sold && !owed && x.high_bidder === me))) continue;
5169
+ // Its lot, or an NFT of the winning bid, that the project no longer holds (its collection took it back or
5170
+ // burned it): the end does not send it anywhere, and this says what the end does instead.
5171
+ const lot = owed ? null : (x.lot.Asset?.[0] ?? null);
5172
+ const bids = sold && x.nft_bids ? x.held : [];
5173
+ const heldOf = lot || bids.length ? await heldEach([...(lot ? [lot] : []), ...bids]) : new Map();
5174
+ const lostBids = bids.filter(nft => heldOf.get(nft)?.held === false);
5175
+ if ((lot && heldOf.get(lot)?.held === false) || lostBids.length) {
5176
+ const lotGone = lot && heldOf.get(lot)?.held === false;
5177
+ out.push({
5178
+ kind: 'auction',
5179
+ key: a.key,
5180
+ gone: true,
5181
+ assets: [...(lotGone ? [lot] : []), ...lostBids],
5182
+ says: goneSaleSays(a.name, x, me, lotGone ? lot : null, lostBids),
5183
+ });
5184
+ continue;
5185
+ }
4552
5186
  // The lot went first (an end too wide for one transaction, or a seller's account that couldn't take the money
4553
5187
  // then): only the money is left to pay.
4554
- if (BigInt(x.proceeds_owed) > 0n) {
4555
- if (x.seller === me)
4556
- out.push({
4557
- kind: 'auction',
4558
- key: a.key,
4559
- says: `The winner of “${a.name}” has the lot, and the winning bid is still to be paid to you: end it again, and it is paid (once your account can take it).`,
4560
- });
4561
- return;
4562
- }
4563
- if (x.high_bidder === me) out.push({ kind: 'auction', key: a.key, says: `You won “${a.name}”: take it.` });
5188
+ if (owed)
5189
+ out.push({
5190
+ kind: 'auction',
5191
+ key: a.key,
5192
+ 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).`,
5193
+ });
5194
+ else if (x.high_bidder === me) out.push({ kind: 'auction', key: a.key, says: `You won “${a.name}”: take it.` });
4564
5195
  // The seller's own auction, over: ending it pays them (or brings an unsold lot home).
4565
- else if (x.seller === me)
5196
+ else
4566
5197
  out.push({
4567
5198
  kind: 'auction',
4568
5199
  key: a.key,
4569
- says:
4570
- BigInt(x.high) > 0n
4571
- ? `Your auction “${a.name}” is over: end it, and the winning bid is paid to you.`
4572
- : `Your auction “${a.name}” ended with no bid: take your lot back.`,
5200
+ says: sold
5201
+ ? `Your auction “${a.name}” is over: end it, and the winning bid is paid to you.`
5202
+ : `Your auction “${a.name}” ended with no bid: take your lot back.`,
4573
5203
  });
4574
- });
5204
+ }
4575
5205
  for (const l of spec.lotteries ?? []) {
4576
5206
  // A lottery the page names but nobody has written yet (a raffle still to open) has nothing to finish.
4577
5207
  if (!(await client.account(loot.pda.lottery(project, l.id)))) continue;
@@ -4610,17 +5240,32 @@ export function solanaApp({
4610
5240
  const parts = e.buyer === me ? loot.partsOwed(r, i) : [];
4611
5241
  return parts.length ? [{ entry: i, parts }] : [];
4612
5242
  });
4613
- if (mine.length)
5243
+ if (mine.length) {
5244
+ // An NFT of these parts that the project no longer holds (its collection took it back or burned it) is
5245
+ // not sent anywhere: the claim settles its part without it, and says so here first.
5246
+ const { tickets, prizes } = await roundNfts(r, mine);
5247
+ const heldOf = tickets.length + prizes.length ? await heldEach([...tickets, ...prizes]) : new Map();
5248
+ const lost = list => [...new Set(list)].filter(nft => heldOf.get(nft)?.held === false);
5249
+ const [lostTickets, lostPrizes] = [lost(tickets), lost(prizes)];
5250
+ const back = r.status === 'Cancelled' || Boolean(r.terms.returns);
5251
+ const owed =
5252
+ r.status === 'Cancelled'
5253
+ ? `Recover your ticket payment for “${l.name}”.`
5254
+ : `Take what you are owed in “${l.name}”.`;
4614
5255
  out.push({
4615
5256
  kind: 'pull',
4616
5257
  key: l.key,
4617
5258
  round,
4618
5259
  pulls: mine,
4619
- says:
4620
- r.status === 'Cancelled'
4621
- ? `Recover your ticket payment for “${l.name}”.`
4622
- : `Take what you are owed in “${l.name}”.`,
5260
+ ...(lostTickets.length + lostPrizes.length
5261
+ ? {
5262
+ gone: true,
5263
+ assets: [...lostTickets, ...lostPrizes],
5264
+ says: `${owed} ${goneRoundSays(lostTickets, lostPrizes, back)}`,
5265
+ }
5266
+ : { says: owed }),
4623
5267
  });
5268
+ }
4624
5269
  // Anyone may pay everyone else what they are owed, and the parts owed in common (about 0.002 SOL of account
4625
5270
  // rent for each payee who has no account for it yet, which stays with them): the last pull closes the round,
4626
5271
  // and its rent goes back to whoever fronted it (the lottery, or its first buyer).
@@ -4722,9 +5367,18 @@ export function solanaApp({
4722
5367
  },
4723
5368
 
4724
5369
  /**
4725
- * Finishes one of `claims()`, signed by this player. Returns the signature. A winner of a contest of NFTs picks
4726
- * which loser's NFT they take (`{ take: asset }`, the NFT of a losing entry that still holds one; the first found by default):
4727
- * only the winner can, while any are left.
5370
+ * Finishes one of `claims()`, signed by this player. Returns the signature. A `release` flagged `gone` closes its
5371
+ * hold on the proof that the project no longer holds the NFT: nothing is sent, the hold's rent goes back to whoever
5372
+ * paid it, and a loan's debt ends unpaid. A winner of a contest of NFTs picks
5373
+ * which loser's NFT they take (`{ take: asset }`, the NFT of a losing entry that still holds one; by default the
5374
+ * first found that the project still holds): only the winner can, while any are left.
5375
+ * An NFT a lottery's round or a contest owes that the project no longer holds (its collection took it back or
5376
+ * burned it) is not sent anywhere, and the claim still finishes. A ticket whose price sends it on (burned, given,
5377
+ * stashed) settles on the proof with nothing moved. A ticket going back to its buyer, a place's NFT and a
5378
+ * contest's NFT are only handed out by the program, which reads nothing first: the claim has it make a hold of
5379
+ * that NFT (`["hold", nft, 0]`) and closes that hold on the proof right after, in the same transaction when it
5380
+ * fits. The signer pays that hold's rent and gets it back at once. The program's receipts for it are those of a
5381
+ * hold made (`Held`, and `PrizeGiven` for a place's prize) and then `NftGone`: no `Released`.
4728
5382
  */
4729
5383
  async claim(c, { take, build = false, ...others } = NO_OPTIONS) {
4730
5384
  only(others, ['take', 'build'], 'claim');
@@ -4755,37 +5409,25 @@ export function solanaApp({
4755
5409
  // lets it pick (`take`: one asset, or several; the first left by default), as many as fit.
4756
5410
  const nfts = [];
4757
5411
  const kinds = {};
5412
+ // The NFTs of this claim that are proven gone from the project, each with what shows it and who staked it.
5413
+ // The contest only hands an NFT out and reads nothing, so each is passed as its hold (`["hold", nft, 0]`, made
5414
+ // for this player, who gets it), and that hold closes on the proof right after the claim: nothing is sent.
5415
+ const lost = new Map();
4758
5416
  if (contest.nft) {
4759
5417
  const proofs = {};
4760
- const kindOf = async (asset, to) => {
4761
- const { before, hasAuthority: _, ...kind } = await nftOf(asset, me, { to });
4762
- proofs[asset] = before;
4763
- kinds[asset] = kind;
4764
- };
4765
- if (Number(own.amount) === 1) {
4766
- nfts.push(i);
4767
- await kindOf(own.nft, me);
4768
- }
4769
- const share = won && !(own.bits & loot.ENDED) ? loot.shareOf(contest, own) - Number(own.taken) : 0;
4770
- const left = entries
4771
- .map((e, j) => ({ e, j }))
4772
- .filter(({ e }) => Number(e.side) !== Number(contest.winner) && Number(e.amount) === 1);
4773
- const wanted = take ? [take].flat() : [];
4774
- const picked = (wanted.length ? left.filter(({ e }) => wanted.includes(e.nft)) : left).slice(
4775
- 0,
4776
- Math.min(share, 4),
4777
- );
4778
- if (wanted.length && picked.length < Math.min(wanted.length, share))
4779
- throw new Error(`${wanted.join(', ')} is not a loser’s NFT left in this contest for you to pick.`);
4780
- for (const { e, j } of picked) {
5418
+ for (const { j, nft } of await contestNfts(contest, entries, i, take)) {
5419
+ const { before, hasAuthority: _, gone, ...kind } = await leaving(nft, me, { to: me });
5420
+ proofs[nft] = before;
5421
+ kinds[nft] = kind;
5422
+ if (gone) lost.set(nft, { kind, player: me, rentTo: entries[j].player });
4781
5423
  nfts.push(j);
4782
- await kindOf(e.nft, me);
4783
5424
  }
4784
5425
  // A compressed NFT's proof is long (up to 17 nodes): such a claim moves one NFT, as a v0 transaction with a
4785
5426
  // lookup table (`send`), and the next claim takes the next.
4786
5427
  if (nfts.length > 1 && nfts.some(j => kinds[entries[j].nft]?.compressed)) nfts.splice(1);
4787
5428
  for (const j of nfts) given.before.push(...proofs[entries[j].nft]);
4788
5429
  }
5430
+ const closes = nfts.map(j => entries[j].nft).filter(nft => lost.has(nft));
4789
5431
  const claimed = [
4790
5432
  computeUnitLimit(800_000),
4791
5433
  ...given.before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
@@ -4799,19 +5441,37 @@ export function solanaApp({
4799
5441
  player: me,
4800
5442
  nfts: contest.nft ? nfts : undefined,
4801
5443
  kinds,
5444
+ hold: closes,
4802
5445
  collection: given.collection,
4803
5446
  poolMints: given.poolMints,
4804
5447
  items: given.items,
4805
5448
  nft: given.nft,
4806
5449
  programs,
4807
5450
  }),
5451
+ ...closes.map(nft => releaseOwed(nft, lost.get(nft).kind, lost.get(nft), me, g)),
4808
5452
  ];
4809
5453
  // A prize that rolls: asked for in the claim's own transaction and settled by a key after (one prompt).
4810
5454
  return contest.prize?.Roll && !build ? (await sendRolling(claimed)).signature : send(claimed);
4811
5455
  }
4812
5456
  if (c.kind === 'release') {
4813
- if (c.gone) throw new Error(c.says);
4814
5457
  const hold = await read('Hold', c.hold);
5458
+ // Its NFT proven gone from the project: the hold closes on that proof, at any time, and nothing is sent. Its
5459
+ // rent goes back to whoever paid it; a loan's debt ends unpaid.
5460
+ const proof = await goneProof(c.asset, me);
5461
+ if (proof)
5462
+ return send([
5463
+ ...proof.before,
5464
+ loot.releaseIx({
5465
+ signer: me,
5466
+ project,
5467
+ asset: c.asset,
5468
+ ...proof.kind,
5469
+ hold,
5470
+ address: c.hold,
5471
+ treasury: g.treasury,
5472
+ ...(hold.debt ? { to: hold.debt.lender } : {}),
5473
+ }),
5474
+ ]);
4815
5475
  const { before, hasAuthority: _, ...nft } = await nftOf(c.asset, me, { to: me });
4816
5476
  // An overdue loan's NFT goes to its lender.
4817
5477
  return send([