@xgamesfun/solana 0.28.1 → 0.28.3

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 +48 -38
  27. package/js/play/sponsor.js +4 -4
  28. package/js/results/index.js +36 -14
  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 +14 -7
  49. package/types/results/server.d.ts +3 -3
  50. package/types/results/wire.d.ts +8 -8
@@ -0,0 +1,136 @@
1
+ // The cluster the client works on, and loot's address there. loot has an address of its own on each cluster, so what
2
+ // is signed for one (a voucher, a verdict) never counts on the other, and every address this client derives, every
3
+ // instruction it builds and everything it signs follows the cluster's address.
4
+ //
5
+ // A process works on one cluster. It is devnet unless the process names another: the environment variable
6
+ // XSOLANA_CLUSTER, or `useCluster` before anything is derived or built. Once an address has been derived the
7
+ // cluster is settled, and naming another is refused: addresses of two clusters never mix by accident.
8
+ //
9
+ // A server that answers for both clusters gives each piece of work its own: `withCluster` (js/mcp/with-cluster.mjs, Node)
10
+ // runs a function, and everything it awaits, on the cluster named; inside it the client is on that cluster whatever
11
+ // the process's own is. Browser-safe.
12
+ import { checkCluster, rightCluster } from '../chain/rpc.js';
13
+ import IDL from './idl/loot.js';
14
+
15
+ /**
16
+ * Each cluster: loot's program there (devnet's is the IDL's own address), the chain as a wallet names it (Wallet
17
+ * Standard), and the RPC used when none is given. Mainnet has none: its public RPC refuses or limits what loot reads
18
+ * (a program's accounts, a compressed NFT's proof), so a mainnet RPC is always named (XSOLANA_RPC_URL, or `rpcUrl`).
19
+ */
20
+ export const CLUSTERS = Object.freeze({
21
+ devnet: Object.freeze({ program: IDL.address, chain: 'solana:devnet', rpc: 'https://api.devnet.solana.com' }),
22
+ mainnet: Object.freeze({
23
+ program: 'xgames8C758PyweWB9sDw8evvwsTVwyA2fGRUeAXVPy',
24
+ chain: 'solana:mainnet',
25
+ rpc: null,
26
+ }),
27
+ });
28
+
29
+ const known = name => {
30
+ if (!Object.hasOwn(CLUSTERS, name))
31
+ throw new Error(`"${name}" is not a cluster loot knows: ${Object.keys(CLUSTERS).join(' or ')}.`);
32
+ return name;
33
+ };
34
+
35
+ const fromEnv = globalThis.process?.env?.XSOLANA_CLUSTER?.trim() || null;
36
+ let name = known(fromEnv ?? 'devnet');
37
+ // Settled: named by the environment or by `useCluster`, or already used to derive an address.
38
+ let settled = Boolean(fromEnv);
39
+ // The cluster of the work being done now, when a server gives each piece of work its own (`scopeClusters`).
40
+ let scoped = null;
41
+ const within = () => {
42
+ const cluster = scoped?.();
43
+ return cluster ? known(cluster) : null;
44
+ };
45
+
46
+ /**
47
+ * loot's program on this process's own cluster. A live binding: it follows `useCluster`. It knows nothing of
48
+ * `withCluster`: code that may run for either cluster asks `lootProgram()`, as the client itself does.
49
+ * @type {string}
50
+ */
51
+ export let LOOT_PROGRAM = CLUSTERS[name].program;
52
+
53
+ /**
54
+ * For a server's own set-up (js/mcp/with-cluster.mjs does it): `current()` answers the cluster of the work being done now,
55
+ * or nothing outside any. While it answers one, the client is on that cluster.
56
+ * @param {(() => string | undefined | null) | null} current
57
+ */
58
+ export function scopeClusters(current) {
59
+ scoped = current;
60
+ }
61
+
62
+ /**
63
+ * Names the cluster this process works on (`devnet` or `mainnet`) and returns it. Call it first, before any address
64
+ * is derived: it throws when the process already works on another cluster. Inside `withCluster` it names nothing: it
65
+ * throws unless the cluster named is the one that work is on.
66
+ * @param {'devnet' | 'mainnet'} cluster
67
+ * @returns {'devnet' | 'mainnet'}
68
+ */
69
+ export function useCluster(cluster) {
70
+ known(cluster);
71
+ const here = within();
72
+ if (here) {
73
+ if (cluster !== here) throw new Error(`This is being done on ${here}; it can't also be done on ${cluster}.`);
74
+ return here;
75
+ }
76
+ if (cluster !== name) {
77
+ if (settled)
78
+ throw new Error(
79
+ `This process already works on ${name}; it can't also work on ${cluster}. Start one for ${cluster} (XSOLANA_CLUSTER=${cluster}).`,
80
+ );
81
+ name = cluster;
82
+ LOOT_PROGRAM = CLUSTERS[name].program;
83
+ }
84
+ settled = true;
85
+ return name;
86
+ }
87
+
88
+ /**
89
+ * The cluster the client works on now: inside `withCluster` that work's, else the process's own (asking settles it).
90
+ * @returns {'devnet' | 'mainnet'}
91
+ */
92
+ export function cluster() {
93
+ const here = within();
94
+ if (here) return here;
95
+ settled = true;
96
+ return name;
97
+ }
98
+
99
+ /**
100
+ * loot's program on the cluster the client works on now (`cluster()`): what derives an address, builds an
101
+ * instruction or reads who owns an account calls this.
102
+ * @returns {string}
103
+ */
104
+ export function lootProgram() {
105
+ return CLUSTERS[cluster()].program;
106
+ }
107
+
108
+ /** Whether the client works on mainnet now: what is paid and won there is real money. */
109
+ export const realMoney = () => cluster() === 'mainnet';
110
+
111
+ // The tests' own switch, set by js/test/on-mainnet.mjs before a run of the whole suite on mainnet's address and by
112
+ // nothing else: the tests' economies, which say devnet or name no network, are then read as mainnet's.
113
+ const TEST_NETWORK = Symbol.for('xsolana.test.network');
114
+
115
+ /** The cluster an economy is on: the `network` its file names, devnet when it names none. */
116
+ export const networkOf = economy => globalThis[TEST_NETWORK] ?? economy?.network ?? 'devnet';
117
+
118
+ /**
119
+ * The RPC URL this process reads and sends through: the one given, else XSOLANA_RPC_URL, else the cluster's own
120
+ * (devnet's public RPC; mainnet has none). A URL that names another cluster is refused.
121
+ */
122
+ export function clusterRpcUrl(url = globalThis.process?.env?.XSOLANA_RPC_URL || null) {
123
+ const on = cluster();
124
+ const chosen = url || CLUSTERS[on].rpc;
125
+ if (!chosen)
126
+ throw new Error(
127
+ `No RPC for ${on}: name one (XSOLANA_RPC_URL, or rpcUrl). ${on === 'mainnet' ? 'Mainnet’s public RPC does not serve what loot reads, so there is no default.' : ''}`.trim(),
128
+ );
129
+ return rightCluster(chosen, on);
130
+ }
131
+
132
+ /** Refuses a chain that is not this process's cluster, by its genesis block (`rpc.js`, `checkCluster`). */
133
+ export const checkOnCluster = rpc => checkCluster(rpc, cluster());
134
+
135
+ /** This process's cluster as a wallet names it (Wallet Standard): `solana:devnet` or `solana:mainnet`. */
136
+ export const walletChain = () => CLUSTERS[cluster()].chain;
@@ -20,7 +20,7 @@ import { meta } from '../chain/programs.js';
20
20
  import { sha256 } from '../chain/sha256.js';
