@xgamesfun/solana 0.26.4 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/js/play/index.js CHANGED
@@ -371,23 +371,27 @@ export function solanaApp({
371
371
  before: tokenMetadata ? [computeUnitLimit(400_000), ...before] : [],
372
372
  };
373
373
  }
374
+ /** Whether a Core asset's account shows the project holds it: held by another, or burned (one byte left), it is gone. */
375
+ const coreHeld = account => account.data[0] === 1 && parseCoreAsset(account.data).owner === project;
374
376
  /**
375
- * Whether the project still holds `asset`, as the program reads it (`piece::held`): { held, evidence }. A Core asset by
376
- * its owner (one burned is gone); a Token Metadata NFT by the project's token account of it, and when that holds none,
377
- * by the account that holds it now or by its mint when it was burned (`evidence`, what a call-off passes to show it
378
- * gone); a compressed NFT by the RPC's DAS record of it (proven again by `nftOf`). `held` is null when nothing shows
379
- * either way.
377
+ * Whether the project still holds `asset`, as the program reads it (`piece::held`): { held, evidence, das }. A Core
378
+ * asset by its owner (one burned is gone); a Token Metadata NFT by the project's token account of it, and when that
379
+ * holds none, by the account that holds it now or by its mint when it was burned (`evidence`, what shows it gone); a
380
+ * compressed NFT by the RPC's DAS record of it (`das`, proven again on chain before anything relies on it). `held` is
381
+ * null when nothing shows either way. `holder`: a wallet thought to hold it now (a player handing it in), whose own
382
+ * token account is read before any search.
380
383
  */
