@xgamesfun/solana 0.28.1 → 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 (50) 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 +16 -4
  8. package/js/approve/server.mjs +31 -26
  9. package/js/approve/with-key.mjs +7 -4
  10. package/js/chain/borsh.js +5 -3
  11. package/js/chain/rpc.js +26 -10
  12. package/js/client/accounts.js +6 -6
  13. package/js/client/cluster.js +136 -0
  14. package/js/client/grant.js +2 -2
  15. package/js/client/ix.js +16 -16
  16. package/js/client/pda.js +18 -5
  17. package/js/economy/check.mjs +28 -13
  18. package/js/economy/explain.mjs +2 -1
  19. package/js/economy/guide.mjs +6 -4
  20. package/js/economy/live.mjs +2 -2
  21. package/js/economy/plan.mjs +1 -1
  22. package/js/economy/schema.json +2 -2
  23. package/js/mcp/server.mjs +35 -4
  24. package/js/mcp/tools.mjs +56 -45
  25. package/js/mcp/with-cluster.mjs +25 -0
  26. package/js/play/index.js +42 -37
  27. package/js/play/sponsor.js +4 -4
  28. package/js/results/index.js +7 -6
  29. package/js/results/server.js +4 -3
  30. package/js/results/wire.js +9 -8
  31. package/package.json +6 -1
  32. package/release.json +6 -1
  33. package/skills/xsolana-economy/SKILL.md +13 -9
  34. package/skills/xsolana-economy/references/apps.md +2 -1
  35. package/skills/xsolana-economy/references/telling-the-person.md +1 -1
  36. package/skills/xsolana-economy/references/trust-and-cheating.md +1 -1
  37. package/types/chain/borsh.d.ts +4 -2
  38. package/types/chain/rpc.d.ts +10 -4
  39. package/types/client/cluster.d.ts +61 -0
  40. package/types/client/idl/loot.d.ts +9 -9
  41. package/types/client/pda.d.ts +1 -1
  42. package/types/economy/check.d.mts +1 -2
  43. package/types/economy/plan.d.mts +14 -14
  44. package/types/mcp/server.d.mts +1 -1
  45. package/types/mcp/tools.d.mts +9 -7
  46. package/types/mcp/with-cluster.d.mts +8 -0
  47. package/types/play/index.d.ts +2 -2
  48. package/types/results/index.d.ts +6 -6
  49. package/types/results/server.d.ts +3 -3
  50. 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.1 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.1 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.1 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.1 check xsolana.json
97
- npx -y @xgamesfun/solana@0.28.1 simulate xsolana.json
98
- npx -y @xgamesfun/solana@0.28.1 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.1 explain xsolana.json
102
- npx -y @xgamesfun/solana@0.28.1 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.1 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.1 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.1 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() {
@@ -65,6 +66,13 @@ export function approvalPage(token) {
65
66
  // How many of the lines about what the wallet added have been said on this page.
66
67
  let addedSaid = 0;
67
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.';
68
76
  last = state;
69
77
  // What the wallet added to transactions that landed (its own checks): they were sent as it signed them. Said
70
78
  // whenever a state arrives, so one that landed in a batch that then stopped is said too.
@@ -84,7 +92,7 @@ export function approvalPage(token) {
84
92
  $('sub').textContent = state.done
85
93
  ? nothingSent
86
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.`
87
- : `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.`
88
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}` : ''}` : ''}`;
89
97
  $('cost').textContent = state.setupSol !== null && state.mode === 'new' ? `about ${state.setupSol} SOL` : '';
90
98
  const steps = $('steps');
@@ -205,7 +213,7 @@ export function approvalPage(token) {
205
213
  const found = list();
206
214
  if (!found.length) {
207
215
  log(
208
- '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.`,
209
217
  'bad',
210
218
  );
211
219
  return;
@@ -245,7 +253,11 @@ export function approvalPage(token) {
245
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('. ')}` : ''}`,
246
254
  );
247
255
  const sign = signingWallet.features['solana:signTransaction'];
248
- 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
+ }));
249
261
  const outputs = await sign.signTransaction(...inputs);
250
262
  const signed = outputs.map(o => toBase64(o.signedTransaction));
251
263
  $('status').textContent = 'Sending and confirming…';
