@xgamesfun/solana 0.28.0 → 0.28.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/DISCLAIMER.md +3 -1
  2. package/README.md +18 -16
  3. package/bin/xsolana.mjs +27 -1
  4. package/js/approve/details.mjs +3 -3
  5. package/js/approve/live-check.mjs +2 -2
  6. package/js/approve/page.html +5 -5
  7. package/js/approve/page.js +24 -4
  8. package/js/approve/server.mjs +66 -78
  9. package/js/approve/wallet-adds.mjs +180 -0
  10. package/js/approve/with-key.mjs +7 -4
  11. package/js/chain/borsh.js +5 -3
  12. package/js/chain/pda.js +6 -0
  13. package/js/chain/rpc.js +26 -10
  14. package/js/client/accounts.js +6 -6
  15. package/js/client/cluster.js +136 -0
  16. package/js/client/grant.js +2 -2
  17. package/js/client/ix.js +16 -16
  18. package/js/client/pda.js +18 -5
  19. package/js/economy/check.mjs +28 -13
  20. package/js/economy/explain.mjs +2 -1
  21. package/js/economy/guide.mjs +6 -4
  22. package/js/economy/live.mjs +2 -2
  23. package/js/economy/plan.mjs +3 -3
  24. package/js/economy/schema.json +2 -2
  25. package/js/mcp/server.mjs +35 -4
  26. package/js/mcp/tools.mjs +56 -45
  27. package/js/mcp/with-cluster.mjs +25 -0
  28. package/js/play/index.js +42 -37
  29. package/js/play/sponsor.js +4 -4
  30. package/js/results/index.js +7 -6
  31. package/js/results/server.js +4 -3
  32. package/js/results/wire.js +9 -8
  33. package/package.json +6 -1
  34. package/release.json +6 -1
  35. package/skills/xsolana-economy/SKILL.md +13 -9
  36. package/skills/xsolana-economy/references/apps.md +2 -1
  37. package/skills/xsolana-economy/references/telling-the-person.md +1 -1
  38. package/skills/xsolana-economy/references/trust-and-cheating.md +1 -1
  39. package/types/chain/borsh.d.ts +4 -2
  40. package/types/chain/pda.d.ts +6 -0
  41. package/types/chain/rpc.d.ts +10 -4
  42. package/types/client/cluster.d.ts +61 -0
  43. package/types/client/idl/loot.d.ts +9 -9
  44. package/types/client/pda.d.ts +1 -1
  45. package/types/economy/check.d.mts +1 -2
  46. package/types/economy/plan.d.mts +16 -16
  47. package/types/mcp/server.d.mts +1 -1
  48. package/types/mcp/tools.d.mts +9 -7
  49. package/types/mcp/with-cluster.d.mts +8 -0
  50. package/types/play/index.d.ts +2 -2
  51. package/types/results/index.d.ts +6 -6
  52. package/types/results/server.d.ts +3 -3
  53. package/types/results/wire.d.ts +8 -8
package/DISCLAIMER.md CHANGED
@@ -19,7 +19,9 @@ Read this before you use this package, its tools or the loot program. Using them
19
19
  wallets, token and NFT programs, npm and any results server. Any of them can fail, change or stop.
20
20
  - The public results server (`https://play.xgames.fun/xsolana`) is offered as is, may change or stop at any time, and
21
21
  carries no promise of uptime or of what it vouches for.
22
- - **Devnet only.** Nothing here is meant for real money until the program has been independently audited.
22
+ - **Mainnet is real money, and the program has not been independently audited.** loot runs on devnet, where nothing
23
+ has value, and on mainnet, where everything does. An economy is on devnet unless its file says
24
+ `"network": "mainnet"`. What is sent to the program on mainnet can be lost, and you use it there at your own risk.
23
25
 
24
26
  ## Use it legally