21
21
  import { recordKey16 } from './accounts.js';
22
22
  import { ASSOCIATED_TOKEN_PROGRAM, gateAccounts, lootIx, prizeMetas, prizeNonce, shownFor, tailOf } from './ix.js';
23
- import { LOOT_PROGRAM, pda } from './pda.js';
23
+ import { lootProgram, pda } from './pda.js';
24
24
  import IDL from './idl/loot.js';
25
25
 
26
26
  export const VOUCHER_DOMAIN = 'loot-grant-v5';
@@ -74,7 +74,7 @@ const roundsOf = (rounds, round, amount) =>
74
74
 
75
75
  /** A voucher's body, which its key signs the sha256 of (`voucherBytes`). */
76
76
  export function voucherBody({
77
- program = LOOT_PROGRAM,
77
+ program = lootProgram(),
78
78
  grant,
79
79
  incarnation,
80
80
  wallet,
package/js/client/ix.js CHANGED
@@ -67,7 +67,7 @@ import {
67
67
  ata,
68
68
  BUBBLEGUM_PROGRAM,
69
69
  COMPRESSION_PROGRAM,
70
- LOOT_PROGRAM,
70
+ lootProgram,
71
71
  MPL_CORE_PROGRAM,
72
72
  NOOP_PROGRAM,
73
73
  pda,
@@ -204,7 +204,7 @@ export function lootIx(
204
204
  if (a.pda && !a.pda.program) {
205
205
  const seeds = a.pda.seeds.map(s => seedBytes(s, known, args, def));
206
206
  if (seeds.every(Boolean)) {
207
- known[a.name] = findProgramAddress(seeds, LOOT_PROGRAM)[0];
207
+ known[a.name] = findProgramAddress(seeds, lootProgram())[0];
208
208
  progress = true;
209
209
  }
210
210
  }
@@ -215,13 +215,13 @@ export function lootIx(
215
215
  const key = known[a.name];
216
216
  if (key === null || (key === undefined && a.optional)) {
217
217
  if (!a.optional) throw new Error(`${name}: ${a.name} is not optional`);
218
- return meta(LOOT_PROGRAM, false, false);
218
+ return meta(lootProgram(), false, false);
219
219
  }
220
220
  if (key === undefined) throw new Error(`${name}: missing account ${a.group ? `${a.group}.` : ''}${a.name}`);
221
221
  const isWritable = (a.writable === true || writable.includes(a.name)) && !readonly.includes(a.name);
222
222
  return meta(key, a.signer === true || signer.includes(a.name), isWritable);
223
223
  });
224
- return { programId: LOOT_PROGRAM, keys: [...keys, ...remaining], data: encodeInstruction(IDL, name, args) };
224
+ return { programId: lootProgram(), keys: [...keys, ...remaining], data: encodeInstruction(IDL, name, args) };
225
225
  }
226
226
 
227
227
  // ---------------------------------------------------------------- the accounts after an instruction's own
@@ -548,7 +548,7 @@ const PIECE = leaves(instructionDef('hold').accounts).filter(a => a.group === 'p
548
548
  function pieceMetas(p) {
549
549
  return PIECE.map(a => {
550
550
  const key = p.accounts[a.name];
551
- if (key == null) return meta(LOOT_PROGRAM, false, false);
551
+ if (key == null) return meta(lootProgram(), false, false);
552
552
  return meta(key, false, a.writable === true || p.writable.includes(a.name));
553
553
  });
554
554
  }
@@ -1302,7 +1302,7 @@ export function callbackRollIx({
1302
1302
  );
1303
1303
  keys.push(...tailOf(extra));
1304
1304
  return {
1305
- programId: LOOT_PROGRAM,
1305
+ programId: lootProgram(),
1306
1306
  keys,
1307
1307
  data: encodeInstruction(IDL, 'callback_roll', { randomness: Uint8Array.from(randomness) }),
1308
1308
  };
@@ -1643,7 +1643,7 @@ function orderPiece(project, orderAddress, order, subject = {}, receiver) {
1643
1643
  * own address, a record, a stash, a fee box), where nothing could move it again. A settle's fate and an NFT lottery
1644
1644
  * ticket's price take it as `handBack`.
1645
1645
  */
1646
- export const handsBack = account => account?.owner === LOOT_PROGRAM;
1646
+ export const handsBack = account => account?.owner === lootProgram();
1647
1647
 
1648
1648
  /** Where an NFT that is burned but can't be (any kind but Core) is sent: an address nobody holds the key to. */
1649
1649
  export const INCINERATOR = '1nc1nerator11111111111111111111111111111111';
@@ -2079,7 +2079,7 @@ export const VERDICT_DOMAIN = 'loot-verdict-v4';
2079
2079
  * the contest it was signed for, never one opened later at the same id. `decide` rebuilds them from its own accounts
2080
2080
  * and arguments.
2081
2081
  */
2082
- export function verdictBytes({ program = LOOT_PROGRAM, contest, incarnation, side, expires, recipient = null }) {
2082
+ export function verdictBytes({ program = lootProgram(), contest, incarnation, side, expires, recipient = null }) {
2083
2083
  if (incarnation == null) throw new Error('A verdict signs the contest’s incarnation (the decoded Contest’s).');
2084
2084
  const named = recipient == null ? new Uint8Array(0) : addressBytes(recipient);
2085
2085
  return concat(
@@ -2169,7 +2169,7 @@ export const rollIx = ({ authority, project, reference: ref }) =>
2169
2169
 
2170
2170
  /** The oracle's answer to a `roll` (for a local simulation). */
2171
2171
  export const callbackRandomIx = ({ project, reference: ref, randomness }) => ({
2172
- programId: LOOT_PROGRAM,
2172
+ programId: lootProgram(),
2173
2173
  keys: [
2174
2174
  meta(pda.oracleIdentity(), true, false),
2175
2175
  meta(project, false, false),
@@ -2373,7 +2373,7 @@ export function drawLotteryIx({
2373
2373
 
2374
2374
  /** The oracle's answer to a round's draw (for a local simulation): the round's record is its only account. */
2375
2375
  export const callbackLotteryIx = ({ round, randomness }) => ({
2376
- programId: LOOT_PROGRAM,
2376
+ programId: lootProgram(),
2377
2377
  keys: [meta(pda.oracleIdentity(), true, false), meta(round, false, true)],
2378
2378
  data: encodeInstruction(IDL, 'callback_lottery', { randomness: Uint8Array.from(randomness) }),
2379
2379
  });
@@ -3053,7 +3053,7 @@ export function anotherMetas(p) {
3053
3053
  const writable = new Set([...p.writable, ...PIECE.filter(a => a.writable).map(a => a.name)]);
3054
3054
  return [
3055
3055
  ...ANOTHER[p.kind].map(name =>
3056
- p.accounts[name] == null ? meta(LOOT_PROGRAM, false, false) : meta(p.accounts[name], false, writable.has(name)),
3056
+ p.accounts[name] == null ? meta(lootProgram(), false, false) : meta(p.accounts[name], false, writable.has(name)),
3057
3057
  ),
3058
3058
  ...p.proof,
3059
3059
  ];
@@ -3310,7 +3310,7 @@ export const closeLeafIx = ({ asset, tree, payer, signs = true }) =>
3310
3310
 
3311
3311
  const PROVE_LEAF = leaves(instructionDef('prove_leaf').accounts).map(a => a.name);
3312
3312
  const CLOSE_LEAF = leaves(instructionDef('close_leaf').accounts).map(a => a.name);
3313
- const isLoot = (ix, name) => ix.programId === LOOT_PROGRAM && instructionOf(ix.data)?.name === name;
3313
+ const isLoot = (ix, name) => ix.programId === lootProgram() && instructionOf(ix.data)?.name === name;
3314
3314
 
3315
3315
  /**
3316
3316
  * `instructions` with a `close_leaf` after them for each compressed NFT's record a `prove_leaf` among them makes, its
@@ -3469,8 +3469,8 @@ function nftSeat({
3469
3469
  const seatGroup = (seat, p, box = null, order = null) => [
3470
3470
  meta(seat, false, true),
3471
3471
  ...anotherMetas({ ...p, proof: [] }),
3472
- meta(box ?? LOOT_PROGRAM, false, false),
3473
- meta(order ?? LOOT_PROGRAM, false, Boolean(order)),
3472
+ meta(box ?? lootProgram(), false, false),
3473
+ meta(order ?? lootProgram(), false, Boolean(order)),
3474
3474
  ...p.proof,
3475
3475
  ];
3476
3476
 
@@ -3653,8 +3653,8 @@ export const harvestIx = ({
3653
3653
  );
3654
3654
  const groups = rest.flatMap(x => [
3655
3655
  meta(x.seat, false, true),
3656
- meta(x.held ?? LOOT_PROGRAM, false, false),
3657
- meta(x.tree ?? LOOT_PROGRAM, false, false),
3656
+ meta(x.held ?? lootProgram(), false, false),
3657
+ meta(x.tree ?? lootProgram(), false, false),
3658
3658
  ]);
3659
3659
  return lootIx(
3660
3660
  'harvest',
package/js/client/pda.js CHANGED
@@ -3,9 +3,22 @@
3
3
  import { addressBytes } from '../chain/base58.js';
4
4
  import { intLE, utf8, uintLE } from '../chain/bytes.js';
5
5
  import { associatedTokenAddress, findProgramAddress, MPL_CORE_PROGRAM, TOKEN_PROGRAM } from '../chain/pda.js';
6
- import IDL from './idl/loot.js';
6
+ import { lootProgram } from './cluster.js';
7
7
 
8
- export const LOOT_PROGRAM = IDL.address;
8
+ // loot's program on this process's cluster (`cluster.js`): devnet unless the process names mainnet.
9
+ export {
10
+ checkOnCluster,
11
+ CLUSTERS,
12
+ cluster,
13
+ clusterRpcUrl,
14
+ LOOT_PROGRAM,
15
+ lootProgram,
16
+ networkOf,
17
+ realMoney,
18
+ scopeClusters,
19
+ useCluster,
20
+ walletChain,
21
+ } from './cluster.js';
9
22
  /** MagicBlock's VRF program and its default queue, the only queue the program accepts. */
10
23
  export const VRF_PROGRAM = 'Vrf1RNUjXmQGjmQrQLvJHs9SNkvDJEsRVFPkfSQUwGz';
11
24
  export const VRF_QUEUE = 'Cuj97ggrhhidhbu39TijNVqE74xvKJ69gDervRUXAxGh';
@@ -25,7 +38,7 @@ const u64 = n => uintLE(n, 8);
25
38
  const i64 = n => intLE(n, 8);
26
39
 
27
40
  /** The PDA of these seeds under the loot program, with its bump. */
28
- export const lootAddress = (seeds, program = LOOT_PROGRAM) => findProgramAddress(seeds, program);
41
+ export const lootAddress = (seeds, program = lootProgram()) => findProgramAddress(seeds, program);
29
42
  const at = (...seeds) => lootAddress(seeds)[0];
30
43
 
31
44
  export const pda = {
@@ -104,9 +117,9 @@ export const pda = {
104
117
  /** The program's identity, which signs its requests to the oracle. */
105
118
  identity: () => at(seed('identity')),
106
119
  /** The identity the oracle signs callbacks with: the VRF program's PDA for this program. */
107
- oracleIdentity: () => findProgramAddress([seed('identity'), key(LOOT_PROGRAM)], VRF_PROGRAM)[0],
120
+ oracleIdentity: () => findProgramAddress([seed('identity'), key(lootProgram())], VRF_PROGRAM)[0],
108
121
  /** The program's ProgramData account (its upgrade authority signs `init_platform`). */
109
- programData: () => findProgramAddress([key(LOOT_PROGRAM)], BPF_LOADER_UPGRADEABLE)[0],
122
+ programData: () => findProgramAddress([key(lootProgram())], BPF_LOADER_UPGRADEABLE)[0],
110
123
  };
111
124
 
112
125
  /** `owner`'s associated token account of `mint` (owner may be a PDA: a project's vault, a pool is not one). */
@@ -8,7 +8,7 @@ import IDL from '../client/idl/loot.js';
8
8
  import { gateList } from '../client/grant.js';
9
9
  import { MAX_BID_NFTS, MAX_SHARES } from '../client/ix.js';
10
10
  import { MAX_POTS } from '../client/accounts.js';
11
- import { vault } from '../client/pda.js';
11
+ import { cluster, CLUSTERS, networkOf, realMoney, useCluster, vault } from '../client/pda.js';
12
12
  import { ASSUMED_TRIES, boxBytes, buyFits, drawsFit, MAX_ORDER_BYTES, tableBudget } from '../client/sizes.js';
13
13
  import { TOKEN_2022_PROGRAM, TOKEN_PROGRAM } from '../chain/pda.js';
14
14
  import { addressBytes, encodeBase58 } from '../chain/base58.js';
@@ -693,10 +693,7 @@ export function dateWords(seconds) {
693
693
  * (`ASSUMED_TRIES`).
694
694
  * Returns { ok, refusals: [{ code, where, message, fix }], warnings: [{ code, where, message, fix? }] }.
695
695
  */
696
- export function check(
697
- economy,
698
- { network = 'devnet', now = Math.floor(Date.now() / 1000), project = economy?.live?.project ?? null } = {},
699
- ) {
696
+ export function check(economy, { now = Math.floor(Date.now() / 1000), project = economy?.live?.project ?? null } = {}) {
700
697
  const refusals = [];
701
698
  const warnings = [];
702
699
  const refuse = (code, where, message, fix) => refusals.push({ code, where, message, fix });
@@ -717,13 +714,27 @@ export function check(
717
714
  `The economy names ${STAND_IN_OWNER}, the address the setup plan stands in for your wallet before one connects. Nobody holds its key: whatever is sent there is lost.`,
718
715
  'Name a real wallet there.',
719
716
  );
720
- if (network !== 'devnet' || (economy?.network && economy.network !== 'devnet'))
717
+ // The economy's cluster: the network it names, devnet when it names none. This process works on one cluster, and
718
+ // the first economy it checks names it when nothing did before.
719
+ const named = networkOf(economy);
720
+ if (!Object.hasOwn(CLUSTERS, named))
721
721
  refuse(
722
722
  'E-NETWORK',
723
723
  'economy.network',
724
- `The economy names the network “${economy?.network ?? network}”. loot runs on devnet only: mainnet waits for an audit.`,
725
- 'Set the network to "devnet".',
724
+ `The economy names the network “${named}”. loot runs on ${Object.keys(CLUSTERS).join(' and on ')}.`,
725
+ 'Set the network to "devnet", or to "mainnet".',
726
726
  );
727
+ else
728
+ try {
729
+ useCluster(named);
730
+ } catch {
731
+ refuse(
732
+ 'E-NETWORK',
733
+ 'economy.network',
734
+ `The economy is on ${named}, and this process works on ${cluster()}: one process works on one cluster.`,
735
+ `Run it where the cluster is ${named}: the xsolana command takes the cluster from the file, and a server or a script is started with XSOLANA_CLUSTER=${named}.`,
736
+ );
737
+ }
727
738
  const oldVersion = economy?.version === 1;
728
739
  if (oldVersion)
729
740
  refuse(
@@ -4925,7 +4936,9 @@ export function check(
4925
4936
  'W-REVENUE-SHARE',
4926
4937
  where,
4927
4938
  `${label(f)} pays stakers a share of what players spend (${revenue.map(x => x.by).join(', ')}). Paying holders out of a project’s revenue is what regulators look at when they decide whether a token is a security.`,
4928
- 'Fine on devnet. Before mainnet, ask counsel, or reward stakers from emissions (a schedule that mints a game token) rather than from sales.',
4939
+ realMoney()
4940
+ ? 'This economy is on mainnet, where that is real money: ask counsel first, or reward stakers from emissions (a schedule that mints a game token) rather than from sales.'
4941
+ : 'Fine on devnet. Before mainnet, ask counsel, or reward stakers from emissions (a schedule that mints a game token) rather than from sales.',
4929
4942
  );
4930
4943
  }
4931
4944
 
@@ -5030,7 +5043,9 @@ export function check(
5030
5043
  'W-TOKEN-SALE',
5031
5044
  `${where}.prize`,
5032
5045
  `${label(c)} sells ${sold.symbol ?? sold.key} for ${tok.symbol ?? tok.key}: each backer pays in and takes a share of the tokens by what they put in. Selling a token to raise money is what securities law looks at first, and many places limit who may buy.`,
5033
- 'Fine on devnet. Before mainnet, ask counsel.',
5046
+ realMoney()
5047
+ ? 'This economy is on mainnet, where that is real money: ask counsel first.'
5048
+ : 'Fine on devnet. Before mainnet, ask counsel.',
5034
5049
  );
5035
5050
  // One NFT, the last units of an item, a pool's prize: the winning entries share it in the order they are claimed.
5036
5051
  const scarce =
@@ -5119,7 +5134,7 @@ export function check(
5119
5134
  warn(
5120
5135
  'W-CHANCE-PRIZE',
5121
5136
  where,
5122
- `${label(c)} lets players put money on an outcome: that is betting, which is regulated in most places once it is real money (state rules on prediction markets are in flux). loot runs on devnet only.`,
5137
+ `${label(c)} lets players put money on an outcome: that is betting, which is regulated in most places once it is real money (state rules on prediction markets are in flux). ${realMoney() ? 'This economy is on mainnet: it is real money.' : 'This economy is on devnet, which is not real money.'}`,
5123
5138
  'Before mainnet: counsel, an age and region gate, and a name that does not call it an investment.',
5124
5139
  );
5125
5140
  }
@@ -5688,14 +5703,14 @@ export function check(
5688
5703
  warn(
5689
5704
  'W-CHANCE-ITEMS',
5690
5705
  'economy',
5691
- 'Players pay for a random collectible they can resell (a loot box or gacha). Some places treat paid loot boxes of tradeable items as gambling, and many require the odds to be shown. loot runs on devnet only; before mainnet, counsel and an age and region gate.',
5706
+ `Players pay for a random collectible they can resell (a loot box or gacha). Some places treat paid loot boxes of tradeable items as gambling, and many require the odds to be shown. ${realMoney() ? 'This economy is on mainnet, so it is real money: counsel and an age and region gate come first.' : 'This economy is on devnet; before mainnet, counsel and an age and region gate.'}`,
5692
5707
  'Tell the person in plain words: paid random items that can be resold may count as gambling in some places; show the odds on the page, and get a lawyer before real money.',
5693
5708
  );
5694
5709
  if (chance)
5695
5710
  warn(
5696
5711
  'W-CHANCE-PRIZE',
5697
5712
  'economy',
5698
- 'Players pay for a chance at money (a box that pays a prize, a lottery). That is gambling in many places once it is real money, and paid packs with a cash buyback are drawing lawsuits. loot runs on devnet only; before mainnet, counsel and an age and region gate.',
5713
+ `Players pay for a chance at money (a box that pays a prize, a lottery). That is gambling in many places once it is real money, and paid packs with a cash buyback are drawing lawsuits. ${realMoney() ? 'This economy is on mainnet, so it is real money: counsel and an age and region gate come first.' : 'This economy is on devnet; before mainnet, counsel and an age and region gate.'}`,
5699
5714
  'Tell the person this in plain words before they go live: paying for a chance at money is gambling in many places, so before real money they need a lawyer and an age and region gate.',
5700
5715
  );
5701
5716
 
@@ -28,6 +28,7 @@ import {
28
28
  import { farmEnds, playSteps } from './plan.mjs';
29
29
  import { TRUST_WORDS } from './trust.mjs';
30
30
  import { inAll, poolFate, round, simulate, unnamedExceptions, unnamedWords } from './simulate.mjs';
31
+ import { realMoney } from '../client/cluster.js';
31
32
 
32
33
  const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
33
34
  const num = x => (Number.isFinite(x) ? String(round(x, 6)) : String(x));
@@ -613,7 +614,7 @@ export function explainEconomy(economy, { now = Math.floor(Date.now() / 1000) }
613
614
  );
614
615
  lines.push(
615
616
  '',
616
- 'Devnet only: nothing here is real money. Going live is signed on your own machine by the project’s key file, which is what “your wallet” means above. The command first prints everything it would send and sends nothing; it sends only when run again with the code it printed, which your agent is told to do only after your yes. No seed phrase or wallet key is ever asked for.',
617
+ `${realMoney() ? 'This economy is on mainnet: what it takes and what it pays is real money, and loot has not been independently audited.' : 'This economy is on devnet: nothing here is real money.'} Going live is signed on your own machine by the project’s key file, which is what “your wallet” means above. The command first prints everything it would send and sends nothing; it sends only when run again with the code it printed, which your agent is told to do only after your yes. No seed phrase or wallet key is ever asked for.`,
617
618
  );
618
619
  return {
619
620
  ok: true,
@@ -123,7 +123,7 @@ export const CONVENTIONS = [
123
123
  'A box, a shop item and a recipe take `opens` and `closes` (times), and `perWallet` (in all) and `perWalletEach` ({ "count": 1, "every": "1d" }) to limit each wallet.',
124
124
  'Leave out `id`, `index`, `shopBox` and `live`: they are written when the economy goes live.',
125
125
  'Public data only. A spec never holds a private key or a seed phrase (the checker refuses one, E-SECRET).',
126
- 'Devnet only: "network": "devnet".',
126
+ '"network" is "devnet" (what a file that names none is on: nothing there is real money) or "mainnet" (real money). loot has an address of its own on each, and an economy is on one: build and try it on devnet first.',
127
127
  ];
128
128
 
129
129
  /**
@@ -252,8 +252,10 @@ export const CANNOT = [
252
252
  {
253
253
  what: 'mainnet',
254
254
  words: 'mainnet real money production',
255
- why: 'the program isn’t audited yet',
256
- nearest: ['devnet now; mainnet after the audit'],
255
+ why: 'loot runs on mainnet, with real money, and has not been independently audited; an economy is on devnet unless its file says "network": "mainnet"',
256
+ nearest: [
257
+ 'try it on devnet first, then set "network": "mainnet" in a copy of the file and go live again with real SOL',
258
+ ],
257
259
  },
258
260
  ];
259
261
 
@@ -431,7 +433,7 @@ export function guideText(only) {
431
433
  return [
432
434
  only
433
435
  ? `The part “${only}” of a loot economy (* = required):`
434
- : `A loot economy is one JSON spec (xsolana.json) on the shared loot program ${IDL.metadata.version}, devnet only. Nobody writes a contract: the spec is checked, simulated, and put live on the owner’s own machine, signed by the project’s key file once they have said yes. Its parts (* = required field):`,
436
+ : `A loot economy is one JSON spec (xsolana.json) on the shared loot program ${IDL.metadata.version}, on devnet or on mainnet (its "network"; devnet when it names none). Nobody writes a contract: the spec is checked, simulated, and put live on the owner’s own machine, signed by the project’s key file once they have said yes. Its parts (* = required field):`,
435
437
  ...parts.flatMap(p => [
436
438
  `\n${p.part} (${p.type}): ${p.says}${p.atPlayTime ? `\n At play time: ${p.atPlayTime}.` : ''}`,
437
439
  ...p.fields.map(fieldLine),
@@ -2,7 +2,7 @@
2
2
  // the approval page (js/approve) has the owner's wallet sign, and the full simulation sends them to a local chain.
3
3
  import { encodeBase58 } from '../chain/base58.js';
4
4
  import { randomBytes } from '../chain/bytes.js';
5
- import { LOOT_PROGRAM } from '../client/pda.js';
5
+ import { lootProgram } from '../client/pda.js';
6
6
  import { compressedTree, listOf, model, tokenLabels } from './model.mjs';
7
7
  import { auctionSeller, plan, setupContext } from './plan.mjs';
8
8
 
@@ -95,7 +95,7 @@ export function recordLanded(economy, { ctx, signatures = [], at = new Date().to
95
95
  };
96
96
  const { live: _, previous: __, ...published } = out;
97
97
  out.live = {
98
- program: LOOT_PROGRAM,
98
+ program: lootProgram(),
99
99
  project: ctx.project,
100
100
  // A project made with no collection (`"collection": false`) has none to write: the schema takes an address only.
101
101
  ...(ctx.collection ? { collection: ctx.collection } : {}),
@@ -1649,7 +1649,7 @@ const TABLE_GROUP = 24;
1649
1649
  */
1650
1650
  export function standingAddresses(m) {
1651
1651
  const fixed = [
1652
- loot.LOOT_PROGRAM,
1652
+ loot.lootProgram(),
1653
1653
  loot.pda.platform(),
1654
1654
  loot.pda.identity(),
1655
1655
  loot.VRF_PROGRAM,
@@ -12,8 +12,8 @@
12
12
  "description": "The spec format: 2 is loot 0.7 and later."
13
13
  },
14
14
  "network": {
15
- "const": "devnet",
16
- "description": "Devnet only until the program is audited."
15
+ "enum": ["devnet", "mainnet"],
16
+ "description": "The cluster the economy is on: devnet when it names none. loot has an address of its own on each."
17
17
  },
18
18
  "name": {
19
19
  "type": "string",
package/js/mcp/server.mjs CHANGED
@@ -5,13 +5,20 @@
5
5
  // (`xsolana tool`), with the same words. Answers go out as text and as structuredContent.
6
6
  //
7
7
  // stdout is the protocol: everything else goes to stderr.
8
+ import { readFile } from 'node:fs/promises';
9
+ import path from 'node:path';
8
10
  import { validate } from '../economy/validate.mjs';
9
- import { PACKAGE_VERSION, PROGRAM_INFO, REMOTE_TOOLS, tools, VERSION } from './tools.mjs';
11
+ import { CLUSTERS, cluster, networkOf } from '../client/cluster.js';
12
+ import { PACKAGE_VERSION, REMOTE_TOOLS, tools, VERSION } from './tools.mjs';
13
+ import { withCluster } from './with-cluster.mjs';
14
+
15
+ /** Where loot runs, for every agent that connects. */
16
+ const WHERE = `loot ${VERSION} runs on devnet (program ${CLUSTERS.devnet.program}) and on mainnet (program ${CLUSTERS.mainnet.program}). An economy is on devnet, where nothing is real money, unless its file says "network": "mainnet", where everything is. Build and try an economy on devnet first. loot has not been independently audited.`;
10
17
 
11
18
  export const INSTRUCTIONS = [
12
19
  'loot is one shared Solana program for game and community economies (shops, loot boxes, lotteries, auctions, staking farms, crafting, contests, schedules, perks for holders of any NFT, capped rewards). An economy is a JSON spec, not a new smart contract: never write Anchor or Rust for what loot covers.',
13
20
  'Start with xsolana_guide, then xsolana_examples with the person’s own words; write the spec; xsolana_check after every change; xsolana_simulate and xsolana_explain before telling them what it does; xsolana_review before going live (fix its high findings, say honestly what each voucher server checks, and tell the person what a cheater gets); xsolana_save; xsolana_publish when they say to put it live. Going live is signed by a key file on their own machine (xsolana keygen makes one; never ask for, read or print a key) with xsolana approve --keypair, which prints everything it would send and sends nothing until it is run again with the code it printed: do that only after they have read the printout and said yes. That key owns the project for good: a change to an economy that is already live is signed by the key that owns it, never by a new one. A browser wallet’s signing page (approve --browser) only if they ask for one, and they run it themselves.',
14
- `Program ${PROGRAM_INFO.program} on ${PROGRAM_INFO.cluster} (loot ${VERSION}). Devnet only until audited.`,
21
+ WHERE,
15
22
  ].join(' ');
16
23
 
17
24
  /**
@@ -22,7 +29,7 @@ export const REMOTE_INSTRUCTIONS = [
22
29
  INSTRUCTIONS.split(' Start with')[0],
23
30
  'Start with xsolana_guide, then xsolana_examples with the person’s own words; write the spec and pass it whole as "spec" to every tool (this hosted server reads no files); xsolana_check after every change; xsolana_simulate and xsolana_explain before telling them what it does; xsolana_review before going live (fix its high findings, say honestly what each voucher server checks, and tell the person what a cheater gets).',
24
31
  `To go live, the spec goes in a file named xsolana.json on the person’s own computer and is signed there by a key file. In that folder: npx -y @xgamesfun/solana@${PACKAGE_VERSION} keygen ~/.config/xsolana/<name>.json makes the key file (it prints the address, never the key; the person sends that address devnet SOL), then npx -y @xgamesfun/solana@${PACKAGE_VERSION} approve xsolana.json --keypair <that file> prints everything that would be sent and sends nothing. When the person has read that and said yes, the same command with --yes <the code it printed> sends it. Run them yourself if you have a terminal there; otherwise hand them to the person. Never ask for, read or print a key. Say once what the key file is: the project’s owner for good (whoever can read it controls the project; they back it up themselves; nothing restores a lost one) and, unless the spec names a treasury, where what the economy earns lands. A change to an economy that is already live is signed by the key that owns it: never make a new key for it. A browser wallet’s signing page (the same approve command with --browser in place of --keypair), which the person runs themselves, only if they ask for one or a browser wallet owns the project. (xsolana_save and xsolana_publish, which tool texts may name, run only in the local server.)`,
25
- `Program ${PROGRAM_INFO.program} on ${PROGRAM_INFO.cluster} (loot ${VERSION}). Devnet only until audited.`,
32
+ WHERE,
26
33
  ].join(' ');
27
34
 
28
35
  /** A tool as tools/list shows it; on a hosted server, without the `file` argument it can't take. */
@@ -67,12 +74,36 @@ export async function runTool(name, input = {}, ctx = {}) {
67
74
  const bad = inputProblems(tool.inputSchema, input);
68
75
  if (bad.length) return { isError: true, text: bad.join('; ') };
69
76
  try {
70
- return await tool.handler(input, ctx);
77
+ // Each tool runs on the cluster of the economy it is given, so one server answers for devnet and mainnet alike.
78
+ return await withCluster(await clusterOfInput(input, ctx), () => tool.handler(input, ctx));
71
79
  } catch (e) {
72
80
  return { isError: true, text: e.message };
73
81
  }
74
82
  }
75
83
 
84
+ /**
85
+ * The cluster a tool's call is for: the "network" of the economy it is given (passed whole as `spec`, or read from its
86
+ * `file`, which is xsolana.json when none is named), devnet when the economy names none. With no economy to read (no
87
+ * file there, a file that is not JSON, a tool that takes none) or a network loot does not know, it is the server's
88
+ * own cluster, and the tool says what is wrong in its own words.
89
+ */
90
+ async function clusterOfInput(input, ctx) {
91
+ let economy = input?.spec && typeof input.spec === 'object' ? input.spec : null;
92
+ if (!economy && !ctx.remote) {
93
+ try {
94
+ const file = path.resolve(
95
+ ctx.cwd ?? process.cwd(),
96
+ typeof input?.file === 'string' ? input.file : 'xsolana.json',
97
+ );
98
+ economy = JSON.parse(await readFile(file, 'utf8'));
99
+ } catch {
100
+ economy = null;
101
+ }
102
+ }
103
+ const named = economy && typeof economy === 'object' ? networkOf(economy) : null;
104
+ return named && Object.hasOwn(CLUSTERS, named) ? named : cluster();
105
+ }
106
+
76
107
  const jsonable = value =>
77
108
  JSON.parse(
78
109
  JSON.stringify(value, (_, v) =>