@xgamesfun/solana 0.28.1 → 0.28.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/DISCLAIMER.md +3 -1
  2. package/README.md +18 -16
  3. package/bin/xsolana.mjs +27 -1
  4. package/js/approve/details.mjs +3 -3
  5. package/js/approve/live-check.mjs +2 -2
  6. package/js/approve/page.html +5 -5
  7. package/js/approve/page.js +16 -4
  8. package/js/approve/server.mjs +31 -26
  9. package/js/approve/with-key.mjs +7 -4
  10. package/js/chain/borsh.js +5 -3
  11. package/js/chain/rpc.js +26 -10
  12. package/js/client/accounts.js +6 -6
  13. package/js/client/cluster.js +136 -0
  14. package/js/client/grant.js +2 -2
  15. package/js/client/ix.js +16 -16
  16. package/js/client/pda.js +18 -5
  17. package/js/economy/check.mjs +28 -13
  18. package/js/economy/explain.mjs +2 -1
  19. package/js/economy/guide.mjs +6 -4
  20. package/js/economy/live.mjs +2 -2
  21. package/js/economy/plan.mjs +1 -1
  22. package/js/economy/schema.json +2 -2
  23. package/js/mcp/server.mjs +35 -4
  24. package/js/mcp/tools.mjs +56 -45
  25. package/js/mcp/with-cluster.mjs +25 -0
  26. package/js/play/index.js +42 -37
  27. package/js/play/sponsor.js +4 -4
  28. package/js/results/index.js +7 -6
  29. package/js/results/server.js +4 -3
  30. package/js/results/wire.js +9 -8
  31. package/package.json +6 -1
  32. package/release.json +6 -1
  33. package/skills/xsolana-economy/SKILL.md +13 -9
  34. package/skills/xsolana-economy/references/apps.md +2 -1
  35. package/skills/xsolana-economy/references/telling-the-person.md +1 -1
  36. package/skills/xsolana-economy/references/trust-and-cheating.md +1 -1
  37. package/types/chain/borsh.d.ts +4 -2
  38. package/types/chain/rpc.d.ts +10 -4
  39. package/types/client/cluster.d.ts +61 -0
  40. package/types/client/idl/loot.d.ts +9 -9
  41. package/types/client/pda.d.ts +1 -1
  42. package/types/economy/check.d.mts +1 -2
  43. package/types/economy/plan.d.mts +14 -14
  44. package/types/mcp/server.d.mts +1 -1
  45. package/types/mcp/tools.d.mts +9 -7
  46. package/types/mcp/with-cluster.d.mts +8 -0
  47. package/types/play/index.d.ts +2 -2
  48. package/types/results/index.d.ts +6 -6
  49. package/types/results/server.d.ts +3 -3
  50. package/types/results/wire.d.ts +8 -8
package/js/mcp/tools.mjs CHANGED
@@ -9,11 +9,11 @@ import path from 'node:path';
9
9
  import { addressBytes } from '../chain/base58.js';
10
10
  import { metadataAddress } from '../chain/metadata.js';
11
11
  import { parseMint, tokenEntry } from '../chain/mint.js';
12
- import { checkCluster, notMainnet, rpcClient } from '../chain/rpc.js';
12
+ import { rpcClient } from '../chain/rpc.js';
13
13
  import { mintState } from '../chain/token2022.js';
14
14
  import { decodeAny } from '../client/accounts.js';
15
15
  import { IDL } from '../client/ix.js';
16
- import { LOOT_PROGRAM, pda } from '../client/pda.js';
16
+ import { checkOnCluster, cluster, CLUSTERS, clusterRpcUrl, lootProgram, pda, realMoney } from '../client/pda.js';
17
17
  import { check, describe } from '../economy/check.mjs';
18
18
  import { legalFlags } from '../economy/legal.mjs';
19
19
  import { cost, explainCost } from '../economy/cost.mjs';
@@ -73,17 +73,26 @@ export function keyFileFor(name) {
73
73
  }
74
74
  /** The command that makes a project's key file (it prints the key's address, never the key). */
75
75
  export const keygenCommand = keyFile => `npx -y @xgamesfun/solana@${PACKAGE_VERSION} keygen ${keyFile}`;