25
27
 
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@xgamesfun/solana?color=b9f26c&label=npm)](https://www.npmjs.com/package/@xgamesfun/solana)
6
6
  [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
7
- [![network](https://img.shields.io/badge/network-devnet-orange)](#good-to-know)
7
+ [![network](https://img.shields.io/badge/network-devnet%20%7C%20mainnet-orange)](#good-to-know)
8
8
  [![MCP Registry](https://img.shields.io/badge/MCP%20Registry-io.github.XGamesFun%2Fsolana--kit-8a2be2)](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.XGamesFun/solana-kit)
9
9
 
10
10
  You describe your game's or community's economy in a small JSON file: what players buy, open, stake, win or craft.
@@ -15,8 +15,9 @@ write, deploy or audit yourself.
15
15
  Most people never touch the JSON: they ask an AI agent (Claude Code, Codex, Cursor, ChatGPT or Claude) for _"a fishing
16
16
  game where each cast costs 0.02 SOL, with a rare golden fish and a monthly draw"_ and the agent does the rest.
17
17
 
18
- > **Devnet only for now.** The program is not yet independently audited, so nothing here is for real money. The
19
- > tools are provided as is: read [DISCLAIMER.md](DISCLAIMER.md) before you use them.
18
+ > **On devnet and on mainnet.** An economy runs on devnet, where nothing is real money, unless its file says
19
+ > `"network": "mainnet"`, where everything is. The program is not independently audited and the tools are provided as
20
+ > is: read [DISCLAIMER.md](DISCLAIMER.md) before you use them, and before real money above all.
20
21
 
21
22
  ## What you can build
22
23
 
@@ -76,7 +77,7 @@ codex plugin add xsolana@xgamesfun
76
77
 
77
78
  **claude.ai, ChatGPT or Claude Desktop:** add `https://xgames.fun/mcp` as a custom connector. Nothing to install.
78
79
 
79
- **Cursor, Cline, opencode or any MCP client:** run `npx -y --package=@xgamesfun/solana@0.28.0 xsolana mcp`.
80
+ **Cursor, Cline, opencode or any MCP client:** run `npx -y --package=@xgamesfun/solana@0.28.2 xsolana mcp`.
80
81
 
81
82
  **Any agent that reads skills** (Copilot, Gemini CLI, Windsurf…): `npx skills add XGamesFun/solana-kit`.
82
83
 
@@ -87,26 +88,26 @@ are in [docs/harnesses.md](docs/harnesses.md).
87
88
 
88
89
  ```bash
89
90
  # find a worked example close to your idea
90
- npx -y @xgamesfun/solana@0.28.0 examples shop with loot boxes
91
+ npx -y @xgamesfun/solana@0.28.2 examples shop with loot boxes
91
92
 
92
93
  # using a coin you already launched? read it from the chain into its "tokens" entry
93
- npx -y @xgamesfun/solana@0.28.0 token <its mint address>
94
+ npx -y @xgamesfun/solana@0.28.2 token <its mint address>
94
95
 
95
96
  # does it work? where does the money go? what does it cost?
96
- npx -y @xgamesfun/solana@0.28.0 check xsolana.json
97
- npx -y @xgamesfun/solana@0.28.0 simulate xsolana.json
98
- npx -y @xgamesfun/solana@0.28.0 cost xsolana.json
97
+ npx -y @xgamesfun/solana@0.28.2 check xsolana.json
98
+ npx -y @xgamesfun/solana@0.28.2 simulate xsolana.json
99
+ npx -y @xgamesfun/solana@0.28.2 cost xsolana.json
99
100
 
100
101
  # the whole economy in plain words, and what a cheater could get
101
- npx -y @xgamesfun/solana@0.28.0 explain xsolana.json
102
- npx -y @xgamesfun/solana@0.28.0 review xsolana.json
102
+ npx -y @xgamesfun/solana@0.28.2 explain xsolana.json
103
+ npx -y @xgamesfun/solana@0.28.2 review xsolana.json
103
104
 
104
- # go live: a key file for the project (it prints the address to send devnet SOL to, never the key)…
105
- npx -y @xgamesfun/solana@0.28.0 keygen ~/.config/xsolana/my-game.json
105
+ # go live: a key file for the project (it prints the address to send SOL to, never the key)…
106
+ npx -y @xgamesfun/solana@0.28.2 keygen ~/.config/xsolana/my-game.json
106
107
  # …then everything going live would send, and a code; nothing is sent
107
- npx -y @xgamesfun/solana@0.28.0 approve xsolana.json --keypair ~/.config/xsolana/my-game.json
108
+ npx -y @xgamesfun/solana@0.28.2 approve xsolana.json --keypair ~/.config/xsolana/my-game.json
108
109
  # …and with that code, exactly what it listed is sent
109
- npx -y @xgamesfun/solana@0.28.0 approve xsolana.json --keypair ~/.config/xsolana/my-game.json --yes <code>
110
+ npx -y @xgamesfun/solana@0.28.2 approve xsolana.json --keypair ~/.config/xsolana/my-game.json --yes <code>
110
111
  ```
111
112
 
112
113
  Each command names an exact version, so you always run the release you meant to. `xsolana help` lists them all.
@@ -149,7 +150,8 @@ Using `@solana/kit`? `loot.toKitInstruction(ix)` turns any builder's instruction
149
150
 
150
151
  ## Good to know
151
152
 
152
- - **Devnet only, not yet audited.** Mainnet waits for an independent audit.
153
+ - **Devnet and mainnet, not independently audited.** An economy is on devnet unless its file says
154
+ `"network": "mainnet"`. Mainnet is real money: try everything on devnet first.
153
155
  - **Upgrades can mean setting up again.** No program version so far has carried accounts over. Pin the package version.
154
156
  - **Players trust the project.** Your keys can mint and move your project's pools. The program protects players from
155
157
  each other and from mistakes, not from the project they chose.
package/bin/xsolana.mjs CHANGED
@@ -141,6 +141,29 @@ async function main(argv) {
141
141
 
142
142
  const fileInput = () => (positional[0] ? { file: positional[0] } : {});
143
143
 
144
+ /**
145
+ * Names the cluster this command works on from the economy it is given (a file, or a spec passed whole): its
146
+ * "network", devnet when it names none. A file that is missing or not JSON, and a network loot does not know, are
147
+ * left to the tool, which says so in its own words.
148
+ */
149
+ async function clusterOf({ file = 'xsolana.json', spec = null } = {}) {
150
+ const { CLUSTERS, networkOf, useCluster } = await import('../js/client/cluster.js');
151
+ let network;
152
+ try {
153
+ network = networkOf(spec ?? JSON.parse(await readFile(file, 'utf8')));
154
+ } catch {
155
+ return;
156
+ }
157
+ if (!Object.hasOwn(CLUSTERS, network)) return;
158
+ try {
159
+ useCluster(network);
160
+ } catch {
161
+ throw new UsageError(
162
+ `${spec ? 'The economy' : file} is on ${network}, and XSOLANA_CLUSTER says ${process.env.XSOLANA_CLUSTER}: unset it, or change the economy’s "network".`,
163
+ );
164
+ }
165
+ }
166
+
144
167
  switch (command) {
145
168
  case 'mcp': {
146
169
  if (flags.has('--http')) {
@@ -166,6 +189,7 @@ async function main(argv) {
166
189
  case 'review':
167
190
  case 'publish':
168
191
  case 'status':
192
+ await clusterOf(fileInput());
169
193
  await tool(`xsolana_${command}`, fileInput());
170
194
  break;
171
195
  case 'token':
@@ -193,13 +217,14 @@ async function main(argv) {
193
217
  `A new key is in ${made.file}${process.platform === 'win32' ? '' : ', readable by you alone'}. Its address:`,
194
218
  ` ${made.address}`,
195
219
  'Whatever is set up with it, it owns, for good: anyone who can read that file controls the project, and every later change is signed by it. Keep the file where it is and never paste it into a chat, a spec, a repository or a page. Back it up yourself (a USB stick, a password manager): if it is lost, nothing can change the project or move what its address holds.',
196
- `It holds nothing yet. Send it devnet SOL (\`xsolana cost\` says what going live takes; https://faucet.solana.com gives some), then: npx -y @xgamesfun/solana@${PACKAGE_VERSION} approve xsolana.json --keypair ${word}`,
220
+ `It holds nothing yet. Send it SOL (\`xsolana cost\` says what going live takes): devnet SOL for an economy on devnet (https://faucet.solana.com gives some), real SOL for one whose file says "network": "mainnet". Then: npx -y @xgamesfun/solana@${PACKAGE_VERSION} approve xsolana.json --keypair ${word}`,
197
221
  ].join('\n'),
198
222
  );
199
223
  break;
200
224
  }
201
225
  case 'approve': {
202
226
  const file = positional[0] ?? 'xsolana.json';
227
+ await clusterOf({ file });
203
228
  const keyFile = values.get('--keypair');
204
229
  const page = flags.has('--browser');
205
230
  if (keyFile !== undefined && page)
@@ -252,6 +277,7 @@ async function main(argv) {
252
277
  { cause: e },
253
278
  );
254
279
  }
280
+ if (input && typeof input === 'object' && (input.file !== undefined || input.spec)) await clusterOf(input);
255
281
  await tool(positional[0], input);
256
282
  break;
257
283
  }
@@ -5,7 +5,7 @@
5
5
  import { decodeBorsh } from '../chain/borsh.js';
6
6
  import { equalBytes, toHex } from '../chain/bytes.js';
7
7
  import { accountKind, decode, IDL, instructionOf, keyLeaf, merkleRoot } from '../client/index.js';
8
- import { LOOT_PROGRAM, pda } from '../client/pda.js';
8
+ import { lootProgram, pda } from '../client/pda.js';
9
9
  import { dateWords, FAR_SECONDS } from '../economy/check.mjs';
10
10
  import { fromUnits } from '../economy/model.mjs';
11
11
  import { grantArgs } from '../economy/plan.mjs';
@@ -38,7 +38,7 @@ const leafNames = list => list.flatMap(a => (a.accounts ? leafNames(a.accounts)
38
38
 
39
39
  /** A loot instruction read back: { name, args, accounts: { name: address } }, or null for another program's. */
40
40
  function readLootInstruction(ix) {
41
- if (ix.programId !== LOOT_PROGRAM) return null;
41
+ if (ix.programId !== lootProgram()) return null;
42
42
  const def = instructionOf(ix.data);
43
43
  if (!def) return null;
44
44
  const args = {};
@@ -104,7 +104,7 @@ export async function liveStepDetails(instructions, names, chain) {
104
104
  reads.push(null);
105
105
  continue;
106
106
  }
107
- if (account.owner !== LOOT_PROGRAM || accountKind(account.data) !== RECORD_ACCOUNT[kind])
107
+ if (account.owner !== lootProgram() || accountKind(account.data) !== RECORD_ACCOUNT[kind])
108
108
  throw new Error(`${read.accounts.target} is not the ${kind.toLowerCase()} it should be.`);
109
109
  reads.push({
110
110
  ...read,
@@ -10,7 +10,7 @@ import { METADATA_LIMITS, metadataAddress, parseRecord } from '../chain/metadata
10
10
  import { parseMint } from '../chain/mint.js';
11
11
  import { mintState, powersOf } from '../chain/token2022.js';
12
12
  import { accountKind, decode } from '../client/accounts.js';
13
- import { BUBBLEGUM_PROGRAM, LOOT_PROGRAM, pda } from '../client/pda.js';
13
+ import { BUBBLEGUM_PROGRAM, lootProgram, pda } from '../client/pda.js';
14
14
  import { METAPLEX_METADATA_PROGRAM, SYSTEM_PROGRAM } from '../chain/pda.js';
15
15
  import { tokenLabels } from '../economy/model.mjs';
16
16
  import { madeSoFar } from '../economy/plan.mjs';
@@ -43,7 +43,7 @@ export async function checkLive({ live, m, wallet, chain }) {
43
43
  let project = own;
44
44
  if (made.project) {
45
45
  const account = await read(made.project);
46
- if (!account || account.owner !== LOOT_PROGRAM || accountKind(account.data) !== 'Project')
46
+ if (!account || account.owner !== lootProgram() || accountKind(account.data) !== 'Project')
47
47
  throw new LiveMismatch(
48
48
  `The file names the project ${made.project}, and there is no loot project at that address.`,
49
49
  );
@@ -161,7 +161,7 @@
161
161
  <body>
162
162
  <main>
163
163
  <header>
164
- <p class="muted mono flush">loot · devnet · runs on your computer only</p>
164
+ <p class="muted mono flush">loot · <span id="cluster">devnet</span> · runs on your computer only</p>
165
165
  <h1 id="title">Approve going live</h1>
166
166
  <p class="muted" id="sub">Loading…</p>
167
167
  </header>
@@ -173,8 +173,8 @@
173
173
  </div>
174
174
  <div class="row" id="wallets" hidden></div>
175
175
  <p class="muted note">
176
- Set the wallet to <b>devnet</b>. Each approval shows you the transactions before you sign; nothing leaves your
177
- wallet without it. Nobody else ever sees your key.
176
+ Set the wallet to <b id="walletcluster">devnet</b>. Each approval shows you the transactions before you sign;
177
+ nothing leaves your wallet without it. Nobody else ever sees your key.
178
178
  </p>
179
179
  </section>
180
180
 
@@ -202,8 +202,8 @@
202
202
  <ul class="warn gap" id="legal" hidden></ul>
203
203
  <p class="muted note">The full text is in DISCLAIMER.md, in the package.</p>
204
204
  <label class="row gap"
205
- ><input type="checkbox" id="legalok" /> I have read this and accept it. Devnet is not real money; real money
206
- needs every point above answered first.</label
205
+ ><input type="checkbox" id="legalok" /> I have read this and accept it.
206
+ <span id="money">Devnet is not real money; real money needs every point above answered first.</span></label
207
207
  >
208
208
  </section>
209
209
 
@@ -5,7 +5,8 @@
5
5
  // session's token from its <meta name="xsolana-token"> (the page allows no inline script).
6
6
  import { fromBase64, toBase64 } from '/js/chain/bytes.js';
7
7
 
8
- const CHAIN = 'solana:devnet';
8
+ // The cluster of this approval (the economy's own), as the server says it: devnet until the first answer.
9
+ let onCluster = 'devnet';
9
10
  const $ = id => document.getElementById(id);
10
11
 
11
12
  function walletRegistry() {
@@ -62,8 +63,23 @@ export function approvalPage(token) {
62
63
  let last = null;
63
64
  // The legal lines the box was last shown with.
64
65
  let legalShown = new Set();
66
+ // How many of the lines about what the wallet added have been said on this page.
67
+ let addedSaid = 0;
65
68
  function render(state) {
69
+ onCluster = state.cluster ?? 'devnet';
70
+ $('cluster').textContent = onCluster;
71
+ $('walletcluster').textContent = onCluster;
72
+ $('money').textContent =
73
+ onCluster === 'mainnet'
74
+ ? 'This is mainnet: everything sent here is real money, and every point above is yours to have answered first.'
75
+ : 'Devnet is not real money; real money needs every point above answered first.';
66
76
  last = state;
77
+ // What the wallet added to transactions that landed (its own checks): they were sent as it signed them. Said
78
+ // whenever a state arrives, so one that landed in a batch that then stopped is said too.
79
+ const addedLines = state.walletAdded ?? [];
80
+ for (const line of addedLines.slice(addedSaid))
81
+ log(`${line} It was sent as your wallet signed it: everything listed here is in it, untouched.`);
82
+ addedSaid = Math.max(addedSaid, addedLines.length);
67
83
  // A token's name the spec changes and the chain can't take (`unwritten`): said whether or not anything else was
68
84
  // sent. With nothing else to send, the page never calls that a landing.
69
85
  const unwritten = state.unwritten ?? [];
@@ -76,7 +92,7 @@ export function approvalPage(token) {
76
92
  $('sub').textContent = state.done
77
93
  ? nothingSent
78
94
  ? `The only change in ${state.file} is a token’s name, and it can’t be written on chain: nothing was sent, and the file is as it was. See below.`
79
- : `Every step landed on devnet. ${state.file} now holds the project’s addresses: you can close this page.`
95
+ : `Every step landed on ${state.cluster}. ${state.file} now holds the project’s addresses: you can close this page.`
80
96
  : `${state.mode === 'change' ? 'A change to a live economy' : state.mode === 'resume' ? 'A setup that was cut short goes on' : 'A new economy'} from ${state.file}, on the shared loot program ${state.program.slice(0, 6)}…${state.project ? `, in the project ${state.project}${state.wallet ? `, made from your wallet ${state.wallet}` : ''}` : ''}`;
81
97
  $('cost').textContent = state.setupSol !== null && state.mode === 'new' ? `about ${state.setupSol} SOL` : '';
82
98
  const steps = $('steps');
@@ -197,7 +213,7 @@ export function approvalPage(token) {
197
213
  const found = list();
198
214
  if (!found.length) {
199
215
  log(
200
- 'No Solana wallet in this browser. Install one (Phantom, Solflare, Backpack), set it to devnet, then reload.',
216
+ `No Solana wallet in this browser. Install one (Phantom, Solflare, Backpack), set it to ${onCluster}, then reload.`,
201
217
  'bad',
202
218
  );
203
219
  return;
@@ -237,7 +253,11 @@ export function approvalPage(token) {
237
253
  `Asking your wallet to approve ${batch.transactions.length} transaction${batch.transactions.length === 1 ? '' : 's'} in the project ${batch.project}: ${batch.says.join(' | ')}${details.length ? `. ${details.join('. ')}` : ''}`,
238
254
  );
239
255
  const sign = signingWallet.features['solana:signTransaction'];
240
- const inputs = batch.transactions.map(t => ({ account: signer, transaction: fromBase64(t), chain: CHAIN }));
256
+ const inputs = batch.transactions.map(t => ({
257
+ account: signer,
258
+ transaction: fromBase64(t),
259
+ chain: `solana:${onCluster}`,
260
+ }));
241
261
  const outputs = await sign.signTransaction(...inputs);
242
262
  const signed = outputs.map(o => toBase64(o.signedTransaction));
243
263
  $('status').textContent = 'Sending and confirming…';
@@ -2,8 +2,9 @@
2
2
  // wallet, served only when asked for (`xsolana approve --keypair`, js/approve/with-key.mjs, drives this same approval
3
3
  // with a key file and no page). The
4
4
  // server builds each transaction (js/economy/plan.mjs), has the fresh keys a setup makes sign their part, and sends
5
- // only what it prepared: a wallet's answer must carry the exact message prepared for it and that wallet's valid
6
- // signature. Each prepared batch answers once and expires after 90 seconds. What lands is written back into the spec
5
+ // only what it prepared and showed: a wallet's answer must carry that wallet's valid signature of the exact message
6
+ // prepared for it, or of that message with nothing but a wallet's own additions (js/approve/wallet-adds.mjs: its
7
+ // Lighthouse checks, a compute budget). Each prepared batch answers once and expires after 90 seconds. What lands is written back into the spec
7
8
  // file as it lands, so a setup cut short goes on from where it stopped. The server never sees the owner's key.
8
9
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
9
10
  import { chmod, mkdtemp, open as openFile, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
@@ -13,14 +14,14 @@ import path from 'node:path';
13
14
  import { fileURLToPath } from 'node:url';
14
15
  import { spawn } from 'node:child_process';
15
16
  import { equalBytes, fromBase64, toBase64 } from '../chain/bytes.js';
16
- import { alreadyProcessed, checkCluster, notMainnet, rpcClient } from '../chain/rpc.js';
17
+ import { alreadyProcessed, rpcClient } from '../chain/rpc.js';
17
18
  import { addressBytes, encodeBase58 } from '../chain/base58.js';
18
19
  import { slotHashes, tableAddress } from '../chain/lookup.js';
19
- import { COMPUTE_BUDGET_PROGRAM, SYSVAR_SLOT_HASHES } from '../chain/pda.js';
20
+ import { SYSVAR_SLOT_HASHES } from '../chain/pda.js';
20
21
  import { MINT_BYTES } from '../chain/token.js';
21
- import { compileMessage, readMessage, readTransaction, wireTransaction } from '../chain/tx.js';
22
+ import { compileMessage, readTransaction, wireTransaction } from '../chain/tx.js';
22
23
  import { accountKind, decode } from '../client/accounts.js';
23
- import { LOOT_PROGRAM, pda } from '../client/pda.js';
24
+ import { checkOnCluster, cluster, CLUSTERS, clusterRpcUrl, lootProgram, pda } from '../client/pda.js';
24
25
  import { check, describe } from '../economy/check.mjs';
25
26
  import { cost } from '../economy/cost.mjs';
26
27
  import { LEGAL, legalFlags } from '../economy/legal.mjs';
@@ -30,10 +31,10 @@ import { madeSoFar, plan, PER_APPROVAL } from '../economy/plan.mjs';
30
31
  import { compareDetails, liveStepDetails, namesOf, outsideAddresses, stepDetails } from './details.mjs';
31
32
  import { freshKeypair, verifySignature } from './ed25519.mjs';
32
33
  import { checkLive, checkMints, LiveMismatch, MintMismatch, readRenames } from './live-check.mjs';
34
+ import { walletAdded } from './wallet-adds.mjs';
33
35
 
34
36
  const PREPARE_TTL_MS = 90_000;
35
37
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
36
- const DEVNET = 'https://api.devnet.solana.com';
37
38
 
38
39
  /** A refusal with the HTTP status the page gets. */
39
40
  class ApproveError extends Error {
@@ -44,37 +45,43 @@ class ApproveError extends Error {
44
45
  }
45
46
 
46
47
  /**
47
- * The chain the approval sends to: devnet by default (XSOLANA_RPC_URL to use another devnet RPC, or a local validator).
48
- * A mainnet URL is refused at once, and before anything is sent the chain's genesis block is checked: mainnet and
49
- * testnet are refused whatever the URL says.
48
+ * The chain the approval sends to: this process's cluster (`client/cluster.js`), through XSOLANA_RPC_URL or, on
49
+ * devnet, its public RPC (a local validator passes for devnet). A URL that names another cluster is refused at once,
50
+ * and before anything is sent the chain's genesis block is checked: another cluster is refused whatever the URL says.
50
51
  */
51
- export function devnetChain(url = process.env.XSOLANA_RPC_URL || DEVNET) {
52
- const rpc = rpcClient(notMainnet(url));
53
- let cluster = null;
52
+ export function clusterChain(url) {
53
+ // A URL that names another cluster is refused here. With none to use (mainnet has no RPC of its own), the refusal
54
+ // waits for the first request, so what is wrong with the economy's file is said first.
55
+ let made = null;
56
+ const client = () => (made ??= rpcClient(clusterRpcUrl(url)));
57
+ if (url || process.env.XSOLANA_RPC_URL || CLUSTERS[cluster()].rpc) client();
58
+ let genesis = null;
54
59
  const checked = () =>
55
- (cluster ??= checkCluster(rpc).catch(e => {
56
- cluster = null;
60
+ (genesis ??= checkOnCluster(client()).catch(e => {
61
+ genesis = null;
57
62
  throw e;
58
63
  }));
59
64
  return {
60
- url,
65
+ get url() {
66
+ return client().url ?? url;
67
+ },
61
68
  latestBlockhash: async () => {
62
69
  await checked();
63
- return rpc.latestBlockhash();
70
+ return client().latestBlockhash();
64
71
  },
65
- rent: async bytes => BigInt(await rpc.call('getMinimumBalanceForRentExemption', [bytes])),
72
+ rent: async bytes => BigInt(await client().call('getMinimumBalanceForRentExemption', [bytes])),
66
73
  send: async wire => {
67
74
  await checked();
68
- return rpc.send(wire);
75
+ return client().send(wire);
69
76
  },
70
- confirm: (signature, options) => rpc.confirm(signature, options),
71
- account: address => rpc.account(address),
77
+ confirm: (signature, options) => client().confirm(signature, options),
78
+ account: address => client().account(address),
72
79
  /** A sent transaction's status from the whole history: { err, confirmationStatus }, or null when unseen. */
73
80
  signatureStatus: async signature =>
74
- (await rpc.call('getSignatureStatuses', [[signature], { searchTransactionHistory: true }])).value[0],
75
- blockHeight: () => rpc.blockHeight(),
81
+ (await client().call('getSignatureStatuses', [[signature], { searchTransactionHistory: true }])).value[0],
82
+ blockHeight: () => client().blockHeight(),
76
83
  /** The chain's current epoch (a mint's transfer fee changes on one). */
77
- epoch: async () => Number((await rpc.call('getEpochInfo')).epoch),
84
+ epoch: async () => Number((await client().call('getEpochInfo')).epoch),
78
85
  };
79
86
  }
80
87
 
@@ -126,7 +133,7 @@ function walletAddress(wallet) {
126
133
  */
127
134
  export async function createApproval({
128
135
  file,
129
- chain = devnetChain(),
136
+ chain = clusterChain(),
130
137
  now = () => Math.floor(Date.now() / 1000),
131
138
  clock = Date.now,
132
139
  write = writeWhole,
@@ -150,6 +157,8 @@ export async function createApproval({
150
157
  // Goes up each time the live section changes: a batch prepared before a change is never sent.
151
158
  let generation = 0;
152
159
  const signatures = [];
160
+ // What a wallet added to transactions this session sent (its own checks, a compute budget): said on the page.
161
+ const walletAdds = [];
153
162
  const at = now();
154
163
  const mode = !spec.live ? 'new' : spec.live.partial ? 'resume' : 'change';
155
164
  /**
@@ -385,8 +394,8 @@ export async function createApproval({
385
394
  name: spec.name ?? path.basename(file),
386
395
  file: path.basename(file),
387
396
  mode,
388
- program: LOOT_PROGRAM,
389
- cluster: 'devnet',
397
+ program: lootProgram(),
398
+ cluster: cluster(),
390
399
  setupSol: cost(spec, { now: at, live: live(), ...scope() }).setup?.totalSol ?? null,
391
400
  warnings: [
392
401
  ...(extra.refused ? [extra.refused] : []),
@@ -413,6 +422,8 @@ export async function createApproval({
413
422
  steps,
414
423
  landed: [...done].map(id => ({ id, says: said.get(id)?.says ?? id, details: said.get(id)?.details ?? [] })),
415
424
  signatures: [...signatures],
425
+ // (what a wallet added to transactions it signed, in words: they were sent as it signed them)
426
+ walletAdded: [...walletAdds],
416
427
  // The project the setup sends to, once a wallet is connected: always that wallet's own (or the one it is the
417
428
  // authority of).
418
429
  project: extra.project ?? null,
@@ -597,7 +608,7 @@ export async function createApproval({
597
608
  prepared = null;
598
609
  if (batch.generation !== generation)
599
610
  throw new ApproveError(409, 'Something landed after that batch was built: ask for the next one.');
600
- const wires = checkSigned(batch.wires, signed, wallet);
611
+ const { wires, added } = checkSigned(batch.wires, signed, wallet);
601
612
  busy = true;
602
613
  try {
603
614
  for (const [i, wire] of wires.entries()) {
@@ -654,6 +665,8 @@ export async function createApproval({
654
665
  }
655
666
  }
656
667
  signatures.push(signature);
668
+ // (what the wallet added to it is said once it has landed, never of one that did not)
669
+ if (added[i]) walletAdds.push(added[i]);
657
670
  await landed(sent, batch.ctx, signature, { more: i < wires.length - 1 });
658
671
  }
659
672
  } finally {
@@ -730,7 +743,7 @@ export async function createApproval({
730
743
  async function projectOwner(project) {
731
744
  if (!project || !chain.account) return null;
732
745
  const account = await chain.account(project).catch(() => null);
733
- return account && account.owner === LOOT_PROGRAM && accountKind(account.data) === 'Project'
746
+ return account && account.owner === lootProgram() && accountKind(account.data) === 'Project'
734
747
  ? decode('Project', account.data).owner
735
748
  : null;
736
749
  }
@@ -803,7 +816,7 @@ export async function createApproval({
803
816
  }
804
817
  const progress = {
805
818
  ...was,
806
- program: LOOT_PROGRAM,
819
+ program: lootProgram(),
807
820
  ...(made.has('project') ? { project: ctx.project, treasury: ctx.treasury, authority: ctx.authority } : {}),
808
821
  ...(made.has('collection') && ctx.collection ? { collection: ctx.collection } : {}),
809
822
  ...(made.has('tree') && ctx.tree ? { tree: ctx.tree } : {}),
@@ -928,53 +941,18 @@ const sameText = (a, b) => {
928
941
  return x.length === y.length && timingSafeEqual(x, y);
929
942
  };
930
943
 
931
- /** What a Compute Budget instruction asks for, by its first byte. */
932
- const BUDGET_ASKS = {
933
- 1: 'a heap size',
934
- 2: 'a compute unit limit',
935
- 3: 'a compute unit price (a priority fee of its own)',
936
- 4: 'a limit on the account data loaded',
937
- };
938
-
939
944
  /**
940
- * What a wallet did to a message it was to sign as it stood, in words for the refusal: " (it added …)" when the
941
- * message it signed is the prepared one with instructions added (a wallet that adds its own priority fee, or checks
942
- * of its own), " (it put another blockhash on it)", or '' when the change is anything else or can't be read.
945
+ * The wallet's signed copies, as they are sent: { wires, added }. Each is the exact prepared message with the wallet's
946
+ * valid signature, or, from a wallet that adds to what it signs, the wallet's own message with its signature, taken
947
+ * only when everything prepared is in it untouched and what it added is a wallet's own (`walletAdded`,
948
+ * js/approve/wallet-adds.mjs: instructions of the Lighthouse program, a heap size). `added`: for each transaction, what
949
+ * was added to it in words, or null. Anything else is refused, and nothing is sent.
943
950
  */
944
- export function walletChange(ours, theirs) {
945
- try {
946
- const [a, b] = [readMessage(ours), readMessage(theirs)];
947
- const told = m =>
948
- m.instructions.map(ix => ({
949
- program: m.keys[ix.programIndex],
950
- kind: ix.data[0],
951
- text: `${m.keys[ix.programIndex]} ${ix.accountIndexes.map(i => m.keys[i] ?? `#${i}`).join(',')} ${toBase64(ix.data)}`,
952
- }));
953
- const mine = told(a);
954
- const added = [];
955
- let at = 0;
956
- for (const ix of told(b)) {
957
- if (at < mine.length && ix.text === mine[at].text) at++;
958
- else added.push(ix);
959
- }
960
- if (at !== mine.length || !added.length)
961
- return a.blockhash !== b.blockhash ? ' (it put another blockhash on it)' : '';
962
- const what = added.map(ix =>
963
- ix.program === COMPUTE_BUDGET_PROGRAM
964
- ? (BUDGET_ASKS[ix.kind] ?? 'a compute budget instruction')
965
- : `an instruction of the program ${ix.program}`,
966
- );
967
- return ` (it added ${[...new Set(what)].join(', ')})`;
968
- } catch {
969
- return '';
970
- }
971
- }
972
-
973
- /** The wallet's signed copies: each the exact prepared message, each with the wallet's valid signature. */
974
951
  function checkSigned(prepared, signed, wallet) {
975
952
  if (!Array.isArray(signed) || signed.length !== prepared.length)
976
953
  throw new ApproveError(400, 'The wallet’s answer does not match the request.');
977
- return prepared.map((wire, i) => {
954
+ const added = prepared.map(() => null);
955
+ const wires = prepared.map((wire, i) => {
978
956
  let mine;
979
957
  let theirs;
980
958
  try {
@@ -983,11 +961,20 @@ function checkSigned(prepared, signed, wallet) {
983
961
  } catch {
984
962
  throw new ApproveError(400, 'The wallet returned something that is not a transaction.');
985
963
  }
986
- if (!equalBytes(mine.message, theirs.message))
987
- throw new ApproveError(
988
- 400,
989
- `The wallet changed the transaction before signing it${walletChange(mine.message, theirs.message)}, so nothing was sent: only what was prepared and shown here is ever sent. A wallet that adds to what it signs can’t approve this way: use one that signs a transaction as it stands. (A setup that has not started can be signed by a key file instead: xsolana approve --keypair. One that has started is finished by the key that started it.)`,
990
- );
964
+ if (!equalBytes(mine.message, theirs.message)) {
965
+ const taken = walletAdded(mine.message, theirs.message, wallet);
966
+ if (!taken.ok)
967
+ throw new ApproveError(
968
+ 400,
969
+ `The wallet changed the transaction before signing it (it ${taken.why}), so nothing was sent. A wallet may add its own checks (instructions of the Lighthouse program) to a transaction it alone signs; everything that was prepared and shown here must be in it untouched, and nothing else may be. (A setup that has not started can be signed by a key file instead: xsolana approve --keypair. One that has started is finished by the key that started it.)`,
970
+ );
971
+ // The wallet's own message, with the wallet's own signature: it is the transaction's one signer.
972
+ const signature = theirs.signatures[0];
973
+ if (theirs.signers[0] !== wallet || !verifySignature(wallet, theirs.message, signature))
974
+ throw new ApproveError(400, 'The wallet’s signature is missing or does not match your wallet.');
975
+ added[i] = `Transaction ${i + 1} of its batch: your wallet added ${taken.added.join(', ')}.`;
976
+ return wireTransaction(theirs.message, [signature]);
977
+ }
991
978
  const slot = mine.signers.indexOf(wallet);
992
979
  if (slot < 0) throw new ApproveError(400, 'This transaction was not prepared for that wallet.');
993
980
  const signature = theirs.signatures[slot];
@@ -997,6 +984,7 @@ function checkSigned(prepared, signed, wallet) {
997
984
  signatures[slot] = signature;
998
985
  return wireTransaction(mine.message, signatures);
999
986
  });
987
+ return { wires, added };
1000
988
  }
1001
989
 
1002
990
  // ---------------------------------------------------------------- the page
@@ -1093,7 +1081,7 @@ export async function approve({
1093
1081
  file = 'xsolana.json',
1094
1082
  port,
1095
1083
  open = true,
1096
- chain = devnetChain(),
1084
+ chain = clusterChain(),
1097
1085
  log = (...a) => console.error(...a),
1098
1086
  openLink = openBrowser,
1099
1087
  } = {}) {