@xgamesfun/solana 0.24.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/DISCLAIMER.md +1 -1
  2. package/NOTICE +4 -4
  3. package/README.md +100 -52
  4. package/bin/xsolana.mjs +32 -11
  5. package/examples/blind-forge.json +2 -4
  6. package/examples/coin-flip-arena.json +2 -5
  7. package/examples/duel-arena.json +1 -4
  8. package/examples/fair-presale.json +1 -3
  9. package/examples/hero-trials.json +14 -2
  10. package/examples/ideas.json +788 -788
  11. package/examples/nft-raffle.json +1 -0
  12. package/examples/partner-vault.json +19 -3
  13. package/examples/sacrifice-raffle.json +2 -10
  14. package/examples/shelter-fair.json +1 -0
  15. package/examples/stakers-draw.json +4 -7
  16. package/examples/trade-in-shop.json +2 -3
  17. package/js/approve/details.mjs +117 -48
  18. package/js/approve/ed25519.mjs +5 -1
  19. package/js/approve/live-check.mjs +83 -26
  20. package/js/approve/page.html +217 -78
  21. package/js/approve/page.js +43 -15
  22. package/js/approve/server.mjs +276 -74
  23. package/js/chain/base58.js +7 -1
  24. package/js/chain/borsh.js +15 -11
  25. package/js/chain/bytes.js +11 -8
  26. package/js/chain/core.js +21 -5
  27. package/js/chain/curve.js +1 -1
  28. package/js/chain/lookup.js +118 -36
  29. package/js/chain/mint.js +6 -2
  30. package/js/chain/pda.js +2 -2
  31. package/js/chain/programs.js +6 -6
  32. package/js/chain/rpc.js +40 -14
  33. package/js/chain/token.js +13 -2
  34. package/js/chain/token2022.js +55 -16
  35. package/js/chain/tx.js +32 -22
  36. package/js/client/accounts.js +270 -80
  37. package/js/client/compressed.js +4 -3
  38. package/js/client/grant.js +165 -40
  39. package/js/client/idl/loot.js +1 -1
  40. package/js/client/idl/loot.json +104 -16
  41. package/js/client/ix.js +2121 -457
  42. package/js/client/pda.js +20 -18
  43. package/js/client/policy.js +12 -5
  44. package/js/client/random.js +2 -2
  45. package/js/client/sizes.js +220 -65
  46. package/js/client/traits.js +14 -5
  47. package/js/connect/connect.mjs +59 -17
  48. package/js/economy/check.mjs +3884 -690
  49. package/js/economy/cost.mjs +381 -57
  50. package/js/economy/examples.mjs +176 -35
  51. package/js/economy/explain.mjs +468 -110
  52. package/js/economy/guide.mjs +282 -59
  53. package/js/economy/legal.mjs +12 -6
  54. package/js/economy/live.mjs +62 -20
  55. package/js/economy/model.mjs +320 -97
  56. package/js/economy/plan.mjs +1022 -203
  57. package/js/economy/review.mjs +760 -0
  58. package/js/economy/schema.json +134 -372
  59. package/js/economy/secrets.mjs +17 -7
  60. package/js/economy/simulate.mjs +771 -178
  61. package/js/economy/trust.mjs +15 -0
  62. package/js/economy/txsize.mjs +15 -3
  63. package/js/economy/validate.mjs +61 -24
  64. package/js/index.js +1 -0
  65. package/js/mcp/server.mjs +43 -15
  66. package/js/mcp/tools.mjs +318 -80
  67. package/js/play/index.js +1960 -461
  68. package/js/play/keys.js +35 -9
  69. package/js/results/index.js +225 -32
  70. package/js/results/server.js +230 -0
  71. package/js/results/wire.js +64 -16
  72. package/package.json +6 -4
  73. package/release.json +5 -5
  74. package/skills/xsolana-economy/SKILL.md +52 -22
  75. package/skills/xsolana-economy/references/after-launch.md +32 -21
  76. package/skills/xsolana-economy/references/apps.md +47 -36
  77. package/skills/xsolana-economy/references/sentence-to-spec.md +149 -129
  78. package/skills/xsolana-economy/references/telling-the-person.md +15 -6
  79. package/skills/xsolana-economy/references/trust-and-cheating.md +126 -0
  80. package/types/chain/core.d.ts +1 -1
  81. package/types/chain/lookup.d.ts +1 -1
  82. package/types/client/accounts.d.ts +24 -3
  83. package/types/client/grant.d.ts +2 -2
  84. package/types/client/idl/loot.d.ts +22 -0
  85. package/types/client/ix.d.ts +54 -45
  86. package/types/client/pda.d.ts +3 -1
  87. package/types/economy/cost.d.mts +1 -1
  88. package/types/economy/explain.d.mts +3 -0
  89. package/types/economy/guide.d.mts +5 -0
  90. package/types/economy/model.d.mts +48 -3
  91. package/types/economy/plan.d.mts +44 -2
  92. package/types/economy/review.d.mts +45 -0
  93. package/types/economy/trust.d.mts +8 -0
  94. package/types/index.d.ts +1 -0
  95. package/types/mcp/tools.d.mts +62 -0
  96. package/types/play/index.d.ts +14 -25
  97. package/types/results/index.d.ts +8 -7
  98. package/types/results/server.d.ts +126 -0
  99. package/types/results/wire.d.ts +1 -1
