@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
@@ -0,0 +1,180 @@
1
+ // What a wallet may add to a transaction it was asked to sign as it stood, and nothing else.
2
+ //
3
+ // The approval page prepares each transaction, shows what it does, and sends what the wallet signed. Some wallets add
4
+ // to a transaction before they sign it: Phantom adds its own checks (instructions of the Lighthouse program, which
5
+ // read accounts and fail the transaction when something is not as the wallet expected). The page takes such a
6
+ // transaction only when everything that was prepared and shown is still in it, untouched, and everything else is of
7
+ // the two programs a wallet adds for its own sake: Compute Budget and Lighthouse. Then what is sent is the wallet's
8
+ // own message with the wallet's own signature. Anything else is refused, as any change was before.
9
+ //
10
+ // The rule, each part of which is needed:
11
+ // - Both messages are legacy, with no lookup table: every account is in the message itself, so every account is
12
+ // compared by its address.
13
+ // - The wallet is the message's one signer and its fee payer, in both. A transaction a fresh key signs too (a new
14
+ // mint, a new collection) can't be changed: that key signed the prepared message.
15
+ // - The same blockhash.
16
+ // - Every prepared instruction is there, in the prepared order, with the same program, the same accounts and the
17
+ // same data.
18
+ // - Every account a prepared instruction names (its program too) is a signer or writable in the wallet's message
19
+ // exactly as it was in the prepared one. Whether an account can be written is the message's word, not one
20
+ // instruction's: an added instruction that asks to write an account of ours would make it writable for ours too.
21
+ // - Every other instruction is the Lighthouse program's, or a Compute Budget instruction that asks for a heap size
22
+ // or a limit on loaded data the prepared transaction does not set. Never a unit limit or a unit price: the fee of a
23
+ // prepared transaction is the one it was shown with.
24
+ // - Nothing added names the loader of upgradeable programs, which would let a program's own account be written.
25
+ import { equalBytes } from '../chain/bytes.js';
26
+ import { COMPUTE_BUDGET_PROGRAM, LIGHTHOUSE_PROGRAM } from '../chain/pda.js';
27
+ import { readMessage } from '../chain/tx.js';
28
+
29
+ /** What a Compute Budget instruction asks for, by its first byte. */
30
+ const BUDGET_ASKS = {
31
+ 1: 'a heap size',
32
+ 2: 'a compute unit limit',
33
+ 3: 'a compute unit price (a priority fee of its own)',
34
+ 4: 'a limit on the account data loaded',
35
+ };
36
+
37
+ /** The kinds that set a transaction's fee: its unit limit and its unit price. */
38
+ const FEE_KINDS = new Set([2, 3]);
39
+ /** The loader of upgradeable programs: named anywhere in a transaction, it lets a program's own account be written. */
40
+ const UPGRADEABLE_LOADER = 'BPFLoaderUpgradeab1e11111111111111111111111';
41
+
42
+ /** A refusal of a wallet's message: `why`, in words that go after "it …". */
43
+ class Refused extends Error {}
44
+
45
+ /**
46
+ * A legacy message's accounts and instructions with nothing left as a number:
47
+ * { blockhash, signers, flags (address → { signer, writable }), instructions: [{ program, accounts: [address], data }] }.
48
+ * Throws `Refused` for a message that is not legacy, that names an account twice, or whose own numbers do not add up.
49
+ */
50
+ function laidOut(message) {
51
+ let m;
52
+ try {
53
+ m = readMessage(message);
54
+ } catch {
55
+ throw new Refused('returned something that is not a transaction');
56
+ }
57
+ if (m.version !== 'legacy' || m.lookups.length)
58
+ throw new Refused('returned a transaction that loads accounts from a lookup table, which can’t be compared');
59
+ const { numRequiredSignatures: signed, numReadonlySigned, numReadonlyUnsigned } = m.header;
60
+ const count = m.keys.length;
61
+ if (signed < 1 || signed > count || numReadonlySigned >= signed || numReadonlyUnsigned > count - signed)
62
+ throw new Refused('returned a transaction whose header does not add up');
63
+ if (new Set(m.keys).size !== count) throw new Refused('returned a transaction that names an account twice');
64
+ const flags = new Map(
65
+ m.keys.map((address, i) => [
66
+ address,
67
+ {
68
+ signer: i < signed,
69
+ writable: i < signed - numReadonlySigned || (i >= signed && i < count - numReadonlyUnsigned),
70
+ },
71
+ ]),
72
+ );
73
+ const at = i => {
74
+ if (i >= count) throw new Refused('returned a transaction that names an account it does not list');
75
+ return m.keys[i];
76
+ };
77
+ return {
78
+ blockhash: m.blockhash,
79
+ signers: m.keys.slice(0, signed),
80
+ flags,
81
+ instructions: m.instructions.map(ix => ({
82
+ program: at(ix.programIndex),
83
+ accounts: ix.accountIndexes.map(at),
84
+ data: ix.data,
85
+ })),
86
+ };
87
+ }
88
+
89
+ const sameInstruction = (a, b) =>
90
+ a.program === b.program &&
91
+ a.accounts.length === b.accounts.length &&
92
+ a.accounts.every((address, i) => address === b.accounts[i]) &&
93
+ equalBytes(a.data, b.data);
94
+
95
+ /**
96
+ * Whether `theirs`, the message a wallet signed, is `ours`, the message prepared for it, with nothing but a wallet's
97
+ * own additions. `wallet`: the address the transaction was prepared for.
98
+ * → { ok: true, added: [what it added, in words] } or { ok: false, why: what it did, in words after "it …" }.
99
+ * Two messages that are the same bytes are not this function's to judge (nothing was added).
100
+ */
101
+ export function walletAdded(ours, theirs, wallet) {
102
+ try {
103
+ // (the prepared message is this package's own: one this can't lay out is one a wallet may not change)
104
+ let a;
105
+ try {
106
+ a = laidOut(ours);
107
+ } catch {
108
+ throw new Refused('changed a transaction that can only be sent as it was prepared');
109
+ }
110
+ const b = laidOut(theirs);
111
+ if (a.signers.length !== 1 || a.signers[0] !== wallet)
112
+ throw new Refused(
113
+ 'changed a transaction that another key signs too (a new account’s own key signed it as it was prepared)',
114
+ );
115
+ if (b.signers.length !== 1) throw new Refused('added a signer');
116
+ if (b.signers[0] !== wallet) throw new Refused('put another fee payer on it');
117
+ if (a.blockhash !== b.blockhash) throw new Refused('put another blockhash on it');
118
+
119
+ // Every prepared instruction, in its order; whatever stands between them is what the wallet added.
120
+ const added = [];
121
+ let next = 0;
122
+ for (const ix of b.instructions) {
123
+ if (next < a.instructions.length && sameInstruction(ix, a.instructions[next])) next++;
124
+ else added.push(ix);
125
+ }
126
+ if (next !== a.instructions.length) throw new Refused('changed, dropped or moved one of the prepared instructions');
127
+ if (!added.length) throw new Refused('changed the transaction’s accounts');
128
+
129
+ // No account of ours is more, or less, than it was: a signer, or writable.
130
+ const mine = new Set(a.instructions.flatMap(ix => [ix.program, ...ix.accounts]));
131
+ for (const address of mine) {
132
+ const was = a.flags.get(address);
133
+ const is = b.flags.get(address);
134
+ if (!is || was.signer !== is.signer || was.writable !== is.writable)
135
+ throw new Refused(
136
+ `changed what the account ${address} is in the transaction (${was.writable ? 'it could be written, and no longer can' : 'it could only be read, and can now be written'})`,
137
+ );
138
+ }
139
+
140
+ // The network lets a program's own account be written only in a transaction that names the upgradeable loader:
141
+ // without it, an account that is called as a program is read-only whatever the message says. So the loader, named
142
+ // by anything added, would change what a prepared instruction can write. It is never the wallet's to add.
143
+ if (b.flags.has(UPGRADEABLE_LOADER) && !a.flags.has(UPGRADEABLE_LOADER))
144
+ throw new Refused('named the upgradeable loader, which the prepared transaction does not');
145
+
146
+ // What it added is a wallet's own: instructions of the Lighthouse program, or a heap size or a limit on loaded
147
+ // data the transaction did not set. Never a unit limit or a unit price: a prepared transaction's fee is its own
148
+ // (this package's setups set both), and a wallet's price on top of it is not taken.
149
+ const set = new Set(
150
+ a.instructions.filter(ix => ix.program === COMPUTE_BUDGET_PROGRAM).map(ix => (ix.data.length ? ix.data[0] : -1)),
151
+ );
152
+ const words = [];
153
+ let own = 0;
154
+ for (const ix of added) {
155
+ if (ix.program === LIGHTHOUSE_PROGRAM) {
156
+ own++;
157
+ continue;
158
+ }
159
+ if (ix.program !== COMPUTE_BUDGET_PROGRAM) throw new Refused(`added an instruction of the program ${ix.program}`);
160
+ const kind = ix.data.length ? ix.data[0] : -1;
161
+ const what = BUDGET_ASKS[kind];
162
+ if (!what) throw new Refused('added a compute budget instruction of a kind not known here');
163
+ if (ix.accounts.length)
164
+ throw new Refused('added a compute budget instruction that names accounts, which none does');
165
+ if (set.has(kind))
166
+ throw new Refused(
167
+ `added ${what.replace(/^a /, 'a second ')}: the transaction sets its own, and the network refuses one that sets it twice`,
168
+ );
169
+ if (FEE_KINDS.has(kind))
170
+ throw new Refused(`added ${what}: the fee of a transaction prepared here is the one it was shown with`);
171
+ set.add(kind);
172
+ words.push(what);
173
+ }
174
+ if (own) words.push(`${own} instruction${own === 1 ? '' : 's'} of the Lighthouse program (a wallet’s own checks)`);
175
+ return { ok: true, added: words };
176
+ } catch (e) {
177
+ if (!(e instanceof Refused)) throw e;
178
+ return { ok: false, why: e.message };
179
+ }
180
+ }
@@ -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/pda.js CHANGED
@@ -10,6 +10,12 @@ export const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';
10
10
  export const TOKEN_2022_PROGRAM = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb';
11
11
  export const ASSOCIATED_TOKEN_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL';
12
12
  export const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111';
13
+ /**
14
+ * Lighthouse: a program of assertions (it reads accounts and fails the transaction when one is not as expected),
15
+ * which a wallet may add to a transaction before signing it (Phantom does). The same address on mainnet and devnet:
16
+ * its source's `declare_id!` and its README, github.com/Jac0xb/lighthouse.
17
+ */
18
+ export const LIGHTHOUSE_PROGRAM = 'L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95';
13
19
  export const METAPLEX_METADATA_PROGRAM = 'metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s';
14
20
  /** Metaplex Core: the program of Core assets and collections. */
15
21
  export const MPL_CORE_PROGRAM = 'CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d';
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 {
@@ -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',