@@ -14,14 +14,14 @@ import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { spawn } from 'node:child_process';
16
16
  import { equalBytes, fromBase64, toBase64 } from '../chain/bytes.js';
17
- import { alreadyProcessed, checkCluster, notMainnet, rpcClient } from '../chain/rpc.js';
17
+ import { alreadyProcessed, rpcClient } from '../chain/rpc.js';
18
18
  import { addressBytes, encodeBase58 } from '../chain/base58.js';
19
19
  import { slotHashes, tableAddress } from '../chain/lookup.js';
20
20
  import { SYSVAR_SLOT_HASHES } from '../chain/pda.js';
21
21
  import { MINT_BYTES } from '../chain/token.js';
22
22
  import { compileMessage, readTransaction, wireTransaction } from '../chain/tx.js';
23
23
  import { accountKind, decode } from '../client/accounts.js';
24
- import { LOOT_PROGRAM, pda } from '../client/pda.js';
24
+ import { checkOnCluster, cluster, CLUSTERS, clusterRpcUrl, lootProgram, pda } from '../client/pda.js';
25
25
  import { check, describe } from '../economy/check.mjs';
26
26
  import { cost } from '../economy/cost.mjs';
27
27
  import { LEGAL, legalFlags } from '../economy/legal.mjs';
@@ -35,7 +35,6 @@ import { walletAdded } from './wallet-adds.mjs';
35
35
 
36
36
  const PREPARE_TTL_MS = 90_000;
37
37
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
38
- const DEVNET = 'https://api.devnet.solana.com';
39
38
 
40
39
  /** A refusal with the HTTP status the page gets. */
