@xgamesfun/solana 0.0.0-stage → 0.20.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 (134) hide show
  1. package/DISCLAIMER.md +41 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +13 -0
  4. package/README.md +172 -2
  5. package/bin/xsolana.mjs +189 -0
  6. package/examples/artwork-auction.json +51 -0
  7. package/examples/blind-forge.json +128 -0
  8. package/examples/coin-flip-arena.json +108 -0
  9. package/examples/duel-arena.json +137 -0
  10. package/examples/elements-set.json +281 -0
  11. package/examples/event-badges.json +67 -0
  12. package/examples/expedition-guild.json +161 -0
  13. package/examples/fair-presale.json +48 -0
  14. package/examples/falling-drop.json +90 -0
  15. package/examples/guild-missions.json +144 -0
  16. package/examples/halving-seasons.json +112 -0
  17. package/examples/hero-trials.json +96 -0
  18. package/examples/ideas.json +789 -0
  19. package/examples/level-ladder.json +65 -0
  20. package/examples/merge-2048.json +128 -0
  21. package/examples/nft-raffle.json +33 -0
  22. package/examples/paper-hands-farm.json +56 -0
  23. package/examples/partner-vault.json +115 -0
  24. package/examples/sacrifice-raffle.json +118 -0
  25. package/examples/scorched-treasury.json +84 -0
  26. package/examples/shelter-fair.json +164 -0
  27. package/examples/spark-pity.json +153 -0
  28. package/examples/stakers-draw.json +125 -0
  29. package/examples/star-fleet.json +157 -0
  30. package/examples/starforce.json +116 -0
  31. package/examples/stud-farm.json +134 -0
  32. package/examples/team-vesting.json +70 -0
  33. package/examples/trade-in-shop.json +168 -0
  34. package/js/approve/details.mjs +262 -0
  35. package/js/approve/ed25519.mjs +36 -0
  36. package/js/approve/live-check.mjs +144 -0
  37. package/js/approve/page.html +86 -0
  38. package/js/approve/page.js +203 -0
  39. package/js/approve/server.mjs +711 -0
  40. package/js/chain/base58.js +73 -0
  41. package/js/chain/borsh.js +261 -0
  42. package/js/chain/bytes.js +141 -0
  43. package/js/chain/core.js +169 -0
  44. package/js/chain/curve.js +52 -0
  45. package/js/chain/lookup.js +273 -0
  46. package/js/chain/mint.js +96 -0
  47. package/js/chain/pda.js +66 -0
  48. package/js/chain/programs.js +102 -0
  49. package/js/chain/rpc.js +217 -0
  50. package/js/chain/sha256.js +68 -0
  51. package/js/chain/token.js +85 -0
  52. package/js/chain/token2022.js +247 -0
  53. package/js/chain/tx.js +224 -0
  54. package/js/client/accounts.js +347 -0
  55. package/js/client/compressed.js +46 -0
  56. package/js/client/grant.js +240 -0
  57. package/js/client/idl/loot.js +2 -0
  58. package/js/client/idl/loot.json +17064 -0
  59. package/js/client/index.js +9 -0
  60. package/js/client/ix.js +2156 -0
  61. package/js/client/pda.js +110 -0
  62. package/js/client/policy.js +48 -0
  63. package/js/client/sizes.js +54 -0
  64. package/js/client/traits.js +44 -0
  65. package/js/connect/connect.mjs +182 -0
  66. package/js/economy/check.mjs +1991 -0
  67. package/js/economy/cost.mjs +170 -0
  68. package/js/economy/examples.mjs +114 -0
  69. package/js/economy/explain.mjs +235 -0
  70. package/js/economy/guide.mjs +231 -0
  71. package/js/economy/legal.mjs +15 -0
  72. package/js/economy/live.mjs +107 -0
  73. package/js/economy/model.mjs +583 -0
  74. package/js/economy/plan.mjs +887 -0
  75. package/js/economy/schema.json +2923 -0
  76. package/js/economy/secrets.mjs +60 -0
  77. package/js/economy/simulate.mjs +952 -0
  78. package/js/economy/txsize.mjs +45 -0
  79. package/js/economy/validate.mjs +85 -0
  80. package/js/index.js +10 -0
  81. package/js/mcp/server.mjs +113 -0
  82. package/js/mcp/tools.mjs +479 -0
  83. package/js/play/index.js +2109 -0
  84. package/js/results/index.js +184 -0
  85. package/js/results/wire.js +90 -0
  86. package/package.json +100 -4
  87. package/release.json +8 -0
  88. package/skills/xsolana-economy/SKILL.md +105 -0
  89. package/skills/xsolana-economy/references/after-launch.md +114 -0
  90. package/skills/xsolana-economy/references/apps.md +157 -0
  91. package/skills/xsolana-economy/references/sentence-to-spec.md +172 -0
  92. package/skills/xsolana-economy/references/telling-the-person.md +93 -0
  93. package/types/chain/base58.d.ts +10 -0
  94. package/types/chain/borsh.d.ts +23 -0
  95. package/types/chain/bytes.d.ts +45 -0
  96. package/types/chain/core.d.ts +50 -0
  97. package/types/chain/curve.d.ts +1 -0
  98. package/types/chain/lookup.d.ts +191 -0
  99. package/types/chain/mint.d.ts +25 -0
  100. package/types/chain/pda.d.ts +25 -0
  101. package/types/chain/programs.d.ts +65 -0
  102. package/types/chain/rpc.d.ts +66 -0
  103. package/types/chain/sha256.d.ts +1 -0
  104. package/types/chain/token.d.ts +141 -0
  105. package/types/chain/token2022.d.ts +53 -0
  106. package/types/chain/tx.d.ts +60 -0
  107. package/types/client/accounts.d.ts +178 -0
  108. package/types/client/compressed.d.ts +29 -0
  109. package/types/client/grant.d.ts +125 -0
  110. package/types/client/idl/loot.d.ts +4467 -0
  111. package/types/client/index.d.ts +8 -0
  112. package/types/client/ix.d.ts +1687 -0
  113. package/types/client/pda.d.ts +91 -0
  114. package/types/client/policy.d.ts +23 -0
  115. package/types/client/sizes.d.ts +13 -0
  116. package/types/client/traits.d.ts +12 -0
  117. package/types/economy/check.d.mts +105 -0
  118. package/types/economy/cost.d.mts +68 -0
  119. package/types/economy/examples.d.mts +27 -0
  120. package/types/economy/explain.d.mts +19 -0
  121. package/types/economy/guide.d.mts +140 -0
  122. package/types/economy/legal.d.mts +12 -0
  123. package/types/economy/live.d.mts +109 -0
  124. package/types/economy/model.d.mts +363 -0
  125. package/types/economy/plan.d.mts +1013 -0
  126. package/types/economy/secrets.d.mts +4 -0
  127. package/types/economy/simulate.d.mts +156 -0
  128. package/types/economy/txsize.d.mts +17 -0
  129. package/types/economy/validate.d.mts +3 -0
  130. package/types/index.d.ts +9 -0
  131. package/types/mcp/tools.d.mts +627 -0
  132. package/types/play/index.d.ts +469 -0
  133. package/types/results/index.d.ts +155 -0
  134. package/types/results/wire.d.ts +122 -0
