@xgamesfun/solana 0.27.0 → 0.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +34 -17
  2. package/bin/xsolana.mjs +72 -7
  3. package/js/approve/details.mjs +1 -1
  4. package/js/approve/keyfile.mjs +242 -0
  5. package/js/approve/live-check.mjs +5 -2
  6. package/js/approve/server.mjs +94 -13
  7. package/js/approve/with-key.mjs +298 -0
  8. package/js/chain/lookup.js +11 -0
  9. package/js/chain/programs.js +49 -1
  10. package/js/chain/rpc.js +53 -6
  11. package/js/economy/check.mjs +2 -2
  12. package/js/economy/cost.mjs +20 -4
  13. package/js/economy/explain.mjs +3 -3
  14. package/js/economy/guide.mjs +2 -2
  15. package/js/economy/live.mjs +1 -1
  16. package/js/economy/plan.mjs +34 -6
  17. package/js/economy/review.mjs +1 -1
  18. package/js/economy/schema.json +1 -1
  19. package/js/economy/txsize.mjs +3 -2
  20. package/js/mcp/server.mjs +3 -3
  21. package/js/mcp/tools.mjs +51 -14
  22. package/js/play/index.js +466 -118
  23. package/package.json +5 -1
  24. package/release.json +1 -1
  25. package/skills/xsolana-economy/SKILL.md +58 -19
  26. package/skills/xsolana-economy/references/after-launch.md +43 -12
  27. package/skills/xsolana-economy/references/apps.md +7 -3
  28. package/skills/xsolana-economy/references/sentence-to-spec.md +3 -3
  29. package/skills/xsolana-economy/references/telling-the-person.md +18 -11
  30. package/skills/xsolana-economy/references/trust-and-cheating.md +4 -2
  31. package/types/approve/keyfile.d.mts +36 -0
  32. package/types/chain/lookup.d.ts +12 -0
  33. package/types/chain/programs.d.ts +22 -0
  34. package/types/chain/rpc.d.ts +15 -1
  35. package/types/economy/plan.d.mts +21 -4
  36. package/types/mcp/server.d.mts +4 -3
  37. package/types/mcp/tools.d.mts +14 -5
  38. package/types/play/index.d.ts +178 -29
package/js/play/index.js CHANGED
@@ -37,12 +37,14 @@ import {
37
37
  requestHeapFrame,
38
38
  syncNative,
39
39
  transferLamports,
40
+ withComputeBudget,
40
41
  withSolWrap,
41
42
  } from '../chain/programs.js';
42
43
  import { checkCluster, notMainnet, rpcClient } from '../chain/rpc.js';
43
44
  import { extension, mayStartFrozen, mintPowers, resolveTransfers } from '../chain/token2022.js';