@@ -49,25 +49,27 @@ class ApproveError extends Error {
49
49
  export function devnetChain(url = process.env.XSOLANA_RPC_URL || DEVNET) {
50
50
  const rpc = rpcClient(notMainnet(url));
51
51
  let cluster = null;
52
- const checked = () => (cluster ??= checkCluster(rpc).catch((e) => {
53
- cluster = null;
54
- throw e;
55
- }));
52
+ const checked = () =>
53
+ (cluster ??= checkCluster(rpc).catch(e => {
54
+ cluster = null;
55
+ throw e;
56
+ }));
56
57
  return {
57
58
  url,
58
59
  latestBlockhash: async () => {
59
60
  await checked();
60
61
  return rpc.latestBlockhash();
61
62
  },
62
- rent: async (bytes) => BigInt(await rpc.call('getMinimumBalanceForRentExemption', [bytes])),
63
- send: async (wire) => {
63
+ rent: async bytes => BigInt(await rpc.call('getMinimumBalanceForRentExemption', [bytes])),
64
+ send: async wire => {
64
65
  await checked();
65
66
  return rpc.send(wire);
66
67
  },
67
68
  confirm: (signature, options) => rpc.confirm(signature, options),
68
- account: (address) => rpc.account(address),
69
+ account: address => rpc.account(address),
69
70
  /** A sent transaction's status from the whole history: { err, confirmationStatus }, or null when unseen. */
70
- signatureStatus: async (signature) => (await rpc.call('getSignatureStatuses', [[signature], { searchTransactionHistory: true }])).value[0],
71
+ signatureStatus: async signature =>
72
+ (await rpc.call('getSignatureStatuses', [[signature], { searchTransactionHistory: true }])).value[0],
71
73
  blockHeight: () => rpc.blockHeight(),
72
74
  };
73
75
  }
@@ -79,7 +81,10 @@ export function devnetChain(url = process.env.XSOLANA_RPC_URL || DEVNET) {
79
81
  */
80
82
  export async function writeWhole(file, text) {
81
83
  const target = await realpath(file).catch(() => file);
82
- const mode = await stat(target).then((s) => s.mode & 0o777, () => 0o644);
84
+ const mode = await stat(target).then(
85
+ s => s.mode & 0o777,
86
+ () => 0o644,
87
+ );
83
88
  const temp = path.join(path.dirname(target), `.${path.basename(target)}.${randomBytes(6).toString('hex')}.tmp`);
84
89
  try {
85
90
  const handle = await openFile(temp, 'wx', mode);
@@ -103,7 +108,10 @@ function walletAddress(wallet) {
103
108
  try {
104
109
  addressBytes(text);
105
110
  } catch {
106
- throw new ApproveError(400, `${text ? JSON.stringify(text.slice(0, 60)) : 'No wallet'}: that is not a Solana address. Connect a wallet and try again.`);
111
+ throw new ApproveError(
112
+ 400,
113
+ `${text ? JSON.stringify(text.slice(0, 60)) : 'No wallet'}: that is not a Solana address. Connect a wallet and try again.`,
114
+ );
107
115
  }
108
116
  return text;
109
117
  }
@@ -112,18 +120,25 @@ function walletAddress(wallet) {
112
120
  * One approval session for the spec in `file`. `chain`: { latestBlockhash, rent(bytes), send(wire), confirm(sig) }.
113
121
  * `write(file, text)` writes the spec file (`writeWhole`). Exposed for tests; `approve` serves it.
114
122
  */
115
- export async function createApproval({ file, chain = devnetChain(), now = () => Math.floor(Date.now() / 1000), clock = Date.now, write = writeWhole }) {
123
+ export async function createApproval({
124
+ file,
125
+ chain = devnetChain(),
126
+ now = () => Math.floor(Date.now() / 1000),
127
+ clock = Date.now,
128
+ write = writeWhole,
129
+ }) {
116
130
  let spec = JSON.parse(await readFile(file, 'utf8'));
117
131
  if (spec.economy) spec = spec.economy;
118
132
  const verdict = check(spec, { now: now() });
119
- if (!verdict.ok) throw new ApproveError(400, `${file} does not check:\n${describe({ refusals: verdict.refusals, warnings: [] })}`);
133
+ if (!verdict.ok)
134
+ throw new ApproveError(400, `${file} does not check:\n${describe({ refusals: verdict.refusals, warnings: [] })}`);
120
135
  const keys = new Map();
121
136
  let prepared = null;
122
137
  let busy = false;
123
138
  // One wallet read, prepare or complete at a time: each settles and writes the live section, and a batch built while
124
139
  // another batch lands would send that batch's steps again.
125
140
  let queue = Promise.resolve();
126
- const serial = (fn) => {
141
+ const serial = fn => {
127
142
  const run = queue.then(fn, fn);
128
143
  queue = run.catch(() => {});
129
144
  return run;
@@ -150,13 +165,19 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
150
165
  await checkMints({ m: model(spec, { now: at }), chain });
151
166
  } catch (e) {
152
167
  if (!(e instanceof MintMismatch)) throw e;
153
- throw new ApproveError(409, `${e.message} Nothing was sent: correct ${path.basename(file)}, then run \`xsolana approve\` again.`);
168
+ throw new ApproveError(
169
+ 409,
170
+ `${e.message} Nothing was sent: correct ${path.basename(file)}, then run \`xsolana approve\` again.`,
171
+ );
154
172
  }
155
173
  try {
156
174
  return await checkLive({ live: live(), m: model(spec, { now: at }), wallet, chain });
157
175
  } catch (e) {
158
176
  if (!(e instanceof LiveMismatch)) throw e;
159
- throw new ApproveError(409, `${e.message} Nothing was sent. ${path.basename(file)}’s live section is not what the chain says: restore the file \`xsolana approve\` wrote, or remove its live section to start a new setup.`);
177
+ throw new ApproveError(
178
+ 409,
179
+ `${e.message} Nothing was sent. ${path.basename(file)}’s live section is not what the chain says: restore the file \`xsolana approve\` wrote, or remove its live section to start a new setup.`,
180
+ );
160
181
  }
161
182
  }
162
183
 
@@ -166,15 +187,30 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
166
187
  for (const name of p0.freshKeys) if (!keys.has(name)) keys.set(name, freshKeypair());
167
188
  // The accounts the owner makes outside loot: a mint, and the project's tree of compressed NFTs.
168
189
  const tree = compressedTree(model(spec, { now: at }));
169
- const rents = new Map(await Promise.all([MINT_BYTES, ...(tree ? [tree.bytes] : [])].map(async (bytes) => [bytes, boundedRent(bytes, await chain.rent(bytes))])));
170
- const rent = (bytes) => {
190
+ const rents = new Map(
191
+ await Promise.all(
192
+ [MINT_BYTES, ...(tree ? [tree.bytes] : [])].map(async bytes => [
193
+ bytes,
194
+ boundedRent(bytes, await chain.rent(bytes)),
195
+ ]),
196
+ ),
197
+ );
198
+ const rent = bytes => {
171
199
  if (!rents.has(bytes)) throw new Error(`No rent known for ${bytes} bytes.`);
172
200
  return rents.get(bytes);
173
201
  };
174
202
  const addresses = Object.fromEntries([...keys].map(([n, k]) => [n, k.address]));
175
203
  // A recent slot, for the project's standing lookup table (made at go-live at a slot still in the slot hashes when it lands).
176
204
  const hashes = slotHashes((await chain.account(SYSVAR_SLOT_HASHES).catch(() => null))?.data);
177
- const p = prepare(spec, { owner, keys: addresses, live: live() ?? {}, now: at, rent, only, slot: hashes[0] ?? null });
205
+ const p = prepare(spec, {
206
+ owner,
207
+ keys: addresses,
208
+ live: live() ?? {},
209
+ now: at,
210
+ rent,
211
+ only,
212
+ slot: hashes[0] ?? null,
213
+ });
178
214
  if (!p.ok) throw new ApproveError(400, describe({ refusals: p.refusals, warnings: [] }));
179
215
  return p;
180
216
  }
@@ -183,7 +219,10 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
183
219
  * What each step pays to whom and which keys it trusts, read back from its instructions, in full. A change says, for
184
220
  * a record that is live already, how each line differs from what is on chain (`readLive`).
185
221
  */
186
- const names = () => namesOf(model(spec, { now: at }), { treasury: madeSoFar(live())?.treasury ?? model(spec, { now: at }).treasuryWallet ?? null });
222
+ const names = () =>
223
+ namesOf(model(spec, { now: at }), {
224
+ treasury: madeSoFar(live())?.treasury ?? model(spec, { now: at }).treasuryWallet ?? null,
225
+ });
187
226
  /**
188
227
  * What is live, read from the chain once a wallet connects and the file's live section is checked: for each step,
189
228
  * the lines of the records it writes as they are on chain now ([] for a record not there yet), and the treasury.
@@ -204,10 +243,11 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
204
243
  // A grant published before and gone from the chain (closed) that the change makes again with a list: a list leaf
205
244
  // names no grant or incarnation, so the same root would pay every listed round again.
206
245
  const remade = [];
207
- const published = new Set((spec.live.published.grants ?? []).map((g) => g.key));
246
+ const published = new Set((spec.live.published.grants ?? []).map(g => g.key));
208
247
  for (const g of model(spec, { now: at }).grants) {
209
248
  if (!g.list?.length || !published.has(g.key) || !p.ctx.project) continue;
210
- if (!(await chain.account(pda.grant(p.ctx.project, g.id)).catch(() => 'unread'))) remade.push(`“${g.name ?? g.key}”`);
249
+ if (!(await chain.account(pda.grant(p.ctx.project, g.id)).catch(() => 'unread')))
250
+ remade.push(`“${g.name ?? g.key}”`);
211
251
  }
212
252
  wasLive = { steps: was, treasury: p.ctx.treasury, remade };
213
253
  }
@@ -217,13 +257,27 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
217
257
  return was ? compareDetails(lines, was) : lines;
218
258
  };
219
259
  /** The lines a change is held against for its outside-address warning: what was read from the chain, or nothing yet. */
220
- const wasLines = () => (spec.live?.published ? [...(wasLive ? [...wasLive.steps.values()].flat() : []), ...(wasLive?.treasury ? [`Treasury: ${wasLive.treasury}`] : [])] : null);
221
- const remadeLists = () => (wasLive?.remade?.length ? `This change makes ${wasLive.remade.join(', ')} again after it was closed, with the same list: a list names no grant, so every wallet on it could claim every listed round again. Give the grant a fresh list (new rounds) before you approve.` : null);
222
- const unread = () => (spec.live?.published && !wasLive ? 'What is live has not been read from the chain yet: connect your wallet, and each line is marked against what is on chain.' : null);
260
+ const wasLines = () =>
261
+ spec.live?.published
262
+ ? [
263
+ ...(wasLive ? [...wasLive.steps.values()].flat() : []),
264
+ ...(wasLive?.treasury ? [`Treasury: ${wasLive.treasury}`] : []),
265
+ ]
266
+ : null;
267
+ const remadeLists = () =>
268
+ wasLive?.remade?.length
269
+ ? `This change makes ${wasLive.remade.join(', ')} again after it was closed, with the same list: a list names no grant, so every wallet on it could claim every listed round again. Give the grant a fresh list (new rounds) before you approve.`
270
+ : null;
271
+ const unread = () =>
272
+ spec.live?.published && !wasLive
273
+ ? 'What is live has not been read from the chain yet: connect your wallet, and each line is marked against what is on chain.'
274
+ : null;
223
275
 
224
276
  // The project's own settings a change names are never published: the page says so.
225
277
  const changed = mode === 'change' ? changedParts(spec).settings : [];
226
- const settings = changed.length ? `This change names new project settings (${changed.join(', ')}) that publishing never writes: on chain they stay as they are.` : null;
278
+ const settings = changed.length
279
+ ? `This change names new project settings (${changed.join(', ')}) that publishing never writes: on chain they stay as they are.`
280
+ : null;
227
281
 
228
282
  const legal = legalFlags(verdict);
229
283
 
@@ -231,7 +285,17 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
231
285
  const said = new Map();
232
286
  function view(p, n = names(), extra = {}) {
233
287
  const done = new Set(live()?.landed ?? []);
234
- const steps = p.ok ? p.transactions.flatMap((t) => t.steps.map((s, i) => ({ id: s.id, says: s.says, details: detailsOf(t, i, n), signer: t.signer, done: done.has(s.id) }))) : [];
288
+ const steps = p.ok
289
+ ? p.transactions.flatMap(t =>
290
+ t.steps.map((s, i) => ({
291
+ id: s.id,
292
+ says: s.says,
293
+ details: detailsOf(t, i, n),
294
+ signer: t.signer,
295
+ done: done.has(s.id),
296
+ })),
297
+ )
298
+ : [];
235
299
  for (const s of steps) said.set(s.id, s);
236
300
  return {
237
301
  name: spec.name ?? path.basename(file),
@@ -240,13 +304,25 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
240
304
  program: LOOT_PROGRAM,
241
305
  cluster: 'devnet',
242
306
  setupSol: cost(spec, { now: at, live: live(), only }).setup?.totalSol ?? null,
243
- warnings: [...(extra.refused ? [extra.refused] : []), ...verdict.warnings.filter((w) => !Object.hasOwn(LEGAL, w.code)).map((w) => w.message), ...[settings, unread(), remadeLists(), outsideAddresses(steps.flatMap((s) => s.details), { was: wasLines(), owner: n.owner })].filter(Boolean)],
307
+ warnings: [
308
+ ...(extra.refused ? [extra.refused] : []),
309
+ ...verdict.warnings.filter(w => !Object.hasOwn(LEGAL, w.code)).map(w => w.message),
310
+ ...[
311
+ settings,
312
+ unread(),
313
+ remadeLists(),
314
+ outsideAddresses(
315
+ steps.flatMap(s => s.details),
316
+ { was: wasLines(), owner: n.owner },
317
+ ),
318
+ ].filter(Boolean),
319
+ ],
244
320
  // The legal flags in plain words, shown in a block of their own whatever the agent said: Approve waits until the
245
321
  // owner ticks that they read them.
246
322
  legal,
247
323
  authority: model(spec).authority,
248
324
  steps,
249
- landed: [...done].map((id) => ({ id, says: said.get(id)?.says ?? id, details: said.get(id)?.details ?? [] })),
325
+ landed: [...done].map(id => ({ id, says: said.get(id)?.says ?? id, details: said.get(id)?.details ?? [] })),
250
326
  signatures: [...signatures],
251
327
  // The project the setup sends to, once a wallet is connected: always that wallet's own (or the one it is the
252
328
  // authority of).
@@ -308,17 +384,26 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
308
384
  const v = view(p0, namesOf(model(spec, { now: at }), { owner: wallet }), { refused: e.message });
309
385
  return { ...v, remaining: p0.ok ? p0.transactions.length : 0, done: false };
310
386
  }
311
- if (!p0.ok || !p0.transactions.length) return { ...view(p0, names(), { project: trusted.project }), remaining: 0, done: p0.ok };
387
+ if (!p0.ok || !p0.transactions.length)
388
+ return { ...view(p0, names(), { project: trusted.project }), remaining: 0, done: p0.ok };
312
389
  const p = await build(trusted.owner);
313
390
  await readLive(p);
314
- const v = view(p, namesOf(model(spec, { now: at }), { owner: wallet, treasury: p.ctx.treasury }), { project: p.ctx.project });
391
+ const v = view(p, namesOf(model(spec, { now: at }), { owner: wallet, treasury: p.ctx.treasury }), {
392
+ project: p.ctx.project,
393
+ });
315
394
  return { ...v, remaining: p.transactions.length, done: false };
316
395
  }
317
396
 
318
397
  async function prepareFor(wallet) {
319
398
  // A second ask while a batch is out for signing (a double click) gets that same batch, not a new one that would
320
399
  // make the first signature fail.
321
- if (prepared && prepared.wallet === wallet && prepared.generation === generation && prepared.expires > clock() + 15_000) return answer(prepared);
400
+ if (
401
+ prepared &&
402
+ prepared.wallet === wallet &&
403
+ prepared.generation === generation &&
404
+ prepared.expires > clock() + 15_000
405
+ )
406
+ return answer(prepared);
322
407
  await settlePending();
323
408
  const trusted = await trust(wallet);
324
409
  const p = await build(trusted.owner);
@@ -327,18 +412,24 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
327
412
  const authority = p.ctx.authority;
328
413
  const first = p.transactions[0];
329
414
  const signer = first.signer === 'authority' ? authority : p.ctx.owner;
330
- if (signer !== wallet) throw new ApproveError(403, first.signer === 'authority' ? `The next step is the authority’s: connect the wallet ${authority} and approve it there.` : `The next step is the project owner’s: connect the wallet ${p.ctx.owner} and approve it there.`);
415
+ if (signer !== wallet)
416
+ throw new ApproveError(
417
+ 403,
418
+ first.signer === 'authority'
419
+ ? `The next step is the authority’s: connect the wallet ${authority} and approve it there.`
420
+ : `The next step is the project owner’s: connect the wallet ${p.ctx.owner} and approve it there.`,
421
+ );
331
422
  const batch = [];
332
423
  for (const t of p.transactions) {
333
424
  if (t.signer !== first.signer || batch.length >= PER_APPROVAL) break;
334
425
  batch.push(t);
335
426
  }
336
427
  const { blockhash, lastValidBlockHeight } = await chain.latestBlockhash();
337
- const wires = batch.map((t) => {
428
+ const wires = batch.map(t => {
338
429
  const message = compileMessage({ payer: wallet, blockhash, instructions: t.instructions });
339
430
  const { signers } = readTransaction(wireTransaction(message));
340
- const sigs = signers.map((s) => {
341
- const fresh = [...keys.values()].find((k) => k.address === s);
431
+ const sigs = signers.map(s => {
432
+ const fresh = [...keys.values()].find(k => k.address === s);
342
433
  return fresh ? fresh.sign(message) : new Uint8Array(64);
343
434
  });
344
435
  return wireTransaction(message, sigs);
@@ -346,13 +437,27 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
346
437
  // What each transaction pays to whom, read back from the very instructions the wallet signs.
347
438
  const n = namesOf(model(spec, { now: at }), { owner: wallet, treasury: p.ctx.treasury });
348
439
  // Every line names the project it goes to first: always the wallet's own, or the one it is the authority of.
349
- const details = batch.map((t) => [`Project: ${p.ctx.project}${p.ctx.owner === wallet ? ' (your own)' : ` (owned by ${p.ctx.owner})`}`, ...t.steps.flatMap((_, i) => detailsOf(t, i, n))]);
350
- prepared = { id: randomBytes(12).toString('hex'), wallet, wires, batch, details, ctx: p.ctx, lastValidBlockHeight, generation, expires: clock() + PREPARE_TTL_MS };
440
+ const details = batch.map(t => [
441
+ `Project: ${p.ctx.project}${p.ctx.owner === wallet ? ' (your own)' : ` (owned by ${p.ctx.owner})`}`,
442
+ ...t.steps.flatMap((_, i) => detailsOf(t, i, n)),
443
+ ]);
444
+ prepared = {
445
+ id: randomBytes(12).toString('hex'),
446
+ wallet,
447
+ wires,
448
+ batch,
449
+ details,
450
+ ctx: p.ctx,
451
+ lastValidBlockHeight,
452
+ generation,
453
+ expires: clock() + PREPARE_TTL_MS,
454
+ };
351
455
  return answer(prepared);
352
456
  }
353
457
 
354
458
  async function complete(wallet, id, signed) {
355
- if (!prepared || !sameText(prepared.id, String(id ?? ''))) throw new ApproveError(409, 'That batch is not the one prepared: ask for the next one.');
459
+ if (!prepared || !sameText(prepared.id, String(id ?? '')))
460
+ throw new ApproveError(409, 'That batch is not the one prepared: ask for the next one.');
356
461
  if (prepared.expires < clock()) {
357
462
  prepared = null;
358
463
  throw new ApproveError(410, 'That batch expired (90 seconds). Ask for it again.');
@@ -360,7 +465,8 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
360
465
  if (prepared.wallet !== wallet) throw new ApproveError(403, 'That batch was prepared for another wallet.');
361
466
  const batch = prepared;
362
467
  prepared = null;
363
- if (batch.generation !== generation) throw new ApproveError(409, 'Something landed after that batch was built: ask for the next one.');
468
+ if (batch.generation !== generation)
469
+ throw new ApproveError(409, 'Something landed after that batch was built: ask for the next one.');
364
470
  const wires = checkSigned(batch.wires, signed, wallet);
365
471
  busy = true;
366
472
  try {
@@ -370,7 +476,12 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
370
476
  // sent twice (`settlePending`).
371
477
  const signature = encodeBase58(readTransaction(wire).signatures[0]);
372
478
  const sent = batch.batch[i];
373
- await remember({ signature, steps: sent.steps.map((s) => s.id), made: madeBy(sent, batch.ctx), lastValidBlockHeight: batch.lastValidBlockHeight });
479
+ await remember({
480
+ signature,
481
+ steps: sent.steps.map(s => s.id),
482
+ made: madeBy(sent, batch.ctx),
483
+ lastValidBlockHeight: batch.lastValidBlockHeight,
484
+ });
374
485
  try {
375
486
  await chain.send(wire);
376
487
  } catch (e) {
@@ -386,7 +497,11 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
386
497
  const status = chain.signatureStatus ? await chain.signatureStatus(signature).catch(() => null) : null;
387
498
  if (!landedStatus(status)) {
388
499
  if (status?.err || !chain.signatureStatus) await forget(signature);
389
- else throw new ApproveError(503, `${e.message} It may still land: it is looked up on chain before anything is sent again, so approving again never sends it twice. Wait a minute, then ask for the next batch.`);
500
+ else
501
+ throw new ApproveError(
502
+ 503,
503
+ `${e.message} It may still land: it is looked up on chain before anything is sent again, so approving again never sends it twice. Wait a minute, then ask for the next batch.`,
504
+ );
390
505
  throw e;
391
506
  }
392
507
  }
@@ -401,13 +516,21 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
401
516
 
402
517
  /** A prepared batch as the page gets it: the wires to sign, and what each does in words. */
403
518
  function answer(b) {
404
- return { id: b.id, project: b.ctx.project, transactions: b.wires.map(toBase64), says: b.batch.map((t) => t.steps.map((s) => s.says).join('; ')), details: b.details };
519
+ return {
520
+ id: b.id,
521
+ project: b.ctx.project,
522
+ transactions: b.wires.map(toBase64),
523
+ says: b.batch.map(t => t.steps.map(s => s.says).join('; ')),
524
+ details: b.details,
525
+ };
405
526
  }
406
527
 
407
528
  /** What a transaction's steps make, from the context it was built with: recorded when it lands. */
408
529
  function madeBy(transaction, ctx) {
409
- const ids = new Set(transaction.steps.map((s) => s.id));
410
- const mints = Object.fromEntries([...ids].filter((id) => id.startsWith('token:')).map((id) => [id.slice(6), ctx.mints[id.slice(6)]]));
530
+ const ids = new Set(transaction.steps.map(s => s.id));
531
+ const mints = Object.fromEntries(
532
+ [...ids].filter(id => id.startsWith('token:')).map(id => [id.slice(6), ctx.mints[id.slice(6)]]),
533
+ );
411
534
  return {
412
535
  ...(ids.has('project') ? { project: ctx.project, treasury: ctx.treasury, authority: ctx.authority } : {}),
413
536
  ...(ids.has('collection') && ctx.collection ? { collection: ctx.collection } : {}),
@@ -423,7 +546,16 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
423
546
  */
424
547
  async function remember(entry) {
425
548
  const was = spec.live ?? {};
426
- spec = { ...spec, live: { ...was, landed: was.landed ?? [], partial: true, ...(only ? { changing: [...only] } : {}), pending: [...(was.pending ?? []), entry].slice(-60) } };
549
+ spec = {
550
+ ...spec,
551
+ live: {
552
+ ...was,
553
+ landed: was.landed ?? [],
554
+ partial: true,
555
+ ...(only ? { changing: [...only] } : {}),
556
+ pending: [...(was.pending ?? []), entry].slice(-60),
557
+ },
558
+ };
427
559
  generation++;
428
560
  await write(file, `${JSON.stringify(spec, null, 2)}\n`);
429
561
  }
@@ -431,7 +563,7 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
431
563
  /** Takes a transaction out of the pending list: it landed (and is recorded), failed, or can no longer land. */
432
564
  async function forget(signature) {
433
565
  if (!spec.live?.pending) return;
434
- const pending = spec.live.pending.filter((p) => p.signature !== signature);
566
+ const pending = spec.live.pending.filter(p => p.signature !== signature);
435
567
  const { pending: _, ...rest } = spec.live;
436
568
  spec = { ...spec, live: pending.length ? { ...rest, pending } : rest };
437
569
  generation++;
@@ -442,7 +574,9 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
442
574
  async function projectOwner(project) {
443
575
  if (!project || !chain.account) return null;
444
576
  const account = await chain.account(project).catch(() => null);
445
- return account && account.owner === LOOT_PROGRAM && accountKind(account.data) === 'Project' ? decode('Project', account.data).owner : null;
577
+ return account && account.owner === LOOT_PROGRAM && accountKind(account.data) === 'Project'
578
+ ? decode('Project', account.data).owner
579
+ : null;
446
580
  }
447
581
 
448
582
  /**
@@ -459,7 +593,11 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
459
593
  const status = await chain.signatureStatus(entry.signature);
460
594
  if (landedStatus(status)) {
461
595
  if (!signatures.includes(entry.signature)) signatures.push(entry.signature);
462
- await landed({ steps: entry.steps.map((id) => ({ id })) }, { ...(entry.made ?? {}), owner: await projectOwner(entry.made?.project ?? spec.live?.project) }, entry.signature);
596
+ await landed(
597
+ { steps: entry.steps.map(id => ({ id })) },
598
+ { ...(entry.made ?? {}), owner: await projectOwner(entry.made?.project ?? spec.live?.project) },
599
+ entry.signature,
600
+ );
463
601
  continue;
464
602
  }
465
603
  if (status?.err) {
@@ -472,7 +610,10 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
472
610
  await forget(entry.signature);
473
611
  continue;
474
612
  }
475
- throw new ApproveError(409, `The transaction ${entry.signature}, sent earlier, has not landed yet and may still: wait a minute and try again, so nothing is sent twice.`);
613
+ throw new ApproveError(
614
+ 409,
615
+ `The transaction ${entry.signature}, sent earlier, has not landed yet and may still: wait a minute and try again, so nothing is sent twice.`,
616
+ );
476
617
  }
477
618
  }
478
619
 
@@ -483,10 +624,11 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
483
624
  */
484
625
  async function landed(transaction, ctx, signature) {
485
626
  const was = spec.live ?? {};
486
- const ids = transaction.steps.map((s) => s.id);
627
+ const ids = transaction.steps.map(s => s.id);
487
628
  const made = new Set(ids);
488
629
  const mints = { ...(was.mints ?? {}) };
489
- for (const id of ids) if (id.startsWith('token:') && ctx.mints?.[id.slice(6)]) mints[id.slice(6)] = ctx.mints[id.slice(6)];
630
+ for (const id of ids)
631
+ if (id.startsWith('token:') && ctx.mints?.[id.slice(6)]) mints[id.slice(6)] = ctx.mints[id.slice(6)];
490
632
  const progress = {
491
633
  ...was,
492
634
  program: LOOT_PROGRAM,
@@ -499,8 +641,10 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
499
641
  partial: true,
500
642
  ...(only ? { changing: [...only] } : {}),
501
643
  // (each signature once: one found again by `settlePending` may have been recorded already)
502
- transactions: (was.transactions ?? []).some((t) => t.signature === signature) ? was.transactions : [...(was.transactions ?? []), { signature }].slice(-60),
503
- pending: (was.pending ?? []).filter((p) => p.signature !== signature),
644
+ transactions: (was.transactions ?? []).some(t => t.signature === signature)
645
+ ? was.transactions
646
+ : [...(was.transactions ?? []), { signature }].slice(-60),
647
+ pending: (was.pending ?? []).filter(p => p.signature !== signature),
504
648
  };
505
649
  if (!progress.pending.length) delete progress.pending;
506
650
  if (!Object.keys(progress.mints).length) delete progress.mints;
@@ -511,7 +655,13 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
511
655
  if (rest.ok && rest.transactions.length === 0) {
512
656
  const { live: _, ...bare } = spec;
513
657
  // Everything the setup made, whichever session or transaction made it.
514
- const all = { ...ctx, ...Object.fromEntries(['project', 'treasury', 'authority', 'collection', 'tree', 'tables'].map((k) => [k, progress[k] ?? ctx[k]])), mints: { ...(ctx.mints ?? {}), ...(progress.mints ?? {}) } };
658
+ const all = {
659
+ ...ctx,
660
+ ...Object.fromEntries(
661
+ ['project', 'treasury', 'authority', 'collection', 'tree', 'tables'].map(k => [k, progress[k] ?? ctx[k]]),
662
+ ),
663
+ mints: { ...(ctx.mints ?? {}), ...(progress.mints ?? {}) },
664
+ };
515
665
  const final = recordLanded(bare, { ctx: all, now: at, at: new Date(clock()).toISOString() });
516
666
  final.live = { ...final.live, transactions: progress.transactions };
517
667
  spec = final;
@@ -529,12 +679,19 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
529
679
  function boundedRent(bytes, lamports) {
530
680
  const most = (BigInt(bytes) + ACCOUNT_OVERHEAD) * MOST_RENT_PER_BYTE;
531
681
  const given = BigInt(lamports);
532
- if (given > most) throw new ApproveError(502, `The RPC says an account of ${bytes} bytes needs ${given} lamports of rent; no cluster charges more than ${most}. Nothing was sent: use another RPC (XSOLANA_RPC_URL).`);
682
+ if (given > most)
683
+ throw new ApproveError(
684
+ 502,
685
+ `The RPC says an account of ${bytes} bytes needs ${given} lamports of rent; no cluster charges more than ${most}. Nothing was sent: use another RPC (XSOLANA_RPC_URL).`,
686
+ );
533
687
  return given;
534
688
  }
535
689
 
536
690
  /** Whether a signature's status says it landed: confirmed or finalized, and not failed. */
537
- const landedStatus = (status) => Boolean(status && !status.err && (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized'));
691
+ const landedStatus = status =>
692
+ Boolean(
693
+ status && !status.err && (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized'),
694
+ );
538
695
 
539
696
  const sameText = (a, b) => {
540
697
  const x = Buffer.from(String(a));
@@ -544,7 +701,8 @@ const sameText = (a, b) => {
544
701
 
545
702
  /** The wallet's signed copies: each the exact prepared message, each with the wallet's valid signature. */
546
703
  function checkSigned(prepared, signed, wallet) {
547
- if (!Array.isArray(signed) || signed.length !== prepared.length) throw new ApproveError(400, 'The wallet’s answer does not match the request.');
704
+ if (!Array.isArray(signed) || signed.length !== prepared.length)
705
+ throw new ApproveError(400, 'The wallet’s answer does not match the request.');
548
706
  return prepared.map((wire, i) => {
549
707
  let mine;
550
708
  let theirs;
@@ -554,11 +712,13 @@ function checkSigned(prepared, signed, wallet) {
554
712
  } catch {
555
713
  throw new ApproveError(400, 'The wallet returned something that is not a transaction.');
556
714
  }
557
- if (!equalBytes(mine.message, theirs.message)) throw new ApproveError(400, 'The wallet changed the transaction before signing it, so nothing was sent.');
715
+ if (!equalBytes(mine.message, theirs.message))
716
+ throw new ApproveError(400, 'The wallet changed the transaction before signing it, so nothing was sent.');
558
717
  const slot = mine.signers.indexOf(wallet);
559
718
  if (slot < 0) throw new ApproveError(400, 'This transaction was not prepared for that wallet.');
560
719
  const signature = theirs.signatures[slot];
561
- if (!verifySignature(wallet, mine.message, signature)) throw new ApproveError(400, 'The wallet’s signature is missing or does not match your wallet.');
720
+ if (!verifySignature(wallet, mine.message, signature))
721
+ throw new ApproveError(400, 'The wallet’s signature is missing or does not match your wallet.');
562
722
  const signatures = [...mine.signatures];
563
723
  signatures[slot] = signature;
564
724
  return wireTransaction(mine.message, signatures);
@@ -589,7 +749,7 @@ export function ownRequest(headers, port) {
589
749
  const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
590
750
  if (!hosts.includes(String(headers.host ?? '').toLowerCase())) return false;
591
751
  if (headers.origin === undefined) return true;
592
- return hosts.some((h) => String(headers.origin).toLowerCase() === `http://${h}`);
752
+ return hosts.some(h => String(headers.origin).toLowerCase() === `http://${h}`);
593
753
  }
594
754
 
595
755
  /** A request's body, as text: refused (413) past `limit` bytes, and the rest of it read and let go. */
@@ -597,7 +757,7 @@ function readBody(req, limit = BODY_LIMIT) {
597
757
  return new Promise((resolve, reject) => {
598
758
  const chunks = [];
599
759
  let size = 0;
600
- req.on('data', (chunk) => {
760
+ req.on('data', chunk => {
601
761
  size += chunk.length;
602
762
  if (size > limit) return reject(new ApproveError(413, 'That request is too large.'));
603
763
  chunks.push(chunk);
@@ -629,9 +789,18 @@ export async function openBrowser(url, { spawnImpl = spawn, dir = tmpdir() } = {
629
789
  try {
630
790
  await chmod(folder, 0o700);
631
791
  const file = path.join(folder, 'open.html');
632
- const href = url.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
633
- await writeFile(file, `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="refresh" content="0;url=${href}"><title>xsolana approve</title><a href="${href}">Open the approval page</a>\n`, { mode: 0o600, flag: 'wx' });
634
- const [cmd, args] = process.platform === 'darwin' ? ['open', [file]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', file]] : ['xdg-open', [file]];
792
+ const href = url.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
793
+ await writeFile(
794
+ file,
795
+ `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="refresh" content="0;url=${href}"><title>xsolana approve</title><a href="${href}">Open the approval page</a>\n`,
796
+ { mode: 0o600, flag: 'wx' },
797
+ );
798
+ const [cmd, args] =
799
+ process.platform === 'darwin'
800
+ ? ['open', [file]]
801
+ : process.platform === 'win32'
802
+ ? ['cmd', ['/c', 'start', '', file]]
803
+ : ['xdg-open', [file]];
635
804
  spawnImpl(cmd, args, { stdio: 'ignore', detached: true }).unref();
636
805
  } catch {
637
806
  /* the link is printed anyway */
@@ -646,16 +815,33 @@ export async function openBrowser(url, { spawnImpl = spawn, dir = tmpdir() } = {
646
815
  * after. The default `openLink` keeps even the code off the opener's command line (`openBrowser`); one that answers a
647
816
  * function has it called once the code is used.
648
817
  */
649
- export async function approve({ file = 'xsolana.json', port, open = true, chain = devnetChain(), log = (...a) => console.error(...a), openLink = openBrowser } = {}) {
818
+ export async function approve({
819
+ file = 'xsolana.json',
820
+ port,
821
+ open = true,
822
+ chain = devnetChain(),
823
+ log = (...a) => console.error(...a),
824
+ openLink = openBrowser,
825
+ } = {}) {
650
826
  const session = await createApproval({ file: path.resolve(file), chain });
651
827
  const token = randomBytes(18).toString('base64url');
652
828
  let code = randomBytes(18).toString('base64url');
653
829
  // (removes the file the browser was opened with, once: `openLink` answers how)
654
830
  let removeOpener = null;
655
- const forget = () => { const remove = removeOpener; removeOpener = null; remove?.(); };
831
+ const forget = () => {
832
+ const remove = removeOpener;
833
+ removeOpener = null;
834
+ remove?.();
835
+ };
656
836
  const page = await readFile(path.join(ROOT, 'approve/page.html'), 'utf8');
657
837
  const policy = pagePolicy(page);
658
- const headers = { 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer', 'content-security-policy': policy, 'x-frame-options': 'DENY' };
838
+ const headers = {
839
+ 'cache-control': 'no-store',
840
+ 'x-content-type-options': 'nosniff',
841
+ 'referrer-policy': 'no-referrer',
842
+ 'content-security-policy': policy,
843
+ 'x-frame-options': 'DENY',
844
+ };
659
845
  const server = createServer(async (req, res) => {
660
846
  const url = new URL(req.url, 'http://127.0.0.1');
661
847
  const send = (status, body, type = 'application/json') => {
@@ -664,27 +850,41 @@ export async function approve({ file = 'xsolana.json', port, open = true, chain
664
850
  };
665
851
  try {
666
852
  // Only a request to this server by its own name (DNS rebinding: a site whose name now points at 127.0.0.1).
667
- if (!ownRequest(req.headers, server.address().port)) return send(403, 'Open the link `xsolana approve` printed.', 'text/plain');
853
+ if (!ownRequest(req.headers, server.address().port))
854
+ return send(403, 'Open the link `xsolana approve` printed.', 'text/plain');
668
855
  if (req.method === 'GET' && url.pathname === '/') {
669
856
  const once = url.searchParams.get('c');
670
857
  if (once !== null) {
671
- if (!code || !sameText(once, code)) return send(403, 'This link has been opened already: open the link `xsolana approve` printed.', 'text/plain');
858
+ if (!code || !sameText(once, code))
859
+ return send(
860
+ 403,
861
+ 'This link has been opened already: open the link `xsolana approve` printed.',
862
+ 'text/plain',
863
+ );
672
864
  code = null;
673
865
  forget();
674
866
  res.writeHead(303, { ...headers, location: `/?t=${token}` });
675
867
  return res.end();
676
868
  }
677
- if (!sameText(url.searchParams.get('t') ?? '', token)) return send(403, 'Open the link `xsolana approve` printed.', 'text/plain');
869
+ if (!sameText(url.searchParams.get('t') ?? '', token))
870
+ return send(403, 'Open the link `xsolana approve` printed.', 'text/plain');
678
871
  return send(200, page.replace('__TOKEN__', token), 'text/html; charset=utf-8');
679
872
  }
680
- if (req.method === 'GET' && BROWSER_FILES.test(url.pathname)) return send(200, await readFile(path.join(ROOT, url.pathname.slice(4)), 'utf8'), 'text/javascript; charset=utf-8');
681
- if (!sameText(req.headers['x-xsolana-token'] ?? '', token)) return send(403, { error: 'Not this session’s page.' });
873
+ if (req.method === 'GET' && BROWSER_FILES.test(url.pathname))
874
+ return send(
875
+ 200,
876
+ await readFile(path.join(ROOT, url.pathname.slice(4)), 'utf8'),
877
+ 'text/javascript; charset=utf-8',
878
+ );
879
+ if (!sameText(req.headers['x-xsolana-token'] ?? '', token))
880
+ return send(403, { error: 'Not this session’s page.' });
682
881
  if (req.method === 'GET' && url.pathname === '/api/state') {
683
882
  const wallet = url.searchParams.get('wallet');
684
883
  return send(200, wallet ? await session.stateFor(wallet) : session.state());
685
884
  }
686
885
  const body = req.method === 'POST' ? await readJson(req) : {};
687
- if (req.method === 'POST' && url.pathname === '/api/prepare') return send(200, await session.prepare(String(body.wallet ?? '')));
886
+ if (req.method === 'POST' && url.pathname === '/api/prepare')
887
+ return send(200, await session.prepare(String(body.wallet ?? '')));
688
888
  if (req.method === 'POST' && url.pathname === '/api/complete') {
689
889
  const state = await session.complete(String(body.wallet ?? ''), body.id, body.transactions);
690
890
  if (state.done) {
@@ -712,7 +912,9 @@ export async function approve({ file = 'xsolana.json', port, open = true, chain
712
912
  const link = `${origin}/?t=${token}`;
713
913
  const opened = `${origin}/?c=${code}`;
714
914
  log(`Approve going live in your wallet (devnet): ${link}`);
715
- log('Leave this running until the page says it is live. Ctrl+C stops it; running it again goes on from where it stopped.');
915
+ log(
916
+ 'Leave this running until the page says it is live. Ctrl+C stops it; running it again goes on from where it stopped.',
917
+ );
716
918
  if (open && !process.env.XSOLANA_NO_OPEN) {
717
919
  const remove = await openLink(opened);
718
920
  if (typeof remove === 'function') {