41
40
  class ApproveError extends Error {
@@ -46,37 +45,43 @@ class ApproveError extends Error {
46
45
  }
47
46
 
48
47
  /**
49
- * The chain the approval sends to: devnet by default (XSOLANA_RPC_URL to use another devnet RPC, or a local validator).
50
- * A mainnet URL is refused at once, and before anything is sent the chain's genesis block is checked: mainnet and
51
- * 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.
52
51
  */
53
- export function devnetChain(url = process.env.XSOLANA_RPC_URL || DEVNET) {
54
- const rpc = rpcClient(notMainnet(url));
55
- 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;
56
59
  const checked = () =>
57
- (cluster ??= checkCluster(rpc).catch(e => {
58
- cluster = null;
60
+ (genesis ??= checkOnCluster(client()).catch(e => {
61
+ genesis = null;
59
62
  throw e;
60
63
  }));
61
64
  return {
62
- url,
65
+ get url() {
66
+ return client().url ?? url;
67
+ },
63
68
  latestBlockhash: async () => {
64
69
  await checked();
65
- return rpc.latestBlockhash();
70
+ return client().latestBlockhash();
66
71
  },
67
- rent: async bytes => BigInt(await rpc.call('getMinimumBalanceForRentExemption', [bytes])),
72
+ rent: async bytes => BigInt(await client().call('getMinimumBalanceForRentExemption', [bytes])),
68
73
  send: async wire => {
69
74
  await checked();
70
- return rpc.send(wire);
75
+ return client().send(wire);
71
76
  },
72
- confirm: (signature, options) => rpc.confirm(signature, options),
73
- account: address => rpc.account(address),
77
+ confirm: (signature, options) => client().confirm(signature, options),
78
+ account: address => client().account(address),
74
79
  /** A sent transaction's status from the whole history: { err, confirmationStatus }, or null when unseen. */
75
80
  signatureStatus: async signature =>
76
- (await rpc.call('getSignatureStatuses', [[signature], { searchTransactionHistory: true }])).value[0],
77
- blockHeight: () => rpc.blockHeight(),
81
+ (await client().call('getSignatureStatuses', [[signature], { searchTransactionHistory: true }])).value[0],
82
+ blockHeight: () => client().blockHeight(),
78
83
  /** The chain's current epoch (a mint's transfer fee changes on one). */
79
- epoch: async () => Number((await rpc.call('getEpochInfo')).epoch),
84
+ epoch: async () => Number((await client().call('getEpochInfo')).epoch),
80
85
  };
81
86
  }
82
87
 
@@ -128,7 +133,7 @@ function walletAddress(wallet) {
128
133
  */
129
134
  export async function createApproval({
130
135
  file,
131
- chain = devnetChain(),
136
+ chain = clusterChain(),
132
137
  now = () => Math.floor(Date.now() / 1000),
133
138
  clock = Date.now,
134
139
  write = writeWhole,
@@ -389,8 +394,8 @@ export async function createApproval({
389
394
  name: spec.name ?? path.basename(file),
390
395
  file: path.basename(file),
391
396
  mode,
392
- program: LOOT_PROGRAM,
393
- cluster: 'devnet',
397
+ program: lootProgram(),
398
+ cluster: cluster(),
394
399
  setupSol: cost(spec, { now: at, live: live(), ...scope() }).setup?.totalSol ?? null,
395
400
  warnings: [
396
401
  ...(extra.refused ? [extra.refused] : []),
@@ -738,7 +743,7 @@ export async function createApproval({
738
743
  async function projectOwner(project) {
739
744
  if (!project || !chain.account) return null;
740
745
  const account = await chain.account(project).catch(() => null);
741
- return account && account.owner === LOOT_PROGRAM && accountKind(account.data) === 'Project'
746
+ return account && account.owner === lootProgram() && accountKind(account.data) === 'Project'
742
747
  ? decode('Project', account.data).owner
743
748
  : null;
744
749
  }
@@ -811,7 +816,7 @@ export async function createApproval({
811
816
  }
812
817
  const progress = {
813
818
  ...was,
814
- program: LOOT_PROGRAM,
819
+ program: lootProgram(),
815
820
  ...(made.has('project') ? { project: ctx.project, treasury: ctx.treasury, authority: ctx.authority } : {}),
816
821
  ...(made.has('collection') && ctx.collection ? { collection: ctx.collection } : {}),
817
822
  ...(made.has('tree') && ctx.tree ? { tree: ctx.tree } : {}),
@@ -1076,7 +1081,7 @@ export async function approve({
1076
1081
  file = 'xsolana.json',
1077
1082
  port,
1078
1083
  open = true,
1079
- chain = devnetChain(),
1084
+ chain = clusterChain(),
1080
1085
  log = (...a) => console.error(...a),
1081
1086
  openLink = openBrowser,
1082
1087
  } = {}) {
@@ -11,7 +11,8 @@ import path from 'node:path';
11
11
  import { fromBase64, toBase64 } from '../chain/bytes.js';
12
12
  import { readTransaction, wireTransaction } from '../chain/tx.js';
13
13
  import { readKeyFile } from './keyfile.mjs';
14
- import { createApproval, devnetChain } from './server.mjs';
14
+ import { clusterChain, createApproval } from './server.mjs';
15
+ import { realMoney } from '../client/cluster.js';
15
16
 
16
17
  /** The notice the page has its owner tick before Approve (js/approve/page.html, "Read this first"), word for word. */
17
18
  export const NOTICE = [
@@ -122,7 +123,7 @@ export function approvalText(state, { keyFile, balance = null }) {
122
123
  lines.push('', 'Read this first');
123
124
  for (const line of [...NOTICE, ...state.legal.map(f => f.says)]) lines.push(`- ${line}`);
124
125
  lines.push(
125
- 'The full text is in DISCLAIMER.md, in the package. Devnet is not real money; real money needs every point above answered first.',
126
+ `The full text is in DISCLAIMER.md, in the package. ${realMoney() ? 'This is mainnet: everything sent here is real money, and every point above is yours to have answered first.' : 'Devnet is not real money; real money needs every point above answered first.'}`,
126
127
  );
127
128
 
128
129
  if (state.warnings.length) {
@@ -156,7 +157,7 @@ export async function approveWithKey({
156
157
  keyFile,
157
158
  yes = null,
158
159
  confirm = null,
159
- chain = devnetChain(),
160
+ chain = clusterChain(),
160
161
  out = line => console.log(line),
161
162
  now,
162
163
  keyReader = readKeyFile,
@@ -204,7 +205,9 @@ export async function approveWithKey({
204
205
  out('');
205
206
  if (balance === 0n) {
206
207
  out(
207
- `Nothing was sent: the key holds no SOL. Send devnet SOL to ${key.address} first (https://faucet.solana.com gives some), then run this again.`,
208
+ realMoney()
209
+ ? `Nothing was sent: the key holds no SOL. This economy is on mainnet: send SOL to ${key.address} first, then run this again.`
210
+ : `Nothing was sent: the key holds no SOL. Send devnet SOL to ${key.address} first (https://faucet.solana.com gives some), then run this again.`,
208
211
  );
209
212
  return { ok: false, sent: 0, done: false, code };
210
213
  }
package/js/chain/borsh.js CHANGED
@@ -242,9 +242,11 @@ export function isAccount(idl, name, data) {
242
242
  * [{ name, data }]. Only lines the program itself printed count: the log names which program each line comes from
243
243
  * (`Program <id> invoke [n]` … `success`), and any program can print a line that looks like an event. Logs alone
244
244
  * cannot say whether the transaction succeeded (the runtime can fail it after every instruction logged success), so
245
- * the caller decides that from the transaction's `meta.err` and reads events only when it is null.
245
+ * the caller decides that from the transaction's `meta.err` and reads events only when it is null. `program` is the
246
+ * address the program runs at: the IDL's own unless the caller names another (the program has an address of its own on
247
+ * each cluster, and an IDL holds one).
246
248
  */
247
- export function decodeEvents(idl, logs) {
249
+ export function decodeEvents(idl, logs, program = idl.address) {
248
250
  const out = [];
249
251
  const stack = [];
250
252
  for (const line of logs ?? []) {
@@ -259,7 +261,7 @@ export function decodeEvents(idl, logs) {
259
261
  continue;
260
262
  }
261
263
  const m = /^Program data: (.+)$/.exec(line);
262
- if (!m || stack[stack.length - 1] !== idl.address) continue;
264
+ if (!m || stack[stack.length - 1] !== program) continue;
263
265
  let bytes;
264
266
  try {
265
267
  bytes = fromBase64(m[1]);
package/js/chain/rpc.js CHANGED
@@ -47,23 +47,39 @@ export const GENESIS = Object.freeze({
47
47
  testnet: '4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQawwpjk2NsNY',
48
48
  });
49
49
 
50
- const DEVNET_ONLY =
51
- 'loot runs on devnet only: mainnet waits for an audit. Unset XSOLANA_RPC_URL, or point it at a devnet RPC.';
50
+ const ON_DEVNET =
51
+ 'This works on devnet (an economy that names no network is on devnet). Unset XSOLANA_RPC_URL, or point it at a devnet RPC.';
52
+ const ON_MAINNET = 'This works on mainnet. Point XSOLANA_RPC_URL at a mainnet RPC.';
52
53
 
53
- /** Refuses an RPC URL that names mainnet. Returns the URL. */
54
- export function notMainnet(url) {
55
- if (/mainnet/i.test(String(url))) throw new RpcError(`${url} is a mainnet RPC. ${DEVNET_ONLY}`);
54
+ /**
55
+ * Refuses an RPC URL that names another cluster than the one the caller works on (`devnet` or `mainnet`). Returns the
56
+ * URL. A URL names nothing for certain: `checkCluster` reads the chain itself.
57
+ */
58
+ export function rightCluster(url, cluster = 'devnet') {
59
+ const text = String(url);
60
+ if (cluster === 'mainnet') {
61
+ if (/devnet|testnet/i.test(text)) throw new RpcError(`${url} is not a mainnet RPC. ${ON_MAINNET}`);
62
+ } else if (/mainnet/i.test(text)) throw new RpcError(`${url} is a mainnet RPC. ${ON_DEVNET}`);
56
63
  return url;
57
64
  }
58
65
 
66
+ /** Refuses an RPC URL that names mainnet. Returns the URL. */
67
+ export const notMainnet = url => rightCluster(url, 'devnet');
68
+
59
69
  /**
60
- * Refuses a chain that is mainnet or testnet, by its genesis block, whatever its URL says (a local validator is
61
- * let through). `rpc` has `call`. Returns the genesis hash.
70
+ * Refuses a chain that is not the cluster the caller works on, by its genesis block, whatever its URL says. On
71
+ * devnet, mainnet and testnet are refused and a local validator is let through; on mainnet, only mainnet itself
72
+ * passes. `rpc` has `call`. Returns the genesis hash.
62
73
  */
63
- export async function checkCluster(rpc) {
74
+ export async function checkCluster(rpc, cluster = 'devnet') {
64
75
  const genesis = await rpc.call('getGenesisHash');
65
- if (genesis === GENESIS.mainnet) throw new RpcError(`${rpc.url ?? 'This RPC'} is on mainnet. ${DEVNET_ONLY}`);
66
- if (genesis === GENESIS.testnet) throw new RpcError(`${rpc.url ?? 'This RPC'} is on testnet. ${DEVNET_ONLY}`);
76
+ const here = rpc.url ?? 'This RPC';
77
+ if (cluster === 'mainnet') {
78
+ if (genesis !== GENESIS.mainnet) throw new RpcError(`${here} is not on mainnet. ${ON_MAINNET}`);
79
+ return genesis;
80
+ }
81
+ if (genesis === GENESIS.mainnet) throw new RpcError(`${here} is on mainnet. ${ON_DEVNET}`);
82
+ if (genesis === GENESIS.testnet) throw new RpcError(`${here} is on testnet. ${ON_DEVNET}`);
67
83
  return genesis;
68
84
  }
69
85
  const decodeAccount = raw =>
@@ -7,7 +7,7 @@ import { decodeAccount, decodeBorsh, decodeEvents } from '../chain/borsh.js';
7
7
  import { compareBytes, concat, readUintLE, toHex, uintLE } from '../chain/bytes.js';
8
8
  import { sha256 } from '../chain/sha256.js';
9
9
  import { SYSTEM_PROGRAM } from '../chain/pda.js';
10
- import { LOOT_PROGRAM, pda } from './pda.js';
10
+ import { lootProgram, pda } from './pda.js';
11
11
  import IDL from './idl/loot.js';
12
12
 
13
13
  /** The bytes of one time in a farm's record of ends (`decodeEnds`): rent its writer pays, not refunded when a seat leaves. */
@@ -243,7 +243,7 @@ export function readListAccount(account) {
243
243
  !account.executable
244
244
  )
245
245
  return null;
246
- if (account.owner !== LOOT_PROGRAM || account.executable) throw new Error('The list has an invalid owner.');
246
+ if (account.owner !== lootProgram() || account.executable) throw new Error('The list has an invalid owner.');
247
247
  return decodeList(account.data);
248
248
  }
249
249
 
@@ -855,14 +855,14 @@ export function receipts(tx, err) {
855
855
  throw new Error(
856
856
  "loot.receipts(logs, err) needs the transaction's meta.err (null when it succeeded): a failed transaction keeps its logs",
857
857
  );
858
- return err === null ? decodeEvents(IDL, tx) : [];
858
+ return err === null ? decodeEvents(IDL, tx, lootProgram()) : [];
859
859
  }
860
860
  const meta = tx?.meta;
861
861
  if (!meta || meta.err === undefined)
862
862
  throw new Error(
863
863
  'loot.receipts(tx) needs the transaction getTransaction returns, with its meta.err and meta.logMessages',
864
864
  );
865
- return meta.err === null ? decodeEvents(IDL, meta.logMessages ?? []) : [];
865
+ return meta.err === null ? decodeEvents(IDL, meta.logMessages ?? [], lootProgram()) : [];
866
866
  }
867
867
 
868
868
  /** The program's errors by code: { code: { name, msg } }. */
@@ -886,7 +886,7 @@ export function lootError(logs) {
886
886
  }
887
887
  const failed = /^Program (\w+) failed: (.*)$/.exec(line);
888
888
  if (failed) {
889
- if (failed[1] !== LOOT_PROGRAM) return null;
889
+ if (failed[1] !== lootProgram()) return null;
890
890
  const m = /custom program error: 0x([0-9a-f]+)/i.exec(failed[2]);
891
891
  const code = m ? parseInt(m[1], 16) : null;
892
892
  return code !== null && ERRORS[code] ? { code, ...ERRORS[code] } : null;
@@ -896,7 +896,7 @@ export function lootError(logs) {
896
896
  continue;
897
897
  }
898
898
  // A line that names no program (a log line, or an error's own text) is the running program's, when it is known.
899
- if (running.length && running.at(-1) !== LOOT_PROGRAM) continue;
899
+ if (running.length && running.at(-1) !== lootProgram()) continue;
900
900
  const named = /Error Code: (\w+)\. Error Number: (\d+)\. Error Message: (.+?)\.?$/.exec(line);
901
901
  if (named)
902
902
  return {