76
- export const CLUSTER = 'devnet';
77
76
  export const DEVNET_RPC = 'https://api.devnet.solana.com';
78
- /** What every answer carries, so an agent never has to remember the program's address. */
77
+ /** The RPC a tool reads through: the test's own when it gives one, else this process's cluster's (XSOLANA_RPC_URL, or devnet's public RPC). */
78
+ const chainOf = ctx =>
79
+ ctx?.rpc ? ctx.rpc(process.env.XSOLANA_RPC_URL || CLUSTERS[cluster()].rpc) : rpcClient(clusterRpcUrl());
80
+ /**
81
+ * What every answer carries, so an agent never has to remember the program's address. The program and the cluster are
82
+ * read when asked for: this process's cluster (`client/cluster.js`).
83
+ */
79
84
  export const PROGRAM_INFO = Object.freeze({
80
- program: LOOT_PROGRAM,
81
- cluster: CLUSTER,
85
+ get program() {
86
+ return lootProgram();
87
+ },
88
+ get cluster() {
89
+ return cluster();
90
+ },
82
91
  version: VERSION,
83
92
  idl: '@xgamesfun/solana/idl.json',
84
93
  package: `@xgamesfun/solana@${PACKAGE_VERSION}`,
85
94
  });
86
- const footer = `\n\nloot ${VERSION} · program ${LOOT_PROGRAM} · ${CLUSTER}`;
95
+ const footer = () => `\n\nloot ${VERSION} · program ${lootProgram()} · ${cluster()}`;
87
96
  const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
88
97
  const nowOf = ctx => ctx?.now ?? Math.floor(Date.now() / 1000);
89
98
  const cwdOf = ctx => ctx?.cwd ?? process.cwd();
@@ -298,7 +307,7 @@ export const tools = [
298
307
  },
299
308
  async handler({ part } = NO_OPTIONS) {
300
309
  return {
301
- text: guideText(part) + footer,
310
+ text: guideText(part) + footer(),
302
311
  parts: catalog(part),
303
312
  ...(part ? {} : { cannot: CANNOT }),
304
313
  ...PROGRAM_INFO,
@@ -327,7 +336,7 @@ export const tools = [
327
336
  name: ex.name,
328
337
  spec: ex.spec,
329
338
  checks: verdict.ok ? 'clean' : verdict.refusals.map(r => r.code),
330
- text: `${ex.spec.name}: ${ex.spec.prompt ?? ''}\nCopy the spec, change it to what the person asked, then run xsolana_check on it. Existing mints in examples are devnet stand-ins: use the person’s own.${footer}`,
339
+ text: `${ex.spec.name}: ${ex.spec.prompt ?? ''}\nCopy the spec, change it to what the person asked, then run xsolana_check on it. Existing mints in examples are devnet stand-ins: use the person’s own.${footer()}`,
331
340
  };
332
341
  }
333
342
  if (!query) {
@@ -345,7 +354,7 @@ export const tools = [
345
354
  `${all.length} worked examples (xsolana_examples {"name": "<name>"} gives the whole spec):`,
346
355
  ...all.map(e => `- ${e.name}: ${e.title}. ${e.prompt ?? ''} [${e.parts.join(', ')}]`),
347
356
  `\nAnd ${ideas().length} rated economies: pass a query to search them.`,
348
- ].join('\n') + footer,
357
+ ].join('\n') + footer(),
349
358
  };
350
359
  }
351
360
  const r = search(query, { limit, fit });
@@ -370,7 +379,7 @@ export const tools = [
370
379
  ]
371
380
  .filter(Boolean)
372
381
  .join('\n');
373
- return { examples: r.examples, ideas: r.ideas, hows: r.hows, limits: r.limits, text: text + footer };
382
+ return { examples: r.examples, ideas: r.ideas, hows: r.hows, limits: r.limits, text: text + footer() };
374
383
  },
375
384
  },
376
385
  {
@@ -391,7 +400,7 @@ export const tools = [
391
400
  from,
392
401
  refusals: verdict.refusals,
393
402
  warnings: verdict.warnings,
394
- text: `${head}${tail}${footer}`,
403
+ text: `${head}${tail}${footer()}`,
395
404
  };
396
405
  },