381
- async function heldNow(asset) {
384
+ async function heldNow(asset, { holder = null } = {}) {
382
385
  const account = await client.account(asset);
383
- if (account?.owner === MPL_CORE)
384
- return { held: account.data[0] === 1 && parseCoreAsset(account.data).owner === project };
386
+ if (account?.owner === MPL_CORE) return { held: coreHeld(account) };
385
387
  if (account?.owner === TOKEN_PROGRAM) {
386
388
  const u64 = (data, at) => new DataView(data.buffer, data.byteOffset).getBigUint64(at, true);
387
389
  const amount = data => (data?.length >= 72 ? u64(data, 64) : 0n);
388
390
  if (amount((await client.account(loot.ata(project, asset)))?.data) === 1n) return { held: true };
389
391
  // Burned: its mint holds none.
390
392
  if (account.data.length >= 44 && u64(account.data, 36) === 0n) return { held: false, evidence: asset };
393
+ const own = holder ? loot.ata(holder, asset) : null;
394
+ if (own && amount((await client.account(own))?.data) === 1n) return { held: false, evidence: own };
391
395
  const found = await client.programAccounts(TOKEN_PROGRAM, [
392
396
  { dataSize: 165 },
393
397
  { memcmp: { offset: 0, bytes: asset } },
@@ -398,7 +402,50 @@ export function solanaApp({
398
402
  if (account) return { held: null };
399
403
  const das = await client.call('getAsset', { id: asset }).catch(() => null);
400
404
  if (!das?.compression?.compressed) return { held: null };
401
- return { held: !das.compression.burnt && das.ownership?.owner === project };
405
+ // DAS says a burned NFT at the asset's top level (`burnt`).
406
+ const burnt = das.burnt === true || das.compression.burnt === true;
407
+ return { held: !burnt && das.ownership?.owner === project, das };
408
+ }
409
+ /** `heldNow` of several NFTs: the Core assets from one read of them all, any other kind one by one. By asset. */
410
+ async function heldEach(assets) {
411
+ const unique = [...new Set(assets)];
412
+ const found = unique.length ? await client.accounts(unique) : [];
413
+ const out = new Map();
414
+ for (const [i, asset] of unique.entries())
415
+ out.set(asset, found[i]?.owner === MPL_CORE ? { held: coreHeld(found[i]) } : await heldNow(asset));
416
+ return out;
417
+ }
418
+ /**
419
+ * What proves `asset` gone from the project, as the instruction that lets go of it takes it (loot 0.27: a record
420
+ * whose NFT is proven gone closes): { before, kind }, or null when the project still holds it or nothing shows either
421
+ * way. Whoever controls a collection the project does not can take an NFT out of the project's hands or burn it. A
422
+ * Core asset shows it by itself (`kind`: its collection now); a Token Metadata NFT by the token account that holds it
423
+ * now, or its mint once burned (`kind.tokenMetadata.evidence`); a compressed NFT by its record, proven again as its
424
+ * leaf is now (`before`, by `signer`). `holder`: as `heldNow` takes it.
425
+ */
426
+ async function goneProof(asset, signer = wallet.address, { holder = null } = {}) {
427
+ const now = await heldNow(asset, { holder });
428
+ if (now.held !== false) return null;
429
+ if (now.das) {
430
+ const proof = await client.call('getAssetProof', { id: asset });
431
+ const tree = await client.account(now.das.compression.tree);
432
+ const compressed = loot.compressedFromDas({ asset: now.das, proof, treeData: tree.data });
433
+ return { before: [computeUnitLimit(1_000_000), loot.proveLeafIx({ signer, compressed })], kind: { compressed } };
434
+ }
435
+ if (now.evidence)
436
+ return { before: [], kind: { tokenMetadata: { programmable: false, ruleSet: null, evidence: now.evidence } } };
437
+ return { before: [], kind: { collection: (await collectionOf(asset)) ?? MPL_CORE } };
438
+ }
439
+ /**
440
+ * An NFT a record of the project's is to send on (`nftOf`, to `to`), or, when it is proven gone from the project
441
+ * (`goneProof`), what shows that, with `gone` true: the instruction then finishes without it and nothing is sent.
442
+ */
443
+ async function leaving(asset, signer = wallet.address, { to, payer = signer } = {}) {
444
+ const proof = await goneProof(asset, signer);
445
+ if (!proof) return { ...(await nftOf(asset, signer, { to, payer })), gone: false };
446
+ // A Core asset shows it by itself, with the accounts it always took.
447
+ if (!proof.kind.tokenMetadata && !proof.kind.compressed) return { ...(await nftOf(asset, signer)), gone: true };
448
+ return { before: proof.before, hasAuthority: false, ...proof.kind, gone: true };
402
449
  }
403
450
  /**
404
451
  * 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
@@ -1066,7 +1113,10 @@ export function solanaApp({
1066
1113
  /**
1067
1114
  * Waits for an order's roll (the oracle answers a moment after the open), then settles it. With `burner` (the
1068
1115
  * throwaway key the open funded), the burner signs and pays the settle, and gives the rest back: no wallet prompt.
1069
- * The order names it (`settler`), so it may settle; when the player or the project did first, the burner only gives its lamports back. `putBack`: NFTs
1116
+ * The order names it (`settler`), so it may settle; when the player or the project did first, the burner only gives its lamports back.
1117
+ * An NFT of the order the project no longer holds (its collection took it back or burned it) is passed over: a
1118
+ * stash's NFT won is not given (its place in `assets` is null) and the NFT rolled on is not sent back; `gone` lists
1119
+ * them, with `says`. `putBack`: NFTs
1070
1120
  * won that the player puts back into their stash in place of taking them (one whose collection now refuses to let it
1071
1121
  * move); `giveUp`: the places in the order's `won` of tokens or collectibles the player gives up because they can't
1072
1122
  * receive them (an account the token's issuer froze, a collection that refuses the mint): tokens given up stay in the
@@ -1191,6 +1241,9 @@ export function solanaApp({
1191
1241
  };
1192
1242
  };
1193
1243
  const holders = await holdersOf(order.shares);
1244
+ // NFTs of this order the project no longer holds (their collection took them back or burned them): the settle
1245
+ // passes each over on that proof. A stash's NFT won is then not given; the NFT rolled on is not sent back.
1246
+ const lost = new Set();
1194
1247
  // Given by its fate to a wallet that is an account of the loot program, the NFT goes back to the player.
1195
1248
  const gives = order.held ? order.fate?.then?.Give?.[0] : undefined;
1196
1249
  const handBack = gives !== undefined && loot.handsBack(await client.account(gives));
@@ -1207,15 +1260,25 @@ export function solanaApp({
1207
1260
  const {
1208
1261
  before,
1209
1262
  hasAuthority: _,
1263
+ gone,
1210
1264
  ...kind
1211
- } = await nftOf(won.Nft.nft, signer, { to: order.player, payer: wallet.address });
1265
+ } = await leaving(won.Nft.nft, signer, { to: order.player, payer: wallet.address });
1266
+ if (gone) lost.add(won.Nft.nft);
1212
1267
  nfts[won.Nft.nft] = kind;
1213
1268
  proving.push(...before);
1214
1269
  }
1215
1270
  }
1216
- // The NFT the roll was on: its kind, and for a compressed one a proof first.
1217
- const { before: subjectProof, ...subject } =
1218
- order.subject && order.subject !== NOBODY ? await nftOf(order.subject, signer) : { before: [] };
1271
+ // The NFT the roll was on: its kind, and for a compressed one a proof first. One the order holds may be gone.
1272
+ const {
1273
+ before: subjectProof,
1274
+ gone: subjectGone,
1275
+ ...subject
1276
+ } = order.subject && order.subject !== NOBODY
1277
+ ? order.held
1278
+ ? await leaving(order.subject, signer)
1279
+ : await nftOf(order.subject, signer)
1280
+ : { before: [] };
1281
+ if (subjectGone) lost.add(order.subject);
1219
1282
  const ix = loot.settleIx({
1220
1283
  signer,
1221
1284
  orderAddress,
@@ -1296,7 +1359,7 @@ export function solanaApp({
1296
1359
  if (burner && !keepBurner && !emptied) await emptyKey(burner);
1297
1360
  else if (burner && emptied) keys?.forget(burner.name);
1298
1361
  const given = new Set(giveUp.map(Number));
1299
- const assets = wonAssets(order).map((a, k) => (putBack.includes(a) || given.has(k) ? null : a));
1362
+ const assets = wonAssets(order).map((a, k) => (putBack.includes(a) || given.has(k) || lost.has(a) ? null : a));
1300
1363
  return {
1301
1364
  order: orderAddress,
1302
1365
  won: order.won,
@@ -1304,6 +1367,13 @@ export function solanaApp({
1304
1367
  settled: signature,
1305
1368
  ...(signatures.length ? { parts: [...signatures, signature] } : {}),
1306
1369
  ...(assets.find(Boolean) ? { asset: assets.find(Boolean) } : {}),
1370
+ // What the settle passed over: not given (a stash's NFT won), not sent back (the NFT rolled on).
1371
+ ...(lost.size
1372
+ ? {
1373
+ gone: [...lost],
1374
+ says: 'The project no longer holds an NFT of this order (whoever controls its collection took it back or burned it): it was not given or sent back. Everything else settled as rolled.',
1375
+ }
1376
+ : {}),
1307
1377
  };
1308
1378
  }
1309
1379
 
@@ -1595,7 +1665,8 @@ export function solanaApp({
1595
1665
  // A box that rolls on an NFT takes the one the player brings (`on`), whatever its kind.
1596
1666
  if (box.subject && !on) throw new Error('This box rolls on an NFT: say which (on).');
1597
1667
  // A box that burns it, gives it away or moves its numbers takes it into the project's hands until the roll settles.
1598
- if (box.subject && loot.boxHolds(box)) await noLeftovers(on, acceptLeftover);
1668
+ // What an earlier owner of it left in the project is cleared first (`clearLeftovers`).
1669
+ const clears = box.subject && loot.boxHolds(box) ? await clearLeftovers(on, acceptLeftover) : [];
1599
1670
  const brought = box.subject ? await nftOf(on) : { before: [] };
1600
1671
  const { before: proving, hasAuthority, ...kind } = brought;
1601
1672
  const subject = box.subject
@@ -1713,7 +1784,7 @@ export function solanaApp({
1713
1784
  x.keys[0]?.pubkey === associatedTokenAddress(wallet.address, NATIVE_MINT)
1714
1785
  ),
1715
1786
  );
1716
- const signature = await send(instructions, [], { keepWrapped: solPrize });
1787
+ const signature = await send(await closesFirst(clears, instructions), [], { keepWrapped: solPrize });
1717
1788
  // The slot it landed in: the order is read from a node that has seen it (a load-balanced RPC's node lagging
1718
1789
  // behind answers nothing for an order just opened).
1719
1790
  const landed = (await client.signatureStatus?.(signature).catch(() => null))?.slot ?? null;
@@ -1751,6 +1822,17 @@ export function solanaApp({
1751
1822
  );
1752
1823
  }
1753
1824
 
1825
+ /**
1826
+ * Refuses, in words, to buy or rent the listing of an NFT the project no longer holds (whoever controls its
1827
+ * collection took it back or burned it): nothing is there. Sent all the same, the take would only close the listing.
1828
+ */
1829
+ async function listedStill(asset) {
1830
+ if ((await heldNow(asset)).held === false)
1831
+ throw new Error(
1832
+ `The project no longer holds the NFT ${asset}: whoever controls its collection took it back or burned it while it was listed. There is nothing to buy or rent, and nothing was sent. Anyone can close this listing for nothing (closeGone): its rent goes back to its maker.`,
1833
+ );
1834
+ }
1835
+
1754
1836
  /** A box bought at once when it needs no roll (`buyBox`: a shop item, a bundle, a fixed pack of any count), or
1755
1837
  * opened and collected: one that rolls, gives a fresh item (its traits are rolled), or rolls on an NFT. A fixed pack
1756
1838
  * too big for one transaction is opened instead (the oracle answers it like any box). */
@@ -2084,52 +2166,68 @@ export function solanaApp({
2084
2166
  return { signature, releases };
2085
2167
  }
2086
2168
 
2087
- /**
2088
- * A hold's claim, or, when the project no longer holds its NFT (whoever controls its collection took it back or
2089
- * burned it while it was held), the same claim flagged `gone`: the hold's record still says the NFT is held, and it
2090
- * is not, so nothing can be sent.
2091
- */
2092
- async function goneOr(asset, claim) {
2093
- if ((await heldNow(asset)).held !== false) return claim;
2094
- return {
2095
- ...claim,
2096
- gone: true,
2097
- says: 'The project no longer holds this NFT: whoever controls its collection took it back or burned it while it was held. Its hold says otherwise, but nothing can be sent, and nothing was paid against it.',
2098
- };
2099
- }
2169
+ /** What closing a hold whose NFT is gone does, in words, for `claims()`. */
2170
+ const goneHoldSays = hold =>
2171
+ `The project no longer holds this NFT: whoever controls its collection took it back or burned it while it was held. Its hold can be closed now, by anyone: closing it sends nothing, and the hold's rent goes back to whoever paid it (${hold.payer}).${hold.debt ? ' It was a loan: nothing is repaid, the borrower keeps what they borrowed, and the lender gets neither the NFT nor the repayment.' : ''}`;
2172
+ /** The same for a listing. */
2173
+ const GONE_LISTING =
2174
+ '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.';
2100
2175
 
2101
2176
  /**
2102
- * The records of this project an earlier owner of `asset` left behind: its listing at
2103
- * `["listing", project, asset]` (one read) and its holds (one search, by the asset's address), each that names a wallet
2104
- * other than the player's. Each is { kind, address, says }. Such a record stays when whoever controls the NFT's
2105
- * collection moved it out of the project's hands without the program, and the program hands out whatever the project
2106
- * holds at that address: once this player's deposit puts the NFT back in the project's hands, the record can send it
2107
- * to the wallet it names. NFTs of the project's own collections are not read: those collections refuse the permanent
2108
- * delegates that take an NFT out.
2177
+ * This project's records of `asset`: its listing at `["listing", project, asset]` (one read) and its holds (one
2178
+ * search, by the asset's address). Each is { kind: 'listing' | 'hold', address, record }.
2109
2179
  */
2110
- async function leftovers(asset) {
2111
- if (ours(await collectionOf(asset))) return [];
2112
- const me = wallet.address;
2180
+ async function recordsOf(asset) {
2113
2181
  const out = [];
2114
2182
  const listingAt = loot.pda.listing(project, asset);
2115
2183
  const listing = await readMaybe('Listing', listingAt);
2116
- if (listing && listing.maker !== me) {
2117
- const sale = Boolean(listing.deal.Sell);
2118
- out.push({
2119
- kind: 'listing',
2120
- address: listingAt,
2121
- says: `A listing of this NFT (${listingAt}) is still open in this project, left by an earlier owner, ${listing.maker}. Once the project holds the NFT again, ${listing.maker} can take it home by closing that listing${sale ? `, and anyone can buy it at the listing's price, which is paid to ${listing.maker}` : ''}.`,
2122
- });
2123
- }
2184
+ if (listing) out.push({ kind: 'listing', address: listingAt, record: listing });
2124
2185
  const tag = { memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS.Hold)) } };
2125
2186
  const holds = await client.programAccounts(loot.LOOT_PROGRAM, [
2126
2187
  tag,
2127
2188
  { memcmp: { offset: 1, bytes: project } },
2128
2189
  { memcmp: { offset: 33, bytes: asset } },
2129
2190
  ]);
2191
+ for (const { pubkey, account } of holds)
2192
+ out.push({ kind: 'hold', address: pubkey, record: loot.decode('Hold', account.data) });
2193
+ return out;
2194
+ }
2195
+
2196
+ /**
2197
+ * The records of this project an earlier owner of `asset` left behind (`recordsOf`), each that names a wallet other
2198
+ * than the player's: { kind, address, record, says }. Such a record is left when whoever controls the NFT's
2199
+ * collection moved it out of the project's hands without the program. Until it is closed, the program hands out
2200
+ * whatever the project holds at that address: once this player's deposit puts the NFT back in the project's hands, the
2201
+ * record can send it to the wallet it names. `own`: the player's own listing of it counts too (a route that lists:
2202
+ * the program takes no second listing of an NFT while one is open). NFTs of the project's own collections are not
2203
+ * read: those collections refuse the permanent delegates that take an NFT out.
2204
+ */
2205
+ async function leftovers(asset, { own = false } = {}) {
2206
+ if (ours(await collectionOf(asset))) return [];
2207
+ const me = wallet.address;
2208
+ const out = [];
2130
2209
  const time = BigInt(await chainNow());
2131
- for (const { pubkey, account } of holds) {
2132
- const hold = loot.decode('Hold', account.data);
2210
+ const iso = at => new Date(Number(at) * 1000).toISOString();
2211
+ for (const r of await recordsOf(asset)) {
2212
+ if (r.kind === 'listing') {
2213
+ const listing = r.record;
2214
+ const mine = listing.maker === me;
2215
+ if (mine && !own) continue;
2216
+ const sale = Boolean(listing.deal.Sell);
2217
+ // A running rental is kept: nobody but its maker can even stop the listing until it has run.
2218
+ const rented =
2219
+ time < BigInt(listing.until)
2220
+ ? ` It is rented until ${iso(listing.until)}: the program keeps a running rental, so nobody can close this listing before then (anyone can after, for nothing).`
2221
+ : '';
2222
+ out.push({
2223
+ ...r,
2224
+ says: mine
2225
+ ? `Your own earlier listing of this NFT (${r.address}) is still open in this project.${rented}`
2226
+ : `A listing of this NFT (${r.address}) is still open in this project, left by an earlier owner, ${listing.maker}. Once the project holds the NFT again, ${listing.maker} can take it home by closing that listing${sale ? `, and anyone can buy it at the listing's price, which is paid to ${listing.maker}` : ''}.${rented}`,
2227
+ });
2228
+ continue;
2229
+ }
2230
+ const hold = r.record;
2133
2231
  const to = hold.player === project ? 'the project’s treasury' : hold.player;
2134
2232
  const names = [hold.player, hold.debt?.lender].filter(Boolean);
2135
2233
  if (names.every(x => x === me)) continue;
@@ -2140,31 +2238,137 @@ export function solanaApp({
2140
2238
  : `a loan's hold: anyone who repays it (${hold.debt.repay} to ${hold.debt.lender}) sends the NFT to ${to}, and once it is due anyone sends it to the lender`
2141
2239
  : due
2142
2240
  ? `a hold that is over: anyone can send the NFT to ${to} at once`
2143
- : `a hold that runs until ${new Date(Number(hold.free_at) * 1000).toISOString()}: the project's authority decides where the NFT goes until then, and anyone sends it to ${to} after`;
2241
+ : `a hold that runs until ${iso(hold.free_at)}: the project's authority decides where the NFT goes until then, and anyone sends it to ${to} after`;
2144
2242
  out.push({
2145
- kind: 'hold',
2146
- address: pubkey,
2147
- says: `This project still has ${what} (${pubkey}), left from an earlier owner of this NFT. Once the project holds the NFT again, it can be sent there.`,
2243
+ ...r,
2244
+ says: `This project still has ${what} (${r.address}), left from an earlier owner of this NFT. Once the project holds the NFT again, it can be sent there.`,
2148
2245
  });
2149
2246
  }
2150
2247
  return out;
2151
2248
  }
2152
2249
 
2153
2250
  /**
2154
- * Refuses, in words, to hand `asset` to the project while a record an earlier owner left can take it (`leftovers`),
2155
- * unless the caller says the player accepts that (`accept` true). `listing`: the route is a listing, which the program
2156
- * refuses outright while a listing of the NFT is open (its address is taken), whatever the option says.
2251
+ * The instructions that close `records` of `asset` (`recordsOf`) on the proof that the project no longer holds it
2252
+ * (`proof`, from `goneProof`), as `signer` sends them: a hold by `release`, a listing by `take_listing`. Anyone may
2253
+ * send either, at any time; nothing is sent or paid, and no project fee is taken. A listing whose rental still runs
2254
+ * can't be closed (`Rented`): the program keeps a running rental, and the listing closes once it has run.
2255
+ * Returns { closes, closed: [{ kind, address }], waits: the records left }.
2157
2256
  */
2158
- async function noLeftovers(asset, accept, { listing = false } = {}) {
2159
- const found = await leftovers(asset);
2160
- if (listing && found.some(x => x.kind === 'listing'))
2257
+ async function closesOf(asset, records, proof, signer = wallet.address) {
2258
+ const g = await projectNow();
2259
+ const time = BigInt(await chainNow());
2260
+ const closes = [];
2261
+ const closed = [];
2262
+ const waits = [];
2263
+ for (const r of records) {
2264
+ if (r.kind === 'listing') {
2265
+ if (time < BigInt(r.record.until)) {
2266
+ waits.push(r);
2267
+ continue;
2268
+ }
2269
+ closes.push(
2270
+ loot.takeListingIx({
2271
+ taker: signer,
2272
+ project,
2273
+ projectState: g,
2274
+ listing: r.record,
2275
+ holders: await holdersOf(r.record.shares ?? []),
2276
+ programs,
2277
+ ...proof.kind,
2278
+ }),
2279
+ );
2280
+ } else {
2281
+ const hold = r.record;
2282
+ closes.push(
2283
+ loot.releaseIx({
2284
+ signer,
2285
+ project,
2286
+ asset,
2287
+ ...proof.kind,
2288
+ hold,
2289
+ address: r.address,
2290
+ treasury: g.treasury,
2291
+ ...(hold.debt ? { to: hold.debt.lender } : {}),
2292
+ }),
2293
+ );
2294
+ }
2295
+ closed.push({ kind: r.kind, address: r.address });
2296
+ }
2297
+ return { closes: closes.length ? [...proof.before, ...closes] : [], closed, waits };
2298
+ }
2299
+
2300
+ /**
2301
+ * The deposit guard. Before the player hands `asset` to the project, what an earlier owner left of it there
2302
+ * (`leftovers`) is cleared: the player holds the NFT, so it is provably gone from the project, and each leftover hold
2303
+ * closes by `release` on that proof and a leftover listing by `take_listing` on it (anyone may, at any time, and
2304
+ * nothing is paid). Returns those instructions, tried in a dry run, to go in front of the deposit (`closesFirst`);
2305
+ * none when nothing was left. The closes cost the player their transaction fee only: each record's rent goes to
2306
+ * whoever that record names (a hold's payer, a listing's maker), never to the player.
2307
+ * What can't be closed still refuses the deposit, in words, with nothing sent: a listing whose rental still runs
2308
+ * (the program keeps it until the rental has run), or a record of an NFT nothing shows gone. `accept` true goes
2309
+ * ahead all the same (the closes that can be made still go first). `listing`: the route is a listing, which the
2310
+ * program refuses outright while a listing of the NFT is open (its address is taken), whatever the option says.
2311
+ */
2312
+ async function clearLeftovers(asset, accept, { listing = false } = {}) {
2313
+ const found = await leftovers(asset, { own: listing });
2314
+ if (!found.length) return [];
2315
+ const proof = await goneProof(asset, wallet.address, { holder: wallet.address });
2316
+ let { closes, waits } = proof ? await closesOf(asset, found, proof) : { closes: [], waits: found };
2317
+ let why = '';
2318
+ if (closes.length) {
2319
+ const trial = [computeUnitLimit(1_400_000), ...closes.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM)];
2320
+ // Tried in a dry run when they are one transaction as they stand; a longer list is made to fit as it is sent.
2321
+ const one = compileFitting({
2322
+ payer: wallet.address,
2323
+ blockhash: NOBODY,
2324
+ instructions: trial,
2325
+ tables: await standing(),
2326
+ });
2327
+ const tried = one ? await dryRun(wallet.address, trial) : { ok: true };
2328
+ if (!tried.ok) {
2329
+ why = tried.error ? ` Closing it was refused in a dry run: ${tried.error.msg} (${tried.error.name}).` : '';
2330
+ [closes, waits] = [[], found];
2331
+ }
2332
+ }
2333
+ const open = waits.find(x => x.kind === 'listing');
2334
+ if (listing && open)
2161
2335
  throw new Error(
2162
- `${found.find(x => x.kind === 'listing').says} The program refuses another listing of this NFT while that one is open (its address is taken): nothing was sent.`,
2336
+ `${open.says}${why} The program refuses another listing of this NFT while that one is open (its address is taken): nothing was sent.`,
2163
2337
  );
2164
- if (!found.length || accept === true) return;
2165
- throw new Error(
2166
- `${found.map(x => x.says).join(' ')} This deposit would put the NFT where that record can take it: it would be lost to you. Nothing was sent. Go ahead only if the player accepts this: pass acceptLeftover: true.`,
2167
- );
2338
+ if (waits.length && accept !== true)
2339
+ throw new Error(
2340
+ `${waits.map(x => x.says).join(' ')}${why} This deposit would put the NFT where that record can take it: it would be lost to you. Nothing was sent. Go ahead only if the player accepts this: pass acceptLeftover: true.`,
2341
+ );
2342
+ return closes;
2343
+ }
2344
+
2345
+ /**
2346
+ * A deposit's `instructions` with `closes` (`clearLeftovers`) in front of them: in the same transaction when both
2347
+ * fit one as the wallet sends it (a legacy one, or one with the project's standing tables); when they do not, the
2348
+ * closes are sent first in a transaction of their own, and the deposit's instructions come back alone.
2349
+ */
2350
+ async function closesFirst(closes, instructions) {
2351
+ if (!closes.length) return instructions;
2352
+ const isLimit = ix => ix.programId === COMPUTE_BUDGET_PROGRAM && ix.data[0] === 2;
2353
+ // One proof of a compressed NFT serves both: the closes read its record first, then the deposit moves it.
2354
+ const proven = new Set(closes.filter(isProveLeaf).map(ix => ix.keys[1].pubkey));
2355
+ const together = [
2356
+ computeUnitLimit(1_400_000),
2357
+ ...closes.filter(ix => !isLimit(ix)),
2358
+ ...instructions.filter(ix => !isLimit(ix) && !(isProveLeaf(ix) && proven.has(ix.keys[1].pubkey))),
2359
+ ];
2360
+ const fits =
2361
+ Boolean(
2362
+ compileFitting({
2363
+ payer: wallet.address,
2364
+ blockhash: NOBODY,
2365
+ instructions: loot.withLeafCloses(together),
2366
+ tables: await standing(),
2367
+ }),
2368
+ ) && transactionLocks(wallet.address, together) <= 64;
2369
+ if (fits) return together;
2370
+ await send(closes);
2371
+ return instructions;
2168
2372
  }
2169
2373
 
2170
2374
  /** Who holds each collectible a split's holder shares name now: { asset: holder }. */
@@ -2596,15 +2800,18 @@ export function solanaApp({
2596
2800
  return out;
2597
2801
  },
2598
2802
 
2599
- /** Gives an order's payment back once its time has come (a roll that failed or never came). */
2803
+ /** Gives an order's payment back once its time has come (a roll that failed or never came). An NFT the project
2804
+ * held for the roll goes back with it; one it no longer holds (its collection took it back or burned it) is not
2805
+ * looked for, and the whole payment goes back all the same. */
2600
2806
  async refund(orderAddress) {
2601
2807
  const order = await read('Order', orderAddress);
2602
2808
  // An NFT the project held for the roll goes back.
2603
2809
  const {
2604
2810
  before = [],
2605
2811
  hasAuthority: _,
2812
+ gone: _gone,
2606
2813
  ...subject
2607
- } = order.held ? await nftOf(order.subject, wallet.address, { to: order.player }) : {};
2814
+ } = order.held ? await leaving(order.subject, wallet.address, { to: order.player }) : {};
2608
2815
  return send([...before, loot.refundIx({ signer: wallet.address, orderAddress, order, subject, programs })]);
2609
2816
  },
2610
2817
 
@@ -2839,12 +3046,13 @@ export function solanaApp({
2839
3046
  shown ??= await passing(lottery.gate, 'buy a ticket');
2840
3047
  listProof = listProofOf(lottery.gate, l.gate?.list, listProof);
2841
3048
  let nft;
3049
+ let clears = [];
2842
3050
  const before = [];
2843
3051
  if (terms.nft_ticket) {
2844
3052
  if (!paidWith) throw new Error(`A ticket of “${l.name}” costs an NFT: say which (nft).`);
2845
3053
  if (BigInt(count) !== 1n) throw new Error('An NFT pays for one ticket at a time.');
2846
- // The project holds an NFT ticket until the round is drawn.
2847
- await noLeftovers(paidWith, acceptLeftover);
3054
+ // The project holds an NFT ticket until the round is drawn: what an earlier owner left of it goes first.
3055
+ clears = await clearLeftovers(paidWith, acceptLeftover);
2848
3056
  const { before: proof, ...kind } = await nftOf(paidWith);
2849
3057
  nft = { asset: paidWith, ...kind };
2850
3058
  before.push(...proof);
@@ -2914,9 +3122,12 @@ export function solanaApp({
2914
3122
  }
2915
3123
  const buy = [...before, ...withSolWrap(wallet.address, mint, BigInt(terms.price) * BigInt(count), [ix])];
2916
3124
  return send(
2917
- draw.length
2918
- ? [computeUnitLimit(800_000), ...draw, ...buy.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM)]
2919
- : buy,
3125
+ await closesFirst(
3126
+ clears,
3127
+ draw.length
3128
+ ? [computeUnitLimit(800_000), ...draw, ...buy.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM)]
3129
+ : buy,
3130
+ ),
2920
3131
  );
2921
3132
  },
2922
3133
 
@@ -3012,9 +3223,9 @@ export function solanaApp({
3012
3223
  only(others, ['extra', 'transfers', 'acceptLeftover'], 'stakeNft');
3013
3224
  const f = part('farms', farmKey);
3014
3225
  const farm = await read('Farm', loot.pda.farm(project, f.id));
3015
- // A farm that keeps its NFTs in escrow hands this one to the project; one that freezes or watches leaves it in the
3016
- // wallet, where no record of the project's can move it.
3017
- if (farm.custody === 'Escrow') await noLeftovers(asset, acceptLeftover);
3226
+ // A farm that keeps its NFTs in escrow hands this one to the project (what an earlier owner left of it there is
3227
+ // cleared first); one that freezes or watches leaves it in the wallet, where no record of the project's can move it.
3228
+ const clears = farm.custody === 'Escrow' ? await clearLeftovers(asset, acceptLeftover) : [];
3018
3229
  await openFor(f, farm, tier);
3019
3230
  const token = await farmTokens([farm.rewards_mint], [], { extra, transfers });
3020
3231
  const { before, ...nft } = await nftOf(asset);
@@ -3036,11 +3247,24 @@ export function solanaApp({
3036
3247
  // prize and as a forfeit, is sized before it goes in, and an NFT that could never come back out isn't staked.
3037
3248
  if (!farm.farmed.Tokens) await exitFits(f, farm, asset, nft, before, tier, token);
3038
3249
  return send(
3039
- await walking(f, farm, [
3040
- ...refresh,
3041
- ...before,
3042
- loot.stakeNftIx({ player: wallet.address, project, id: f.id, farm, asset, ...nft, previous, tier, ...token }),
3043
- ]),
3250
+ await closesFirst(
3251
+ clears,
3252
+ await walking(f, farm, [
3253
+ ...refresh,
3254
+ ...before,
3255
+ loot.stakeNftIx({
3256
+ player: wallet.address,
3257
+ project,
3258
+ id: f.id,
3259
+ farm,
3260
+ asset,
3261
+ ...nft,
3262
+ previous,
3263
+ tier,
3264
+ ...token,
3265
+ }),
3266
+ ]),
3267
+ ),
3044
3268
  );
3045
3269
  },
3046
3270
 
@@ -3081,8 +3305,10 @@ export function solanaApp({
3081
3305
  const boxes = roll != null ? { [roll]: await read('LootBox', loot.pda.box(project, roll)) } : {};
3082
3306
  // A mission whose box holds the NFT it rolls on takes it into the project's hands on the way out, from a farm that
3083
3307
  // left it in the wallet.
3084
- if (roll != null && farm.custody !== 'Escrow' && loot.boxHolds(boxes[roll]))
3085
- await noLeftovers(asset, acceptLeftover);
3308
+ const clears =
3309
+ roll != null && farm.custody !== 'Escrow' && loot.boxHolds(boxes[roll])
3310
+ ? await clearLeftovers(asset, acceptLeftover)
3311
+ : [];
3086
3312
  // A roll still at the NFT's order address blocks the leave, unless it was asked and came back with nothing to give
3087
3313
  // (Waiting, asked once): that one stands in for this. Any other is asked (its player or the project's keys), answered and settled first.
3088
3314
  if (roll != null) {
@@ -3125,7 +3351,7 @@ export function solanaApp({
3125
3351
  { code: tried.error.code, name: 'PrizeNotCovered' },
3126
3352
  );
3127
3353
  }
3128
- const { signature, rolls } = await sendRolling(steps);
3354
+ const { signature, rolls } = await sendRolling(await closesFirst(clears, steps));
3129
3355
  const rolled = rolls.length ? { rolled: rolls[0] } : {};
3130
3356
  if (roll != null)
3131
3357
  return {
@@ -3466,23 +3692,37 @@ export function solanaApp({
3466
3692
  /**
3467
3693
  * Hands an NFT of any kind to the project until a time (a hold): `seconds` from now, or `{ until }` (Unix seconds).
3468
3694
  * The hold's address names its end. Returns { signature, freeAt }.
3695
+ *
3696
+ * Like every route that hands an NFT in (`stakeNft` in escrow, `borrow`, `enter`, `bid`, `listForSale`,
3697
+ * `listForRent`, a ticket paid with an NFT, a box that rolls on one), it first clears what an earlier owner of the
3698
+ * NFT left in this project: a hold or a listing that stayed open when whoever controls the NFT's collection took it
3699
+ * out of the project's hands. Left open, such a record could take the NFT once the project holds it again. The
3700
+ * player holds the NFT now, so the program closes each on that proof (a hold by `release`, a listing by
3701
+ * `take_listing`, nothing sent or paid), in front of the deposit in the same transaction, or in a transaction of
3702
+ * their own before it when the two do not fit one. The closes cost the player their transaction fee only: each
3703
+ * record's rent goes to whoever that record names (a hold's payer, a listing's maker), never to the player. Only
3704
+ * what can't be closed still refuses the deposit, in words (a listing whose rental still runs: the program keeps
3705
+ * it until the rental has run); `acceptLeftover: true` goes ahead all the same. NFTs of the project's own
3706
+ * collections are not read.
3469
3707
  */
3470
3708
  async hold(asset, seconds, { acceptLeftover, ...others } = NO_OPTIONS) {
3471
3709
  only(others, ['acceptLeftover'], 'hold');
3472
- await noLeftovers(asset, acceptLeftover);
3710
+ const clears = await clearLeftovers(asset, acceptLeftover);
3473
3711
  const freeAt = BigInt(typeof seconds === 'object' ? seconds.until : now() + Number(seconds));
3474
3712
  const { before, hasAuthority: _, ...nft } = await nftOf(asset);
3475
- const signature = await send([
3476
- ...before,
3477
- loot.holdIx({
3478
- player: wallet.address,
3479
- project,
3480
- asset,
3481
- ...nft,
3482
- collection: nft.collection ?? (await collectionOf(asset)) ?? MPL_CORE,
3483
- freeAt,
3484
- }),
3485
- ]);
3713
+ const signature = await send(
3714
+ await closesFirst(clears, [
3715
+ ...before,
3716
+ loot.holdIx({
3717
+ player: wallet.address,
3718
+ project,
3719
+ asset,
3720
+ ...nft,
3721
+ collection: nft.collection ?? (await collectionOf(asset)) ?? MPL_CORE,
3722
+ freeAt,
3723
+ }),
3724
+ ]),
3725
+ );
3486
3726
  return { signature, freeAt };
3487
3727
  },
3488
3728
 
@@ -3540,7 +3780,7 @@ export function solanaApp({
3540
3780
  */
3541
3781
  async borrow(maker, id, asset, { due, acceptRisks = false, acceptLeftover, extra, ...others } = NO_OPTIONS) {
3542
3782
  only(others, ['due', 'acceptRisks', 'acceptLeftover', 'extra'], 'borrow');
3543
- await noLeftovers(asset, acceptLeftover);
3783
+ const clears = await clearLeftovers(asset, acceptLeftover);
3544
3784
  const loan = await this.loanOffer(maker, id);
3545
3785
  extra ??= await hookTail([loan.mint]);
3546
3786
  if (loan.risks.length && acceptRisks !== true)
@@ -3564,11 +3804,13 @@ export function solanaApp({
3564
3804
  programs: loanPrograms,
3565
3805
  extra,
3566
3806
  });
3567
- const signature = await send([
3568
- ...before,
3569
- createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, loan.mint, loan.program),
3570
- ix,
3571
- ]);
3807
+ const signature = await send(
3808
+ await closesFirst(clears, [
3809
+ ...before,
3810
+ createAssociatedTokenAccountIdempotent(wallet.address, wallet.address, loan.mint, loan.program),
3811
+ ix,
3812
+ ]),
3813
+ );
3572
3814
  return { signature, due, hold: loot.pda.hold(asset, due) };
3573
3815
  },
3574
3816
 
@@ -3593,7 +3835,9 @@ export function solanaApp({
3593
3835
  * borrower in the same instruction: one prompt. `repay`: the debt the payer was shown (`loan()`'s, in base units);
3594
3836
  * the program refuses any other (`TermsChanged`). An NFT its collection took back or burned while the project held
3595
3837
  * it is refused here, before anything is signed: the program takes no repayment for it, and the borrower keeps what
3596
- * they borrowed. When the NFT can't move now (frozen for now, or its collection refuses the move), or `home` is
3838
+ * they borrowed. Such a loan is closed for nothing instead: `claims()` lists it for its borrower and its lender, and
3839
+ * `claim` of it closes the hold (nothing is repaid, and the hold's rent goes back to whoever paid it). When the NFT
3840
+ * can't move now (frozen for now, or its collection refuses the move), or `home` is
3597
3841
  * false, the money goes alone, so the loan does not fall due: the hold is then over, and anyone sends the NFT home
3598
3842
  * once it can move (`claims()` lists it). A compressed NFT is proven in the same transaction; the money alone for one
3599
3843
  * whose proof keeps going out of date goes without it. Returns { signature, home }, and `says` when the NFT waits.
@@ -3614,7 +3858,7 @@ export function solanaApp({
3614
3858
  );
3615
3859
  if ((await heldNow(asset)).held === false)
3616
3860
  throw new Error(
3617
- `The project no longer holds the NFT ${asset}: whoever controls its collection took it back or burned it while it was held. The program takes no repayment for it (NotTheNft): the borrower keeps what they borrowed.`,
3861
+ `The project no longer holds the NFT ${asset}: whoever controls its collection took it back or burned it while it was held. The program takes no repayment for it (NotTheNft), so nothing was sent: the borrower keeps what they borrowed. This loan can be closed for nothing: claims() lists it for its borrower and for its lender, and claim() of it closes the hold (nothing is repaid, and the hold's rent goes back to whoever paid it).`,
3618
3862
  );
3619
3863
  const { before, hasAuthority: _, ...nft } = await nftOf(asset, wallet.address, { to: hold.player });
3620
3864
  const kind = nft.compressed || nft.tokenMetadata ? nft : { collection: (await collectionOf(asset)) ?? MPL_CORE };
@@ -3672,23 +3916,25 @@ export function solanaApp({
3672
3916
  listProof = listProofOf(contest.gate, null, listProof);
3673
3917
  if (contest.nft) {
3674
3918
  if (!staked) throw new Error('This contest takes an NFT: say which (nft).');
3675
- await noLeftovers(staked, acceptLeftover);
3919
+ const clears = await clearLeftovers(staked, acceptLeftover);
3676
3920
  const { before, ...kind } = await nftOf(staked);
3677
- return send([
3678
- ...before,
3679
- loot.enterIx({
3680
- player: wallet.address,
3681
- project,
3682
- id,
3683
- contest,
3684
- side,
3685
- amount: 1n,
3686
- shown,
3687
- listProof,
3688
- nft: { asset: staked, ...kind },
3689
- programs,
3690
- }),
3691
- ]);
3921
+ return send(
3922
+ await closesFirst(clears, [
3923
+ ...before,
3924
+ loot.enterIx({
3925
+ player: wallet.address,
3926
+ project,
3927
+ id,
3928
+ contest,
3929
+ side,
3930
+ amount: 1n,
3931
+ shown,
3932
+ listProof,
3933
+ nft: { asset: staked, ...kind },
3934
+ programs,
3935
+ }),
3936
+ ]),
3937
+ );
3692
3938
  }
3693
3939
  const t = [...tokens.values()].find(x => mintOf(x.key) === contest.mint);
3694
3940
  const value = units(amount, t?.source === 'sol' ? 9 : (t?.decimals ?? 0));
@@ -3803,7 +4049,8 @@ export function solanaApp({
3803
4049
  if (bundle.length > loot.MAX_BID_NFTS) throw new Error(`A bid holds at most ${loot.MAX_BID_NFTS} NFTs.`);
3804
4050
  if (auction.held.length >= loot.MAX_BID_NFTS)
3805
4051
  throw new Error(`The high bid holds ${loot.MAX_BID_NFTS} NFTs, the most a bid holds: it can't be topped.`);
3806
- for (const asset of bundle) await noLeftovers(asset, acceptLeftover);
4052
+ const clears = [];
4053
+ for (const asset of bundle) clears.push(...(await clearLeftovers(asset, acceptLeftover)));
3807
4054
  const before = [];
3808
4055
  const kindOf = async (asset, to) => {
3809
4056
  const { before: proof, ...kind } = await nftOf(asset, wallet.address, { to });
@@ -3826,7 +4073,13 @@ export function solanaApp({
3826
4073
  held,
3827
4074
  programs,
3828
4075
  });
3829
- return send([computeUnitLimit(800_000), ...before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM), ix]);
4076
+ return send(
4077
+ await closesFirst(clears, [
4078
+ computeUnitLimit(800_000),
4079
+ ...before.filter(x => x.programId !== COMPUTE_BUDGET_PROGRAM),
4080
+ ix,
4081
+ ]),
4082
+ );
3830
4083
  }
3831
4084
  const mint = auction.mint;
3832
4085
  const value = units(amount, decimalsOf(a.currency));
@@ -3938,21 +4191,23 @@ export function solanaApp({
3938
4191
  */
3939
4192
  async listForSale(asset, { price, currency, shares = [], acceptLeftover, ...others } = NO_OPTIONS) {
3940
4193
  only(others, ['price', 'currency', 'shares', 'acceptLeftover'], 'listForSale');
3941
- await noLeftovers(asset, acceptLeftover, { listing: true });
4194
+ const clears = await clearLeftovers(asset, acceptLeftover, { listing: true });
3942
4195
  const mint = mintOf(currency);
3943
4196
  const { before, ...nft } = await nftOf(asset);
3944
- return send([
3945
- ...before,
3946
- loot.openListingIx({
3947
- maker: wallet.address,
3948
- project,
3949
- asset,
3950
- mint,
3951
- deal: { Sell: { price: units(price, decimalsOf(currency)) } },
3952
- shares,
3953
- ...nft,
3954
- }),
3955
- ]);
4197
+ return send(
4198
+ await closesFirst(clears, [
4199
+ ...before,
4200
+ loot.openListingIx({
4201
+ maker: wallet.address,
4202
+ project,
4203
+ asset,
4204
+ mint,
4205
+ deal: { Sell: { price: units(price, decimalsOf(currency)) } },
4206
+ shares,
4207
+ ...nft,
4208
+ }),
4209
+ ]),
4210
+ );
3956
4211
  },
3957
4212
 
3958
4213
  /**
@@ -3964,31 +4219,40 @@ export function solanaApp({
3964
4219
  { price, lasts, maxPeriods = 1, currency, shares = [], acceptLeftover, ...others } = NO_OPTIONS,
3965
4220
  ) {
3966
4221
  only(others, ['price', 'lasts', 'maxPeriods', 'currency', 'shares', 'acceptLeftover'], 'listForRent');
3967
- await noLeftovers(asset, acceptLeftover, { listing: true });
4222
+ const clears = await clearLeftovers(asset, acceptLeftover, { listing: true });
3968
4223
  const mint = mintOf(currency);
3969
4224
  const { before, ...nft } = await nftOf(asset);
3970
- return send([
3971
- ...before,
3972
- loot.openListingIx({
3973
- maker: wallet.address,
3974
- project,
3975
- asset,
3976
- mint,
3977
- deal: { Rent: { price: units(price, decimalsOf(currency)), lasts, max_periods: maxPeriods } },
3978
- shares,
3979
- ...nft,
3980
- }),
3981
- ]);
4225
+ return send(
4226
+ await closesFirst(clears, [
4227
+ ...before,
4228
+ loot.openListingIx({
4229
+ maker: wallet.address,
4230
+ project,
4231
+ asset,
4232
+ mint,
4233
+ deal: { Rent: { price: units(price, decimalsOf(currency)), lasts, max_periods: maxPeriods } },
4234
+ shares,
4235
+ ...nft,
4236
+ }),
4237
+ ]),
4238
+ );
3982
4239
  },
3983
4240
 
3984
- /** The listing of `asset` (one account read), or null: { listing, asset, maker, mint, deal, renter, until }. */
4241
+ /**
4242
+ * The listing of `asset` (one account read), or null: { listing, asset, maker, mint, deal, renter, until }. One more
4243
+ * read says whether the project still holds the NFT: when whoever controls its collection took it back or burned
4244
+ * it, the listing is flagged `gone` with `says`, what that means in words. There is then nothing to buy or rent,
4245
+ * and the listing can be closed for nothing (`unlist` by its maker, `closeGone` by anyone).
4246
+ */
3985
4247
  async listing(asset) {
3986
4248
  const listing = await readMaybe('Listing', loot.pda.listing(project, asset));
3987
4249
  if (!listing) return null;
4250
+ const gone = (await heldNow(asset)).held === false;
3988
4251
  return {
3989
4252
  listing: loot.pda.listing(project, asset),
3990
4253
  ...listing,
3991
4254
  renter: loot.renterOf(listing, await chainNow()),
4255
+ ...(gone ? { gone: true, says: GONE_LISTING } : {}),
3992
4256
  };
3993
4257
  },
3994
4258
 
@@ -4002,7 +4266,12 @@ export function solanaApp({
4002
4266
  return holds ? renter : null;
4003
4267
  },
4004
4268
 
4005
- /** The project's listings, a maker's (`maker`) or all: one search of the program's accounts. */
4269
+ /**
4270
+ * The project's listings, a maker's (`maker`) or all: one search of the program's accounts, then one read of the
4271
+ * listed NFTs together, which flags a listing `gone` (with `says`) when the project no longer holds its NFT: a
4272
+ * Core asset held by another or burned, a Token Metadata NFT whose token left the project's account. A compressed
4273
+ * NFT is not read here (an indexer's answer for each): `listing(asset)` says it for one.
4274
+ */
4006
4275
  async listings({ maker, ...others } = NO_OPTIONS) {
4007
4276
  only(others, ['maker'], 'listings');
4008
4277
  const filters = [
@@ -4011,19 +4280,36 @@ export function solanaApp({
4011
4280
  ...(maker ? [{ memcmp: { offset: 33, bytes: maker } }] : []),
4012
4281
  ];
4013
4282
  const time = await chainNow();
4014
- return (await client.programAccounts(loot.LOOT_PROGRAM, filters)).map(({ pubkey, account }) => {
4283
+ const found = (await client.programAccounts(loot.LOOT_PROGRAM, filters)).map(({ pubkey, account }) => {
4015
4284
  const listing = loot.decode('Listing', account.data);
4016
4285
  return { listing: pubkey, ...listing, renter: loot.renterOf(listing, time) };
4017
4286
  });
4287
+ // Each NFT and the project's token account of it, all in one read.
4288
+ const read = found.length ? await client.accounts(found.flatMap(l => [l.asset, loot.ata(project, l.asset)])) : [];
4289
+ return found.map((l, i) => {
4290
+ const [nft, held] = [read[2 * i], read[2 * i + 1]];
4291
+ const gone =
4292
+ nft?.owner === MPL_CORE
4293
+ ? !coreHeld(nft)
4294
+ : nft?.owner === TOKEN_PROGRAM &&
4295
+ !(
4296
+ held?.data?.length >= 72 &&
4297
+ new DataView(held.data.buffer, held.data.byteOffset).getBigUint64(64, true) === 1n
4298
+ );
4299
+ return gone ? { ...l, gone: true, says: GONE_LISTING } : l;
4300
+ });
4018
4301
  },
4019
4302
 
4020
4303
  /**
4021
4304
  * Buys a listed NFT at the terms the player saw: `listing`, as `listing()` or `listings()` showed it. It is
4022
4305
  * required: the terms signed are those, never the listing read now, so a price or a share changed since is refused.
4306
+ * A listing whose NFT the project no longer holds (its collection took it back or burned it) is refused in words
4307
+ * before anything is signed: nothing is there to buy, and anyone can close that listing for nothing (`closeGone`).
4023
4308
  */
4024
4309
  async buyListing(asset, { listing, ...others } = NO_OPTIONS) {
4025
4310
  only(others, ['listing'], 'buyListing');
4026
4311
  await shownListing(asset, listing, 'Sell');
4312
+ await listedStill(asset);
4027
4313
  const { before, ...nft } = await nftOf(asset, wallet.address, { to: wallet.address });
4028
4314
  const holders = await holdersOf(listing.shares ?? []);
4029
4315
  return send([
@@ -4044,11 +4330,13 @@ export function solanaApp({
4044
4330
 
4045
4331
  /**
4046
4332
  * Rents a listed NFT for `periods` periods, at the terms the player saw: `listing`, as `listing()` or `listings()`
4047
- * showed it (required, as for `buyListing`).
4333
+ * showed it (required, as for `buyListing`). One whose NFT the project no longer holds is refused in words, as
4334
+ * `buyListing` refuses it.
4048
4335
  */
4049
4336
  async rentListing(asset, { periods = 1, listing, ...others } = NO_OPTIONS) {
4050
4337
  only(others, ['periods', 'listing'], 'rentListing');
4051
4338
  await shownListing(asset, listing, 'Rent');
4339
+ await listedStill(asset);
4052
4340
  const holders = await holdersOf(listing.shares ?? []);
4053
4341
  const total = BigInt(listing.deal.Rent.price) * BigInt(periods);
4054
4342
  // A compressed NFT is read by its record, proven first in the same transaction.
@@ -4070,13 +4358,46 @@ export function solanaApp({
4070
4358
  ]);
4071
4359
  },
4072
4360
 
4073
- /** Takes a listed NFT home (its maker). While a rental runs it only stops the listing; call again once it is over. */
4361
+ /**
4362
+ * Takes a listed NFT home (its maker). While a rental runs it only stops the listing; call again once it is over.
4363
+ * A listing whose NFT the project no longer holds (its collection took it back or burned it) closes without it, on
4364
+ * that proof: nothing is sent, and the listing's rent comes back to its maker. While a rental of it still runs,
4365
+ * that close too only stops the listing, and closes it once the rental has run.
4366
+ */
4074
4367
  async unlist(asset) {
4075
4368
  const listing = await read('Listing', loot.pda.listing(project, asset));
4369
+ const proof = await goneProof(asset);
4370
+ if (proof)
4371
+ return send([...proof.before, loot.closeListingIx({ maker: wallet.address, project, listing, ...proof.kind })]);
4076
4372
  const { before, ...nft } = await nftOf(asset, wallet.address, { to: wallet.address });
4077
4373
  return send([...before, loot.closeListingIx({ maker: wallet.address, project, listing, ...nft })]);
4078
4374
  },
4079
4375
 
4376
+ /**
4377
+ * 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.
4386
+ */
4387
+ async closeGone(asset) {
4388
+ const records = await recordsOf(asset);
4389
+ if (!records.length) return { signature: null, closed: [], waits: [] };
4390
+ const proof = await goneProof(asset);
4391
+ if (!proof)
4392
+ throw new Error(
4393
+ `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
+ );
4395
+ 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 };
4399
+ },
4400
+
4080
4401
  // ---------------------------------------------------------------- raffles
4081
4402
 
4082
4403
  /**
@@ -4413,7 +4734,9 @@ export function solanaApp({
4413
4734
 
4414
4735
  /**
4415
4736
  * What this player can finish now, themselves: a box to settle or refund, a contest entry to claim, a held
4416
- * collectible to take home, an auction they won (or one off by the clock: their bid back), a lottery place they
4737
+ * collectible to take home, a hold of theirs or a loan they made or took whose NFT the project no longer holds (its
4738
+ * collection took it back or burned it: listed at any time, also while the hold runs, as a `release` flagged
4739
+ * `gone`, whose claim closes the hold and sends nothing), an auction they won (or one off by the clock: their bid back), a lottery place they
4417
4740
  * won, a draw that is due (anyone may reveal it, or ask again when the oracle never answered; it takes 0.0008
4418
4741
  * SOL), and a drawn lottery's unpaid places, which anyone may pay so the next round can open. [{ kind, says, … }];
4419
4742
  * a winner's first claim of a contest of tokens carries `potUnits` and `prizeUnits`, its part as the decision wrote
@@ -4486,46 +4809,31 @@ export function solanaApp({
4486
4809
  });
4487
4810
  }
4488
4811
  // The player's holds; the project's treasury wallet's also those owed to the treasury (a hold whose player is the
4489
- // project's own address: a dropped stash NFT, a lottery's or an auction's NFT owed to it).
4812
+ // project's own address: a dropped stash NFT, a lottery's or an auction's NFT owed to it). And the loans this
4813
+ // wallet made: one search of the project's holds finds them all.
4490
4814
  const g = await projectNow();
4491
4815
  const holdsOf = [me, ...(g.treasury === me ? [project] : [])];
4492
- const holds = (
4493
- await Promise.all(
4494
- holdsOf.map(who => client.programAccounts(loot.LOOT_PROGRAM, [tag('Hold'), at(1, project), at(65, who)])),
4495
- )
4496
- ).flat();
4497
- for (const { pubkey, account } of holds) {
4498
- const hold = loot.decode('Hold', account.data);
4499
- // A repaid loan is a plain hold that is over (no debt, `free_at` 0): its NFT goes home whenever. An unpaid one's
4500
- // is the lender's once due.
4501
- if (!hold.debt && time >= BigInt(hold.free_at))
4502
- out.push(
4503
- await goneOr(hold.asset, {
4504
- kind: 'release',
4505
- asset: hold.asset,
4506
- hold: pubkey,
4507
- says:
4508
- hold.player === project
4509
- ? 'An NFT owed to the treasury: send it there.'
4510
- : 'Your collectible back: its hold is over.',
4511
- }),
4512
- );
4513
- }
4514
- // A loan this wallet made that is overdue and unpaid: its NFT is the lender's to take.
4515
- for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [
4516
- tag('Hold'),
4517
- at(1, project),
4518
- ])) {
4519
- const hold = loot.decode('Hold', account.data);
4520
- if (hold.debt?.lender === me && time >= BigInt(hold.free_at))
4521
- out.push(
4522
- await goneOr(hold.asset, {
4523
- kind: 'release',
4524
- asset: hold.asset,
4525
- hold: pubkey,
4526
- says: 'A loan you made is overdue and unpaid: take its NFT.',
4527
- }),
4528
- );
4816
+ const holds = (await client.programAccounts(loot.LOOT_PROGRAM, [tag('Hold'), at(1, project)]))
4817
+ .map(({ pubkey, account }) => ({ pubkey, hold: loot.decode('Hold', account.data) }))
4818
+ .filter(({ hold }) => holdsOf.includes(hold.player) || hold.debt?.lender === me);
4819
+ // Whether the project still holds each one's NFT: whoever controls its collection can take it back or burn it.
4820
+ const heldOf = await heldEach(holds.map(({ hold }) => hold.asset));
4821
+ for (const { pubkey, hold } of holds) {
4822
+ const claim = { kind: 'release', asset: hold.asset, hold: pubkey };
4823
+ // An NFT proven gone: its hold closes at any time, also while it runs and for a loan before it is due.
4824
+ if (heldOf.get(hold.asset)?.held === false) out.push({ ...claim, gone: true, says: goneHoldSays(hold) });
4825
+ // A repaid loan is a plain hold that is over (no debt, `free_at` 0): its NFT goes home whenever.
4826
+ else if (!hold.debt && holdsOf.includes(hold.player) && time >= BigInt(hold.free_at))
4827
+ out.push({
4828
+ ...claim,
4829
+ says:
4830
+ hold.player === project
4831
+ ? 'An NFT owed to the treasury: send it there.'
4832
+ : 'Your collectible back: its hold is over.',
4833
+ });
4834
+ // A loan this wallet made that is overdue and unpaid: its NFT is the lender's to take.
4835
+ else if (hold.debt?.lender === me && time >= BigInt(hold.free_at))
4836
+ out.push({ ...claim, says: 'A loan you made is overdue and unpaid: take its NFT.' });
4529
4837
  }
4530
4838
  // A compressed NFT's proof record this wallet paid for and left open: closing it brings its rent back.
4531
4839
  for (const { account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Proven')])) {
@@ -4722,7 +5030,9 @@ export function solanaApp({
4722
5030
  },
4723
5031
 
4724
5032
  /**
4725
- * Finishes one of `claims()`, signed by this player. Returns the signature. A winner of a contest of NFTs picks
5033
+ * Finishes one of `claims()`, signed by this player. Returns the signature. A `release` flagged `gone` closes its
5034
+ * hold on the proof that the project no longer holds the NFT: nothing is sent, the hold's rent goes back to whoever
5035
+ * paid it, and a loan's debt ends unpaid. A winner of a contest of NFTs picks
4726
5036
  * which loser's NFT they take (`{ take: asset }`, the NFT of a losing entry that still holds one; the first found by default):
4727
5037
  * only the winner can, while any are left.
4728
5038
  */
@@ -4810,8 +5120,24 @@ export function solanaApp({
4810
5120
  return contest.prize?.Roll && !build ? (await sendRolling(claimed)).signature : send(claimed);
4811
5121
  }
4812
5122
  if (c.kind === 'release') {
4813
- if (c.gone) throw new Error(c.says);
4814
5123
  const hold = await read('Hold', c.hold);
5124
+ // Its NFT proven gone from the project: the hold closes on that proof, at any time, and nothing is sent. Its
5125
+ // rent goes back to whoever paid it; a loan's debt ends unpaid.
5126
+ const proof = await goneProof(c.asset, me);
5127
+ if (proof)
5128
+ return send([
5129
+ ...proof.before,
5130
+ loot.releaseIx({
5131
+ signer: me,
5132
+ project,
5133
+ asset: c.asset,
5134
+ ...proof.kind,
5135
+ hold,
5136
+ address: c.hold,
5137
+ treasury: g.treasury,
5138
+ ...(hold.debt ? { to: hold.debt.lender } : {}),
5139
+ }),
5140
+ ]);
4815
5141
  const { before, hasAuthority: _, ...nft } = await nftOf(c.asset, me, { to: me });
4816
5142
  // An overdue loan's NFT goes to its lender.
4817
5143
  return send([