44
45
  import {
45
46
  compileFitting,
47
+ compileKeepingKind,
46
48
  closeTableIx,
47
49
  deactivateTableIx,
48
50
  readTable,
@@ -74,6 +76,9 @@ export {
74
76
 
75
77
  // The options of a call made without any: read-only, and typed so the generated declarations take any option.
76
78
  /** A lottery's steps in `claims()`: each depends on the one before, so `claimAll` takes one of a lottery's a call. */
79
+ /** The compute units a sponsored step asks for: all a transaction can have. It sets a price of 0, so they cost nothing. */
80
+ const SPONSORED_UNITS = 1_400_000;
81
+
77
82
  const LOTTERY_STEPS = new Set(['reveal', 'cancel-draw', 'pull', 'payout']);
78
83
  /** The claims that are one transaction of the wallet's: `claimAll` signs them together. */
79
84
  const BATCHED = new Set(['contest', 'release', 'lookup-table', 'close-leaf', ...LOTTERY_STEPS]);
@@ -421,10 +426,10 @@ export function solanaApp({
421
426
  * way. Whoever controls a collection the project does not can take an NFT out of the project's hands or burn it. A
422
427
  * Core asset shows it by itself (`kind`: its collection now); a Token Metadata NFT by the token account that holds it
423
428
  * 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.
429
+ * leaf is now (`before`, by `signer`). `holder`: as `heldNow` takes it. `now`: what `heldNow` read of it already.
425
430
  */
426
- async function goneProof(asset, signer = wallet.address, { holder = null } = {}) {
427
- const now = await heldNow(asset, { holder });
431
+ async function goneProof(asset, signer = wallet.address, { holder = null, now = null } = {}) {
432
+ now ??= await heldNow(asset, { holder });
428
433
  if (now.held !== false) return null;
429
434
  if (now.das) {
430
435
  const proof = await client.call('getAssetProof', { id: asset });
@@ -439,14 +444,33 @@ export function solanaApp({
439
444
  /**
440
445
  * 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
446
  * (`goneProof`), what shows that, with `gone` true: the instruction then finishes without it and nothing is sent.
447
+ * `now`: what `heldNow` read of it already.
442
448
  */
443
- async function leaving(asset, signer = wallet.address, { to, payer = signer } = {}) {
444
- const proof = await goneProof(asset, signer);
449
+ async function leaving(asset, signer = wallet.address, { to, payer = signer, now = null } = {}) {
450
+ const proof = await goneProof(asset, signer, { now });
445
451
  if (!proof) return { ...(await nftOf(asset, signer, { to, payer })), gone: false };
446
452
  // A Core asset shows it by itself, with the accounts it always took.
447
453
  if (!proof.kind.tokenMetadata && !proof.kind.compressed) return { ...(await nftOf(asset, signer)), gone: true };
448
454
  return { before: proof.before, hasAuthority: false, ...proof.kind, gone: true };
449
455
  }
456
+ /**
457
+ * The release of a hold a route just made of `asset`, an NFT it owed and could only hand out (`kind`: from
458
+ * `leaving`). The program makes such a hold at `["hold", asset, 0]` for `player`, the route's `signer` paying its
459
+ * rent and `rentTo` named for its NFT's account's (`hold::owe`). The release right after it, by the same signer,
460
+ * ends it: for an NFT proven gone from the project it closes the hold on the proof and nothing is sent (`NftGone`);
461
+ * for any other it sends the NFT on to `player` (`Released`). Either way the hold's rent comes back to the signer.
462
+ * `g`: the project, for a hold owed to the treasury.
463
+ */
464
+ const releaseOwed = (asset, kind, { player, rentTo }, signer, g) =>
465
+ loot.releaseIx({
466
+ signer,
467
+ project,
468
+ asset,
469
+ ...kind,
470
+ hold: { player, payer: signer, rent_to: rentTo, free_at: 0n },
471
+ address: loot.pda.hold(asset, 0),
472
+ treasury: g.treasury,
473
+ });
450
474
  /**
451
475
  * 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
476
  * money in: accounts that start frozen or can be switched to it (a payee closes their own account, and the one
@@ -780,17 +804,26 @@ export function solanaApp({
780
804
  const { blockhash, lastValidBlockHeight } = await client.latestBlockhash();
781
805
  // Lent the most, the step is tried; then sent lent the same, the player giving back last all but what it costs in
782
806
  // fees and rent: a step can need more SOL midway than it costs (a wrapped-SOL account it opens and closes).
783
- const compile = (back = 0n) =>
784
- compileFitting({
807
+ // It leads with a compute unit limit (all a transaction can have) and a unit price of 0, the price a sponsor
808
+ // pays unless its server says more: a wallet that adds its own priority fee only where a transaction sets none
809
+ // (Phantom does, to one with no signature on it yet) is then handed one it signs as it stands, and the sponsor's
810
+ // server, which refuses a price it did not agree to, takes it. A step keeps the kind of transaction it had: one
811
+ // that fits a legacy transaction stays one (without the two where they would not fit), and only a step that
812
+ // needed the project's lookup tables before goes as v0.
813
+ const compile = (back = 0n) => {
814
+ const list = [
815
+ sponsorTopUp(sponsor.address, me, sponsorMax),
816
+ ...instructions,
817
+ ...(back > 0n ? [sponsorGiveBack(sponsor.address, me, back)] : []),
818
+ ];
819
+ return compileKeepingKind({
785
820
  payer: sponsor.address,
786
821
  blockhash,
787
- instructions: [
788
- sponsorTopUp(sponsor.address, me, sponsorMax),
789
- ...instructions,
790
- ...(back > 0n ? [sponsorGiveBack(sponsor.address, me, back)] : []),
791
- ],
822
+ led: withComputeBudget(list, { units: SPONSORED_UNITS, microLamports: 0n }),
823
+ plain: list,
792
824
  tables,
793
825
  });
826
+ };
794
827
  const tried = compile();
795
828
  if (!tried) return null;
796
829
  const addresses = namedIn([sponsorTopUp(sponsor.address, me, 0n), ...instructions]);
@@ -1958,16 +1991,18 @@ export function solanaApp({
1958
1991
  const lotNow = lot ? await heldNow(lot) : { held: null };
1959
1992
  const bidNow = [];
1960
1993
  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);
1994
+ const lostBids = sold && auction.nft_bids ? auction.held.filter((_, i) => bidNow[i].held === false) : [];
1995
+ const gone = lotNow.held === false || lostBids.length > 0;
1962
1996
  const ending = sold && lot && (callOff || gone) ? 'CallOff' : 'Deliver';
1963
1997
  const home = !sold || ending === 'CallOff';
1964
1998
  // The lot to its winner, or home to its seller; a bid's NFTs to whoever owned the lot, or back to their bidder.
1999
+ // One that is gone is not sent: its accounts only show the end that the project no longer holds it.
1965
2000
  const before = [];
1966
- const lotKind = lot ? await kindFor(lot, home ? auction.seller : auction.high_bidder, lotNow.evidence, before) : {};
2001
+ const lotKind = lot ? await kindFor(lot, home ? auction.seller : auction.high_bidder, lotNow, before) : {};
1967
2002
  const bidTo = ending === 'CallOff' ? auction.high_bidder : owner;
1968
2003
  const held = [];
1969
2004
  if (sold && auction.nft_bids)
1970
- for (const [i, nft] of auction.held.entries()) held.push(await kindFor(nft, bidTo, bidNow[i].evidence, before));
2005
+ for (const [i, nft] of auction.held.entries()) held.push(await kindFor(nft, bidTo, bidNow[i], before));
1971
2006
  if (sold && !home) holders ??= await holdersOf(auction.shares);
1972
2007
  const end = (ending, hold = false) =>
1973
2008
  loot.endAuctionIx({ ...base, auction, item, ...lotKind, held, holders, ending, hold });
@@ -1982,6 +2017,24 @@ export function solanaApp({
1982
2017
  ...(made ? { asset: made } : {}),
1983
2018
  };
1984
2019
  if (ending === 'CallOff') Object.assign(result, { calledOff: true, gone });
2020
+ // What the end did about an NFT that is gone, in words: nothing here says it moved.
2021
+ if (gone) {
2022
+ const which =
2023
+ lotNow.held === false
2024
+ ? `the lot (${lot})`
2025
+ : `${lostBids.length === 1 ? 'an NFT' : 'NFTs'} of the winning bid (${lostBids.join(', ')})`;
2026
+ const did = !sold
2027
+ ? 'Nothing went home: the auction is closed, and its rent went back to its seller.'
2028
+ : ending === 'Deliver'
2029
+ ? 'Nothing was paid for it: the lot went to its winner, and the rest of the bid was paid.'
2030
+ : lotNow.held === false
2031
+ ? `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.`
2032
+ : 'The sale was called off: the lot went home to its seller, and the rest of the bid went back to its bidder.';
2033
+ Object.assign(result, {
2034
+ gone: true,
2035
+ says: `The project no longer holds ${which}: whoever controls its collection took it back or burned it while the auction held it. ${did}`,
2036
+ });
2037
+ }
1985
2038
 
1986
2039
  // 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
2040
  const outs = [
@@ -2049,15 +2102,15 @@ export function solanaApp({
2049
2102
  return holding();
2050
2103
  }
2051
2104
 
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 });
2105
+ /** An NFT's kind on an auction's end, going to `to`: what `nftOf` reads, its proof put in `before`. `now`: what
2106
+ * `heldNow` read of it; one that is gone carries what shows it instead (a Token Metadata NFT's account that holds it
2107
+ * now, or its mint; a compressed NFT's leaf as it is now), and is sent nowhere. */
2108
+ async function kindFor(asset, to, now, before) {
2109
+ const { before: proof, hasAuthority: _h, gone: _g, ...kind } = await leaving(asset, wallet.address, { to, now });
2056
2110
  before.push(...proof);
2057
2111
  return {
2058
2112
  ...kind,
2059
2113
  collection: kind.compressed || kind.tokenMetadata ? undefined : ((await collectionOf(asset)) ?? MPL_CORE),
2060
- ...(evidence && kind.tokenMetadata ? { evidence } : {}),
2061
2114
  };
2062
2115
  }
2063
2116
 
@@ -2106,16 +2159,30 @@ export function solanaApp({
2106
2159
  if (opens.length) await send([...new Map(opens.map(x => [x.keys[1].pubkey, x])).values()]);
2107
2160
  const before = [];
2108
2161
  const held = [];
2109
- if (auction.nft_bids) for (const nft of auction.held) held.push(await kindFor(nft, auction.seller, null, before));
2162
+ // An NFT of the bid that is gone (its collection took it back or burned it) is not paid: its accounts show the
2163
+ // end that the project no longer holds it, and the end passes it over.
2164
+ const lost = [];
2165
+ if (auction.nft_bids)
2166
+ for (const nft of auction.held) {
2167
+ const now = await heldNow(nft);
2168
+ if (now.held === false) lost.push(nft);
2169
+ held.push(await kindFor(nft, auction.seller, now, before));
2170
+ }
2110
2171
  const end = (hold = false) => loot.endAuctionIx({ ...base, auction, held, holders, hold });
2111
2172
  const steps = ix => [
2112
2173
  computeUnitLimit(1_000_000),
2113
2174
  ...before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
2114
2175
  ix,
2115
2176
  ];
2177
+ const passed = lost.length
2178
+ ? {
2179
+ gone: true,
2180
+ 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.`,
2181
+ }
2182
+ : {};
2116
2183
  if (sendPlan({ payer: wallet.address, instructions: steps(end()), alone: isProveLeaf }))
2117
2184
  try {
2118
- return { paid: await send(steps(end())) };
2185
+ return { paid: await send(steps(end())), ...passed };
2119
2186
  } catch (e) {
2120
2187
  // The lot is the winner's already: what is owed waits until the account it goes into can take it.
2121
2188
  return {
@@ -2123,9 +2190,9 @@ export function solanaApp({
2123
2190
  says: `The lot is the winner’s; the money waits (${e.message}): end the auction again to pay it.`,
2124
2191
  };
2125
2192
  }
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 };
2193
+ const outs = auction.held.filter(x => !lost.includes(x)).map(x => [x, auction.seller]);
2194
+ const { signature, releases } = await heldThenReleased(at, steps(end(outs.map(([x]) => x))), outs);
2195
+ return { paid: signature, releases, ...passed };
2129
2196
  }
2130
2197
 
2131
2198
  /**
@@ -2141,9 +2208,12 @@ export function solanaApp({
2141
2208
  const signature = await send([...instructions, fund]);
2142
2209
  const releases = [];
2143
2210
  for (const [asset, to] of outs) {
2211
+ // Taken back or burned since the end made it a hold: its release closes the hold on that proof, and sends nothing.
2212
+ let lost = false;
2144
2213
  const release = async signer => {
2145
2214
  const hold = await read('Hold', loot.pda.hold(asset, 0));
2146
- const { before: proof, hasAuthority: _h, ...kind } = await nftOf(asset, signer, { to });
2215
+ const { before: proof, hasAuthority: _h, gone, ...kind } = await leaving(asset, signer, { to });
2216
+ lost = gone;
2147
2217
  return [
2148
2218
  ...proof,
2149
2219
  loot.releaseIx({
@@ -2160,7 +2230,8 @@ export function solanaApp({
2160
2230
  const steps = await release(key.address);
2161
2231
  const byKey = (await dryRun(key.address, steps)).ok ? await sendAs(key, steps).catch(() => null) : null;
2162
2232
  // 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 }))));
2233
+ const sent = byKey ?? (await send(await release(wallet.address)).catch(e => ({ asset, waits: e.message })));
2234
+ releases.push(lost && typeof sent === 'string' ? { asset, gone: true, closed: sent } : sent);
2164
2235
  }
2165
2236
  await emptyKey(key);
2166
2237
  return { signature, releases };
@@ -2173,6 +2244,50 @@ export function solanaApp({
2173
2244
  const GONE_LISTING =
2174
2245
  '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
2246
 
2247
+ /**
2248
+ * What ending an auction does when its lot or an NFT of its winning bid is gone, in words, for `claims()`: `x` the
2249
+ * auction, `me` the wallet told (its seller, or its high bidder), `lot` the lot when it is gone, `bids` the NFTs of
2250
+ * the winning bid that are gone.
2251
+ */
2252
+ function goneSaleSays(name, x, me, lot, bids) {
2253
+ const [sold, owed] = [BigInt(x.high) > 0n, BigInt(x.proceeds_owed) > 0n];
2254
+ const mine = sold && x.high_bidder === me;
2255
+ const why = 'whoever controls its collection took it back or burned it while the auction held it';
2256
+ const lostBids = `${bids.length === 1 ? 'an NFT' : 'NFTs'} of ${mine ? 'your' : 'the winning'} bid (${bids.join(', ')})`;
2257
+ if (lot && !sold)
2258
+ 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.`;
2259
+ if (lot) {
2260
+ const back = x.nft_bids
2261
+ ? `the NFTs of ${mine ? 'your' : 'the winning'} bid that the project still holds go back to ${mine ? 'you' : 'their bidder'}`
2262
+ : mine
2263
+ ? 'your bid comes back to you'
2264
+ : 'the winning bid goes back to its bidder';
2265
+ 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.` : ''}`;
2266
+ }
2267
+ const first = `The project no longer holds ${lostBids}: ${why}.`;
2268
+ // A player's lot that has not moved: the two sides move together or not at all, so the sale is called off.
2269
+ if (x.lot.Asset && !owed)
2270
+ 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.`;
2271
+ // The lot went first, or it is the project's own: the end passes that NFT over and pays the rest.
2272
+ return owed
2273
+ ? `${first} The winner of “${name}” has the lot: ending it again pays you the rest of the bid, and nothing for the one gone.`
2274
+ : `${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.`;
2275
+ }
2276
+ /** What claiming does about the NFTs of a round's parts that are gone, in words, for `claims()`: `tickets` the NFTs
2277
+ * that paid for the wallet's tickets, `prizes` the NFTs its places won; `back` when its tickets go back to their
2278
+ * buyers (a round called off, a lottery that returns its tickets). */
2279
+ const goneRoundSays = (tickets, prizes, back) =>
2280
+ [
2281
+ tickets.length
2282
+ ? `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.`
2283
+ : '',
2284
+ prizes.length
2285
+ ? `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.`
2286
+ : '',
2287
+ ]
2288
+ .filter(Boolean)
2289
+ .join(' ');
2290
+
2176
2291
  /**
2177
2292
  * This project's records of `asset`: its listing at `["listing", project, asset]` (one read) and its holds (one
2178
2293
  * search, by the asset's address). Each is { kind: 'listing' | 'hold', address, record }.
@@ -2193,6 +2308,30 @@ export function solanaApp({
2193
2308
  return out;
2194
2309
  }
2195
2310
 
2311
+ /**
2312
+ * The stashes of this project that list `asset`: a project's stash (`kind` 'stash') or a lottery's own
2313
+ * ('lottery-stash'), each { kind, address, id } (`id`: the stash's, or the lottery's). A stash keeps its NFTs in
2314
+ * rows of no fixed place, so no filter finds one NFT's row: this is one search of the project's stashes
2315
+ * (`getProgramAccounts` by tag, project and payload), which returns every stash of the project whole, 36 bytes for
2316
+ * each NFT a stash lists, and reads them here.
2317
+ */
2318
+ async function stashesOf(asset) {
2319
+ const at = (offset, bytes) => ({ memcmp: { offset, bytes } });
2320
+ const found = await client.programAccounts(loot.LOOT_PROGRAM, [
2321
+ at(0, encodeBase58(Uint8Array.of(loot.TAGS.Table))),
2322
+ at(1, project),
2323
+ at(37, encodeBase58(Uint8Array.of(loot.PAYLOAD.Nft))),
2324
+ ]);
2325
+ const out = [];
2326
+ for (const { pubkey, account } of found) {
2327
+ const stash = loot.decodeStash(account.data);
2328
+ if (!stash?.nfts.includes(asset)) continue;
2329
+ const own = pubkey === loot.pda.stash(project, stash.id);
2330
+ out.push({ kind: own ? 'stash' : 'lottery-stash', address: pubkey, id: stash.id });
2331
+ }
2332
+ return out;
2333
+ }
2334
+
2196
2335
  /**
2197
2336
  * The records of this project an earlier owner of `asset` left behind (`recordsOf`), each that names a wallet other
2198
2337
  * than the player's: { kind, address, record, says }. Such a record is left when whoever controls the NFT's
@@ -2470,6 +2609,77 @@ export function solanaApp({
2470
2609
  ];
2471
2610
  }
2472
2611
 
2612
+ /** How many of the losers' NFTs left a winner's claim reads to choose its picks among. */
2613
+ const PICKS_READ = 8;
2614
+ /**
2615
+ * The NFTs a claim of entry `i` of a contest of NFTs moves, as `claim` sends it: the entry's own while the contest
2616
+ * holds it, and for a winner the losers' NFTs its share still lets it pick, at most 4 a claim. `take` names the
2617
+ * picks (one asset, or several). With none named, the first of the losers' NFTs left that the project still holds
2618
+ * are picked (the first `PICKS_READ` are read), and one that is gone only when no other is left there: a pick of an
2619
+ * NFT gone takes nothing. Each is { j, nft, gone }: its entry's place in the list, and whether the project no longer
2620
+ * holds it (its collection took it back or burned it).
2621
+ */
2622
+ async function contestNfts(contest, entries, i, take) {
2623
+ const own = entries[i];
2624
+ const won = contest.verdict === 'Won' && Number(contest.winner) === Number(own.side);
2625
+ const out = Number(own.amount) === 1 ? [{ j: i, nft: own.nft }] : [];
2626
+ const share = won && !(own.bits & loot.ENDED) ? loot.shareOf(contest, own) - Number(own.taken) : 0;
2627
+ const left = entries
2628
+ .map((e, j) => ({ j, nft: e.nft, e }))
2629
+ .filter(({ e }) => Number(e.side) !== Number(contest.winner) && Number(e.amount) === 1);
2630
+ const wanted = take ? [take].flat() : [];
2631
+ const among =
2632
+ share <= 0 ? [] : wanted.length ? left.filter(x => wanted.includes(x.nft)) : left.slice(0, PICKS_READ);
2633
+ if (wanted.length && among.length < Math.min(wanted.length, share))
2634
+ throw new Error(`${wanted.join(', ')} is not a loser’s NFT left in this contest for you to pick.`);
2635
+ const heldOf = await heldEach([...out, ...among].map(x => x.nft));
2636
+ const isGone = x => heldOf.get(x.nft)?.held === false;
2637
+ const picks = wanted.length ? among : [...among.filter(x => !isGone(x)), ...among.filter(isGone)];
2638
+ out.push(...picks.slice(0, Math.min(share, 4)));
2639
+ return out.map(x => ({ j: x.j, nft: x.nft, gone: isGone(x) }));
2640
+ }
2641
+ /** What a contest's claim does about its NFTs that are gone, in words, for `claims()`: `own` the wallet's own NFT
2642
+ * when it is gone, `picks` the losers' NFTs gone that its picks land on. */
2643
+ const goneContestSays = (own, picks) =>
2644
+ [
2645
+ own
2646
+ ? `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.`
2647
+ : '',
2648
+ picks.length
2649
+ ? `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.`
2650
+ : '',
2651
+ ]
2652
+ .filter(Boolean)
2653
+ .join(' ');
2654
+
2655
+ /**
2656
+ * The NFTs the pulls of a wallet's parts of round `r` (`mine`: [{ entry, parts }]) are to hand out: each entry's NFT
2657
+ * ticket ('Own'), and each NFT its places are still owed (`Prize`: the lottery's own stash's, as the pulls take them
2658
+ * out; a project stash's named one). Returns { tickets, prizes }, their addresses.
2659
+ */
2660
+ async function roundNfts(r, mine) {
2661
+ const [tickets, prizes] = [[], []];
2662
+ let own;
2663
+ for (const { entry, parts } of mine) {
2664
+ const e = r.entries[entry];
2665
+ for (const part of parts) {
2666
+ if (part === 'Own' && e.nft !== NOBODY) tickets.push(e.nft);
2667
+ if (!part.Prize || loot.tokenRun(r, Number(part.Prize[0]))) continue;
2668
+ const run = Number(part.Prize[0]);
2669
+ const place = r.terms.places[run];
2670
+ if (place.prize?.Nft) prizes.push(place.prize.Nft.nft);
2671
+ if (!loot.ownStash(place)) continue;
2672
+ own ??= { stash: loot.decodeStash((await client.account(loot.pda.lotteryStash(r.lottery)))?.data ?? null) };
2673
+ const predicted = loot.stashNfts(r, own.stash);
2674
+ for (const p of loot.prizePlacesOwed(r, entry, run)) {
2675
+ const nft = predicted[loot.nftSlot(r, p)];
2676
+ if (nft) prizes.push(nft);
2677
+ }
2678
+ }
2679
+ }
2680
+ return { tickets, prizes };
2681
+ }
2682
+
2473
2683
  /**
2474
2684
  * When a draw of round `r` (at `key`) would cancel it (an open round short of its least tickets; a waiting one past
2475
2685
  * the timeout), the pulls of `who`'s own parts as the cancelled round owes them (a few entries' worth), to go right
@@ -2529,6 +2739,11 @@ export function solanaApp({
2529
2739
  * The pulls of `parts` of entry `i` of a round (`partsOwed`; `i` null for parts owed in common, `commonOwed`), signed by
2530
2740
  * `signer`, and the rolls they make ([{ order, box }], `loot.pullRolls`). With `closes`, the last pull carries the
2531
2741
  * round's close (when the parts it pays are the last owed).
2742
+ * An NFT of a part that is proven gone from the project is not sent anywhere. A ticket whose price sends it on
2743
+ * passes what proves it gone, and its part settles (`NftGone`, the round its record). A ticket going back to its
2744
+ * buyer and a place's NFT are only handed out by the program: the pull passes each as its hold, and a release on
2745
+ * the proof follows it here, which closes the hold (`NftGone`, the hold its record). A pull makes holds of all its
2746
+ * prize NFTs or of none, so with one of them gone the others are made holds too and released on to their winner.
2532
2747
  */
2533
2748
  async function pulls(key, r, i, parts, signer, { closes = false, lotteryClosed = false } = {}) {
2534
2749
  const e = i == null ? null : r.entries[i];
@@ -2544,20 +2759,38 @@ export function solanaApp({
2544
2759
  const extra = await hookTail([...roundMints(r), ...r.prizes.map(p => p.mint)]);
2545
2760
  for (const [k, part] of parts.entries()) {
2546
2761
  const more = {};
2762
+ // The NFTs this pull makes holds in place of handing them out (`hold::owe`: for whom, and whose its NFT's
2763
+ // account's rent is), each released right after it. The round only hands an NFT out and reads nothing, so one
2764
+ // proven gone from the project is made a hold, and that hold closes on the proof.
2765
+ const holdsMade = new Map();
2547
2766
  if (part === 'Own' && e.nft !== NOBODY) {
2548
2767
  const then = r.status === 'Cancelled' || r.terms.returns ? null : r.terms.nft_ticket?.then;
2549
2768
  more.handBack = Boolean(then?.Give) && loot.handsBack(await client.account(then.Give[0]));
2550
2769
  const to = !then || more.handBack ? e.buyer : then.Give ? then.Give[0] : undefined;
2551
- const { before = [], hasAuthority: _, ...kind } = await nftOf(e.nft, signer, { to });
2770
+ const { before = [], hasAuthority: _, gone, ...kind } = await leaving(e.nft, signer, { to });
2552
2771
  out.push(...before);
2553
- more.ticket = kind;
2772
+ // Where its price sends it on (burned, given, stashed) the round reads the ticket itself: proven gone, it goes
2773
+ // nowhere and the part settles. Going back to its buyer it is only handed out: made a hold, closed below.
2774
+ if (gone && !then) {
2775
+ more.hold = true;
2776
+ holdsMade.set(e.nft, { kind, player: e.buyer, rentTo: e.buyer });
2777
+ } else more.ticket = kind;
2554
2778
  } else if (part.Prize && !loot.tokenRun(r, Number(part.Prize[0]))) {
2555
2779
  // 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
2780
  // out), a project stash's NFT, an item, or a roll (asked in the same transaction).
2557
2781
  const run = Number(part.Prize[0]);
2558
2782
  const given = r.terms.places[run].prize;
2559
- const inputs = await prizeInputs([given], signer, e.buyer);
2783
+ // An NFT's kind is read below, with whether the project still holds it.
2784
+ const inputs = await prizeInputs([given.Nft || given.Held ? null : given], signer, e.buyer);
2560
2785
  out.push(...inputs.before);
2786
+ const handed = new Map();
2787
+ const hand = async nft => {
2788
+ if (inputs.nfts[nft]) return;
2789
+ const { before, hasAuthority: _, gone, ...kind } = await leaving(nft, signer, { to: e.buyer });
2790
+ inputs.nfts[nft] = kind;
2791
+ out.push(...before);
2792
+ handed.set(nft, { kind, gone, player: e.buyer, rentTo: signer });
2793
+ };
2561
2794
  const stash = loot.ownStash(r.terms.places[run])
2562
2795
  ? loot.decodeStash((await client.account(loot.pda.lotteryStash(r.lottery)))?.data ?? null)
2563
2796
  : null;
@@ -2569,10 +2802,7 @@ export function solanaApp({
2569
2802
  const predicted = loot.stashNfts(r, stash);
2570
2803
  for (const place of loot.prizePlacesOwed(r, i, run)) {
2571
2804
  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);
2805
+ if (nft) await hand(nft);
2576
2806
  }
2577
2807
  }
2578
2808
  const stashes = given.Nft
@@ -2582,10 +2812,17 @@ export function solanaApp({
2582
2812
  ) ?? { nfts: [] },
2583
2813
  }
2584
2814
  : {};
2815
+ // A project stash's named NFT is handed out only while that stash lists it.
2816
+ if (given.Nft && stashes[given.Nft.stash].nfts.includes(given.Nft.nft)) await hand(given.Nft.nft);
2817
+ // One of them gone: a pull makes holds of all its NFTs or of none, so each becomes a hold, and its release
2818
+ // below closes the one that is gone and sends any other on to its winner.
2819
+ const holds = [...handed.values()].some(x => x.gone);
2820
+ if (holds) for (const [nft, x] of handed) holdsMade.set(nft, x);
2585
2821
  more.prize = {
2586
2822
  stash,
2587
2823
  stashes,
2588
2824
  nfts: inputs.nfts,
2825
+ hold: holds,
2589
2826
  collection: inputs.collection,
2590
2827
  poolMints: inputs.poolMints,
2591
2828
  items: inputs.items,
@@ -2620,7 +2857,8 @@ export function solanaApp({
2620
2857
  ) && (await dryRun(signer, tx)).ok
2621
2858
  );
2622
2859
  };
2623
- let most = owed;
2860
+ // (Made holds, each NFT's release follows the pull in its transaction: a few places a pull then.)
2861
+ let most = holds ? Math.min(owed, 4) : owed;
2624
2862
  while (most > 1 && !(await sized(most))) most = Math.max(1, Math.floor(most * 0.75));
2625
2863
  if (most < owed) [more.prize.most, partial] = [most, true];
2626
2864
  heap = true;
@@ -2647,21 +2885,26 @@ export function solanaApp({
2647
2885
  }
2648
2886
  const last = k === parts.length - 1;
2649
2887
  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
- );
2888
+ const pull = loot.pullIx({
2889
+ payer: signer,
2890
+ project,
2891
+ roundAddress: key,
2892
+ round,
2893
+ entry: i ?? 0,
2894
+ part,
2895
+ projectState: g,
2896
+ lotteryClosed,
2897
+ programs,
2898
+ extra,
2899
+ ...more,
2900
+ });
2901
+ out.push(pull);
2902
+ // Each hold this pull makes (a pull that stops short carries only some) is released at once: one whose NFT is
2903
+ // gone closes on the proof and nothing is sent, any other goes on to its winner, and the hold's rent comes back
2904
+ // to the signer, who paid it.
2905
+ for (const [nft, { kind, player, rentTo }] of holdsMade)
2906
+ if (pull.keys.some(x => x.pubkey === loot.pda.hold(nft, 0)))
2907
+ out.push(releaseOwed(nft, kind, { player, rentTo }, signer, g));
2665
2908
  }
2666
2909
  return { instructions: out, rolls, heap, partial };
2667
2910
  }
@@ -4033,7 +4276,9 @@ export function solanaApp({
4033
4276
  * Bids `amount` (whole units) on an auction. The bid it tops goes back to its bidder in the same transaction
4034
4277
  * (the program opens their account again if they closed it, paid by this bidder). In SOL, only what the player's
4035
4278
  * wrapped-SOL account does not hold already is wrapped, and the account stays open: if this bid is topped, it comes
4036
- * back there.
4279
+ * back there. In an auction of NFT bids, an NFT of the topped bid that the project no longer holds (its collection
4280
+ * 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
4281
+ * proof in the same transaction, so a high bid with an NFT gone can still be topped.
4037
4282
  */
4038
4283
  async bid(auctionKey, amount, { shown, listProof, nfts, acceptLeftover, ...others } = NO_OPTIONS) {
4039
4284
  only(others, ['shown', 'listProof', 'nfts', 'acceptLeftover'], 'bid');
@@ -4059,9 +4304,17 @@ export function solanaApp({
4059
4304
  };
4060
4305
  const bid = [];
4061
4306
  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.
4307
+ // The bid it tops goes back to its bidder: into a fresh account of theirs when their own can't take it. An
4308
+ // NFT of it that is gone (its collection took it back or burned it) can't go back: the auction only hands it
4309
+ // out, so it is made a hold for its bidder, and that hold closes on the proof right after the bid.
4063
4310
  const held = [];
4064
- for (const asset of auction.held) held.push(await kindOf(asset, auction.high_bidder));
4311
+ const lost = new Map();
4312
+ for (const asset of auction.held) {
4313
+ const { before: proof, gone, ...kind } = await leaving(asset, wallet.address, { to: auction.high_bidder });
4314
+ before.push(...proof);
4315
+ held.push({ asset, ...kind });
4316
+ if (gone) lost.set(asset, kind);
4317
+ }
4065
4318
  const ix = loot.bidIx({
4066
4319
  bidder: wallet.address,
4067
4320
  project,
@@ -4071,13 +4324,19 @@ export function solanaApp({
4071
4324
  listProof,
4072
4325
  nfts: bid,
4073
4326
  held,
4327
+ hold: [...lost.keys()],
4074
4328
  programs,
4075
4329
  });
4330
+ const g = await projectNow();
4331
+ const closes = [...lost].map(([asset, { hasAuthority: _, ...kind }]) =>
4332
+ releaseOwed(asset, kind, { player: auction.high_bidder, rentTo: auction.high_bidder }, wallet.address, g),
4333
+ );
4076
4334
  return send(
4077
4335
  await closesFirst(clears, [
4078
4336
  computeUnitLimit(800_000),
4079
4337
  ...before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
4080
4338
  ix,
4339
+ ...closes,
4081
4340
  ]),
4082
4341
  );
4083
4342
  }
@@ -4125,6 +4384,12 @@ export function solanaApp({
4125
4384
  * the sale is called off on that evidence, which anyone may: the bid goes back to its bidder (`{ calledOff: true }`).
4126
4385
  * With no bid, the lot goes home to its seller. An end too big for one transaction otherwise makes each NFT that may
4127
4386
  * be a hold one, and sends each on with a release after it (`releases`).
4387
+ * An NFT that is gone is never sent, whatever its kind, and the result says so (`gone: true`, and `says`, what the
4388
+ * end did in words): a lot gone with no bid stays where it is and the auction closes, its rent to its seller; a
4389
+ * sale with the lot or an NFT of the bid gone is called off, and what the project still holds goes back; for the
4390
+ * 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.
4391
+ * A hold made at the end whose NFT goes before its release is closed on the proof: its entry in `releases` is
4392
+ * then `{ asset, gone: true, closed }`, `closed` the signature.
4128
4393
  */
4129
4394
  async endAuction(auctionKey, { holders, ...others } = NO_OPTIONS) {
4130
4395
  only(others, ['holders'], 'endAuction');
@@ -4375,27 +4640,47 @@ export function solanaApp({
4375
4640
 
4376
4641
  /**
4377
4642
  * 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.
4643
+ * took it out of the project's hands or burned it): each hold of it (`release`), its listing (`take_listing`),
4644
+ * and its row on each of the project's stashes that lists it (`stash`, out), on the proof that it is gone. Anyone
4645
+ * 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
4646
+ * debt ends unpaid. The sender pays the transaction's fee only; each record's rent goes to whoever that record
4647
+ * names (a hold's payer, a listing's maker), never to the sender, and a stash keeps the rent its row no longer
4648
+ * needs (it goes to the sender only when that is one of the project's keys). With its row off a stash, no roll
4649
+ * draws the NFT, and a prize that names it is passed over in place of failing on an NFT that can't move.
4650
+ * What is left is in `waits`: a listing whose rental still runs (the program keeps a running rental, and the
4651
+ * listing closes once it has run), and a lottery's own stash that lists the NFT (`kind` 'lottery-stash', with its
4652
+ * `lottery`, the lottery's id): only the project's keys take a row out of that one (`unraffle`), gone or not.
4653
+ * Refused in words, with nothing sent, when the project still holds the NFT or nothing shows it gone.
4654
+ * Besides one search of the NFT's holds, this makes one search of the project's stashes, which returns every
4655
+ * stash of the project whole (36 bytes for each NFT a stash lists): a stash's rows have no fixed place to look in.
4656
+ * Returns { signature, closed: [{ kind, address }], unstashed: [{ address, id }], waits: [{ kind, address }] }:
4657
+ * `closed` the holds and the listing it closed, `unstashed` the project's stashes it took the row off;
4658
+ * `signature` is null when there was nothing to close.
4386
4659
  */
4387
4660
  async closeGone(asset) {
4388
- const records = await recordsOf(asset);
4389
- if (!records.length) return { signature: null, closed: [], waits: [] };
4661
+ const [records, stashes] = [await recordsOf(asset), await stashesOf(asset)];
4662
+ if (!records.length && !stashes.length) return { signature: null, closed: [], unstashed: [], waits: [] };
4390
4663
  const proof = await goneProof(asset);
4391
4664
  if (!proof)
4392
4665
  throw new Error(
4393
4666
  `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
4667
  );
4395
4668
  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 };
4669
+ const listed = stashes.filter(x => x.kind === 'stash');
4670
+ // The row comes off each of the project's stashes: free, nothing moves, and where it would go is not read.
4671
+ const offs = listed.map(({ id }) =>
4672
+ loot.stashIx({ signer: wallet.address, project, id, asset, out: wallet.address, ...proof.kind }),
4673
+ );
4674
+ const left = [
4675
+ ...waits.map(({ kind, address }) => ({ kind, address })),
4676
+ ...stashes
4677
+ .filter(x => x.kind === 'lottery-stash')
4678
+ .map(({ kind, address, id }) => ({ kind, address, lottery: id })),
4679
+ ];
4680
+ const unstashed = listed.map(({ address, id }) => ({ address, id }));
4681
+ const instructions = [...(closes.length ? closes : offs.length ? proof.before : []), ...offs];
4682
+ if (!instructions.length) return { signature: null, closed, unstashed, waits: left };
4683
+ return { signature: await send(instructions), closed, unstashed, waits: left };
4399
4684
  },
4400
4685
 
4401
4686
  // ---------------------------------------------------------------- raffles
@@ -4446,9 +4731,20 @@ export function solanaApp({
4446
4731
  return send([loot.upsertIx({ owner: wallet.address, project, def: { Lottery: { id, args } }, lotteryState })]);
4447
4732
  },
4448
4733
 
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). */
4734
+ /**
4735
+ * The raffle's owner takes the NFT back out of lottery `id`'s own stash once nothing promises it (the lottery
4736
+ * ended, or no place names it). When the project no longer holds the NFT (its collection took it back or burned
4737
+ * it), only its row comes off the stash, on that proof: nothing is sent, and the owner gets no NFT back. Only the
4738
+ * project's owner or authority may, gone or not: a lottery's own stash is theirs alone (`NotAuthority` for anyone
4739
+ * else; `closeGone` leaves it and says so). Returns the signature.
4740
+ */
4450
4741
  async unraffle(id, nft) {
4451
- const { before, hasAuthority: _, ...piece } = await nftOf(nft, wallet.address, { to: wallet.address });
4742
+ const {
4743
+ before,
4744
+ hasAuthority: _,
4745
+ gone: _g,
4746
+ ...piece
4747
+ } = await leaving(nft, wallet.address, { to: wallet.address });
4452
4748
  const lotteryState = await readMaybe('Lottery', loot.pda.lottery(project, id));
4453
4749
  return send([
4454
4750
  ...before,
@@ -4741,6 +5037,10 @@ export function solanaApp({
4741
5037
  * SOL), and a drawn lottery's unpaid places, which anyone may pay so the next round can open. [{ kind, says, … }];
4742
5038
  * a winner's first claim of a contest of tokens carries `potUnits` and `prizeUnits`, its part as the decision wrote
4743
5039
  * it (base units, as text). Pass one to `claim`.
5040
+ * A `pull`, a `contest` or an `auction` whose claim would hand out an NFT the project no longer holds is flagged
5041
+ * `gone` too, with `assets` (those NFTs) and `says`, what the claim does about each in words: a ticket's NFT, a
5042
+ * place's NFT, a contest's entry or a loser's NFT picked is not sent anywhere and its part closes; an auction with
5043
+ * its lot or an NFT of its winning bid gone is called off, or ended without it. Show `says` before the player signs.
4744
5044
  */
4745
5045
  async claims() {
4746
5046
  const me = wallet.address;
@@ -4776,8 +5076,8 @@ export function solanaApp({
4776
5076
  const off = loot.contestOff(contest, time);
4777
5077
  if (!(off || contest.verdict === 'Won')) continue;
4778
5078
  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;
5079
+ const entries = list?.owner === loot.LOOT_PROGRAM ? loot.decodeEntries(list.data) : [];
5080
+ const entry = entries.find(e => e.player === me);
4781
5081
  if (!entry) continue;
4782
5082
  const won = !off && Number(contest.winner) === Number(entry.side);
4783
5083
  const first = won && !(entry.bits & loot.CLAIMED);
@@ -4800,11 +5100,32 @@ export function solanaApp({
4800
5100
  : 'Your share of the pot.';
4801
5101
  // A winner's part of a contest of tokens is what the decision wrote, whoever claims first (`contestWinnings`).
4802
5102
  const winnings = first && !contest.nft ? loot.contestWinnings(contest, entry.amount) : null;
5103
+ // A contest of NFTs: what its claim moves, and which of those the project no longer holds (its collection
5104
+ // took it back or burned it). Such an NFT is not sent anywhere: the claim closes it, and says so here first.
5105
+ const moves = contest.nft ? await contestNfts(contest, entries, entries.indexOf(entry), undefined) : [];
5106
+ const lost = moves.filter(m => m.gone);
5107
+ const mine = lost.find(m => m.nft === entry.nft && entries[m.j] === entry);
5108
+ const there = [
5109
+ moves.some(m => entries[m.j] === entry && !m.gone) ? 'your NFT back' : '',
5110
+ moves.some(m => entries[m.j] !== entry && !m.gone) ? 'your pick of the losers’' : '',
5111
+ ]
5112
+ .filter(Boolean)
5113
+ .join(', and ');
5114
+ const goneSays = [
5115
+ off ? 'The contest is off.' : '',
5116
+ there ? `${there[0].toUpperCase()}${there.slice(1)}.` : '',
5117
+ goneContestSays(
5118
+ mine?.nft,
5119
+ lost.filter(m => m !== mine).map(m => m.nft),
5120
+ ),
5121
+ ]
5122
+ .filter(Boolean)
5123
+ .join(' ');
4803
5124
  out.push({
4804
5125
  kind: 'contest',
4805
5126
  id: contest.id,
4806
5127
  side: entry.side,
4807
- says,
5128
+ ...(lost.length ? { gone: true, assets: lost.map(m => m.nft), says: goneSays } : { says }),
4808
5129
  ...(winnings ? { potUnits: String(winnings.pot), prizeUnits: String(winnings.prize) } : {}),
4809
5130
  });
4810
5131
  }
@@ -4854,32 +5175,47 @@ export function solanaApp({
4854
5175
  }
4855
5176
  const auctions = spec.auctions ?? [];
4856
5177
  const found = await client.accounts(auctions.map(a => auctionAt(a)));
4857
- auctions.forEach((a, i) => {
5178
+ for (const [i, a] of auctions.entries()) {
4858
5179
  const x = found[i] && loot.decode('Auction', found[i].data);
4859
- if (!x || time < loot.auctionEnds(x)) return;
5180
+ if (!x || time < loot.auctionEnds(x)) continue;
5181
+ const [sold, owed] = [BigInt(x.high) > 0n, BigInt(x.proceeds_owed) > 0n];
5182
+ if (!(x.seller === me || (sold && !owed && x.high_bidder === me))) continue;
5183
+ // Its lot, or an NFT of the winning bid, that the project no longer holds (its collection took it back or
5184
+ // burned it): the end does not send it anywhere, and this says what the end does instead.
5185
+ const lot = owed ? null : (x.lot.Asset?.[0] ?? null);
5186
+ const bids = sold && x.nft_bids ? x.held : [];
5187
+ const heldOf = lot || bids.length ? await heldEach([...(lot ? [lot] : []), ...bids]) : new Map();
5188
+ const lostBids = bids.filter(nft => heldOf.get(nft)?.held === false);
5189
+ if ((lot && heldOf.get(lot)?.held === false) || lostBids.length) {
5190
+ const lotGone = lot && heldOf.get(lot)?.held === false;
5191
+ out.push({
5192
+ kind: 'auction',
5193
+ key: a.key,
5194
+ gone: true,
5195
+ assets: [...(lotGone ? [lot] : []), ...lostBids],
5196
+ says: goneSaleSays(a.name, x, me, lotGone ? lot : null, lostBids),
5197
+ });
5198
+ continue;
5199
+ }
4860
5200
  // The lot went first (an end too wide for one transaction, or a seller's account that couldn't take the money
4861
5201
  // 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.` });
5202
+ if (owed)
5203
+ out.push({
5204
+ kind: 'auction',
5205
+ key: a.key,
5206
+ 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).`,
5207
+ });
5208
+ else if (x.high_bidder === me) out.push({ kind: 'auction', key: a.key, says: `You won “${a.name}”: take it.` });
4872
5209
  // The seller's own auction, over: ending it pays them (or brings an unsold lot home).
4873
- else if (x.seller === me)
5210
+ else
4874
5211
  out.push({
4875
5212
  kind: 'auction',
4876
5213
  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.`,
5214
+ says: sold
5215
+ ? `Your auction “${a.name}” is over: end it, and the winning bid is paid to you.`
5216
+ : `Your auction “${a.name}” ended with no bid: take your lot back.`,
4881
5217
  });
4882
- });
5218
+ }
4883
5219
  for (const l of spec.lotteries ?? []) {
4884
5220
  // A lottery the page names but nobody has written yet (a raffle still to open) has nothing to finish.
4885
5221
  if (!(await client.account(loot.pda.lottery(project, l.id)))) continue;
@@ -4918,17 +5254,32 @@ export function solanaApp({
4918
5254
  const parts = e.buyer === me ? loot.partsOwed(r, i) : [];
4919
5255
  return parts.length ? [{ entry: i, parts }] : [];
4920
5256
  });
4921
- if (mine.length)
5257
+ if (mine.length) {
5258
+ // An NFT of these parts that the project no longer holds (its collection took it back or burned it) is
5259
+ // not sent anywhere: the claim settles its part without it, and says so here first.
5260
+ const { tickets, prizes } = await roundNfts(r, mine);
5261
+ const heldOf = tickets.length + prizes.length ? await heldEach([...tickets, ...prizes]) : new Map();
5262
+ const lost = list => [...new Set(list)].filter(nft => heldOf.get(nft)?.held === false);
5263
+ const [lostTickets, lostPrizes] = [lost(tickets), lost(prizes)];
5264
+ const back = r.status === 'Cancelled' || Boolean(r.terms.returns);
5265
+ const owed =
5266
+ r.status === 'Cancelled'
5267
+ ? `Recover your ticket payment for “${l.name}”.`
5268
+ : `Take what you are owed in “${l.name}”.`;
4922
5269
  out.push({
4923
5270
  kind: 'pull',
4924
5271
  key: l.key,
4925
5272
  round,
4926
5273
  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}”.`,
5274
+ ...(lostTickets.length + lostPrizes.length
5275
+ ? {
5276
+ gone: true,
5277
+ assets: [...lostTickets, ...lostPrizes],
5278
+ says: `${owed} ${goneRoundSays(lostTickets, lostPrizes, back)}`,
5279
+ }
5280
+ : { says: owed }),
4931
5281
  });
5282
+ }
4932
5283
  // Anyone may pay everyone else what they are owed, and the parts owed in common (about 0.002 SOL of account
4933
5284
  // rent for each payee who has no account for it yet, which stays with them): the last pull closes the round,
4934
5285
  // and its rent goes back to whoever fronted it (the lottery, or its first buyer).
@@ -5033,8 +5384,15 @@ export function solanaApp({
5033
5384
  * Finishes one of `claims()`, signed by this player. Returns the signature. A `release` flagged `gone` closes its
5034
5385
  * hold on the proof that the project no longer holds the NFT: nothing is sent, the hold's rent goes back to whoever
5035
5386
  * 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.
5387
+ * which loser's NFT they take (`{ take: asset }`, the NFT of a losing entry that still holds one; by default the
5388
+ * first found that the project still holds): only the winner can, while any are left.
5389
+ * An NFT a lottery's round or a contest owes that the project no longer holds (its collection took it back or
5390
+ * burned it) is not sent anywhere, and the claim still finishes. A ticket whose price sends it on (burned, given,
5391
+ * stashed) settles on the proof with nothing moved. A ticket going back to its buyer, a place's NFT and a
5392
+ * contest's NFT are only handed out by the program, which reads nothing first: the claim has it make a hold of
5393
+ * that NFT (`["hold", nft, 0]`) and closes that hold on the proof right after, in the same transaction when it
5394
+ * fits. The signer pays that hold's rent and gets it back at once. The program's receipts for it are those of a
5395
+ * hold made (`Held`, and `PrizeGiven` for a place's prize) and then `NftGone`: no `Released`.
5038
5396
  */
5039
5397
  async claim(c, { take, build = false, ...others } = NO_OPTIONS) {
5040
5398
  only(others, ['take', 'build'], 'claim');
@@ -5065,37 +5423,25 @@ export function solanaApp({
5065
5423
  // lets it pick (`take`: one asset, or several; the first left by default), as many as fit.
5066
5424
  const nfts = [];
5067
5425
  const kinds = {};
5426
+ // The NFTs of this claim that are proven gone from the project, each with what shows it and who staked it.
5427
+ // The contest only hands an NFT out and reads nothing, so each is passed as its hold (`["hold", nft, 0]`, made
5428
+ // for this player, who gets it), and that hold closes on the proof right after the claim: nothing is sent.
5429
+ const lost = new Map();
5068
5430
  if (contest.nft) {
5069
5431
  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) {
5432
+ for (const { j, nft } of await contestNfts(contest, entries, i, take)) {
5433
+ const { before, hasAuthority: _, gone, ...kind } = await leaving(nft, me, { to: me });
5434
+ proofs[nft] = before;
5435
+ kinds[nft] = kind;
5436
+ if (gone) lost.set(nft, { kind, player: me, rentTo: entries[j].player });
5091
5437
  nfts.push(j);
5092
- await kindOf(e.nft, me);
5093
5438
  }
5094
5439
  // A compressed NFT's proof is long (up to 17 nodes): such a claim moves one NFT, as a v0 transaction with a
5095
5440
  // lookup table (`send`), and the next claim takes the next.
5096
5441
  if (nfts.length > 1 && nfts.some(j => kinds[entries[j].nft]?.compressed)) nfts.splice(1);
5097
5442
  for (const j of nfts) given.before.push(...proofs[entries[j].nft]);
5098
5443
  }
5444
+ const closes = nfts.map(j => entries[j].nft).filter(nft => lost.has(nft));
5099
5445
  const claimed = [
5100
5446
  computeUnitLimit(800_000),
5101
5447
  ...given.before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
@@ -5109,12 +5455,14 @@ export function solanaApp({
5109
5455
  player: me,
5110
5456
  nfts: contest.nft ? nfts : undefined,
5111
5457
  kinds,
5458
+ hold: closes,
5112
5459
  collection: given.collection,
5113
5460
  poolMints: given.poolMints,
5114
5461
  items: given.items,
5115
5462
  nft: given.nft,
5116
5463
  programs,
5117
5464
  }),
5465
+ ...closes.map(nft => releaseOwed(nft, lost.get(nft).kind, lost.get(nft), me, g)),
5118
5466
  ];
5119
5467
  // A prize that rolls: asked for in the claim's own transaction and settled by a key after (one prompt).
5120
5468
  return contest.prize?.Roll && !build ? (await sendRolling(claimed)).signature : send(claimed);