397
406
  },
@@ -430,7 +439,7 @@ export const tools = [
430
439
  from,
431
440
  summary,
432
441
  ...(input.json ? { simulation: sim } : {}),
433
- text: explain(sim, economy) + footer,
442
+ text: explain(sim, economy) + footer(),
434
443
  };
435
444
  },
436
445
  },
@@ -458,7 +467,7 @@ export const tools = [
458
467
  setup: c.setup,
459
468
  actions: c.actions,
460
469
  fees: c.fees,
461
- text: head + explainCost(c) + names + footer,
470
+ text: head + explainCost(c) + names + footer(),
462
471
  };
463
472
  },
464
473
  },
@@ -471,7 +480,7 @@ export const tools = [
471
480
  async handler(input, ctx) {
472
481
  const { economy, from } = await load(input, ctx);
473
482
  const r = explainEconomy(economy, { now: nowOf(ctx) });
474
- return { ...r, from, text: r.text + footer };
483
+ return { ...r, from, text: r.text + footer() };
475
484
  },
476
485
  },
477
486
  {
@@ -494,7 +503,7 @@ export const tools = [
494
503
  routes: r.routes,
495
504
  findings: r.findings,
496
505
  tradeoffs: r.tradeoffs,
497
- text: `${describeReview(r, economy.name)}${tail}${footer}`,
506
+ text: `${describeReview(r, economy.name)}${tail}${footer()}`,
498
507
  };
499
508
  },
500
509
  },
@@ -574,7 +583,7 @@ export const tools = [
574
583
  ok: true,
575
584
  file: rel,
576
585
  warnings: verdict.warnings,
577
- text: `Saved ${rel} (${plural(verdict.warnings.length, 'warning')}).${dropped}${spec.live ? ' It is live: xsolana_publish publishes only what changed.' : ' Next: xsolana_publish when the person says to put it live.'}${footer}`,
586
+ text: `Saved ${rel} (${plural(verdict.warnings.length, 'warning')}).${dropped}${spec.live ? ' It is live: xsolana_publish publishes only what changed.' : ' Next: xsolana_publish when the person says to put it live.'}${footer()}`,
578
587
  };
579
588
  },
580
589
  },
@@ -592,7 +601,7 @@ export const tools = [
592
601
  transactions: 0,
593
602
  approvals: 0,
594
603
  steps: [],
595
- text: `${from} is live, and its parts are as they are on chain. ${settingsNote(change.settings, economy.live)}${footer}`,
604
+ text: `${from} is live, and its parts are as they are on chain. ${settingsNote(change.settings, economy.live)}${footer()}`,
596
605
  };
597
606
  if (change.scope.live && !change.changes)