@@ -0,0 +1,711 @@
1
+ // `xsolana approve`: a page on 127.0.0.1 where the owner of an economy approves going live in their own wallet. The
2
+ // server builds each transaction (js/economy/plan.mjs), has the fresh keys a setup makes sign their part, and sends
3
+ // only what it prepared: a wallet's answer must carry the exact message prepared for it and that wallet's valid
4
+ // signature. Each prepared batch answers once and expires after 90 seconds. What lands is written back into the spec
5
+ // file as it lands, so a setup cut short goes on from where it stopped. The server never sees the owner's key.
6
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
7
+ import { chmod, mkdtemp, open as openFile, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
8
+ import { tmpdir } from 'node:os';
9
+ import { createServer } from 'node:http';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { spawn } from 'node:child_process';
13
+ import { equalBytes, fromBase64, toBase64 } from '../chain/bytes.js';
14
+ import { checkCluster, notMainnet, rpcClient } from '../chain/rpc.js';
15
+ import { addressBytes, encodeBase58 } from '../chain/base58.js';
16
+ import { MINT_BYTES } from '../chain/token.js';
17
+ import { compileMessage, readTransaction, wireTransaction } from '../chain/tx.js';
18
+ import { accountKind, decode } from '../client/accounts.js';
19
+ import { LOOT_PROGRAM } from '../client/pda.js';
20
+ import { check, describe } from '../economy/check.mjs';
21
+ import { cost } from '../economy/cost.mjs';
22
+ import { LEGAL, legalFlags } from '../economy/legal.mjs';
23
+ import { changedParts, prepare, recordLanded } from '../economy/live.mjs';
24
+ import { ACCOUNT_OVERHEAD, compressedTree, model, MOST_RENT_PER_BYTE } from '../economy/model.mjs';
25
+ import { madeSoFar, plan, PER_APPROVAL } from '../economy/plan.mjs';
26
+ import { compareDetails, liveStepDetails, namesOf, outsideAddresses, stepDetails } from './details.mjs';
27
+ import { freshKeypair, verifySignature } from './ed25519.mjs';
28
+ import { checkLive, checkMints, LiveMismatch, MintMismatch } from './live-check.mjs';
29
+
30
+ const PREPARE_TTL_MS = 90_000;
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const DEVNET = 'https://api.devnet.solana.com';
33
+
34
+ /** A refusal with the HTTP status the page gets. */
35
+ class ApproveError extends Error {
36
+ constructor(status, message) {
37
+ super(message);
38
+ this.status = status;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * The chain the approval sends to: devnet by default (XSOLANA_RPC_URL to use another devnet RPC, or a local validator).
44
+ * A mainnet URL is refused at once, and before anything is sent the chain's genesis block is checked: mainnet and
45
+ * testnet are refused whatever the URL says.
46
+ */
47
+ export function devnetChain(url = process.env.XSOLANA_RPC_URL || DEVNET) {
48
+ const rpc = rpcClient(notMainnet(url));
49
+ let cluster = null;
50
+ const checked = () => (cluster ??= checkCluster(rpc).catch((e) => {
51
+ cluster = null;
52
+ throw e;
53
+ }));
54
+ return {
55
+ url,
56
+ latestBlockhash: async () => {
57
+ await checked();
58
+ return rpc.latestBlockhash();
59
+ },
60
+ rent: async (bytes) => BigInt(await rpc.call('getMinimumBalanceForRentExemption', [bytes])),
61
+ send: async (wire) => {
62
+ await checked();
63
+ return rpc.send(wire);
64
+ },
65
+ confirm: (signature, options) => rpc.confirm(signature, options),
66
+ account: (address) => rpc.account(address),
67
+ /** A sent transaction's status from the whole history: { err, confirmationStatus }, or null when unseen. */
68
+ signatureStatus: async (signature) => (await rpc.call('getSignatureStatuses', [[signature], { searchTransactionHistory: true }])).value[0],
69
+ blockHeight: () => rpc.blockHeight(),
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Writes `text` to `file` whole or not at all: into a new file in the same folder, flushed to disk, then renamed over
75
+ * it. A crash mid-write leaves the file as it was, never cut short. The file keeps its permissions; a link to it is
76
+ * followed, so the file it points at is the one written.
77
+ */
78
+ export async function writeWhole(file, text) {
79
+ const target = await realpath(file).catch(() => file);
80
+ const mode = await stat(target).then((s) => s.mode & 0o777, () => 0o644);
81
+ const temp = path.join(path.dirname(target), `.${path.basename(target)}.${randomBytes(6).toString('hex')}.tmp`);
82
+ try {
83
+ const handle = await openFile(temp, 'wx', mode);
84
+ try {
85
+ await handle.writeFile(text);
86
+ await handle.chmod(mode);
87
+ await handle.sync();
88
+ } finally {
89
+ await handle.close();
90
+ }
91
+ await rename(temp, target);
92
+ } catch (e) {
93
+ await rm(temp, { force: true }).catch(() => {});
94
+ throw e;
95
+ }
96
+ }
97
+
98
+ /** A wallet address as the page sent it, or a refusal (400) when it is not a Solana address. */
99
+ function walletAddress(wallet) {
100
+ const text = String(wallet ?? '');
101
+ try {
102
+ addressBytes(text);
103
+ } catch {
104
+ 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.`);
105
+ }
106
+ return text;
107
+ }
108
+
109
+ /**
110
+ * One approval session for the spec in `file`. `chain`: { latestBlockhash, rent(bytes), send(wire), confirm(sig) }.
111
+ * `write(file, text)` writes the spec file (`writeWhole`). Exposed for tests; `approve` serves it.
112
+ */
113
+ export async function createApproval({ file, chain = devnetChain(), now = () => Math.floor(Date.now() / 1000), clock = Date.now, write = writeWhole }) {
114
+ let spec = JSON.parse(await readFile(file, 'utf8'));
115
+ if (spec.economy) spec = spec.economy;
116
+ const verdict = check(spec, { now: now() });
117
+ if (!verdict.ok) throw new ApproveError(400, `${file} does not check:\n${describe({ refusals: verdict.refusals, warnings: [] })}`);
118
+ const keys = new Map();
119
+ let prepared = null;
120
+ let busy = false;
121
+ // One wallet read, prepare or complete at a time: each settles and writes the live section, and a batch built while
122
+ // another batch lands would send that batch's steps again.
123
+ let queue = Promise.resolve();
124
+ const serial = (fn) => {
125
+ const run = queue.then(fn, fn);
126
+ queue = run.catch(() => {});
127
+ return run;
128
+ };
129
+ // Goes up each time the live section changes: a batch prepared before a change is never sent.
130
+ let generation = 0;
131
+ const signatures = [];
132
+ const at = now();
133
+ const mode = !spec.live ? 'new' : spec.live.partial ? 'resume' : 'change';
134
+ /** The parts this session publishes: all of them for a new economy, what changed for a live one. */
135
+ let only = mode === 'change' ? changedParts(spec).only : spec.live?.changing ? new Set(spec.live.changing) : null;
136
+
137
+ /** What is live so far: the spec's own live section (a resumed or changed economy) and what landed this session. */
138
+ const live = () => spec.live ?? null;
139
+
140
+ /**
141
+ * The owner a setup for `wallet` is built for, and its project, once every mint the spec names is held against the
142
+ * chain (`checkMints`: its decimals and program) and so is the file's live section (`checkLive`): the wallet's own
143
+ * project, or the project whose authority it is. A section that does not match is refused, and nothing is built on
144
+ * it.
145
+ */
146
+ async function trust(wallet) {
147
+ try {
148
+ await checkMints({ m: model(spec, { now: at }), chain });
149
+ } catch (e) {
150
+ if (!(e instanceof MintMismatch)) throw e;
151
+ throw new ApproveError(409, `${e.message} Nothing was sent: correct ${path.basename(file)}, then run \`xsolana approve\` again.`);
152
+ }
153
+ try {
154
+ return await checkLive({ live: live(), m: model(spec, { now: at }), wallet, chain });
155
+ } catch (e) {
156
+ if (!(e instanceof LiveMismatch)) throw e;
157
+ 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.`);
158
+ }
159
+ }
160
+
161
+ async function build(owner) {
162
+ const p0 = plan(spec, { now: at, live: live(), only });
163
+ if (!p0.ok) throw new ApproveError(400, describe({ refusals: p0.refusals, warnings: [] }));
164
+ for (const name of p0.freshKeys) if (!keys.has(name)) keys.set(name, freshKeypair());
165
+ // The accounts the owner makes outside loot: a mint, and the project's tree of compressed NFTs.
166
+ const tree = compressedTree(model(spec, { now: at }));
167
+ const rents = new Map(await Promise.all([MINT_BYTES, ...(tree ? [tree.bytes] : [])].map(async (bytes) => [bytes, boundedRent(bytes, await chain.rent(bytes))])));
168
+ const rent = (bytes) => {
169
+ if (!rents.has(bytes)) throw new Error(`No rent known for ${bytes} bytes.`);
170
+ return rents.get(bytes);
171
+ };
172
+ const addresses = Object.fromEntries([...keys].map(([n, k]) => [n, k.address]));
173
+ const p = prepare(spec, { owner, keys: addresses, live: live() ?? {}, now: at, rent, only });
174
+ if (!p.ok) throw new ApproveError(400, describe({ refusals: p.refusals, warnings: [] }));
175
+ return p;
176
+ }
177
+
178
+ /**
179
+ * What each step pays to whom and which keys it trusts, read back from its instructions, in full. A change says, for
180
+ * a record that is live already, how each line differs from what is on chain (`readLive`).
181
+ */
182
+ const names = () => namesOf(model(spec, { now: at }), { treasury: madeSoFar(live())?.treasury ?? model(spec, { now: at }).treasuryWallet ?? null });
183
+ /**
184
+ * What is live, read from the chain once a wallet connects and the file's live section is checked: for each step,
185
+ * the lines of the records it writes as they are on chain now ([] for a record not there yet), and the treasury.
186
+ * Never the file's own `live.published`: whoever writes the file writes that too. Null until read.
187
+ */
188
+ let wasLive = null;
189
+ async function readLive(p) {
190
+ if (!spec.live?.published || typeof chain.account !== 'function') return;
191
+ // The names a published list or key list goes by: the published spec's, each held against the root on chain.
192
+ const n = namesOf(model(spec.live.published, { now: at }), { treasury: p.ctx.treasury });
193
+ const was = new Map();
194
+ for (const t of p.transactions) {
195
+ for (const [i, step] of t.steps.entries()) {
196
+ const lines = await liveStepDetails(t.built[i], n, chain);
197
+ if (lines) was.set(step.id, lines);
198
+ }
199
+ }
200
+ wasLive = { steps: was, treasury: p.ctx.treasury };
201
+ }
202
+ const detailsOf = (t, i, n) => {
203
+ const lines = stepDetails(t.built[i], n);
204
+ const was = wasLive?.steps.get(t.steps[i].id);
205
+ return was ? compareDetails(lines, was) : lines;
206
+ };
207
+ /** The lines a change is held against for its outside-address warning: what was read from the chain, or nothing yet. */
208
+ const wasLines = () => (spec.live?.published ? [...(wasLive ? [...wasLive.steps.values()].flat() : []), ...(wasLive?.treasury ? [`Treasury: ${wasLive.treasury}`] : [])] : null);
209
+ 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);
210
+
211
+ // The project's own settings a change names are never published: the page says so.
212
+ const changed = mode === 'change' ? changedParts(spec).settings : [];
213
+ const settings = changed.length ? `This change names new project settings (${changed.join(', ')}) that publishing never writes: on chain they stay as they are.` : null;
214
+
215
+ const legal = legalFlags(verdict);
216
+
217
+ /** What each step does, in words, kept after it lands (the plan no longer lists it then). */
218
+ const said = new Map();
219
+ function view(p, n = names(), extra = {}) {
220
+ const done = new Set(live()?.landed ?? []);
221
+ 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) }))) : [];
222
+ for (const s of steps) said.set(s.id, s);
223
+ return {
224
+ name: spec.name ?? path.basename(file),
225
+ file: path.basename(file),
226
+ mode,
227
+ program: LOOT_PROGRAM,
228
+ cluster: 'devnet',
229
+ setupSol: cost(spec, { now: at, live: live(), only }).setup?.totalSol ?? null,
230
+ warnings: [...(extra.refused ? [extra.refused] : []), ...verdict.warnings.filter((w) => !Object.hasOwn(LEGAL, w.code)).map((w) => w.message), ...[settings, unread(), outsideAddresses(steps.flatMap((s) => s.details), { was: wasLines(), owner: n.owner })].filter(Boolean)],
231
+ // The legal flags in plain words, shown in a block of their own whatever the agent said: Approve waits until the
232
+ // owner ticks that they read them.
233
+ legal,
234
+ authority: model(spec).authority,
235
+ steps,
236
+ landed: [...done].map((id) => ({ id, says: said.get(id)?.says ?? id, details: said.get(id)?.details ?? [] })),
237
+ signatures: [...signatures],
238
+ // The project the setup sends to, once a wallet is connected: always that wallet's own (or the one it is the
239
+ // authority of).
240
+ project: extra.project ?? null,
241
+ wallet: n.owner ?? null,
242
+ ...(extra.refused ? { refused: extra.refused } : {}),
243
+ };
244
+ }
245
+
246
+ const session = {
247
+ get spec() {
248
+ return spec;
249
+ },
250
+ /**
251
+ * Ends the session's hold on the fresh keys: each is dropped (it can sign nothing more) and forgotten, and a batch
252
+ * out for signing is withdrawn. Called once the economy is live and when the server closes; a later batch, if
253
+ * any, is built on new keys.
254
+ */
255
+ close() {
256
+ for (const k of keys.values()) k.drop();
257
+ keys.clear();
258
+ prepared = null;
259
+ },
260
+ /** The steps still to go and what is done, built on stand-in addresses until a wallet connects. */
261
+ state() {
262
+ const p = plan(spec, { now: at, live: live(), only });
263
+ const v = view(p);
264
+ return { ...v, remaining: p.ok ? p.transactions.length : 0, done: p.ok && p.transactions.length === 0 };
265
+ },
266
+ /**
267
+ * The same, for the connected `wallet`: built exactly as its batches will be (its own project, the treasury on
268
+ * chain), so the page names that project and calls only that wallet "your wallet". A live section the chain does
269
+ * not bear out is said at the top, and nothing can be approved.
270
+ */
271
+ stateFor(wallet) {
272
+ return serial(() => stateFor(walletAddress(wallet)));
273
+ },
274
+ /** The next batch for `wallet` to sign: [base64 wire], with what each does. */
275
+ prepare(wallet) {
276
+ if (busy) return Promise.reject(new ApproveError(409, 'A batch is being sent: wait for it.'));
277
+ return serial(() => prepareFor(walletAddress(wallet)));
278
+ },
279
+ /** The wallet's answer: checked against what was prepared, then sent and confirmed one by one. */
280
+ complete(wallet, id, signed) {
281
+ return serial(() => complete(walletAddress(wallet), id, signed));
282
+ },
283
+ };
284
+ return session;
285
+
286
+ async function stateFor(wallet) {
287
+ let trusted;
288
+ let p0 = plan(spec, { now: at, live: live(), only });
289
+ try {
290
+ await settlePending();
291
+ p0 = plan(spec, { now: at, live: live(), only });
292
+ trusted = await trust(wallet);
293
+ } catch (e) {
294
+ if (!(e instanceof ApproveError)) throw e;
295
+ const v = view(p0, namesOf(model(spec, { now: at }), { owner: wallet }), { refused: e.message });
296
+ return { ...v, remaining: p0.ok ? p0.transactions.length : 0, done: false };
297
+ }
298
+ if (!p0.ok || !p0.transactions.length) return { ...view(p0, names(), { project: trusted.project }), remaining: 0, done: p0.ok };
299
+ const p = await build(trusted.owner);
300
+ await readLive(p);
301
+ const v = view(p, namesOf(model(spec, { now: at }), { owner: wallet, treasury: p.ctx.treasury }), { project: p.ctx.project });
302
+ return { ...v, remaining: p.transactions.length, done: false };
303
+ }
304
+
305
+ async function prepareFor(wallet) {
306
+ // A second ask while a batch is out for signing (a double click) gets that same batch, not a new one that would
307
+ // make the first signature fail.
308
+ if (prepared && prepared.wallet === wallet && prepared.generation === generation && prepared.expires > clock() + 15_000) return answer(prepared);
309
+ await settlePending();
310
+ const trusted = await trust(wallet);
311
+ const p = await build(trusted.owner);
312
+ if (!p.transactions.length) throw new ApproveError(409, 'Everything has landed already.');
313
+ await readLive(p);
314
+ const authority = p.ctx.authority;
315
+ const first = p.transactions[0];
316
+ const signer = first.signer === 'authority' ? authority : p.ctx.owner;
317
+ 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.`);
318
+ const batch = [];
319
+ for (const t of p.transactions) {
320
+ if (t.signer !== first.signer || batch.length >= PER_APPROVAL) break;
321
+ batch.push(t);
322
+ }
323
+ const { blockhash, lastValidBlockHeight } = await chain.latestBlockhash();
324
+ const wires = batch.map((t) => {
325
+ const message = compileMessage({ payer: wallet, blockhash, instructions: t.instructions });
326
+ const { signers } = readTransaction(wireTransaction(message));
327
+ const sigs = signers.map((s) => {
328
+ const fresh = [...keys.values()].find((k) => k.address === s);
329
+ return fresh ? fresh.sign(message) : new Uint8Array(64);
330
+ });
331
+ return wireTransaction(message, sigs);
332
+ });
333
+ // What each transaction pays to whom, read back from the very instructions the wallet signs.
334
+ const n = namesOf(model(spec, { now: at }), { owner: wallet, treasury: p.ctx.treasury });
335
+ // Every line names the project it goes to first: always the wallet's own, or the one it is the authority of.
336
+ 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))]);
337
+ prepared = { id: randomBytes(12).toString('hex'), wallet, wires, batch, details, ctx: p.ctx, lastValidBlockHeight, generation, expires: clock() + PREPARE_TTL_MS };
338
+ return answer(prepared);
339
+ }
340
+
341
+ async function complete(wallet, id, signed) {
342
+ if (!prepared || !sameText(prepared.id, String(id ?? ''))) throw new ApproveError(409, 'That batch is not the one prepared: ask for the next one.');
343
+ if (prepared.expires < clock()) {
344
+ prepared = null;
345
+ throw new ApproveError(410, 'That batch expired (90 seconds). Ask for it again.');
346
+ }
347
+ if (prepared.wallet !== wallet) throw new ApproveError(403, 'That batch was prepared for another wallet.');
348
+ const batch = prepared;
349
+ prepared = null;
350
+ if (batch.generation !== generation) throw new ApproveError(409, 'Something landed after that batch was built: ask for the next one.');
351
+ const wires = checkSigned(batch.wires, signed, wallet);
352
+ busy = true;
353
+ try {
354
+ for (const [i, wire] of wires.entries()) {
355
+ // The wire's first signature is its own: written into the file before it is sent, so one that lands
356
+ // after its confirmation timed out (or after this process stopped) is found again before anything is
357
+ // sent twice (`settlePending`).
358
+ const signature = encodeBase58(readTransaction(wire).signatures[0]);
359
+ const sent = batch.batch[i];
360
+ await remember({ signature, steps: sent.steps.map((s) => s.id), made: madeBy(sent, batch.ctx), lastValidBlockHeight: batch.lastValidBlockHeight });
361
+ try {
362
+ await chain.send(wire);
363
+ } catch (e) {
364
+ // The node answered and refused it: it was never taken, so it can never land.
365
+ if (e.code !== undefined || !chain.signatureStatus) await forget(signature);
366
+ throw e;
367
+ }
368
+ try {
369
+ await chain.confirm(signature, { lastValidBlockHeight: batch.lastValidBlockHeight });
370
+ } catch (e) {
371
+ // A confirmation can fail without the transaction failing (an RPC hiccup, a slow network): what the
372
+ // chain says about it decides.
373
+ const status = chain.signatureStatus ? await chain.signatureStatus(signature).catch(() => null) : null;
374
+ if (!landedStatus(status)) {
375
+ if (status?.err || !chain.signatureStatus) await forget(signature);
376
+ 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.`);
377
+ throw e;
378
+ }
379
+ }
380
+ signatures.push(signature);
381
+ await landed(sent, batch.ctx, signature);
382
+ }
383
+ } finally {
384
+ busy = false;
385
+ }
386
+ return session.state();
387
+ }
388
+
389
+ /** A prepared batch as the page gets it: the wires to sign, and what each does in words. */
390
+ function answer(b) {
391
+ 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 };
392
+ }
393
+
394
+ /** What a transaction's steps make, from the context it was built with: recorded when it lands. */
395
+ function madeBy(transaction, ctx) {
396
+ const ids = new Set(transaction.steps.map((s) => s.id));
397
+ const mints = Object.fromEntries([...ids].filter((id) => id.startsWith('token:')).map((id) => [id.slice(6), ctx.mints[id.slice(6)]]));
398
+ return {
399
+ ...(ids.has('project') ? { project: ctx.project, treasury: ctx.treasury, authority: ctx.authority } : {}),
400
+ ...(ids.has('collection') && ctx.collection ? { collection: ctx.collection } : {}),
401
+ ...(ids.has('tree') && ctx.tree ? { tree: ctx.tree } : {}),
402
+ ...(Object.keys(mints).length ? { mints } : {}),
403
+ };
404
+ }
405
+
406
+ /**
407
+ * Writes a transaction about to be sent into the file's live section, as not seen landing yet. The section reads as
408
+ * a setup (or a change) under way, as it does once a step lands (`landed`).
409
+ */
410
+ async function remember(entry) {
411
+ const was = spec.live ?? {};
412
+ spec = { ...spec, live: { ...was, landed: was.landed ?? [], partial: true, ...(only ? { changing: [...only] } : {}), pending: [...(was.pending ?? []), entry].slice(-60) } };
413
+ generation++;
414
+ await write(file, `${JSON.stringify(spec, null, 2)}\n`);
415
+ }
416
+
417
+ /** Takes a transaction out of the pending list: it landed (and is recorded), failed, or can no longer land. */
418
+ async function forget(signature) {
419
+ if (!spec.live?.pending) return;
420
+ const pending = spec.live.pending.filter((p) => p.signature !== signature);
421
+ const { pending: _, ...rest } = spec.live;
422
+ spec = { ...spec, live: pending.length ? { ...rest, pending } : rest };
423
+ generation++;
424
+ await write(file, `${JSON.stringify(spec, null, 2)}\n`);
425
+ }
426
+
427
+ /** The owner of `project` on chain, or null when it can't be read. */
428
+ async function projectOwner(project) {
429
+ if (!project || !chain.account) return null;
430
+ const account = await chain.account(project).catch(() => null);
431
+ return account && account.owner === LOOT_PROGRAM && accountKind(account.data) === 'Project' ? decode('Project', account.data).owner : null;
432
+ }
433
+
434
+ /**
435
+ * Looks up each transaction sent but not seen landing (`live.pending`): one that landed is recorded as landed, with
436
+ * what it made; one that failed, or whose blockhash ran out before it landed, is dropped and its steps are made
437
+ * again. One that may still land stops the next batch until it is known, so nothing is sent twice.
438
+ */
439
+ async function settlePending() {
440
+ for (const entry of [...(spec.live?.pending ?? [])]) {
441
+ if (!chain.signatureStatus) {
442
+ await forget(entry.signature);
443
+ continue;
444
+ }
445
+ const status = await chain.signatureStatus(entry.signature);
446
+ if (landedStatus(status)) {
447
+ if (!signatures.includes(entry.signature)) signatures.push(entry.signature);
448
+ await landed({ steps: entry.steps.map((id) => ({ id })) }, { ...(entry.made ?? {}), owner: await projectOwner(entry.made?.project ?? spec.live?.project) }, entry.signature);
449
+ continue;
450
+ }
451
+ if (status?.err) {
452
+ await forget(entry.signature);
453
+ continue;
454
+ }
455
+ // Never seen, and its blockhash has run out: it can no longer land. (One seen but not yet confirmed may still.)
456
+ const height = !status && chain.blockHeight ? await chain.blockHeight() : null;
457
+ if (height !== null && entry.lastValidBlockHeight !== undefined && height > entry.lastValidBlockHeight) {
458
+ await forget(entry.signature);
459
+ continue;
460
+ }
461
+ 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.`);
462
+ }
463
+ }
464
+
465
+ /**
466
+ * One transaction landed: the spec's live section learns its steps, and the file is written at once. It names only
467
+ * what those steps made (the project once `project` landed, a mint once its `token:` step did, …): an account whose
468
+ * own transaction has not landed is made again when the setup goes on.
469
+ */
470
+ async function landed(transaction, ctx, signature) {
471
+ const was = spec.live ?? {};
472
+ const ids = transaction.steps.map((s) => s.id);
473
+ const made = new Set(ids);
474
+ const mints = { ...(was.mints ?? {}) };
475
+ for (const id of ids) if (id.startsWith('token:') && ctx.mints?.[id.slice(6)]) mints[id.slice(6)] = ctx.mints[id.slice(6)];
476
+ const progress = {
477
+ ...was,
478
+ program: LOOT_PROGRAM,
479
+ ...(made.has('project') ? { project: ctx.project, treasury: ctx.treasury, authority: ctx.authority } : {}),
480
+ ...(made.has('collection') && ctx.collection ? { collection: ctx.collection } : {}),
481
+ ...(made.has('tree') && ctx.tree ? { tree: ctx.tree } : {}),
482
+ mints,
483
+ landed: [...new Set([...(was.landed ?? []), ...ids])],
484
+ partial: true,
485
+ ...(only ? { changing: [...only] } : {}),
486
+ // (each signature once: one found again by `settlePending` may have been recorded already)
487
+ transactions: (was.transactions ?? []).some((t) => t.signature === signature) ? was.transactions : [...(was.transactions ?? []), { signature }].slice(-60),
488
+ pending: (was.pending ?? []).filter((p) => p.signature !== signature),
489
+ };
490
+ if (!progress.pending.length) delete progress.pending;
491
+ if (!Object.keys(progress.mints).length) delete progress.mints;
492
+ if (!progress.collection) delete progress.collection;
493
+ spec = { ...spec, live: progress };
494
+ generation++;
495
+ const rest = plan(spec, { now: at, live: spec.live, only });
496
+ if (rest.ok && rest.transactions.length === 0) {
497
+ const { live: _, ...bare } = spec;
498
+ // Everything the setup made, whichever session or transaction made it.
499
+ const all = { ...ctx, ...Object.fromEntries(['project', 'treasury', 'authority', 'collection', 'tree'].map((k) => [k, progress[k] ?? ctx[k]])), mints: { ...(ctx.mints ?? {}), ...(progress.mints ?? {}) } };
500
+ const final = recordLanded(bare, { ctx: all, now: at, at: new Date(clock()).toISOString() });
501
+ final.live = { ...final.live, transactions: progress.transactions };
502
+ spec = final;
503
+ only = changedParts(spec).only;
504
+ }
505
+ await write(file, `${JSON.stringify(spec, null, 2)}\n`);
506
+ }
507
+ }
508
+
509
+ /**
510
+ * The rent the RPC gave for an account of `bytes`, once held against what rent can be: the lamports go into an account
511
+ * the setup makes and can never close, so an RPC that asks more than any cluster charges is refused. Less is safe: the
512
+ * chain refuses an account short of its rent, and nothing is lost.
513
+ */
514
+ function boundedRent(bytes, lamports) {
515
+ const most = (BigInt(bytes) + ACCOUNT_OVERHEAD) * MOST_RENT_PER_BYTE;
516
+ const given = BigInt(lamports);
517
+ 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).`);
518
+ return given;
519
+ }
520
+
521
+ /** Whether a signature's status says it landed: confirmed or finalized, and not failed. */
522
+ const landedStatus = (status) => Boolean(status && !status.err && (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized'));
523
+
524
+ const sameText = (a, b) => {
525
+ const x = Buffer.from(String(a));
526
+ const y = Buffer.from(String(b));
527
+ return x.length === y.length && timingSafeEqual(x, y);
528
+ };
529
+
530
+ /** The wallet's signed copies: each the exact prepared message, each with the wallet's valid signature. */
531
+ function checkSigned(prepared, signed, wallet) {
532
+ if (!Array.isArray(signed) || signed.length !== prepared.length) throw new ApproveError(400, 'The wallet’s answer does not match the request.');
533
+ return prepared.map((wire, i) => {
534
+ let mine;
535
+ let theirs;
536
+ try {
537
+ mine = readTransaction(wire);
538
+ theirs = readTransaction(fromBase64(String(signed[i])));
539
+ } catch {
540
+ throw new ApproveError(400, 'The wallet returned something that is not a transaction.');
541
+ }
542
+ if (!equalBytes(mine.message, theirs.message)) throw new ApproveError(400, 'The wallet changed the transaction before signing it, so nothing was sent.');
543
+ const slot = mine.signers.indexOf(wallet);
544
+ if (slot < 0) throw new ApproveError(400, 'This transaction was not prepared for that wallet.');
545
+ const signature = theirs.signatures[slot];
546
+ if (!verifySignature(wallet, mine.message, signature)) throw new ApproveError(400, 'The wallet’s signature is missing or does not match your wallet.');
547
+ const signatures = [...mine.signatures];
548
+ signatures[slot] = signature;
549
+ return wireTransaction(mine.message, signatures);
550
+ });
551
+ }
552
+
553
+ // ---------------------------------------------------------------- the page
554
+
555
+ const BROWSER_FILES = /^\/js\/(chain\/bytes\.js|approve\/page\.js)$/;
556
+ /** The most a request's body may hold. */
557
+ const BODY_LIMIT = 200_000;
558
+
559
+ /**
560
+ * What the page may load and do (its Content-Security-Policy): scripts only from this server, its one style block by
561
+ * its hash, requests only to this server, and no other page may frame it.
562
+ */
563
+ function pagePolicy(page) {
564
+ const css = /<style>([\s\S]*?)<\/style>/.exec(page)?.[1] ?? '';
565
+ const hash = createHash('sha256').update(css).digest('base64');
566
+ return `default-src 'none'; script-src 'self'; style-src 'sha256-${hash}'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`;
567
+ }
568
+
569
+ /**
570
+ * Whether a request is for this server by its own name: its Host is 127.0.0.1:<port> or localhost:<port>, and its
571
+ * Origin, when it has one, is that too. Another name (a site whose address was pointed at 127.0.0.1) is refused.
572
+ */
573
+ export function ownRequest(headers, port) {
574
+ const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
575
+ if (!hosts.includes(String(headers.host ?? '').toLowerCase())) return false;
576
+ if (headers.origin === undefined) return true;
577
+ return hosts.some((h) => String(headers.origin).toLowerCase() === `http://${h}`);
578
+ }
579
+
580
+ /** A request's body, as text: refused (413) past `limit` bytes, and the rest of it read and let go. */
581
+ function readBody(req, limit = BODY_LIMIT) {
582
+ return new Promise((resolve, reject) => {
583
+ const chunks = [];
584
+ let size = 0;
585
+ req.on('data', (chunk) => {
586
+ size += chunk.length;
587
+ if (size > limit) return reject(new ApproveError(413, 'That request is too large.'));
588
+ chunks.push(chunk);
589
+ });
590
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
591
+ req.on('error', reject);
592
+ });
593
+ }
594
+
595
+ /** A request's body as JSON ({} when empty): refused (400) when it is not JSON. */
596
+ async function readJson(req) {
597
+ const text = await readBody(req);
598
+ try {
599
+ return text ? JSON.parse(text) : {};
600
+ } catch {
601
+ throw new ApproveError(400, 'The request is not JSON.');
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Opens the browser on `url` (the one-time code's link) without putting it on a command line, which other users of the
607
+ * machine can read while the opener runs: the link goes into a page that sends the browser on to it, written
608
+ * to a file only this user can read (0600, in a new 0700 folder), and the opener is given that file's path. Answers a
609
+ * function that removes the file (the server calls it once the code is used, and when it closes).
610
+ */
611
+ export async function openBrowser(url, { spawnImpl = spawn, dir = tmpdir() } = {}) {
612
+ const folder = await mkdtemp(path.join(dir, 'xsolana-approve-'));
613
+ const remove = () => rm(folder, { recursive: true, force: true }).catch(() => {});
614
+ try {
615
+ await chmod(folder, 0o700);
616
+ const file = path.join(folder, 'open.html');
617
+ const href = url.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
618
+ 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' });
619
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [file]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', file]] : ['xdg-open', [file]];
620
+ spawnImpl(cmd, args, { stdio: 'ignore', detached: true }).unref();
621
+ } catch {
622
+ /* the link is printed anyway */
623
+ }
624
+ return remove;
625
+ }
626
+
627
+ /**
628
+ * Serves the approval page for `file` until the economy is live (or the process is stopped). Returns { server, link,
629
+ * opened, session }: `link` carries the session's token and is printed; `opened`, the link the browser is opened with
630
+ * (`openLink`), carries a one-time code instead. The code works once: it answers with `link`, and is no use to anyone
631
+ * after. The default `openLink` keeps even the code off the opener's command line (`openBrowser`); one that answers a
632
+ * function has it called once the code is used.
633
+ */
634
+ export async function approve({ file = 'xsolana.json', port, open = true, chain = devnetChain(), log = (...a) => console.error(...a), openLink = openBrowser } = {}) {
635
+ const session = await createApproval({ file: path.resolve(file), chain });
636
+ const token = randomBytes(18).toString('base64url');
637
+ let code = randomBytes(18).toString('base64url');
638
+ // (removes the file the browser was opened with, once: `openLink` answers how)
639
+ let removeOpener = null;
640
+ const forget = () => { const remove = removeOpener; removeOpener = null; remove?.(); };
641
+ const page = await readFile(path.join(ROOT, 'approve/page.html'), 'utf8');
642
+ const policy = pagePolicy(page);
643
+ const headers = { 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer', 'content-security-policy': policy, 'x-frame-options': 'DENY' };
644
+ const server = createServer(async (req, res) => {
645
+ const url = new URL(req.url, 'http://127.0.0.1');
646
+ const send = (status, body, type = 'application/json') => {
647
+ res.writeHead(status, { ...headers, 'content-type': type });
648
+ res.end(type === 'application/json' ? JSON.stringify(body) : body);
649
+ };
650
+ try {
651
+ // Only a request to this server by its own name (DNS rebinding: a site whose name now points at 127.0.0.1).
652
+ if (!ownRequest(req.headers, server.address().port)) return send(403, 'Open the link `xsolana approve` printed.', 'text/plain');
653
+ if (req.method === 'GET' && url.pathname === '/') {
654
+ const once = url.searchParams.get('c');
655
+ if (once !== null) {
656
+ if (!code || !sameText(once, code)) return send(403, 'This link has been opened already: open the link `xsolana approve` printed.', 'text/plain');
657
+ code = null;
658
+ forget();
659
+ res.writeHead(303, { ...headers, location: `/?t=${token}` });
660
+ return res.end();
661
+ }
662
+ if (!sameText(url.searchParams.get('t') ?? '', token)) return send(403, 'Open the link `xsolana approve` printed.', 'text/plain');
663
+ return send(200, page.replace('__TOKEN__', token), 'text/html; charset=utf-8');
664
+ }
665
+ 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');
666
+ if (!sameText(req.headers['x-xsolana-token'] ?? '', token)) return send(403, { error: 'Not this session’s page.' });
667
+ if (req.method === 'GET' && url.pathname === '/api/state') {
668
+ const wallet = url.searchParams.get('wallet');
669
+ return send(200, wallet ? await session.stateFor(wallet) : session.state());
670
+ }
671
+ const body = req.method === 'POST' ? await readJson(req) : {};
672
+ if (req.method === 'POST' && url.pathname === '/api/prepare') return send(200, await session.prepare(String(body.wallet ?? '')));
673
+ if (req.method === 'POST' && url.pathname === '/api/complete') {
674
+ const state = await session.complete(String(body.wallet ?? ''), body.id, body.transactions);
675
+ if (state.done) {
676
+ session.close();
677
+ log(`Live. ${file} now holds the project’s addresses.`);
678
+ // Nothing is left to approve: the server stops once the page has its answer, and `xsolana approve` ends.
679
+ res.once('finish', () => server.close());
680
+ }
681
+ return send(200, state);
682
+ }
683
+ return send(404, { error: 'Not found.' });
684
+ } catch (e) {
685
+ return send(e.status ?? 500, { error: e.message });
686
+ }
687
+ });
688
+ server.on('close', () => session.close());
689
+ await new Promise((resolve, reject) => {
690
+ server.once('error', reject);
691
+ server.listen(port ?? 0, '127.0.0.1', () => {
692
+ server.off('error', reject);
693
+ resolve();
694
+ });
695
+ });
696
+ const origin = `http://127.0.0.1:${server.address().port}`;
697
+ const link = `${origin}/?t=${token}`;
698
+ const opened = `${origin}/?c=${code}`;
699
+ log(`Approve going live in your wallet (devnet): ${link}`);
700
+ log('Leave this running until the page says it is live. Ctrl+C stops it; running it again goes on from where it stopped.');
701
+ if (open && !process.env.XSOLANA_NO_OPEN) {
702
+ const remove = await openLink(opened);
703
+ if (typeof remove === 'function') {
704
+ // the file that opened the browser goes once the code is used, when the server closes, or after a minute
705
+ removeOpener = remove;
706
+ setTimeout(forget, 60_000).unref();
707
+ server.on('close', forget);
708
+ }
709
+ }
710
+ return { server, link, opened, session };
711
+ }