@xgamesfun/solana 0.27.0 → 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
@@ -421,10 +421,10 @@ export function solanaApp({
421
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
422
  * Core asset shows it by itself (`kind`: its collection now); a Token Metadata NFT by the token account that holds it
423
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.
424
+ * leaf is now (`before`, by `signer`). `holder`: as `heldNow` takes it. `now`: what `heldNow` read of it already.
425
425
  */
426
- async function goneProof(asset, signer = wallet.address, { holder = null } = {}) {
427
- const now = await heldNow(asset, { holder });
426
+ async function goneProof(asset, signer = wallet.address, { holder = null, now = null } = {}) {
427
+ now ??= await heldNow(asset, { holder });
428
428
  if (now.held !== false) return null;
429
429
  if (now.das) {
430
430
  const proof = await client.call('getAssetProof', { id: asset });
@@ -439,14 +439,33 @@ export function solanaApp({
439
439
  /**
440
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
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.
442
443
  */
443
- async function leaving(asset, signer = wallet.address, { to, payer = signer } = {}) {
444
- const proof = await goneProof(asset, signer);
444
+ async function leaving(asset, signer = wallet.address, { to, payer = signer, now = null } = {}) {
445
+ const proof = await goneProof(asset, signer, { now });
445
446
  if (!proof) return { ...(await nftOf(asset, signer, { to, payer })), gone: false };
446
447
  // A Core asset shows it by itself, with the accounts it always took.
447
448
  if (!proof.kind.tokenMetadata && !proof.kind.compressed) return { ...(await nftOf(asset, signer)), gone: true };
448
449
  return { before: proof.before, hasAuthority: false, ...proof.kind, gone: true };
449
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
+ });
450
469
  /**
451
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
452
471
  * money in: accounts that start frozen or can be switched to it (a payee closes their own account, and the one
@@ -1958,16 +1977,18 @@ export function solanaApp({
1958
1977
  const lotNow = lot ? await heldNow(lot) : { held: null };
1959
1978
  const bidNow = [];
1960
1979
  if (sold && auction.nft_bids) for (const nft of auction.held) bidNow.push(await heldNow(nft));
1961
- 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;
1962
1982
  const ending = sold && lot && (callOff || gone) ? 'CallOff' : 'Deliver';
1963
1983
  const home = !sold || ending === 'CallOff';
1964
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.
1965
1986
  const before = [];
1966
- 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) : {};
1967
1988
  const bidTo = ending === 'CallOff' ? auction.high_bidder : owner;
1968
1989
  const held = [];
1969
1990
  if (sold && auction.nft_bids)
1970
- 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));
1971
1992
  if (sold && !home) holders ??= await holdersOf(auction.shares);
1972
1993
  const end = (ending, hold = false) =>
1973
1994
  loot.endAuctionIx({ ...base, auction, item, ...lotKind, held, holders, ending, hold });
@@ -1982,6 +2003,24 @@ export function solanaApp({
1982
2003
  ...(made ? { asset: made } : {}),
1983
2004
  };
1984
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
+ }
1985
2024
 
1986
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.
1987
2026
  const outs = [
@@ -2049,15 +2088,15 @@ export function solanaApp({
2049
2088
  return holding();
2050
2089
  }
2051
2090
 
2052
- /** An NFT's kind on an auction's end, going to `to`: what `nftOf` reads, its proof put in `before`; `evidence` for a Token
2053
- * Metadata NFT that left the project (the account that holds it now, or its mint). */
2054
- async function kindFor(asset, to, evidence, before) {
2055
- 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 });
2056
2096
  before.push(...proof);
2057
2097
  return {
2058
2098
  ...kind,
2059
2099
  collection: kind.compressed || kind.tokenMetadata ? undefined : ((await collectionOf(asset)) ?? MPL_CORE),
2060
- ...(evidence && kind.tokenMetadata ? { evidence } : {}),
2061
2100
  };
2062
2101
  }
2063
2102
 
@@ -2106,16 +2145,30 @@ export function solanaApp({
2106
2145
  if (opens.length) await send([...new Map(opens.map(x => [x.keys[1].pubkey, x])).values()]);
2107
2146
  const before = [];
2108
2147
  const held = [];
2109
- 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
+ }
2110
2157
  const end = (hold = false) => loot.endAuctionIx({ ...base, auction, held, holders, hold });
2111
2158
  const steps = ix => [
2112
2159
  computeUnitLimit(1_000_000),
2113
2160
  ...before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
2114
2161
  ix,
2115
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
+ : {};
2116
2169
  if (sendPlan({ payer: wallet.address, instructions: steps(end()), alone: isProveLeaf }))
2117
2170
  try {
2118
- return { paid: await send(steps(end())) };
2171
+ return { paid: await send(steps(end())), ...passed };
2119
2172
  } catch (e) {
2120
2173
  // The lot is the winner's already: what is owed waits until the account it goes into can take it.
2121
2174
  return {
@@ -2123,9 +2176,9 @@ export function solanaApp({
2123
2176
  says: `The lot is the winner’s; the money waits (${e.message}): end the auction again to pay it.`,
2124
2177
  };
2125
2178
  }
2126
- const outs = auction.held.map(x => [x, auction.seller]);
2127
- const { signature, releases } = await heldThenReleased(at, steps(end(true)), outs);
2128
- 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 };
2129
2182
  }
2130
2183
 
2131
2184
  /**
@@ -2141,9 +2194,12 @@ export function solanaApp({
2141
2194
  const signature = await send([...instructions, fund]);
2142
2195
  const releases = [];
2143
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;
2144
2199
  const release = async signer => {
2145
2200
  const hold = await read('Hold', loot.pda.hold(asset, 0));
2146
- 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;
2147
2203
  return [
2148
2204
  ...proof,
2149
2205
  loot.releaseIx({
@@ -2160,7 +2216,8 @@ export function solanaApp({
2160
2216
  const steps = await release(key.address);
2161
2217
  const byKey = (await dryRun(key.address, steps)).ok ? await sendAs(key, steps).catch(() => null) : null;
2162
2218
  // One that still can't move (frozen for now) stays a hold: claims() lists it for whoever it is owed to.
2163
- 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);
2164
2221
  }
2165
2222
  await emptyKey(key);
2166
2223
  return { signature, releases };
@@ -2173,6 +2230,50 @@ export function solanaApp({
2173
2230
  const GONE_LISTING =
2174
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.';
2175
2232
 
2233
+ /**
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.
2237
+ */
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.`;
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(' ');
2276
+
2176
2277
  /**
2177
2278
  * This project's records of `asset`: its listing at `["listing", project, asset]` (one read) and its holds (one
2178
2279
  * search, by the asset's address). Each is { kind: 'listing' | 'hold', address, record }.
@@ -2193,6 +2294,30 @@ export function solanaApp({
2193
2294
  return out;
2194
2295
  }
2195
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
+
2196
2321
  /**
2197
2322
  * The records of this project an earlier owner of `asset` left behind (`recordsOf`), each that names a wallet other
2198
2323
  * than the player's: { kind, address, record, says }. Such a record is left when whoever controls the NFT's
@@ -2470,6 +2595,77 @@ export function solanaApp({
2470
2595
  ];
2471
2596
  }
2472
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
+
2473
2669
  /**
2474
2670
  * When a draw of round `r` (at `key`) would cancel it (an open round short of its least tickets; a waiting one past
2475
2671
  * the timeout), the pulls of `who`'s own parts as the cancelled round owes them (a few entries' worth), to go right
@@ -2529,6 +2725,11 @@ export function solanaApp({
2529
2725
  * The pulls of `parts` of entry `i` of a round (`partsOwed`; `i` null for parts owed in common, `commonOwed`), signed by
2530
2726
  * `signer`, and the rolls they make ([{ order, box }], `loot.pullRolls`). With `closes`, the last pull carries the
2531
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.
2532
2733
  */
2533
2734
  async function pulls(key, r, i, parts, signer, { closes = false, lotteryClosed = false } = {}) {
2534
2735
  const e = i == null ? null : r.entries[i];
@@ -2544,20 +2745,38 @@ export function solanaApp({
2544
2745
  const extra = await hookTail([...roundMints(r), ...r.prizes.map(p => p.mint)]);
2545
2746
  for (const [k, part] of parts.entries()) {
2546
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();
2547
2752
  if (part === 'Own' && e.nft !== NOBODY) {
2548
2753
  const then = r.status === 'Cancelled' || r.terms.returns ? null : r.terms.nft_ticket?.then;
2549
2754
  more.handBack = Boolean(then?.Give) && loot.handsBack(await client.account(then.Give[0]));
2550
2755
  const to = !then || more.handBack ? e.buyer : then.Give ? then.Give[0] : undefined;
2551
- const { before = [], hasAuthority: _, ...kind } = await nftOf(e.nft, signer, { to });
2756
+ const { before = [], hasAuthority: _, gone, ...kind } = await leaving(e.nft, signer, { to });
2552
2757
  out.push(...before);
2553
- 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;
2554
2764
  } else if (part.Prize && !loot.tokenRun(r, Number(part.Prize[0]))) {
2555
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
2556
2766
  // out), a project stash's NFT, an item, or a roll (asked in the same transaction).
2557
2767
  const run = Number(part.Prize[0]);
2558
2768
  const given = r.terms.places[run].prize;
2559
- 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);
2560
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
+ };
2561
2780
  const stash = loot.ownStash(r.terms.places[run])
2562
2781
  ? loot.decodeStash((await client.account(loot.pda.lotteryStash(r.lottery)))?.data ?? null)
2563
2782
  : null;
@@ -2569,10 +2788,7 @@ export function solanaApp({
2569
2788
  const predicted = loot.stashNfts(r, stash);
2570
2789
  for (const place of loot.prizePlacesOwed(r, i, run)) {
2571
2790
  const nft = predicted[loot.nftSlot(r, place)];
2572
- if (!nft || inputs.nfts[nft]) continue;
2573
- const { before, hasAuthority: _, ...kind } = await nftOf(nft, signer, { to: e.buyer });
2574
- inputs.nfts[nft] = kind;
2575
- out.push(...before);
2791
+ if (nft) await hand(nft);
2576
2792
  }
2577
2793
  }
2578
2794
  const stashes = given.Nft
@@ -2582,10 +2798,17 @@ export function solanaApp({
2582
2798
  ) ?? { nfts: [] },
2583
2799
  }
2584
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);
2585
2807
  more.prize = {
2586
2808
  stash,
2587
2809
  stashes,
2588
2810
  nfts: inputs.nfts,
2811
+ hold: holds,
2589
2812
  collection: inputs.collection,
2590
2813
  poolMints: inputs.poolMints,
2591
2814
  items: inputs.items,
@@ -2620,7 +2843,8 @@ export function solanaApp({
2620
2843
  ) && (await dryRun(signer, tx)).ok
2621
2844
  );
2622
2845
  };
2623
- 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;
2624
2848
  while (most > 1 && !(await sized(most))) most = Math.max(1, Math.floor(most * 0.75));
2625
2849
  if (most < owed) [more.prize.most, partial] = [most, true];
2626
2850
  heap = true;
@@ -2647,21 +2871,26 @@ export function solanaApp({
2647
2871
  }
2648
2872
  const last = k === parts.length - 1;
2649
2873
  const round = closes && last ? { ...r, owed: pullSettles(r, i, part) } : { ...r, owed: Number.MAX_SAFE_INTEGER };
2650
- out.push(
2651
- loot.pullIx({
2652
- payer: signer,
2653
- project,
2654
- roundAddress: key,
2655
- round,
2656
- entry: i ?? 0,
2657
- part,
2658
- projectState: g,
2659
- lotteryClosed,
2660
- programs,
2661
- extra,
2662
- ...more,
2663
- }),
2664
- );
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));
2665
2894
  }
2666
2895
  return { instructions: out, rolls, heap, partial };
2667
2896
  }
@@ -4033,7 +4262,9 @@ export function solanaApp({
4033
4262
  * Bids `amount` (whole units) on an auction. The bid it tops goes back to its bidder in the same transaction
4034
4263
  * (the program opens their account again if they closed it, paid by this bidder). In SOL, only what the player's
4035
4264
  * wrapped-SOL account does not hold already is wrapped, and the account stays open: if this bid is topped, it comes
4036
- * 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.
4037
4268
  */
4038
4269
  async bid(auctionKey, amount, { shown, listProof, nfts, acceptLeftover, ...others } = NO_OPTIONS) {
4039
4270
  only(others, ['shown', 'listProof', 'nfts', 'acceptLeftover'], 'bid');
@@ -4059,9 +4290,17 @@ export function solanaApp({
4059
4290
  };
4060
4291
  const bid = [];
4061
4292
  for (const asset of bundle) bid.push(await kindOf(asset));
4062
- // 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.
4063
4296
  const held = [];
4064
- 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
+ }
4065
4304
  const ix = loot.bidIx({
4066
4305
  bidder: wallet.address,
4067
4306
  project,
@@ -4071,13 +4310,19 @@ export function solanaApp({
4071
4310
  listProof,
4072
4311
  nfts: bid,
4073
4312
  held,
4313
+ hold: [...lost.keys()],
4074
4314
  programs,
4075
4315
  });
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
+ );
4076
4320
  return send(
4077
4321
  await closesFirst(clears, [
4078
4322
  computeUnitLimit(800_000),
4079
4323
  ...before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
4080
4324
  ix,
4325
+ ...closes,
4081
4326
  ]),
4082
4327
  );
4083
4328
  }
@@ -4125,6 +4370,12 @@ export function solanaApp({
4125
4370
  * the sale is called off on that evidence, which anyone may: the bid goes back to its bidder (`{ calledOff: true }`).
4126
4371
  * With no bid, the lot goes home to its seller. An end too big for one transaction otherwise makes each NFT that may
4127
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.
4128
4379
  */
4129
4380
  async endAuction(auctionKey, { holders, ...others } = NO_OPTIONS) {
4130
4381
  only(others, ['holders'], 'endAuction');
@@ -4375,27 +4626,47 @@ export function solanaApp({
4375
4626
 
4376
4627
  /**
4377
4628
  * Closes what this project still records of an NFT it no longer holds (`asset`: whoever controls its collection
4378
- * took it out of the project's hands or burned it): each hold of it (`release`) and its listing (`take_listing`),
4379
- * on the proof that it is gone. Anyone may, at any time, also while a hold runs and for a loan before it is due:
4380
- * nothing is sent or paid, and a loan's debt ends unpaid. The sender pays the transaction's fee only; each
4381
- * record's rent goes to whoever that record names (a hold's payer, a listing's maker), never to the sender. A
4382
- * listing whose rental still runs is left (`waits`): the program keeps a running rental, and the listing closes
4383
- * once it has run. Refused in words, with nothing sent, when the project still holds the NFT or nothing shows it
4384
- * gone. Returns { signature, closed: [{ kind, address }], waits: [{ kind, address }] }; `signature` is null when
4385
- * there was nothing to close.
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.
4386
4645
  */
4387
4646
  async closeGone(asset) {
4388
- const records = await recordsOf(asset);
4389
- if (!records.length) return { signature: null, closed: [], waits: [] };
4647
+ const [records, stashes] = [await recordsOf(asset), await stashesOf(asset)];
4648
+ if (!records.length && !stashes.length) return { signature: null, closed: [], unstashed: [], waits: [] };
4390
4649
  const proof = await goneProof(asset);
4391
4650
  if (!proof)
4392
4651
  throw new Error(
4393
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.`,
4394
4653
  );
4395
4654
  const { closes, closed, waits } = await closesOf(asset, records, proof);
4396
- const left = waits.map(({ kind, address }) => ({ kind, address }));
4397
- if (!closes.length) return { signature: null, closed, waits: left };
4398
- return { signature: await send(closes), closed, waits: left };
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 };
4399
4670
  },
4400
4671
 
4401
4672
  // ---------------------------------------------------------------- raffles
@@ -4446,9 +4717,20 @@ export function solanaApp({
4446
4717
  return send([loot.upsertIx({ owner: wallet.address, project, def: { Lottery: { id, args } }, lotteryState })]);
4447
4718
  },
4448
4719
 
4449
- /** 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
+ */
4450
4727
  async unraffle(id, nft) {
4451
- 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 });
4452
4734
  const lotteryState = await readMaybe('Lottery', loot.pda.lottery(project, id));
4453
4735
  return send([
4454
4736
  ...before,
@@ -4741,6 +5023,10 @@ export function solanaApp({
4741
5023
  * SOL), and a drawn lottery's unpaid places, which anyone may pay so the next round can open. [{ kind, says, … }];
4742
5024
  * a winner's first claim of a contest of tokens carries `potUnits` and `prizeUnits`, its part as the decision wrote
4743
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.
4744
5030
  */
4745
5031
  async claims() {
4746
5032
  const me = wallet.address;
@@ -4776,8 +5062,8 @@ export function solanaApp({
4776
5062
  const off = loot.contestOff(contest, time);
4777
5063
  if (!(off || contest.verdict === 'Won')) continue;
4778
5064
  const list = await client.account(loot.contestEntries(pubkey, contest));
4779
- const entry =
4780
- 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);
4781
5067
  if (!entry) continue;
4782
5068
  const won = !off && Number(contest.winner) === Number(entry.side);
4783
5069
  const first = won && !(entry.bits & loot.CLAIMED);
@@ -4800,11 +5086,32 @@ export function solanaApp({
4800
5086
  : 'Your share of the pot.';
4801
5087
  // A winner's part of a contest of tokens is what the decision wrote, whoever claims first (`contestWinnings`).
4802
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(' ');
4803
5110
  out.push({
4804
5111
  kind: 'contest',
4805
5112
  id: contest.id,
4806
5113
  side: entry.side,
4807
- says,
5114
+ ...(lost.length ? { gone: true, assets: lost.map(m => m.nft), says: goneSays } : { says }),
4808
5115
  ...(winnings ? { potUnits: String(winnings.pot), prizeUnits: String(winnings.prize) } : {}),
4809
5116
  });
4810
5117
  }
@@ -4854,32 +5161,47 @@ export function solanaApp({
4854
5161
  }
4855
5162
  const auctions = spec.auctions ?? [];
4856
5163
  const found = await client.accounts(auctions.map(a => auctionAt(a)));
4857
- auctions.forEach((a, i) => {
5164
+ for (const [i, a] of auctions.entries()) {
4858
5165
  const x = found[i] && loot.decode('Auction', found[i].data);
4859
- 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
+ }
4860
5186
  // The lot went first (an end too wide for one transaction, or a seller's account that couldn't take the money
4861
5187
  // then): only the money is left to pay.
4862
- if (BigInt(x.proceeds_owed) > 0n) {
4863
- if (x.seller === me)
4864
- out.push({
4865
- kind: 'auction',
4866
- key: a.key,
4867
- 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).`,
4868
- });
4869
- return;
4870
- }
4871
- 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.` });
4872
5195
  // The seller's own auction, over: ending it pays them (or brings an unsold lot home).
4873
- else if (x.seller === me)
5196
+ else
4874
5197
  out.push({
4875
5198
  kind: 'auction',
4876
5199
  key: a.key,
4877
- says:
4878
- BigInt(x.high) > 0n
4879
- ? `Your auction “${a.name}” is over: end it, and the winning bid is paid to you.`
4880
- : `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.`,
4881
5203
  });
4882
- });
5204
+ }
4883
5205
  for (const l of spec.lotteries ?? []) {
4884
5206
  // A lottery the page names but nobody has written yet (a raffle still to open) has nothing to finish.
4885
5207
  if (!(await client.account(loot.pda.lottery(project, l.id)))) continue;
@@ -4918,17 +5240,32 @@ export function solanaApp({
4918
5240
  const parts = e.buyer === me ? loot.partsOwed(r, i) : [];
4919
5241
  return parts.length ? [{ entry: i, parts }] : [];
4920
5242
  });
4921
- 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}”.`;
4922
5255
  out.push({
4923
5256
  kind: 'pull',
4924
5257
  key: l.key,
4925
5258
  round,
4926
5259
  pulls: mine,
4927
- says:
4928
- r.status === 'Cancelled'
4929
- ? `Recover your ticket payment for “${l.name}”.`
4930
- : `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 }),
4931
5267
  });
5268
+ }
4932
5269
  // Anyone may pay everyone else what they are owed, and the parts owed in common (about 0.002 SOL of account
4933
5270
  // rent for each payee who has no account for it yet, which stays with them): the last pull closes the round,
4934
5271
  // and its rent goes back to whoever fronted it (the lottery, or its first buyer).
@@ -5033,8 +5370,15 @@ export function solanaApp({
5033
5370
  * Finishes one of `claims()`, signed by this player. Returns the signature. A `release` flagged `gone` closes its
5034
5371
  * hold on the proof that the project no longer holds the NFT: nothing is sent, the hold's rent goes back to whoever
5035
5372
  * paid it, and a loan's debt ends unpaid. A winner of a contest of NFTs picks
5036
- * which loser's NFT they take (`{ take: asset }`, the NFT of a losing entry that still holds one; the first found by default):
5037
- * only the winner can, while any are left.
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`.
5038
5382
  */
5039
5383
  async claim(c, { take, build = false, ...others } = NO_OPTIONS) {
5040
5384
  only(others, ['take', 'build'], 'claim');
@@ -5065,37 +5409,25 @@ export function solanaApp({
5065
5409
  // lets it pick (`take`: one asset, or several; the first left by default), as many as fit.
5066
5410
  const nfts = [];
5067
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();
5068
5416
  if (contest.nft) {
5069
5417
  const proofs = {};
5070
- const kindOf = async (asset, to) => {
5071
- const { before, hasAuthority: _, ...kind } = await nftOf(asset, me, { to });
5072
- proofs[asset] = before;
5073
- kinds[asset] = kind;
5074
- };
5075
- if (Number(own.amount) === 1) {
5076
- nfts.push(i);
5077
- await kindOf(own.nft, me);
5078
- }
5079
- const share = won && !(own.bits & loot.ENDED) ? loot.shareOf(contest, own) - Number(own.taken) : 0;
5080
- const left = entries
5081
- .map((e, j) => ({ e, j }))
5082
- .filter(({ e }) => Number(e.side) !== Number(contest.winner) && Number(e.amount) === 1);
5083
- const wanted = take ? [take].flat() : [];
5084
- const picked = (wanted.length ? left.filter(({ e }) => wanted.includes(e.nft)) : left).slice(
5085
- 0,
5086
- Math.min(share, 4),
5087
- );
5088
- if (wanted.length && picked.length < Math.min(wanted.length, share))
5089
- throw new Error(`${wanted.join(', ')} is not a loser’s NFT left in this contest for you to pick.`);
5090
- 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 });
5091
5423
  nfts.push(j);
5092
- await kindOf(e.nft, me);
5093
5424
  }
5094
5425
  // A compressed NFT's proof is long (up to 17 nodes): such a claim moves one NFT, as a v0 transaction with a
5095
5426
  // lookup table (`send`), and the next claim takes the next.
5096
5427
  if (nfts.length > 1 && nfts.some(j => kinds[entries[j].nft]?.compressed)) nfts.splice(1);
5097
5428
  for (const j of nfts) given.before.push(...proofs[entries[j].nft]);
5098
5429
  }
5430
+ const closes = nfts.map(j => entries[j].nft).filter(nft => lost.has(nft));
5099
5431
  const claimed = [
5100
5432
  computeUnitLimit(800_000),
5101
5433
  ...given.before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
@@ -5109,12 +5441,14 @@ export function solanaApp({
5109
5441
  player: me,
5110
5442
  nfts: contest.nft ? nfts : undefined,
5111
5443
  kinds,
5444
+ hold: closes,
5112
5445
  collection: given.collection,
5113
5446
  poolMints: given.poolMints,
5114
5447
  items: given.items,
5115
5448
  nft: given.nft,
5116
5449
  programs,
5117
5450
  }),
5451
+ ...closes.map(nft => releaseOwed(nft, lost.get(nft).kind, lost.get(nft), me, g)),
5118
5452
  ];
5119
5453
  // A prize that rolls: asked for in the claim's own transaction and settled by a key after (one prompt).
5120
5454
  return contest.prize?.Roll && !build ? (await sendRolling(claimed)).signature : send(claimed);