598
607
  return {
@@ -600,7 +609,7 @@ export const tools = [
600
609
  transactions: 0,
601
610
  approvals: 0,
602
611
  steps: [],
603
- text: `${from} is live, and nothing in it has changed since it went live: there is nothing to publish.${change.notes}${footer}`,
612
+ text: `${from} is live, and nothing in it has changed since it went live: there is nothing to publish.${change.notes}${footer()}`,
604
613
  };
605
614
  const p = plan(economy, { now: nowOf(ctx), ...change.scope });
606
615
  if (!p.ok) return refused(p, from);
@@ -622,7 +631,7 @@ export const tools = [
622
631
  transactions: 0,
623
632
  approvals: 0,
624
633
  steps: [],
625
- text: `Not published: ${NOT_PLAIN(target)}, and the approve command carries the file's name as it is. Ask the person to rename it (${RENAME}), then run xsolana_publish again with the new name.${footer}`,
634
+ text: `Not published: ${NOT_PLAIN(target)}, and the approve command carries the file's name as it is. Ask the person to rename it (${RENAME}), then run xsolana_publish again with the new name.${footer()}`,
626
635
  };
627
636
  const live = Boolean(change.scope.live);
628
637
  const keyFile = keyFileFor(economy.name);
@@ -632,7 +641,7 @@ export const tools = [
632
641
  const command = `${approveCommand(target)} --keypair ${keyFile}`;
633
642
  const text = [
634
643
  ...flagsToTell(economy, nowOf(ctx)),
635
- `${change.scope.live ? 'Publishing this change to the live economy' : 'Going live'}: ${renaming ? 'at most ' : ''}${plural(p.transactions.length, 'transaction')}, about ${c.setup.totalSol} SOL on devnet:`,
644
+ `${change.scope.live ? 'Publishing this change to the live economy' : 'Going live'}: ${renaming ? 'at most ' : ''}${plural(p.transactions.length, 'transaction')}, about ${c.setup.totalSol} SOL on ${cluster()}${realMoney() ? ' (real SOL)' : ''}:`,
636
645
  ...steps.map((s, i) => `${i + 1}. ${s}`),
637
646
  '',
638
647
  `${live ? 'To publish it' : 'To put it live'}, in a terminal in the project folder (you run these; nothing opens, and nothing is sent before the person’s yes):`,
@@ -643,13 +652,13 @@ export const tools = [
643
652
  : [
644
653
  `1. A key file for this project, unless it has one: ${keygen}`,
645
654
  ' It prints the key’s address, never the key. The key file is the project’s owner: whoever can read it controls the project, and which key goes live is for good (every later change is signed by it; these tools can’t hand a project to another owner). It stays where it is: never in a chat, a spec, a repository or a page, and you never open, read or print it. Tell the person to back the file up themselves: if it is lost, nothing can change the economy or move what its address holds.',
646
- ` Everything the setup takes from the owner must be at that address first: the devnet SOL (about ${c.setup.totalSol} SOL here; https://faucet.solana.com gives some), any seed in a coin that already exists, and every NFT a stash, a raffle or an auction names (a collection they already have needs that address as its update authority). If they would rather not move those, offer the browser wallet.`,
655
+ ` Everything the setup takes from the owner must be at that address first: ${realMoney() ? `the SOL (about ${c.setup.totalSol} SOL here: this economy is on mainnet, so it is real SOL)` : `the devnet SOL (about ${c.setup.totalSol} SOL here; https://faucet.solana.com gives some)`}, any seed in a coin that already exists, and every NFT a stash, a raffle or an auction names (a collection they already have needs that address as its update authority). If they would rather not move those, offer the browser wallet.`,
647
656
  ' The steps above call the owner’s address “your wallet”: with a key file, that is the key’s address. Unless the spec names a treasury, what sales leave lands there, and it signs what only the authority signs. If the economy takes money in, ask for the public address of a wallet the person holds and set "treasury": "wallet:<address>" first: it can’t be changed through the spec once live.',
648
657
  ]),
649
658
  `2. ${command}`,
650
659
  ' It prints every step with what it pays and trusts, the warnings, the notice (the program is not independently audited and comes as is, with no warranty; they use it at their own risk, and making sure the economy is legal where it is offered is up to them: DISCLAIMER.md in the package) and a code. It sends nothing.',
651
660
  '3. Show the person that printout and ask. When they say yes, run the same command with --yes <code>: the code stands for exactly what was printed, and anything else is refused. What lands is written back into the file. If it stops part way, run it again without --yes: it prints what is left and a new code. Go on with that code; if it shows a step or a warning they have not seen, ask again.',
652
- `${live ? 'For a project a browser wallet owns' : 'Only if the person asks to sign in a browser wallet instead'}: give them this to run in their own terminal: ${browserCommand} (it serves a page on their own computer where they connect a wallet set to devnet and approve, ${plural(p.approvals, 'approval')}, and keeps running until the page says it is live). Don’t run or open it otherwise.${live ? ' A project a key file owns can’t be signed in a browser wallet.' : ''}`,
661
+ `${live ? 'For a project a browser wallet owns' : 'Only if the person asks to sign in a browser wallet instead'}: give them this to run in their own terminal: ${browserCommand} (it serves a page on their own computer where they connect a wallet set to ${cluster()} and approve, ${plural(p.approvals, 'approval')}, and keeps running until the page says it is live). Don’t run or open it otherwise.${live ? ' A project a key file owns can’t be signed in a browser wallet.' : ''}`,
653
662
  ...(file ? [] : [`(Save it first with xsolana_save: \`approve\` reads ${DEFAULT_FILE}.)`]),
654
663
  ...(change.notes ? [change.notes.trim()] : []),
655
664
  ].join('\n');
@@ -663,7 +672,7 @@ export const tools = [
663
672
  approvals: p.approvals,
664
673
  setupSol: c.setup.totalSol,
665
674
  steps,
666
- text: text + footer,
675
+ text: text + footer(),
667
676
  };
668
677
  },
669
678
  },
@@ -671,19 +680,19 @@ export const tools = [
671
680
  name: 'xsolana_status',
672
681
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
673
682
  description:
674
- 'A live economy as it is on chain: the project, every pool’s balance, every record the spec names (found or missing). Reads devnet (or XSOLANA_RPC_URL).',
683
+ 'A live economy as it is on chain: the project, every pool’s balance, every record the spec names (found or missing). Reads the economy’s own cluster: devnet through its public RPC (or XSOLANA_RPC_URL), mainnet through XSOLANA_RPC_URL, which must then name a mainnet RPC.',
675
684
  inputSchema: { type: 'object', properties: { spec: SOURCE.spec, file: SOURCE.file }, additionalProperties: false },
676
685
  async handler(input, ctx) {
677
686
  const { economy, from } = await load(input, ctx);
678
687
  if (!economy.live?.project)
679
688
  return {
680
689
  ok: false,
681
- text: `${from} is not live yet (no "live" section). xsolana_publish says how to put it live.${footer}`,
690
+ text: `${from} is not live yet (no "live" section). xsolana_publish says how to put it live.${footer()}`,
682
691
  };
683
692
  const m = model(economy);
684
- // Devnet only: a mainnet URL is refused, and so is a chain whose first block is mainnet's or testnet's.
685
- const rpc = (ctx?.rpc ?? rpcClient)(notMainnet(process.env.XSOLANA_RPC_URL || DEVNET_RPC));
686
- if (!ctx?.rpc) await checkCluster(rpc);
693
+ // This process's cluster only: a URL that names another is refused, and so is a chain whose first block is another's.
694
+ const rpc = chainOf(ctx);
695
+ if (!ctx?.rpc) await checkOnCluster(rpc);
687
696
  const project = economy.live.project;
688
697
  const wanted = [
689
698
  ['project', project],
@@ -720,7 +729,7 @@ export const tools = [
720
729
  a.data?.length >= 72 && (part.startsWith('pool:') || part.includes(' (pot '))
721
730
  ? Number(new DataView(a.data.buffer, a.data.byteOffset).getBigUint64(64, true))
722
731
  : undefined;
723
- const decoded = a.owner === LOOT_PROGRAM ? decodeAny(a.data) : null;
732
+ const decoded = a.owner === lootProgram() ? decodeAny(a.data) : null;
724
733
  // A stash: how many NFTs it still holds to give away.
725
734
  const nfts = decoded?.kind === 'Stash' ? decoded.value.nfts.length : undefined;
726
735
  return {
@@ -734,7 +743,7 @@ export const tools = [
734
743
  });
735
744
  const missing = rows.filter(r => !r.found);
736
745
  const text = [
737
- `${economy.name ?? 'The economy'} on ${CLUSTER}: project ${project}.`,
746
+ `${economy.name ?? 'The economy'} on ${cluster()}: project ${project}.`,
738
747
  ...rows.map(
739
748
  r =>
740
749
  `- ${r.part}: ${r.found ? `found${r.amount !== undefined ? `, holds ${r.amount} base units` : ''}${r.nfts !== undefined ? `, holds ${r.nfts} NFT${r.nfts === 1 ? '' : 's'} to give away` : ''}${r.sol !== undefined ? `, holds ${r.sol} SOL for claims` : ''}` : 'missing'} (${r.address})`,
@@ -745,7 +754,7 @@ export const tools = [
745
754
  ]
746
755
  .filter(Boolean)
747
756
  .join('\n');
748
- return { ok: true, rows, text: text + footer };
757
+ return { ok: true, rows, text: text + footer() };
749
758
  },
750
759
  },
751
760
  {
@@ -778,12 +787,12 @@ export const tools = [
778
787
  } catch {
779
788
  return {
780
789
  ok: false,
781
- text: `${JSON.stringify(mint)} is not a Solana address. Give the token’s mint address.${footer}`,
790
+ text: `${JSON.stringify(mint)} is not a Solana address. Give the token’s mint address.${footer()}`,
782
791
  };
783
792
  }
784
- // Devnet only: a mainnet URL is refused, and so is a chain whose first block is mainnet's or testnet's.
785
- const rpc = (ctx?.rpc ?? rpcClient)(notMainnet(process.env.XSOLANA_RPC_URL || DEVNET_RPC));
786
- if (!ctx?.rpc) await checkCluster(rpc);
793
+ // This process's cluster only: a URL that names another is refused, and so is a chain whose first block is another's.
794
+ const rpc = chainOf(ctx);
795
+ if (!ctx?.rpc) await checkOnCluster(rpc);
787
796
  let project = null;
788
797
  if (input.project !== undefined) {
789
798
  project = String(input.project).trim();
@@ -792,7 +801,7 @@ export const tools = [
792
801
  } catch {
793
802
  return {
794
803
  ok: false,
795
- text: `${JSON.stringify(project)} is not a Solana address. Give the project’s address, or leave it out.${footer}`,
804
+ text: `${JSON.stringify(project)} is not a Solana address. Give the project’s address, or leave it out.${footer()}`,
796
805
  };
797
806
  }
798
807
  }
@@ -815,15 +824,17 @@ export const tools = [
815
824
  ok: false,
816
825
  text: `${
817
826
  account
818
- ? `${mint} is not a token mint on ${CLUSTER} (a token account or a wallet is not the mint: an explorer’s token page names it)`
819
- : `There is nothing at ${mint} on ${CLUSTER}. A coin launched on mainnet is not on ${CLUSTER}, where loot runs for now`
820
- }.${footer}`,
827
+ ? `${mint} is not a token mint on ${cluster()} (a token account or a wallet is not the mint: an explorer’s token page names it)`
828
+ : cluster() === 'devnet'
829
+ ? `There is nothing at ${mint} on devnet. A coin launched on mainnet is not on devnet: this reads the cluster of the economy in this folder, devnet unless its file says "network": "mainnet"`
830
+ : `There is nothing at ${mint} on ${cluster()}. A coin made on devnet is not on ${cluster()}`
831
+ }.${footer()}`,
821
832
  };
822
833
  if (token.source === 'sol')
823
834
  return {
824
835
  ok: true,
825
836
  token,
826
- text: `${mint} is wrapped SOL: in a spec that is SOL itself.\n\nIn the spec’s "tokens":\n${JSON.stringify(token)}${footer}`,
837
+ text: `${mint} is wrapped SOL: in a spec that is SOL itself.\n\nIn the spec’s "tokens":\n${JSON.stringify(token)}${footer()}`,
827
838
  };
828
839
  const read = parseMint(mint, account.owner, account.data, record?.data ?? null);
829
840
  const supply = Number(BigInt(read.supply)) / 10 ** read.decimals;
@@ -863,7 +874,7 @@ export const tools = [
863
874
  `Its mint authority is not the project given (${project}): until it is handed over, nothing the economy mints in it lands.`,
864
875
  ].filter(Boolean);
865
876
  const text = [
866
- `The token at ${mint} on ${CLUSTER}: ${read.program === 'token-2022' ? 'a Token-2022' : 'a classic SPL'} token, ${plural(read.decimals, 'decimal')}, ${supply.toLocaleString('en-US')} in existence${p.mintAuthority === 'none' ? ', and nobody can mint more' : ''}. It ${called}.`,
877
+ `The token at ${mint} on ${cluster()}: ${read.program === 'token-2022' ? 'a Token-2022' : 'a classic SPL'} token, ${plural(read.decimals, 'decimal')}, ${supply.toLocaleString('en-US')} in existence${p.mintAuthority === 'none' ? ', and nobody can mint more' : ''}. It ${called}.`,
867
878
  can.length
868
879
  ? `Whoever controls its mint can:\n${can.map(c => `- ${c}`).join('\n')}\nAn economy in it is only as safe as the token: say so where players pay, stake or are paid.`
869
880
  : `Its mint has no key that can freeze it, take it, charge on it or stop a transfer${p.mintAuthority === 'project' ? ' (the project mints it)' : ''}.`,
@@ -883,7 +894,7 @@ export const tools = [
883
894
  mintAuthority: read.mintAuthority,
884
895
  freezeAuthority: read.freezeAuthority,
885
896
  },
886
- text: text + footer,
897
+ text: text + footer(),
887
898
  };
888
899
  },
889
900
  },
@@ -895,7 +906,7 @@ export const tools = [
895
906
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
896
907
  async handler() {
897
908
  const text = [
898
- `loot ${VERSION} on ${CLUSTER}: program ${LOOT_PROGRAM}. Never write another address from memory: take it from this package.`,
909
+ `loot ${VERSION} on ${cluster()}: program ${lootProgram()}. Never write another address from memory: take it from this package.`,
899
910
  'Package: npm install @xgamesfun/solana (plain ES modules, no Solana SDK needed; works in the browser).',
900
911
  ' What only the project’s owner or authority signs (a contest’s result, a payout, a schedule’s run) is not a page’s: a script on the owner’s machine signs it with the project’s key file, `readKeyFile(file)` from "@xgamesfun/solana/keyfile" (an address and a way to sign, never the key; it is also a `wallet` for solanaApp). The skill’s references/after-launch.md has the script.',
901
912
  ' import { solanaApp, walletStandardSigner } from "@xgamesfun/solana/play";',
@@ -941,7 +952,7 @@ export const tools = [
941
952
  'A page builds the instruction, puts it in a transaction, and asks the player’s wallet (Wallet Standard: Phantom, Solflare, Backpack) to sign and send it. The app never holds a key.',
942
953
  'Reads: the project’s records are plain accounts (loot.decode); every move leaves a receipt in the transaction’s logs (loot.receipts(tx)). A backend delivers only from a finalized transaction whose meta.err is null, once per order or reference, and only for a receipt whose data.project is its own (loot.receipts reads every project’s, and anyone can make a project): a failed transaction keeps its logs, and they can read as all-success.',
943
954
  ].join('\n');
944
- return { text: text + footer, ...PROGRAM_INFO };
955
+ return { text: text + footer(), ...PROGRAM_INFO };
945
956
  },
946
957
  },
947
958
  ];
@@ -0,0 +1,25 @@
1
+ // For a server that answers for both clusters in one process (Node): `withCluster(cluster, fn)` runs `fn`, and
2
+ // everything it awaits, with the client on that cluster. Every address derived, instruction built and voucher signed
3
+ // inside it is that cluster's, whatever the process's own cluster is, and two pieces of work on two clusters can run
4
+ // at the same time without touching each other. Outside any, the client is on the process's own cluster
5
+ // (js/client/cluster.js, which stays free of Node so a page can load it: this file is the Node half).
6
+ //
7
+ // Importing this file sets it up: import it once, before the first request is answered.
8
+ import { AsyncLocalStorage } from 'node:async_hooks';
9
+ import { CLUSTERS, scopeClusters } from '../client/cluster.js';
10
+
11
+ const work = new AsyncLocalStorage();
12
+ scopeClusters(() => work.getStore());
13
+
14
+ /**
15
+ * Runs `fn` on `cluster` (`devnet` or `mainnet`) and returns what it returns. A cluster loot does not know is refused.
16
+ * @template T
17
+ * @param {'devnet' | 'mainnet'} cluster
18
+ * @param {() => T} fn
19
+ * @returns {T}
20
+ */
21
+ export function withCluster(cluster, fn) {
22
+ if (!Object.hasOwn(CLUSTERS, cluster))
23
+ throw new Error(`"${cluster}" is not a cluster loot knows: ${Object.keys(CLUSTERS).join(' or ')}.`);
24
+ return work.run(cluster, fn);
25
+ }