@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
package/js/play/index.js CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  withComputeBudget,
41
41
  withSolWrap,
42
42
  } from '../chain/programs.js';
43
- import { checkCluster, notMainnet, rpcClient } from '../chain/rpc.js';
43
+ import { rightCluster, rpcClient } from '../chain/rpc.js';
44
44
  import { extension, mayStartFrozen, mintPowers, resolveTransfers } from '../chain/token2022.js';
45
45
  import {
46
46
  compileFitting,
@@ -84,14 +84,13 @@ const LOTTERY_STEPS = new Set(['reveal', 'cancel-draw', 'pull', 'payout']);
84
84
  const BATCHED = new Set(['contest', 'release', 'lookup-table', 'close-leaf', ...LOTTERY_STEPS]);
85
85
  const NO_OPTIONS = Object.freeze(/** @type {Record<string, any>} */ ({}));
86
86
 
87
- const CHAIN = 'solana:devnet';
88
87
  const sleep = ms => new Promise(r => setTimeout(r, ms));
89
88
  const MPL_CORE = loot.MPL_CORE_PROGRAM;
90
89
  const NOBODY = '11111111111111111111111111111111';
91
90
  /** A table made this many slots ago whose transaction never landed was never sent: its blockhashes ran out long since. */
92
91
  const TABLE_UNSENT_SLOTS = 300n;
93
92
  /** A compressed NFT's `prove_leaf`: its record stays good while the leaf is unchanged, so it may go in a transaction of its own first. */
94
- const isProveLeaf = ix => ix.programId === loot.LOOT_PROGRAM && loot.instructionOf(ix.data)?.name === 'prove_leaf';
93
+ const isProveLeaf = ix => ix.programId === loot.lootProgram() && loot.instructionOf(ix.data)?.name === 'prove_leaf';
95
94
  /** A system account must keep this much (rent for 0 bytes) or be emptied. */
96
95
  const RENT_EMPTY = 890_880n;
97
96
  const SIGNATURE_FEE = 5_000n;
@@ -101,7 +100,7 @@ const SIGNATURE_FEE = 5_000n;
101
100
  * `solana:signMessage`, also `signMessage(bytes)`: the app's settle keys are made from one signature of it a session,
102
101
  * so a page that closes can make them again (`recover`).
103
102
  */
104
- export function walletStandardSigner(wallet, account, chain = CHAIN) {
103
+ export function walletStandardSigner(wallet, account, chain = loot.walletChain()) {
105
104
  const feature = wallet.features?.['solana:signTransaction'];
106
105
  if (!feature) throw new Error(`${wallet.name} cannot sign transactions.`);
107
106
  const messages = wallet.features?.['solana:signMessage'];
@@ -165,18 +164,18 @@ const show = (u, decimals) => {
165
164
  };
166
165
 
167
166
  /**
168
- * `client`, kept to devnet: a URL that names mainnet is refused at once, and before its first request goes out the
169
- * chain's genesis block is checked, once (`checkCluster`), so an RPC whose URL hides mainnet or testnet is refused
167
+ * `client`, kept to this page's cluster: a URL that names another is refused at once, and before its first request goes
168
+ * out the chain's genesis block is checked, once (`checkCluster`), so an RPC whose URL hides another cluster is refused
170
169
  * whatever it says. Every RPC reached over http(s) is checked: the app's own client, and one given whose `url` is
171
170
  * http(s). A client with no such URL (a local chain of the caller's own, like the tests' LiteSVM) is used as it is.
172
171
  */
173
- function devnetOnly(client) {
174
- if (typeof client.url === 'string') notMainnet(client.url);
172
+ function onCluster(client) {
173
+ if (typeof client.url === 'string') rightCluster(client.url, loot.cluster());
175
174
  if (!/^(https?|wss?):/i.test(String(client.url ?? ''))) return client;
176
175
  let checked = null;
177
176
  // A check that could not be made (the network did not answer) is made again on the next request.
178
177
  const ready = () =>
179
- (checked ??= checkCluster(client).catch(e => {
178
+ (checked ??= loot.checkOnCluster(client).catch(e => {
180
179
  checked = null;
181
180
  throw e;
182
181
  }));
@@ -203,7 +202,7 @@ function devnetOnly(client) {
203
202
  */
204
203
  export function solanaApp({
205
204
  spec,
206
- rpcUrl = 'https://api.devnet.solana.com',
205
+ rpcUrl = null,
207
206
  rpc = null,
208
207
  wallet,
209
208
  programs = {},
@@ -217,7 +216,13 @@ export function solanaApp({
217
216
  throw new Error(
218
217
  'The rpc given has no call(method, params): a custom rpc needs it (the devnet check, the slot and compressed NFTs are read through it), as well as accounts, account, latestBlockhash, send, confirm and programAccounts.',
219
218
  );
220
- const client = devnetOnly(rpc ?? rpcClient(notMainnet(rpcUrl)));
219
+ // The economy's file names its cluster (devnet when it names none), and this page works on it and no other.
220
+ loot.useCluster(loot.networkOf(spec));
221
+ if (live.program && live.program !== loot.lootProgram())
222
+ throw new Error(
223
+ `This economy went live on the program ${live.program}, which is not loot’s on ${loot.cluster()} (${loot.lootProgram()}): its "network" and its "live" section name two clusters.`,
224
+ );
225
+ const client = onCluster(rpc ?? rpcClient(loot.clusterRpcUrl(rpcUrl)));
221
226
  const project = live.project;
222
227
  /** The settle keys, each named by its job's account (`keys.js`): made again after a reload (`recover`). */
223
228
  const keys = wallet?.address ? keyring({ wallet, project, store: keyStore }) : null;
@@ -252,13 +257,13 @@ export function solanaApp({
252
257
  const read = async (name, address) => {
253
258
  const a = await client.account(address);
254
259
  if (!a) throw new Error(`Nothing at ${address}: is the economy live on this cluster?`);
255
- if (a.owner !== loot.LOOT_PROGRAM) throw new Error(`${address} is not a loot account.`);
260
+ if (a.owner !== loot.lootProgram()) throw new Error(`${address} is not a loot account.`);
256
261
  return loot.decode(name, a.data);
257
262
  };
258
263
  /** A loot account, or null when there is none yet. */
259
264
  const readMaybe = async (name, address) => {
260
265
  const a = await client.account(address);
261
- return a?.owner === loot.LOOT_PROGRAM ? loot.decode(name, a.data) : null;
266
+ return a?.owner === loot.lootProgram() ? loot.decode(name, a.data) : null;
262
267
  };
263
268
  /** The key of the token a lottery's tickets are paid in: the spec's `ticketCurrency`, or the token of its mint. */
264
269
  const ticketCurrencyOf = (l, lottery) =>
@@ -530,7 +535,7 @@ export function solanaApp({
530
535
  const sheetOf = async nft => {
531
536
  const found = await client.account(loot.pda.sheet(project, nft));
532
537
  return Object.fromEntries(
533
- (loot.decodeSheet(found?.owner === loot.LOOT_PROGRAM ? found.data : null)?.attributes ?? []).map(a => [
538
+ (loot.decodeSheet(found?.owner === loot.lootProgram() ? found.data : null)?.attributes ?? []).map(a => [
534
539
  a.key,
535
540
  a.value,
536
541
  ]),
@@ -653,7 +658,7 @@ export function solanaApp({
653
658
  if (loot.readsProfile(rule)) {
654
659
  const found = await client.account(loot.pda.profile(project, wallet.address));
655
660
  const attributes = Object.fromEntries(
656
- (loot.decodeProfile(found?.owner === loot.LOOT_PROGRAM ? found.data : null)?.attributes ?? []).map(a => [
661
+ (loot.decodeProfile(found?.owner === loot.lootProgram() ? found.data : null)?.attributes ?? []).map(a => [
657
662
  a.key,
658
663
  a.value,
659
664
  ]),
@@ -925,7 +930,7 @@ export function solanaApp({
925
930
  const said = String(e?.message ?? '');
926
931
  const m = /custom program error: 0x([0-9a-f]+)/i.exec(said);
927
932
  const failed = e?.logs?.find(line => / failed: custom program error: 0x/i.test(line));
928
- const known = m && (!failed || failed.includes(loot.LOOT_PROGRAM)) ? loot.ERRORS[parseInt(m[1], 16)] : null;
933
+ const known = m && (!failed || failed.includes(loot.lootProgram())) ? loot.ERRORS[parseInt(m[1], 16)] : null;
929
934
  if (!known) throw e;
930
935
  throw Object.assign(new Error(`${known.msg} (${known.name}).`, { cause: e }), {
931
936
  code: parseInt(m[1], 16),
@@ -1080,14 +1085,14 @@ export function solanaApp({
1080
1085
  const watch = [
1081
1086
  ...new Set(
1082
1087
  instructions
1083
- .filter(ix => ix.programId === loot.LOOT_PROGRAM)
1088
+ .filter(ix => ix.programId === loot.lootProgram())
1084
1089
  .flatMap(ix => ix.keys.filter(k => k.isWritable && !k.isSigner).map(k => k.pubkey)),
1085
1090
  ),
1086
1091
  ];
1087
1092
  const after = (await dryRun(wallet.address, instructions, watch)).accounts;
1088
1093
  const waiting = watch.flatMap((address, i) => {
1089
1094
  const a = after[i];
1090
- if (a?.owner !== loot.LOOT_PROGRAM || loot.accountKind(a.data) !== 'Order') return [];
1095
+ if (a?.owner !== loot.lootProgram() || loot.accountKind(a.data) !== 'Order') return [];
1091
1096
  const order = loot.decode('Order', a.data);
1092
1097
  return order.status === 'Waiting' && order.player === wallet.address ? [[address, order]] : [];
1093
1098
  });
@@ -1191,7 +1196,7 @@ export function solanaApp({
1191
1196
  // `landed`: the slot one's own open landed in; the order is read from a node that has seen it.
1192
1197
  const current = async () => {
1193
1198
  const account = await client.account(orderAddress, 'confirmed', landed != null ? { minContextSlot: landed } : {});
1194
- return account?.owner === loot.LOOT_PROGRAM ? loot.decode('Order', account.data) : null;
1199
+ return account?.owner === loot.lootProgram() ? loot.decode('Order', account.data) : null;
1195
1200
  };
1196
1201
  let order = await current();
1197
1202
  // Right after one's own confirmed open, no order is "not seen yet", never "settled already": a node of the RPC may
@@ -2298,7 +2303,7 @@ export function solanaApp({
2298
2303
  const listing = await readMaybe('Listing', listingAt);
2299
2304
  if (listing) out.push({ kind: 'listing', address: listingAt, record: listing });
2300
2305
  const tag = { memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS.Hold)) } };
2301
- const holds = await client.programAccounts(loot.LOOT_PROGRAM, [
2306
+ const holds = await client.programAccounts(loot.lootProgram(), [
2302
2307
  tag,
2303
2308
  { memcmp: { offset: 1, bytes: project } },
2304
2309
  { memcmp: { offset: 33, bytes: asset } },
@@ -2317,7 +2322,7 @@ export function solanaApp({
2317
2322
  */
2318
2323
  async function stashesOf(asset) {
2319
2324
  const at = (offset, bytes) => ({ memcmp: { offset, bytes } });
2320
- const found = await client.programAccounts(loot.LOOT_PROGRAM, [
2325
+ const found = await client.programAccounts(loot.lootProgram(), [
2321
2326
  at(0, encodeBase58(Uint8Array.of(loot.TAGS.Table))),
2322
2327
  at(1, project),
2323
2328
  at(37, encodeBase58(Uint8Array.of(loot.PAYLOAD.Nft))),
@@ -2532,7 +2537,7 @@ export function solanaApp({
2532
2537
  const found = await client.accounts(keys);
2533
2538
  const rounds = keys.map((key, i) => ({
2534
2539
  address: key,
2535
- round: found[i]?.owner === loot.LOOT_PROGRAM ? loot.decodeRound(found[i].data) : null,
2540
+ round: found[i]?.owner === loot.lootProgram() ? loot.decodeRound(found[i].data) : null,
2536
2541
  }));
2537
2542
  return { address, lottery, current: rounds[0].round, rounds: rounds.filter(r => r.round) };
2538
2543
  }
@@ -2587,7 +2592,7 @@ export function solanaApp({
2587
2592
  first && lottery?.owing && lottery.owing !== NOBODY && loot.potIdOf(terms) != null
2588
2593
  ? await client.account(lottery.owing)
2589
2594
  : null;
2590
- const owing = owingAt?.owner === loot.LOOT_PROGRAM ? loot.decodeRound(owingAt.data) : null;
2595
+ const owing = owingAt?.owner === loot.lootProgram() ? loot.decodeRound(owingAt.data) : null;
2591
2596
  // That round's answer says what it left the pot: until it lands (or the round is cancelled), this draw waits.
2592
2597
  if (owing?.status === 'Drawing')
2593
2598
  throw new Error(
@@ -3000,13 +3005,13 @@ export function solanaApp({
3000
3005
  const tag = t => ({ memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS[t])) } });
3001
3006
  const at = (offset, bytes) => ({ memcmp: { offset, bytes } });
3002
3007
  const orders = new Map();
3003
- for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [
3008
+ for (const { pubkey, account } of await client.programAccounts(loot.lootProgram(), [
3004
3009
  tag('Order'),
3005
3010
  at(1, project),
3006
3011
  at(33, me),
3007
3012
  ]))
3008
3013
  orders.set(pubkey, loot.decode('Order', account.data));
3009
- const holds = (await client.programAccounts(loot.LOOT_PROGRAM, [tag('Hold'), at(1, project), at(65, me)])).map(
3014
+ const holds = (await client.programAccounts(loot.lootProgram(), [tag('Hold'), at(1, project), at(65, me)])).map(
3010
3015
  h => h.pubkey,
3011
3016
  );
3012
3017
  // Keys are named by an order of the wallet's, a hold (a loan's release), or an auction (the releases after its end).
@@ -3140,7 +3145,7 @@ export function solanaApp({
3140
3145
  async payNft(asset, { to = null, reference, ...others } = NO_OPTIONS) {
3141
3146
  only(others, ['to', 'reference'], 'payNft');
3142
3147
  // An NFT paid goes to a wallet: never the project's address or an account of the program (ToTheProject).
3143
- if (to && (to === project || (await client.account(to))?.owner === loot.LOOT_PROGRAM))
3148
+ if (to && (to === project || (await client.account(to))?.owner === loot.lootProgram()))
3144
3149
  throw new Error('An NFT paid can’t go to the project or an account of the program: name a wallet, or burn it.');
3145
3150
  const { before, ...nft } = await nftOf(asset);
3146
3151
  return send([...before, loot.payNftIx({ player: wallet.address, project, asset, to, ...nft, reference })]);
@@ -3158,7 +3163,7 @@ export function solanaApp({
3158
3163
  const there = await client.account(loot.pda.lottery(project, l.id));
3159
3164
  // The terms on sale are the round's own copy once someone bought into it, the lottery's before.
3160
3165
  const { lottery, current } =
3161
- there?.owner === loot.LOOT_PROGRAM ? await roundOf(l) : { lottery: null, current: null };
3166
+ there?.owner === loot.lootProgram() ? await roundOf(l) : { lottery: null, current: null };
3162
3167
  const copy = current ?? lottery;
3163
3168
  const terms = copy?.terms;
3164
3169
  const currency = lottery ? ticketCurrencyOf(l, lottery) : (l.ticketCurrency ?? currencyKey);
@@ -3718,7 +3723,7 @@ export function solanaApp({
3718
3723
  const nfts = f.stakes === 'collectibles';
3719
3724
  // A seat names its player (offset 1), not its farm: a seat of this farm is the one at its own address here.
3720
3725
  const found = (
3721
- await client.programAccounts(loot.LOOT_PROGRAM, [
3726
+ await client.programAccounts(loot.lootProgram(), [
3722
3727
  { memcmp: { offset: 0, bytes: encodeBase58(Uint8Array.of(loot.TAGS.Seat)) } },
3723
3728
  { memcmp: { offset: 1, bytes: player } },
3724
3729
  ])
@@ -4545,7 +4550,7 @@ export function solanaApp({
4545
4550
  ...(maker ? [{ memcmp: { offset: 33, bytes: maker } }] : []),
4546
4551
  ];
4547
4552
  const time = await chainNow();
4548
- const found = (await client.programAccounts(loot.LOOT_PROGRAM, filters)).map(({ pubkey, account }) => {
4553
+ const found = (await client.programAccounts(loot.lootProgram(), filters)).map(({ pubkey, account }) => {
4549
4554
  const listing = loot.decode('Listing', account.data);
4550
4555
  return { listing: pubkey, ...listing, renter: loot.renterOf(listing, time) };
4551
4556
  });
@@ -4917,7 +4922,12 @@ export function solanaApp({
4917
4922
  only(others, ['server', 'token', 'fetchImpl'], 'claimWins');
4918
4923
  const base = server.replace(/\/$/, '');
4919
4924
  const ask = async (path, init) => {
4920
- const r = await fetchImpl(path.startsWith('http') ? path : `${base}${path}`, init);
4925
+ // (one server answers for both clusters: a request for mainnet says so, one for devnet names none, as before)
4926
+ const at = path.startsWith('http') ? path : `${base}${path}`;
4927
+ const on = loot.cluster();
4928
+ const url =
4929
+ on === 'devnet' || /[?&]cluster=/.test(at) ? at : `${at}${at.includes('?') ? '&' : '?'}cluster=${on}`;
4930
+ const r = await fetchImpl(url, init);
4921
4931
  // The status first: a proxy's error page is no JSON, and says nothing a player can act on but its status.
4922
4932
  const body = await r.json().catch(() => null);
4923
4933
  if (!r.ok)
@@ -5050,7 +5060,7 @@ export function solanaApp({
5050
5060
  const slot = await slotNow();
5051
5061
  const time = BigInt(now());
5052
5062
  const chainTime = await chainNow();
5053
- for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [
5063
+ for (const { pubkey, account } of await client.programAccounts(loot.lootProgram(), [
5054
5064
  tag('Order'),
5055
5065
  at(1, project),
5056
5066
  at(33, me),
@@ -5068,7 +5078,7 @@ export function solanaApp({
5068
5078
  says: 'A roll you won (a mission’s, a recipe’s, a prize’s): ask for it and see what it gives (one whose box has nothing left to give waits until it has).',
5069
5079
  });
5070
5080
  }
5071
- for (const { pubkey, account } of await client.programAccounts(loot.LOOT_PROGRAM, [
5081
+ for (const { pubkey, account } of await client.programAccounts(loot.lootProgram(), [
5072
5082
  tag('Contest'),
5073
5083
  at(1, project),
5074
5084
  ])) {
@@ -5076,7 +5086,7 @@ export function solanaApp({
5076
5086
  const off = loot.contestOff(contest, time);
5077
5087
  if (!(off || contest.verdict === 'Won')) continue;
5078
5088
  const list = await client.account(loot.contestEntries(pubkey, contest));
5079
- const entries = list?.owner === loot.LOOT_PROGRAM ? loot.decodeEntries(list.data) : [];
5089
+ const entries = list?.owner === loot.lootProgram() ? loot.decodeEntries(list.data) : [];
5080
5090
  const entry = entries.find(e => e.player === me);
5081
5091
  if (!entry) continue;
5082
5092
  const won = !off && Number(contest.winner) === Number(entry.side);
@@ -5134,7 +5144,7 @@ export function solanaApp({
5134
5144
  // wallet made: one search of the project's holds finds them all.
5135
5145
  const g = await projectNow();
5136
5146
  const holdsOf = [me, ...(g.treasury === me ? [project] : [])];
5137
- const holds = (await client.programAccounts(loot.LOOT_PROGRAM, [tag('Hold'), at(1, project)]))
5147
+ const holds = (await client.programAccounts(loot.lootProgram(), [tag('Hold'), at(1, project)]))
5138
5148
  .map(({ pubkey, account }) => ({ pubkey, hold: loot.decode('Hold', account.data) }))
5139
5149
  .filter(({ hold }) => holdsOf.includes(hold.player) || hold.debt?.lender === me);
5140
5150
  // Whether the project still holds each one's NFT: whoever controls its collection can take it back or burn it.
@@ -5157,7 +5167,7 @@ export function solanaApp({
5157
5167
  out.push({ ...claim, says: 'A loan you made is overdue and unpaid: take its NFT.' });
5158
5168
  }
5159
5169
  // A compressed NFT's proof record this wallet paid for and left open: closing it brings its rent back.
5160
- for (const { account } of await client.programAccounts(loot.LOOT_PROGRAM, [tag('Proven')])) {
5170
+ for (const { account } of await client.programAccounts(loot.lootProgram(), [tag('Proven')])) {
5161
5171
  const leaf = (() => {
5162
5172
  try {
5163
5173
  return loot.decodeProven(account.data);
@@ -5335,7 +5345,7 @@ export function solanaApp({
5335
5345
  out.push({ kind: 'vest', key: sc.key, says: `What has vested of “${sc.name ?? sc.key}”: claim it.` });
5336
5346
  }
5337
5347
  // The player's listings whose rental is over: their NFT can go home (or wait for the next renter).
5338
- const mineListed = await client.programAccounts(loot.LOOT_PROGRAM, [tag('Listing'), at(1, project), at(33, me)]);
5348
+ const mineListed = await client.programAccounts(loot.lootProgram(), [tag('Listing'), at(1, project), at(33, me)]);
5339
5349
  for (const { account } of mineListed) {
5340
5350
  const listing = loot.decode('Listing', account.data);
5341
5351
  if (listing.deal.Rent && listing.user !== NOBODY && !loot.renterOf(listing, chainTime))
@@ -5535,7 +5545,7 @@ export function solanaApp({
5535
5545
  : await read('Lottery', loot.pda.lottery(project, l.id));
5536
5546
  const key = c.round ?? loot.currentRound(loot.pda.lottery(project, l.id), lottery);
5537
5547
  const found = await client.account(key);
5538
- const r = found?.owner === loot.LOOT_PROGRAM ? loot.decodeRound(found.data) : null;
5548
+ const r = found?.owner === loot.lootProgram() ? loot.decodeRound(found.data) : null;
5539
5549
  // The ask: the current round's once due, or an older round's again (after the timeout, it cancels the round).
5540
5550
  if (c.kind === 'reveal' || c.kind === 'cancel-draw') {
5541
5551
  const steps = await drawStep(l, lottery, c.round ?? null, r, me);
@@ -5617,7 +5627,7 @@ export function solanaApp({
5617
5627
  const carryOn = async signature => {
5618
5628
  if (build || !partial) return signature;
5619
5629
  const after = await client.account(key);
5620
- const r2 = after?.owner === loot.LOOT_PROGRAM ? loot.decodeRound(after.data) : null;
5630
+ const r2 = after?.owner === loot.lootProgram() ? loot.decodeRound(after.data) : null;
5621
5631
  const moved = r2 && (Number(r2.assigned) > Number(r.assigned) || Number(r2.owed) < Number(r.owed));
5622
5632
  if (!moved) return signature;
5623
5633
  const left =
@@ -36,7 +36,7 @@ import { transferLamports } from '../chain/programs.js';
36
36
  import { encodeBase58 } from '../chain/base58.js';
37
37
  import { fromBase64, toBase64 } from '../chain/bytes.js';
38
38
  import { readMessage, readTransaction, withSignature } from '../chain/tx.js';
39
- import { LOOT_PROGRAM, MPL_CORE_PROGRAM, pda, VRF_PROGRAM } from '../client/index.js';
39
+ import { lootProgram, MPL_CORE_PROGRAM, pda, VRF_PROGRAM } from '../client/index.js';
40
40
 
41
41
  const SYSTEM_PROGRAM = '11111111111111111111111111111111';
42
42
  /** What a signature costs the fee payer, in lamports. */
@@ -82,7 +82,7 @@ export function sponsoredCost({ addresses, pre, post, project, skip = [], progra
82
82
  }
83
83
  if (before) continue;
84
84
  const opened =
85
- after.owner === LOOT_PROGRAM ||
85
+ after.owner === lootProgram() ||
86
86
  (after.owner === MPL_CORE_PROGRAM && !programs.includes(MPL_CORE_PROGRAM)) ||
87
87
  ((after.owner === TOKEN_PROGRAM || after.owner === TOKEN_2022_PROGRAM) &&
88
88
  after.data?.length >= 32 &&
@@ -97,7 +97,7 @@ export function stepShape(instructions) {
97
97
  return {
98
98
  programs: [...new Set(instructions.map(ix => ix.programId))],
99
99
  lootNamed: [
100
- ...new Set(instructions.filter(ix => ix.programId === LOOT_PROGRAM).flatMap(ix => ix.keys.map(k => k.pubkey))),
100
+ ...new Set(instructions.filter(ix => ix.programId === lootProgram()).flatMap(ix => ix.keys.map(k => k.pubkey))),
101
101
  ],
102
102
  };
103
103
  }
@@ -180,7 +180,7 @@ export async function checkSponsored(
180
180
  if (program === COMPUTE_BUDGET_PROGRAM && ix.data[0] === 2 && ix.data.length >= 5)
181
181
  units = BigInt(u32At(ix.data, 1));
182
182
  if (program === COMPUTE_BUDGET_PROGRAM && ix.data[0] === 3 && ix.data.length >= 9) price = u64At(ix.data, 1);
183
- if (program === LOOT_PROGRAM && accounts.includes(project)) ours = true;
183
+ if (program === lootProgram() && accounts.includes(project)) ours = true;
184
184
  }
185
185
  if (!ours) return no('It does nothing in this project.');
186
186
  if (price > BigInt(maxPriorityMicroLamports)) return no('Its priority fee is more than this sponsor pays.');
@@ -22,6 +22,7 @@ import { decodeBase58, encodeBase58 } from '../chain/base58.js';
22
22
  import { toHex } from '../chain/bytes.js';
23
23
  import { pda, voucherBytes } from '../client/index.js';
24
24
  import { canonicalJson, drawText, entryText, lockText, resultBytes, rosterHash } from './wire.js';
25
+ import { cluster as onCluster } from '../client/cluster.js';
25
26
 
26
27
  export {
27
28
  canonicalJson,
@@ -40,7 +41,7 @@ export {
40
41
  rosterHash,
41
42
  } from './wire.js';
42
43
 
43
- /** xgames.fun's results server, on its play host (devnet until the program's audit). */
44
+ /** xgames.fun's results server, on its play host. One host answers for devnet and for mainnet: each request names its cluster. */
44
45
  export const RESULTS_SERVER = 'https://play.xgames.fun/xsolana';
45
46
 
46
47
  const subtle = () => globalThis.crypto.subtle;
@@ -112,7 +113,7 @@ function base64url(text) {
112
113
  }
113
114
 
114
115
  /** One signer's signature of a result: { key, signature }. `signer` is a key from newKey, or anything with sign(). */
115
- export async function signResult(signer, result, cluster = 'devnet') {
116
+ export async function signResult(signer, result, cluster = onCluster()) {
116
117
  return { key: signer.address, signature: encodeBase58(await signer.sign(resultBytes({ ...result, cluster }))) };
117
118
  }
118
119
 
@@ -129,12 +130,12 @@ export async function report({
129
130
  round,
130
131
  endedAt,
131
132
  winners,
132
- cluster = 'devnet',
133
+ cluster = onCluster(),
133
134
  fetchImpl = globalThis.fetch,
134
135
  }) {
135
136
  const result = { game, round, endedAt, winners };
136
137
  const all = [await signResult(signer, result, cluster)];
137
- const response = await fetchImpl(`${server.replace(/\/$/, '')}/v1/results`, {
138
+ const response = await fetchImpl(serverUrl(server, '/v1/results', cluster), {
138
139
  method: 'POST',
139
140
  headers: { 'content-type': 'application/json' },
140
141
  body: JSON.stringify({ ...result, cert, signatures: all }),
@@ -164,9 +165,16 @@ async function answerOf(response) {
164
165
  }
165
166
 
166
167
  /** A wallet's vouchers for a round it won: [{ grant, publicKey, signature, round, expires, … }]. */
167
- export async function fetchVouchers({ server, game, round, wallet, fetchImpl = globalThis.fetch }) {
168
+ export async function fetchVouchers({
169
+ server,
170
+ game,
171
+ round,
172
+ wallet,
173
+ cluster = onCluster(),
174
+ fetchImpl = globalThis.fetch,
175
+ }) {
168
176
  const response = await fetchImpl(
169
- `${server.replace(/\/$/, '')}/v1/vouchers?game=${q(game)}&round=${q(round)}&wallet=${q(wallet)}`,
177
+ serverUrl(server, `/v1/vouchers?game=${q(game)}&round=${q(round)}&wallet=${q(wallet)}`, cluster),
170
178
  );
171
179
  return (await answerOf(response)).vouchers;
172
180
  }
@@ -225,9 +233,19 @@ export async function signVoucher({
225
233
  };
226
234
  }
227
235
 
236
+ /**
237
+ * A path on a results server, for the cluster the request is on. One server answers for both clusters, and a request
238
+ * that names none is devnet's: so nothing is added on devnet (as every request was before), and `cluster=mainnet` on
239
+ * mainnet.
240
+ */
241
+ export function serverUrl(server, path, cluster = onCluster()) {
242
+ const url = `${String(server).replace(/\/$/, '')}${path}`;
243
+ return cluster === 'devnet' ? url : `${url}${url.includes('?') ? '&' : '?'}cluster=${encodeURIComponent(cluster)}`;
244
+ }
245
+
228
246
  /** Posts JSON to the server and answers its body, or throws its { code, why }. */
229
- async function post(server, path, body, fetchImpl) {
230
- const response = await fetchImpl(`${server.replace(/\/$/, '')}${path}`, {
247
+ async function post(server, path, body, fetchImpl, cluster = onCluster()) {
248
+ const response = await fetchImpl(serverUrl(server, path, cluster), {
231
249
  method: 'POST',
232
250
  headers: { 'content-type': 'application/json' },
233
251
  body: JSON.stringify(body),
@@ -236,7 +254,8 @@ async function post(server, path, body, fetchImpl) {
236
254
  }
237
255
 
238
256
  /** Gets JSON from the server and answers its body, or throws its { code, why }. */
239
- const fetchJson = async (server, path, fetchImpl) => answerOf(await fetchImpl(`${server.replace(/\/$/, '')}${path}`));
257
+ const fetchJson = async (server, path, fetchImpl, cluster = onCluster()) =>
258
+ answerOf(await fetchImpl(serverUrl(server, path, cluster)));
240
259
 
241
260
  /**
242
261
  * A draw, from the organizer's side and the players'. `signMessage(text) → signature bytes` is the organizer's wallet
@@ -274,16 +293,17 @@ export const draws = {
274
293
  token = null,
275
294
  wallet = null,
276
295
  signMessage = null,
277
- cluster = 'devnet',
296
+ cluster = onCluster(),
278
297
  fetchImpl = globalThis.fetch,
279
298
  }) => {
280
- if (token) return post(server, `/v1/draws/${seg(game)}/${seg(draw)}/entries`, { token }, fetchImpl);
299
+ if (token) return post(server, `/v1/draws/${seg(game)}/${seg(draw)}/entries`, { token }, fetchImpl, cluster);
281
300
  const text = entryText({ cluster, game, draw });
282
301
  return post(
283
302
  server,
284
303
  `/v1/draws/${seg(game)}/${seg(draw)}/entries`,
285
304
  { wallet, text, signature: encodeBase58(await signMessage(new TextEncoder().encode(text))) },
286
305
  fetchImpl,
306
+ cluster,
287
307
  );
288
308
  },
289
309
  /** Locks the players the server holds now to `beacon`, a drand round still to come. Checks their hash itself. */
@@ -295,11 +315,11 @@ export const draws = {
295
315
  signMessage,
296
316
  by,
297
317
  cert = null,
298
- cluster = 'devnet',
318
+ cluster = onCluster(),
299
319
  net = 'quicknet',
300
320
  fetchImpl = globalThis.fetch,
301
321
  }) => {
302
- const { draw: view } = await fetchJson(server, `/v1/draws/${seg(game)}/${seg(draw)}`, fetchImpl);
322
+ const { draw: view } = await fetchJson(server, `/v1/draws/${seg(game)}/${seg(draw)}`, fetchImpl, cluster);
303
323
  const text = lockText({
304
324
  cluster,
305
325
  game,
@@ -319,6 +339,7 @@ export const draws = {
319
339
  ...(cert ? { cert } : {}),
320
340
  },
321
341
  fetchImpl,
342
+ cluster,
322
343
  );
323
344
  },
324
345
  /** The draw as anyone may check it; settled, with its result, once its beacon round is out. */
@@ -345,7 +366,7 @@ export const giveaways = {
345
366
  endedAt,
346
367
  winners,
347
368
  grant,
348
- cluster = 'devnet',
369
+ cluster = onCluster(),
349
370
  fetchImpl = globalThis.fetch,
350
371
  }) => {
351
372
  const signed = winners.map(w => ({ wallet: w.wallet, place: w.place, grant }));
@@ -357,6 +378,7 @@ export const giveaways = {
357
378
  `/v1/giveaways/${seg(game)}/${seg(giveaway)}/result`,
358
379
  { endedAt, winners: winners.map(({ wallet, place }) => ({ wallet, place })), signature },
359
380
  fetchImpl,
381
+ cluster,
360
382
  );
361
383
  },
362
384
  };
@@ -19,6 +19,7 @@ import { MPL_CORE_PROGRAM, ownedAssetFilters, parseCoreAsset } from '../chain/co
19
19
  import { rpcClient } from '../chain/rpc.js';
20
20
  import { sha256 } from '../chain/sha256.js';
21
21
  import { signVoucher } from './index.js';
22
+ import { cluster as onCluster } from '../client/cluster.js';
22
23
 
23
24
  const subtle = () => globalThis.crypto.subtle;
24
25
  const rpcOf = rpc => (typeof rpc === 'string' ? rpcClient(rpc) : rpc);
@@ -70,7 +71,7 @@ const oneLine = (name, value) => {
70
71
  };
71
72
 
72
73
  /** The exact text a wallet signs to prove it is its owner's: fixed lines, so nothing else can be passed off as it. */
73
- export const proofText = ({ cluster = 'devnet', game, wallet, purpose, nonce, expires }) =>
74
+ export const proofText = ({ cluster = onCluster(), game, wallet, purpose, nonce, expires }) =>
74
75
  [
75
76
  PROOF_TITLE,
76
77
  ...PROOF_FIELDS.map(f => `${f}: ${oneLine(f, { cluster, game, wallet, purpose, nonce, expires }[f])}`),
@@ -83,7 +84,7 @@ export const proofText = ({ cluster = 'devnet', game, wallet, purpose, nonce, ex
83
84
  * (unix seconds, `seconds` from now) and takes each nonce once, so a signed proof can't be replayed.
84
85
  */
85
86
  export function proofRequest({
86
- cluster = 'devnet',
87
+ cluster = onCluster(),
87
88
  game,
88
89
  wallet,
89
90
  purpose = 'sign in',
@@ -127,7 +128,7 @@ export async function verifyProof({
127
128
  wallet,
128
129
  game,
129
130
  purpose = null,
130
- cluster = 'devnet',
131
+ cluster = onCluster(),
131
132
  now = Math.floor(Date.now() / 1000),
132
133
  }) {
133
134
  const fields = readProof(text);
@@ -2,6 +2,7 @@
2
2
  // with whoever signs them: a host server, a player's game page, an owner's wallet. Browser-safe: no Node imports.
3
3
  import { addressBytes } from '../chain/base58.js';
4
4
  import { concat, fromHex, uintLE, utf8 } from '../chain/bytes.js';
5
+ import { cluster as onCluster } from '../client/cluster.js';
5
6
 
6
7
  export const RESULT_DOMAIN = 'loot-result-v1';
7
8
  export const NO_GRANT = 0xffff;
@@ -31,10 +32,10 @@ const write = (kind, values) =>
31
32
  [CERT_KINDS[kind].title, ...CERT_KINDS[kind].fields.map(f => `${f}: ${field(f, values[f])}`)].join('\n');
32
33
 
33
34
  /** The text the owner signs to let `host` report `game`'s results until `until` (unix seconds). */
34
- export const hostCertText = ({ cluster = 'devnet', game, host, until }) =>
35
+ export const hostCertText = ({ cluster = onCluster(), game, host, until }) =>
35
36
  write('host', { cluster, game, host, until });
36
37
  /** The text the owner signs to take `host` back. */
37
- export const revokeText = ({ cluster = 'devnet', game, host }) => write('revoke', { cluster, game, host });
38
+ export const revokeText = ({ cluster = onCluster(), game, host }) => write('revoke', { cluster, game, host });
38
39
 
39
40
  /**
40
41
  * The text the organizer (the game's owner or authority, or a host key they certified) signs to open a draw: a round
@@ -45,7 +46,7 @@ export const revokeText = ({ cluster = 'devnet', game, host }) => write('revoke'
45
46
  * JSON), or 'default'.
46
47
  */
47
48
  export const drawText = ({
48
- cluster = 'devnet',
49
+ cluster = onCluster(),
49
50
  game,
50
51
  draw,
51
52
  engine,
@@ -69,19 +70,19 @@ export const drawText = ({
69
70
  rates,
70
71
  });
71
72
  /** The text the organizer signs to lock a draw's players (`roster`: rosterHash) to a beacon round still to come. */
72
- export const lockText = ({ cluster = 'devnet', game, draw, roster, players, beacon, net = 'quicknet' }) =>
73
+ export const lockText = ({ cluster = onCluster(), game, draw, roster, players, beacon, net = 'quicknet' }) =>
73
74
  write('lock', { cluster, game, draw, roster, players, beacon: `${net} ${beacon}` });
74
75
  /** The text a player's wallet signs to enter a draw (identity 'wallet'). */
75
- export const entryText = ({ cluster = 'devnet', game, draw }) => write('entry', { cluster, game, draw });
76
+ export const entryText = ({ cluster = onCluster(), game, draw }) => write('entry', { cluster, game, draw });
76
77
 
77
78
  /**
78
79
  * The text the owner (or authority) signs to appoint a giveaway host: `who` is "x:<X account id>" (the host signs in
79
80
  * with that X account through the play host) or a wallet address; their giveaways pay from grant `grant` alone.
80
81
  */
81
- export const giveawayHostText = ({ cluster = 'devnet', game, who, slug, grant, until }) =>
82
+ export const giveawayHostText = ({ cluster = onCluster(), game, who, slug, grant, until }) =>
82
83
  write('giveaway', { cluster, game, who, slug, grant, until });
83
84
  /** The text the owner (or authority) signs to take a giveaway host back. */
84
- export const revokeGiveawayText = ({ cluster = 'devnet', game, who }) =>
85
+ export const revokeGiveawayText = ({ cluster = onCluster(), game, who }) =>
85
86
  write('revokeGiveaway', { cluster, game, who });
86
87
 
87
88
  /** A draw's players, as one hash: each `id<TAB>wallet`, sorted by id, one a line; sha256, hex. */
@@ -113,7 +114,7 @@ export function canonicalJson(value) {
113
114
  * Every field has one length, so one set of bytes means one result: an unknown cluster, a round that is not 16 bytes, a
114
115
  * place past 255 and more than 255 winners are refused, never written as something else.
115
116
  */
116
- export function resultBytes({ cluster = 'devnet', game, round, endedAt, winners }) {
117
+ export function resultBytes({ cluster = onCluster(), game, round, endedAt, winners }) {
117
118
  if (!Object.hasOwn(CLUSTERS, cluster))
118
119
  throw new RangeError(
119
120
  `Unknown cluster ${JSON.stringify(cluster)}: a result names ${Object.keys(CLUSTERS).join(' or ')}.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xgamesfun/solana",
3
- "version": "0.28.1",
3
+ "version": "0.28.3",
4
4
  "mcpName": "io.github.XGamesFun/solana-kit",
5
5
  "description": "Loot boxes, gacha, shops, lotteries, auctions, NFT staking, crafting and rewards on Solana without writing a smart contract: one shared program, its JS client, a CLI, and an MCP server and skill so Claude Code, Codex or any AI agent can design, check, simulate and launch an economy.",
6
6
  "license": "Apache-2.0",
@@ -80,6 +80,10 @@
80
80
  "./mcp/http": {
81
81
  "types": "./types/mcp/http.d.mts",
82
82
  "default": "./js/mcp/http.mjs"
83
+ },
84
+ "./with-cluster": {
85
+ "types": "./types/mcp/with-cluster.d.mts",
86
+ "default": "./js/mcp/with-cluster.mjs"
83
87
  }
84
88
  },
85
89
  "sideEffects": [
@@ -102,6 +106,7 @@
102
106
  ],
103
107
  "scripts": {
104
108
  "test": "node --test \"js/test/**/*.test.mjs\"",
109
+ "test:mainnet": "node scripts/test-mainnet.mjs",
105
110
  "lint": "eslint . && prettier --check .",
106
111
  "format": "prettier --write .",
107
112
  "types": "tsc -p tsconfig.types.json",
package/release.json CHANGED
@@ -4,5 +4,10 @@
4
4
  "sbfBytes": 1794616,
5
5
  "sbfSha256": "ac8b09ed2c3a3f7daa2dfab55d2f6dea19fa43e4c98b44a82da2a68a7bc8ebbe",
6
6
  "idlSha256": "ef3b8043d62c606e850df75773b4535cdb08206216e17aca653111a77403abd7",
7
- "clientSha256": "3ef11453089e952e50d9d5a7199d9629b3417a091a071ea6dfd90f42746a2421"
7
+ "clientSha256": "164ed8ee25b5a2714f6c8f5de243d6b650ba0741637ee3dd4c2886f15af3a6c5",
8
+ "mainnet": {
9
+ "programId": "xgames8C758PyweWB9sDw8evvwsTVwyA2fGRUeAXVPy",
10
+ "sbfBytes": 1794616,
11
+ "sbfSha256": "f78e4f0ef38b5f1673936044263293e91172b4bac92c741cc7bb9567617bbdf6"
12
+ }
8
13
  }