@xgamesfun/solana 0.0.0-stage → 0.20.1

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 (134) hide show
  1. package/DISCLAIMER.md +41 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +13 -0
  4. package/README.md +172 -2
  5. package/bin/xsolana.mjs +189 -0
  6. package/examples/artwork-auction.json +51 -0
  7. package/examples/blind-forge.json +128 -0
  8. package/examples/coin-flip-arena.json +108 -0
  9. package/examples/duel-arena.json +137 -0
  10. package/examples/elements-set.json +281 -0
  11. package/examples/event-badges.json +67 -0
  12. package/examples/expedition-guild.json +161 -0
  13. package/examples/fair-presale.json +48 -0
  14. package/examples/falling-drop.json +90 -0
  15. package/examples/guild-missions.json +144 -0
  16. package/examples/halving-seasons.json +112 -0
  17. package/examples/hero-trials.json +96 -0
  18. package/examples/ideas.json +789 -0
  19. package/examples/level-ladder.json +65 -0
  20. package/examples/merge-2048.json +128 -0
  21. package/examples/nft-raffle.json +33 -0
  22. package/examples/paper-hands-farm.json +56 -0
  23. package/examples/partner-vault.json +115 -0
  24. package/examples/sacrifice-raffle.json +118 -0
  25. package/examples/scorched-treasury.json +84 -0
  26. package/examples/shelter-fair.json +164 -0
  27. package/examples/spark-pity.json +153 -0
  28. package/examples/stakers-draw.json +125 -0
  29. package/examples/star-fleet.json +157 -0
  30. package/examples/starforce.json +116 -0
  31. package/examples/stud-farm.json +134 -0
  32. package/examples/team-vesting.json +70 -0
  33. package/examples/trade-in-shop.json +168 -0
  34. package/js/approve/details.mjs +262 -0
  35. package/js/approve/ed25519.mjs +36 -0
  36. package/js/approve/live-check.mjs +144 -0
  37. package/js/approve/page.html +86 -0
  38. package/js/approve/page.js +203 -0
  39. package/js/approve/server.mjs +711 -0
  40. package/js/chain/base58.js +73 -0
  41. package/js/chain/borsh.js +261 -0
  42. package/js/chain/bytes.js +141 -0
  43. package/js/chain/core.js +169 -0
  44. package/js/chain/curve.js +52 -0
  45. package/js/chain/lookup.js +273 -0
  46. package/js/chain/mint.js +96 -0
  47. package/js/chain/pda.js +66 -0
  48. package/js/chain/programs.js +102 -0
  49. package/js/chain/rpc.js +217 -0
  50. package/js/chain/sha256.js +68 -0
  51. package/js/chain/token.js +85 -0
  52. package/js/chain/token2022.js +247 -0
  53. package/js/chain/tx.js +224 -0
  54. package/js/client/accounts.js +347 -0
  55. package/js/client/compressed.js +46 -0
  56. package/js/client/grant.js +240 -0
  57. package/js/client/idl/loot.js +2 -0
  58. package/js/client/idl/loot.json +17064 -0
  59. package/js/client/index.js +9 -0
  60. package/js/client/ix.js +2156 -0
  61. package/js/client/pda.js +110 -0
  62. package/js/client/policy.js +48 -0
  63. package/js/client/sizes.js +54 -0
  64. package/js/client/traits.js +44 -0
  65. package/js/connect/connect.mjs +182 -0
  66. package/js/economy/check.mjs +1991 -0
  67. package/js/economy/cost.mjs +170 -0
  68. package/js/economy/examples.mjs +114 -0
  69. package/js/economy/explain.mjs +235 -0
  70. package/js/economy/guide.mjs +231 -0
  71. package/js/economy/legal.mjs +15 -0
  72. package/js/economy/live.mjs +107 -0
  73. package/js/economy/model.mjs +583 -0
  74. package/js/economy/plan.mjs +887 -0
  75. package/js/economy/schema.json +2923 -0
  76. package/js/economy/secrets.mjs +60 -0
  77. package/js/economy/simulate.mjs +952 -0
  78. package/js/economy/txsize.mjs +45 -0
  79. package/js/economy/validate.mjs +85 -0
  80. package/js/index.js +10 -0
  81. package/js/mcp/server.mjs +113 -0
  82. package/js/mcp/tools.mjs +479 -0
  83. package/js/play/index.js +2109 -0
  84. package/js/results/index.js +184 -0
  85. package/js/results/wire.js +90 -0
  86. package/package.json +100 -4
  87. package/release.json +8 -0
  88. package/skills/xsolana-economy/SKILL.md +105 -0
  89. package/skills/xsolana-economy/references/after-launch.md +114 -0
  90. package/skills/xsolana-economy/references/apps.md +157 -0
  91. package/skills/xsolana-economy/references/sentence-to-spec.md +172 -0
  92. package/skills/xsolana-economy/references/telling-the-person.md +93 -0
  93. package/types/chain/base58.d.ts +10 -0
  94. package/types/chain/borsh.d.ts +23 -0
  95. package/types/chain/bytes.d.ts +45 -0
  96. package/types/chain/core.d.ts +50 -0
  97. package/types/chain/curve.d.ts +1 -0
  98. package/types/chain/lookup.d.ts +191 -0
  99. package/types/chain/mint.d.ts +25 -0
  100. package/types/chain/pda.d.ts +25 -0
  101. package/types/chain/programs.d.ts +65 -0
  102. package/types/chain/rpc.d.ts +66 -0
  103. package/types/chain/sha256.d.ts +1 -0
  104. package/types/chain/token.d.ts +141 -0
  105. package/types/chain/token2022.d.ts +53 -0
  106. package/types/chain/tx.d.ts +60 -0
  107. package/types/client/accounts.d.ts +178 -0
  108. package/types/client/compressed.d.ts +29 -0
  109. package/types/client/grant.d.ts +125 -0
  110. package/types/client/idl/loot.d.ts +4467 -0
  111. package/types/client/index.d.ts +8 -0
  112. package/types/client/ix.d.ts +1687 -0
  113. package/types/client/pda.d.ts +91 -0
  114. package/types/client/policy.d.ts +23 -0
  115. package/types/client/sizes.d.ts +13 -0
  116. package/types/client/traits.d.ts +12 -0
  117. package/types/economy/check.d.mts +105 -0
  118. package/types/economy/cost.d.mts +68 -0
  119. package/types/economy/examples.d.mts +27 -0
  120. package/types/economy/explain.d.mts +19 -0
  121. package/types/economy/guide.d.mts +140 -0
  122. package/types/economy/legal.d.mts +12 -0
  123. package/types/economy/live.d.mts +109 -0
  124. package/types/economy/model.d.mts +363 -0
  125. package/types/economy/plan.d.mts +1013 -0
  126. package/types/economy/secrets.d.mts +4 -0
  127. package/types/economy/simulate.d.mts +156 -0
  128. package/types/economy/txsize.d.mts +17 -0
  129. package/types/economy/validate.d.mts +3 -0
  130. package/types/index.d.ts +9 -0
  131. package/types/mcp/tools.d.mts +627 -0
  132. package/types/play/index.d.ts +469 -0
  133. package/types/results/index.d.ts +155 -0
  134. package/types/results/wire.d.ts +122 -0
@@ -0,0 +1,479 @@
1
+ // The xsolana tools: what an agent needs to turn a person's idea into an economy on the shared loot program, check it,
2
+ // simulate it, price it, explain it, save it and hand the person the step that puts it live in their own wallet.
3
+ // Each tool is { name, description, inputSchema, annotations, handler(input, ctx) → { text, ...data } }; the server
4
+ // (server.mjs) and the terminal (`xsolana tool`) run them the same way. No tool signs, holds a key or talks to a wallet.
5
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
6
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { checkCluster, notMainnet, rpcClient } from '../chain/rpc.js';
9
+ import { decodeAny } from '../client/accounts.js';
10
+ import { IDL } from '../client/ix.js';
11
+ import { LOOT_PROGRAM, pda } from '../client/pda.js';
12
+ import { check, describe } from '../economy/check.mjs';
13
+ import { legalFlags } from '../economy/legal.mjs';
14
+ import { cost, explainCost } from '../economy/cost.mjs';
15
+ import { changedParts } from '../economy/live.mjs';
16
+ import { example, examples, ideas, search, partsUsed } from '../economy/examples.mjs';
17
+ import { explainEconomy } from '../economy/explain.mjs';
18
+ import { CANNOT, catalog, guideText, PARTS } from '../economy/guide.mjs';
19
+ import { model, rent as rentOf } from '../economy/model.mjs';
20
+ import { plan } from '../economy/plan.mjs';
21
+ import { explain, simulate } from '../economy/simulate.mjs';
22
+ import { findSecrets } from '../economy/secrets.mjs';
23
+ import { SCHEMA } from '../economy/validate.mjs';
24
+
25
+ // The options of a call made without any: read-only, and typed so the generated declarations take any option.
26
+ const NO_OPTIONS = Object.freeze(/** @type {Record<string, any>} */ ({}));
27
+
28
+ export const VERSION = IDL.metadata.version;
29
+ /** This package's exact version: every command the tools hand a person names it, so npx runs this very release. */
30
+ export const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
31
+ /** A name xsolana_save writes: parts of letters, digits and . _ - (none starting with - ), split by /: a name the approve command can carry as one word. */
32
+ const SAVE_NAME = /^(?:[A-Za-z0-9._][A-Za-z0-9._-]*\/)*[A-Za-z0-9._][A-Za-z0-9._-]*$/;
33
+ /**
34
+ * A file name relative to the project folder as the approve command carries it: its folders split by / on every
35
+ * system (Windows' economy\xsolana.json too), or null when it is not plain by xsolana_save's rule. A name is never quoted
36
+ * (a quoted word is a POSIX shell's rule; PowerShell, cmd.exe and fish read the same quotes another way and would run
37
+ * the code planted in the name), so a plain one is one word, the same in every shell, and never an option.
38
+ */
39
+ export function plainName(rel, paths = path) {
40
+ const name = String(rel).split(paths.sep).join('/');
41
+ return SAVE_NAME.test(name) ? name : null;
42
+ }
43
+ const NOT_PLAIN = (rel) => `${JSON.stringify(String(rel))} is not a plain file name`;
44
+ const RENAME = 'use letters, digits and . _ - (and / for a folder), as in xsolana.json';
45
+ /**
46
+ * The command an owner runs to approve going live in their own wallet, pinned to this package's exact version, with
47
+ * the spec's file (relative to the project folder, by `paths`' rules: the system's own unless given) as one plain
48
+ * word. A name that is not plain throws.
49
+ */
50
+ export function approveCommand(file = '', paths = path) {
51
+ const name = file ? plainName(file, paths) : '';
52
+ if (name === null) throw new Error(`${NOT_PLAIN(file)}: rename it (${RENAME}).`);
53
+ return `npx -y @xgamesfun/solana@${PACKAGE_VERSION} approve${name ? ` ${name}` : ''}`;
54
+ }
55
+ export const CLUSTER = 'devnet';
56
+ export const DEVNET_RPC = 'https://api.devnet.solana.com';
57
+ /** What every answer carries, so an agent never has to remember the program's address. */
58
+ export const PROGRAM_INFO = Object.freeze({ program: LOOT_PROGRAM, cluster: CLUSTER, version: VERSION, idl: '@xgamesfun/solana/idl.json', package: `@xgamesfun/solana@${PACKAGE_VERSION}` });
59
+ const footer = `\n\nloot ${VERSION} · program ${LOOT_PROGRAM} · ${CLUSTER}`;
60
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
61
+ const nowOf = (ctx) => ctx?.now ?? Math.floor(Date.now() / 1000);
62
+ const cwdOf = (ctx) => ctx?.cwd ?? process.cwd();
63
+ export const DEFAULT_FILE = 'xsolana.json';
64
+
65
+ /** The real path of `p`, or of the nearest folder above it that exists, with the rest added back. */
66
+ function realPath(p) {
67
+ try {
68
+ return realpathSync(p);
69
+ } catch {
70
+ const up = path.dirname(p);
71
+ return up === p ? p : path.join(realPath(up), path.basename(p));
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The spec file `rel` names, inside the project folder: a `.json` file there or in a folder below it. Anything else (an
77
+ * absolute path elsewhere, `..`, a link out of the folder, another kind of file) is refused.
78
+ */
79
+ export function projectFile(rel, ctx) {
80
+ const root = realPath(path.resolve(cwdOf(ctx)));
81
+ const file = path.resolve(root, String(rel));
82
+ const inside = (p) => {
83
+ const r = path.relative(root, p);
84
+ return r !== '' && !r.startsWith('..') && !path.isAbsolute(r);
85
+ };
86
+ if (!inside(file) || !inside(realPath(file))) throw new Error(`${rel} is outside the project folder (${root}): a spec file must be inside it.`);
87
+ if (path.extname(file).toLowerCase() !== '.json') throw new Error(`${rel} is not a .json file: a spec file is JSON.`);
88
+ return file;
89
+ }
90
+
91
+ /** The spec's own top-level keys (schema.json). */
92
+ const SPEC_KEYS = new Set(Object.keys(SCHEMA.properties));
93
+ const plainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
94
+ /** A file's spec: the JSON as it is, or what it holds under `economy` when that is its only key. */
95
+ const specOf = (value) => (plainObject(value) && Object.keys(value).length === 1 && plainObject(value.economy) ? value.economy : value);
96
+ /** The spec to read: what a file holds under `economy` (a game file keeps it beside its own keys), else the JSON. */
97
+ const economyOf = (value) => (plainObject(value) && plainObject(value.economy) ? value.economy : value);
98
+ /**
99
+ * Whether a file's JSON is an economy spec, so xsolana_save may write over it: version 2 with its tokens (every spec that
100
+ * checks has them), a currency if it names one (a spec of one token checks without: that token is its currency)
101
+ * and nothing but the spec's keys, as it is or as the only key of a file, `economy` (a file with other keys beside it
102
+ * would lose them). Every spec xsolana_save writes passes.
103
+ */
104
+ export function isSpec(value) {
105
+ const s = specOf(value);
106
+ return plainObject(s) && s.version === 2 && (s.currency === undefined || typeof s.currency === 'string') && Array.isArray(s.tokens) && Object.keys(s).every((k) => SPEC_KEYS.has(k));
107
+ }
108
+
109
+ // ---------------------------------------------------------------- where a spec comes from
110
+
111
+ const SOURCE = {
112
+ spec: { type: 'object', description: 'An economy spec (version 2) given inline. Takes precedence over file and example.' },
113
+ file: { type: 'string', description: `A JSON file holding the spec, relative to the project folder (default: ${DEFAULT_FILE} when it exists).` },
114
+ example: { type: 'string', description: 'A worked example by name, from xsolana_examples (coin-flip-arena, stud-farm, …).' },
115
+ };
116
+
117
+ async function load(input, ctx) {
118
+ if (input.spec) return { economy: economyOf(input.spec), from: 'the spec given' };
119
+ if (input.example) return { economy: example(input.example).spec, from: `the example ${input.example}` };
120
+ const rel = input.file ?? DEFAULT_FILE;
121
+ const file = projectFile(rel, ctx);
122
+ if (!existsSync(file)) {
123
+ if (input.file) throw new Error(`${rel} does not exist.`);
124
+ throw new Error(`There is no ${DEFAULT_FILE} in ${cwdOf(ctx)} yet. Write a spec (xsolana_guide, xsolana_examples) and pass it as "spec", or save it with xsolana_save.`);
125
+ }
126
+ let value;
127
+ try {
128
+ value = JSON.parse(await readFile(file, 'utf8'));
129
+ } catch (e) {
130
+ // The parser's message quotes the file: say only where it stopped.
131
+ const at = /position \d+/.exec(e.message)?.[0];
132
+ throw new Error(`${rel} is not valid JSON${at ? ` (at ${at})` : ''}.`, { cause: e });
133
+ }
134
+ return { economy: economyOf(value), from: rel, file };
135
+ }
136
+
137
+ const refused = (verdict, from) => ({
138
+ ok: false,
139
+ from,
140
+ refusals: verdict.refusals,
141
+ warnings: verdict.warnings,
142
+ text: `${describe({ refusals: verdict.refusals, warnings: [] })}\n\n${plural(verdict.refusals.length, 'refusal')} in ${from}: fix ${verdict.refusals.length === 1 ? 'it' : 'them'} (each says how), then run xsolana_check again.`,
143
+ });
144
+
145
+ const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
146
+
147
+ /**
148
+ * Carries the program numbers of a live spec's parts (by key) into a new version of it, and its `live` section. Only
149
+ * the file on disk can have a `live` section (`xsolana approve` writes it as the setup lands): one the new version brings
150
+ * is dropped when the file had none, and replaced by the file's when it had one.
151
+ */
152
+ export function carryIds(previous, next) {
153
+ if (!previous?.live) {
154
+ const { live: _, ...rest } = next;
155
+ return rest;
156
+ }
157
+ const out = { ...next, live: previous.live };
158
+ const carry = (list, fields) => {
159
+ if (!next[list]) return;
160
+ const old = new Map((previous[list] ?? []).map((p) => [p.key, p]));
161
+ out[list] = next[list].map((p) => {
162
+ const was = old.get(p.key);
163
+ if (!was) return p;
164
+ const ids = Object.fromEntries(fields.filter((f) => was[f] !== undefined && p[f] === undefined).map((f) => [f, was[f]]));
165
+ return { ...p, ...ids };
166
+ });
167
+ };
168
+ carry('items', ['id', 'shopBox']);
169
+ carry('pools', ['index']);
170
+ for (const list of ['stashes', 'boxes', 'recipes', 'schedules', 'lotteries', 'grants']) carry(list, ['id']);
171
+ // A farm that pays nothing keeps the number of the empty pool made for it; an auction keeps the seller its address names.
172
+ carry('farms', ['id', 'rewardsIndex']);
173
+ carry('auctions', ['id', 'seller']);
174
+ return out;
175
+ }
176
+
177
+ // ---------------------------------------------------------------- the tools
178
+
179
+ /** The legal flags to pass on before the person approves, as lines for an answer: [] when there are none. */
180
+ function flagsToTell(economy, now) {
181
+ const flags = legalFlags(check(economy, { now }));
182
+ return flags.length ? ['Before anything else, tell the person, in plain words (the approval page shows these too, and asks them to tick that they read them):', ...flags.map((f) => `- ${f.says}`), ''] : [];
183
+ }
184
+
185
+ /**
186
+ * A spec that is already live publishes only what changed (as the approve page does): the plan's scope, and in words
187
+ * what can't change or was taken out.
188
+ */
189
+ function liveChange(economy) {
190
+ if (!economy.live?.project || !economy.live?.published) return { scope: {}, notes: '', settings: [] };
191
+ const { only, fixed, removed, settings } = changedParts(economy);
192
+ const notes = [
193
+ settings.length ? `\n${settingsNote(settings, economy.live)}` : '',
194
+ fixed.length ? `\nThese can't change once live, and stay as they were: ${fixed.join(', ')} (a farm, an auction or a pool's token).` : '',
195
+ removed.length ? `\nTaken out of the spec but still on chain: ${removed.join(', ')}. To stop one, keep it and set "active": false.` : '',
196
+ ].join('');
197
+ return { scope: { only, live: economy.live }, notes, settings };
198
+ }
199
+
200
+ /** In words: the spec changes the project's own settings, which publishing never writes. */
201
+ export const settingsNote = (settings, live) =>
202
+ `The spec changes the project’s ${settings.join(', ')}, and publishing never changes those: on chain the authority stays ${live.authority ?? 'as it was'} and the treasury ${live.treasury ?? 'as it was'}. Changing them takes the owner’s update_project, which loot does not send yet; until then, put them back as they were in the spec.`;
203
+
204
+ export const tools = [
205
+ {
206
+ name: 'xsolana_guide',
207
+ annotations: READ,
208
+ description:
209
+ 'Read this first whenever someone wants a web3 economy, game economy, token economy, NFT mechanic or onchain reward system: a shop, loot boxes or packs, gacha, a lottery or raffle, an auction (highest bid wins, the outbid refunded at once), staking or a farm, crafting or merging, seasons and emissions, a prediction pool or contest, quests and airdrops, holder-gated drops, using NFTs of any other collection in a game. loot is ONE shared Solana program (devnet until it is audited): the economy is a JSON spec, not a new smart contract, so never write Anchor/Rust for what it covers. Returns every part an economy can have (fields from the schema, who acts at play time, fees), how to write a spec, and what loot cannot do (with the nearest alternative). Pass `part` for one part with its nested shapes.',
210
+ inputSchema: { type: 'object', properties: { part: { type: 'string', enum: Object.keys(PARTS), description: 'One part, with its nested shapes.' } }, additionalProperties: false },
211
+ async handler({ part } = NO_OPTIONS) {
212
+ return { text: guideText(part) + footer, parts: catalog(part), ...(part ? {} : { cannot: CANNOT }), ...PROGRAM_INFO };
213
+ },
214
+ },
215
+ {
216
+ name: 'xsolana_examples',
217
+ annotations: READ,
218
+ description:
219
+ `Find an economy to start from. With \`query\` (the person’s own words, as they said them), returns the closest worked examples (whole specs that check and go live in the tests), the closest of ${ideas().length} real or trending economies rated against loot (great / some / a-lot / cannot, with what carries it or what is missing), and what loot can’t do exactly in the ask, each with the nearest versions it can. Call it again with the words of any part that looks impossible: the answer is a nearest version to offer, not a dead end. With \`name\`, returns that example’s whole spec to copy and change. With neither, lists every example.`,
220
+ inputSchema: {
221
+ type: 'object',
222
+ properties: {
223
+ query: { type: 'string', description: 'What the person wants, in a few words.' },
224
+ name: { type: 'string', description: 'An example’s name, to get its whole spec.' },
225
+ fit: { type: 'string', enum: ['great', 'some', 'a-lot', 'cannot'], description: 'Only ideas with this fit.' },
226
+ limit: { type: 'integer', minimum: 1, maximum: 50, description: 'How many of each kind (default 6).' },
227
+ },
228
+ additionalProperties: false,
229
+ },
230
+ async handler({ query, name, fit, limit = 6 } = NO_OPTIONS, ctx) {
231
+ if (name) {
232
+ const ex = example(name);
233
+ const verdict = check(ex.spec, { now: nowOf(ctx) });
234
+ return { name: ex.name, spec: ex.spec, checks: verdict.ok ? 'clean' : verdict.refusals.map((r) => r.code), 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}` };
235
+ }
236
+ if (!query) {
237
+ const all = examples().map(({ name: n, spec }) => ({ name: n, title: spec.name, prompt: spec.prompt, parts: partsUsed(spec) }));
238
+ return { examples: all, ideas: { count: ideas().length }, text: [`${all.length} worked examples (xsolana_examples {"name": "<name>"} gives the whole spec):`, ...all.map((e) => `- ${e.name}: ${e.title}. ${e.prompt ?? ''} [${e.parts.join(', ')}]`), `\nAnd ${ideas().length} rated economies: pass a query to search them.`].join('\n') + footer };
239
+ }
240
+ const r = search(query, { limit, fit });
241
+ const text = [
242
+ r.examples.length ? `Examples closest to “${query}” (xsolana_examples {"name": …} gives the whole spec):` : `No worked example matches “${query}”. Start from the nearest idea below, or from xsolana_guide.`,
243
+ ...r.examples.map((e) => `- ${e.name}: ${e.title}. ${e.prompt ?? ''} [${e.parts.join(', ')}]`),
244
+ r.hows.length ? '\nThese look impossible but are one field or a short combination (build them):' : '',
245
+ ...r.hows.map((h) => `- ${h.what}: ${h.how}.`),
246
+ r.ideas.length ? '\nRated economies (rated against this version of loot: a rating is a hint, the parts in xsolana_guide decide):' : '',
247
+ ...r.ideas.map((i) => `- #${i.n} ${i.name} (${i.fit}): ${i.how}`),
248
+ r.limits.length ? '\nWhat no part of loot does here, and the nearest versions (first check that no combination of parts does it; then offer these, best first, with their numbers from xsolana_cost; never end on “it can’t”):' : '',
249
+ ...r.limits.flatMap((l) => [`- ${l.what}: ${l.why}.`, ...l.nearest.map((n, i) => ` ${i + 1}. ${n}`)]),
250
+ r.ideas.some((i) => i.fit === 'cannot') ? '\nA "cannot" means a defining piece was missing when it was rated: check that piece against xsolana_guide’s parts and their combinations before telling the person, then offer the nearest version.' : '',
251
+ ].filter(Boolean).join('\n');
252
+ return { examples: r.examples, ideas: r.ideas, hows: r.hows, limits: r.limits, text: text + footer };
253
+ },
254
+ },
255
+ {
256
+ name: 'xsolana_check',
257
+ annotations: READ,
258
+ description:
259
+ 'Check an economy spec: refusals (each with a code, what it means and its fix) stop it from going live; warnings are what the person must hear (chance that pays money, revenue paid to stakers, an unfunded prize, a grant without limits, a mint’s powers). Run it after every change. Give the spec inline (`spec`), a file, or an example.',
260
+ inputSchema: { type: 'object', properties: SOURCE, additionalProperties: false },
261
+ async handler(input, ctx) {
262
+ const { economy, from } = await load(input, ctx);
263
+ const verdict = check(economy, { now: nowOf(ctx) });
264
+ const head = verdict.refusals.length || verdict.warnings.length ? describe(verdict) : 'No problems found.';
265
+ const tail = verdict.ok ? `\nReady (${plural(verdict.warnings.length, 'warning')}). Next: xsolana_simulate and xsolana_explain, and tell the person what it means in plain words.` : `\n${plural(verdict.refusals.length, 'refusal')}: fix each as its fix says, then check again.`;
266
+ return { ok: verdict.ok, from, refusals: verdict.refusals, warnings: verdict.warnings, text: `${head}${tail}${footer}` };
267
+ },
268
+ },
269
+ {
270
+ name: 'xsolana_simulate',
271
+ annotations: READ,
272
+ description:
273
+ 'The quick simulation (expected values from the spec, instant): what each box, ticket and craft gives back against its price, the house edge, each pool’s money in and out per day and whether it runs dry, settles or grows, farm rewards per unit staked, what players, the treasury and the platform get each day. `scenario` overrides the spec’s own ({"perDay": {"box:flip": 1000}, "days": 30, "staked": {"<farm>": 1000}}). Quote its numbers; never your own arithmetic.',
274
+ inputSchema: { type: 'object', properties: { ...SOURCE, scenario: { type: 'object', description: 'What to assume instead of the spec’s scenario.' }, json: { type: 'boolean', description: 'Also return every row.' } }, additionalProperties: false },
275
+ async handler(input, ctx) {
276
+ const { economy: e, from } = await load(input, ctx);
277
+ const economy = input.scenario ? { ...e, scenario: { ...e.scenario, ...input.scenario } } : e;
278
+ const verdict = check(economy, { now: nowOf(ctx) });
279
+ if (!verdict.ok) return refused(verdict, from);
280
+ const sim = simulate(economy, { now: nowOf(ctx) });
281
+ const summary = {
282
+ boxes: sim.boxes.map((b) => ({ key: b.key, returnPercent: b.returnPercent, houseEdgePercent: b.houseEdgePercent })),
283
+ pools: sim.pools.map((p) => ({ key: p.key, lasts: p.lasts, lastsDays: p.lastsDays, settlesAt: p.settlesAt })),
284
+ farms: sim.farms.map((f) => ({ key: f.key, rewardsPerDay: f.rewardsPerDay, yearlyPercent: f.yearlyPercent })),
285
+ platformSolPerDay: sim.perDay.platform.totalSol,
286
+ };
287
+ return { ok: true, from, summary, ...(input.json ? { simulation: sim } : {}), text: explain(sim, economy) + footer };
288
+ },
289
+ },
290
+ {
291
+ name: 'xsolana_cost',
292
+ annotations: READ,
293
+ description: 'What going live costs (each account’s rent, the program’s fees, network fees, the seeds, the transactions and wallet approvals it takes) and what each action costs whoever signs it while it runs.',
294
+ inputSchema: { type: 'object', properties: SOURCE, additionalProperties: false },
295
+ async handler(input, ctx) {
296
+ const { economy, from } = await load(input, ctx);
297
+ const change = liveChange(economy);
298
+ const c = cost(economy, { now: nowOf(ctx), ...change.scope });
299
+ if (!c.ok) return refused(c, from);
300
+ const head = change.scope.live ? (change.scope.only.size ? `This economy is live: this prices only the change (${[...change.scope.only].join(', ')}).\n` : 'This economy is live and nothing has changed since it went live: publishing costs nothing.\n') : '';
301
+ return { ok: true, from, setup: c.setup, actions: c.actions, fees: c.fees, text: head + explainCost(c) + footer };
302
+ },
303
+ },
304
+ {
305
+ name: 'xsolana_explain',
306
+ annotations: READ,
307
+ description: 'The economy in plain words for the person: what players do and what it costs and is worth, how the pools fill and drain, what runs by itself, what the authority must sign, what going live costs, and what is flagged for the law. Tell them the essentials in a few lines, from this text.',
308
+ inputSchema: { type: 'object', properties: SOURCE, additionalProperties: false },
309
+ async handler(input, ctx) {
310
+ const { economy, from } = await load(input, ctx);
311
+ const r = explainEconomy(economy, { now: nowOf(ctx) });
312
+ return { ...r, from, text: r.text + footer };
313
+ },
314
+ },
315
+ {
316
+ name: 'xsolana_save',
317
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
318
+ description: `Save a spec into the project (default ${DEFAULT_FILE}), only if it checks. A file that is already live keeps its program numbers and its live section. Never put a key in it: the checker refuses one.`,
319
+ inputSchema: { type: 'object', properties: { spec: SOURCE.spec, file: SOURCE.file }, required: ['spec'], additionalProperties: false },
320
+ async handler(input, ctx) {
321
+ const rel = input.file ?? DEFAULT_FILE;
322
+ let file;
323
+ try {
324
+ file = projectFile(rel, ctx);
325
+ } catch (e) {
326
+ return { ok: false, isError: true, text: `Not saved: ${e.message}` };
327
+ }
328
+ // a name the approve command can carry as it is: letters, digits and . _ - in each part, parts split by /
329
+ if (plainName(rel) === null) return { ok: false, isError: true, text: `Not saved: ${NOT_PLAIN(rel)}. Use letters, digits and . _ - (and / for a folder), as in ${DEFAULT_FILE}.` };
330
+ if (findSecrets(input.spec).length) return { ok: false, isError: true, text: 'Not saved: the spec holds what looks like a private key or a seed phrase. Remove it (and treat that wallet as exposed).' };
331
+ let previous = null;
332
+ if (existsSync(file)) {
333
+ try {
334
+ previous = JSON.parse(await readFile(file, 'utf8'));
335
+ } catch {
336
+ previous = null;
337
+ }
338
+ // Only a spec is ever overwritten: never another JSON file that happens to be there (a package.json has a
339
+ // `version` too). A spec is version 2 with only the spec's own keys, as it is or under `economy`.
340
+ if (!isSpec(previous)) return { ok: false, isError: true, text: `Not saved: ${rel} is there already and is not an economy spec. Pick another file name.` };
341
+ }
342
+ // (a spec wrapped under `economy`, given or already in the file, is read the same way xsolana_check reads it)
343
+ const given = economyOf(input.spec);
344
+ const spec = carryIds(previous && economyOf(previous), given);
345
+ const dropped = given.live && !previous?.live ? ' The live section given was not kept: only `xsolana approve` writes one, as what it sends lands.' : '';
346
+ const verdict = check(spec, { now: nowOf(ctx) });
347
+ if (!verdict.ok) return { ...refused(verdict, 'the spec given'), isError: true, text: `Not saved.\n${refused(verdict, 'the spec given').text}` };
348
+ // A folder that isn't there yet is made, inside the project folder (projectFile checked the whole path).
349
+ try {
350
+ await mkdir(path.dirname(file), { recursive: true });
351
+ } catch (e) {
352
+ return { ok: false, isError: true, text: `Not saved: the folder for ${rel} can’t be made (${e.code ?? 'error'}).` };
353
+ }
354
+ await writeFile(file, `${JSON.stringify(spec, null, 2)}\n`);
355
+ return { ok: true, file: rel, warnings: verdict.warnings, 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}` };
356
+ },
357
+ },
358
+ {
359
+ name: 'xsolana_publish',
360
+ annotations: { ...READ, readOnlyHint: true },
361
+ description:
362
+ `When the person says to put it live: returns what going live does (every step, in order, and its cost) and the one command they run themselves to approve it in their own wallet (\`${approveCommand()}\`, pinned to this exact version: give it as it is): it opens a local page where they connect a devnet wallet and approve each transaction. You never sign, never ask for a key, and never run the approval for them.`,
363
+ inputSchema: { type: 'object', properties: SOURCE, additionalProperties: false },
364
+ async handler(input, ctx) {
365
+ const { economy, from, file } = await load(input, ctx);
366
+ const change = liveChange(economy);
367
+ if (change.scope.live && !change.scope.only.size && change.settings.length) return { ok: false, transactions: 0, approvals: 0, steps: [], text: `${from} is live, and its parts are as they are on chain. ${settingsNote(change.settings, economy.live)}${footer}` };
368
+ if (change.scope.live && !change.scope.only.size) return { ok: true, transactions: 0, approvals: 0, steps: [], text: `${from} is live, and nothing in it has changed since it went live: there is nothing to publish.${change.notes}${footer}` };
369
+ const p = plan(economy, { now: nowOf(ctx), ...change.scope });
370
+ if (!p.ok) return refused(p, from);
371
+ const c = cost(economy, { now: nowOf(ctx), ...change.scope });
372
+ const steps = p.transactions.flatMap((t) => t.steps.map((s) => s.says));
373
+ // the file as the approve command names it, relative to the project folder (projectFile's), and plain (plainName)
374
+ const target = file ? path.relative(realPath(path.resolve(cwdOf(ctx))), file) || DEFAULT_FILE : DEFAULT_FILE;
375
+ if (plainName(target) === null) return { ok: false, transactions: 0, approvals: 0, steps: [], 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}` };
376
+ const command = approveCommand(target);
377
+ const text = [
378
+ ...flagsToTell(economy, nowOf(ctx)),
379
+ `${change.scope.live ? 'Publishing this change to the live economy' : 'Going live'}: ${plural(p.transactions.length, 'transaction')}, ${plural(p.approvals, 'wallet approval')}, about ${c.setup.totalSol} SOL on devnet:`,
380
+ ...steps.map((s, i) => `${i + 1}. ${s}`),
381
+ '',
382
+ `To put it live, the person runs this in a terminal in the project folder (not you): ${command}`,
383
+ 'It opens a page on their own computer; they connect a wallet set to devnet and approve. What lands is written back into the file.',
384
+ 'Tell them too, as the page does: 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).',
385
+ ...(file ? [] : [`(Save it first with xsolana_save: the approval reads ${DEFAULT_FILE}.)`]),
386
+ ...(change.notes ? [change.notes.trim()] : []),
387
+ ].join('\n');
388
+ return { ok: true, command, transactions: p.transactions.length, approvals: p.approvals, setupSol: c.setup.totalSol, steps, text: text + footer };
389
+ },
390
+ },
391
+ {
392
+ name: 'xsolana_status',
393
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
394
+ description: '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).',
395
+ inputSchema: { type: 'object', properties: { file: SOURCE.file }, additionalProperties: false },
396
+ async handler(input, ctx) {
397
+ const { economy, from } = await load(input, ctx);
398
+ if (!economy.live?.project) return { ok: false, text: `${from} is not live yet (no "live" section). xsolana_publish says how to put it live.${footer}` };
399
+ const m = model(economy);
400
+ // Devnet only: a mainnet URL is refused, and so is a chain whose first block is mainnet's or testnet's.
401
+ const rpc = (ctx?.rpc ?? rpcClient)(notMainnet(process.env.XSOLANA_RPC_URL || DEVNET_RPC));
402
+ if (!ctx?.rpc) await checkCluster(rpc);
403
+ const project = economy.live.project;
404
+ const wanted = [
405
+ ['project', project],
406
+ ...m.pools.map((p) => [`pool:${p.key}`, pda.pool(project, p.index)]),
407
+ ...m.allBoxes.map((b) => [b.shopOf ? `item:${b.shopOf} (shop)` : `box:${b.key}`, pda.box(project, b.id)]),
408
+ ...m.items.map((it) => [`item:${it.key}`, pda.item(project, it.id)]),
409
+ ...m.farms.map((f) => [`farm:${f.key}`, pda.farm(project, f.id)]),
410
+ ...m.lotteries.map((l) => [`lottery:${l.key}`, pda.lottery(project, l.id)]),
411
+ ...m.schedules.map((s) => [`schedule:${s.key}`, pda.schedule(project, s.id)]),
412
+ ...m.recipes.map((r) => [`recipe:${r.key}`, pda.recipe(project, r.id)]),
413
+ ...m.grants.map((g) => [`grant:${g.key}`, pda.grant(project, g.id)]),
414
+ ...m.stashes.map((s) => [`stash:${s.key}`, pda.stash(project, s.id)]),
415
+ ...(economy.auctions ?? []).filter((a) => a.seller).map((a) => [`auction:${a.key}`, pda.auction(project, a.seller, a.id)]),
416
+ ];
417
+ const accounts = await rpc.accounts(wanted.map(([, a]) => a));
418
+ const rows = wanted.map(([part, address], i) => {
419
+ const a = accounts[i];
420
+ if (!a) return { part, address, found: false };
421
+ // A grant's funds are the SOL it holds beyond its rent: what is left to pay its claims' fees and rent.
422
+ if (part.startsWith('grant:')) return { part, address, found: true, kind: 'Grant', sol: Number(BigInt(a.lamports ?? 0) - rentOf(a.data?.length ?? 0)) / 1e9 };
423
+ const tokenAmount = a.data?.length >= 72 && part.startsWith('pool:') ? Number(new DataView(a.data.buffer, a.data.byteOffset).getBigUint64(64, true)) : undefined;
424
+ const decoded = a.owner === LOOT_PROGRAM ? decodeAny(a.data) : null;
425
+ // A stash: how many NFTs it still holds to give away.
426
+ const nfts = decoded?.kind === 'Stash' ? decoded.value.nfts.length : undefined;
427
+ return { part, address, found: true, ...(tokenAmount !== undefined ? { amount: tokenAmount } : {}), ...(nfts !== undefined ? { nfts } : {}), ...(decoded ? { kind: decoded.kind } : {}) };
428
+ });
429
+ const missing = rows.filter((r) => !r.found);
430
+ const text = [`${economy.name ?? 'The economy'} on ${CLUSTER}: project ${project}.`, ...rows.map((r) => `- ${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})`), missing.length ? `\n${plural(missing.length, 'part')} not on chain: publish again (xsolana_publish) to add them.` : ''].filter(Boolean).join('\n');
431
+ return { ok: true, rows, text: text + footer };
432
+ },
433
+ },
434
+ {
435
+ name: 'xsolana_client',
436
+ annotations: READ,
437
+ description: 'How an app (a game, a site, a bot) uses a live economy: the program’s address and cluster, the npm package and IDL, and the calls a page makes with the player’s own wallet (open a box, buy, stake, craft, enter, claim). Use this instead of writing chain code from memory.',
438
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
439
+ async handler() {
440
+ const text = [
441
+ `loot ${VERSION} on ${CLUSTER}: program ${LOOT_PROGRAM}. Never write another address from memory: take it from this package.`,
442
+ 'Package: npm install @xgamesfun/solana (plain ES modules, no Solana SDK needed; works in the browser).',
443
+ ' import { solanaApp, walletStandardSigner } from "@xgamesfun/solana/play";',
444
+ ' const app = solanaApp({ spec /* the live xsolana.json */, rpcUrl, wallet: walletStandardSigner(wallet, account) });',
445
+ ' app.shop(), app.buy(key, { listing }), app.boxes(), app.openBox(key, { listing }), app.pay(key) (or app.pay(key, { chosenRecipient })),',
446
+ ' `chosenRecipient` (buy, openBox, pay): the wallet (or collectible) the player names for a "chosen" share of the split (a referrer from the link)',
447
+ ' app.lotteries(), app.buyTickets(key, n, { listing }) (or { maxPrice }: a ticket’s most, in base units),',
448
+ ' app.stake(key, amount), app.harvest(key), app.staked(key) (each seat and what it is owed now), app.recipes(), app.craft(key, assets, 1, { listing }), app.claimGrant(key),',
449
+ ' app.collectibles(), app.balances() what a player does. `listing`: the entry shop(), boxes(), lotteries() or recipes() showed the',
450
+ ' player; its price (or version) is the most they pay: one that moved since is refused, never paid unseen',
451
+ ' a prize (a grant, a lottery place, a contest win, a mission, a recipe) is given in the instruction that wins it,',
452
+ ' its accounts from loot.prizeMetas({ project, collection, prize, winner, poolMints, items, nft }) (a roll a buy, a craft',
453
+ ' or a grant claim makes: strict: true, boxes, rollPools, for the box’s prize mints, loot.rollMints); app.claims() finds',
454
+ ' what a player is owed. A roll (a prize of a roll, an auction lot, a box) is an order: app.claim / app.collect, or',
455
+ ' loot.askRollIx({ payer, orderAddress, order, box }) then loot.settleIx(...) hands over what it rolled (a stash NFT too)',
456
+ ' loot.stashIx({ signer, project, id, asset, out }) the owner or authority puts an NFT in a stash, or takes one out',
457
+ ' app.listForSale(asset, { price }), app.listForRent(asset, { price, lasts, maxPeriods }), app.listing(asset), app.listings(),',
458
+ ' app.buyListing(asset, { listing }), app.rentListing(asset, { periods, listing }) (`listing`: what listing() or listings()',
459
+ ' showed the player), app.unlist(asset), app.rentedTo(asset) (one account read) listings, sale and rent',
460
+ ' app.raffle(id, nft, { price, drawsAt, minSold }), app.unraffle(id, nft) a raffle from the lottery’s own stash',
461
+ ' app.compound(key), app.withdraw(key), app.runSchedule(key) compound, take out unbonded tokens, catch a schedule up',
462
+ ' app.claims() also lists a raffle’s prize step (given before the money), unbonded tokens, vested runs, a rental over',
463
+ 'Lower level, for what the player module does not cover:',
464
+ ' import * as loot from "@xgamesfun/solana/client";',
465
+ ' loot.pda.project(owner, number), loot.pda.box(project, id), … every address the program derives',
466
+ ' loot.openBoxIx({ player, project, box, nonce }), loot.buyIx({ player, project, box, items }) … one builder per instruction; each returns { programId, keys, data }',
467
+ ' loot.payNftIx({ player, project, asset, to }) pay with an NFT (burned, or given to `to`): the app admits by the receipt',
468
+ ' loot.openListingIx / takeListingIx / closeListingIx, loot.listingTerms(listing), loot.pda.listing(project, asset), loot.renterOf(listing, now)',
469
+ ' loot.lotteryStep(lottery, contribution) then loot.finishLotteryContributionIx(...) a drawn round’s prizes first, then its money',
470
+ ' loot.withdrawable(seat, now), loot.runsDue(schedule, now, vaultBalance), loot.pda.vest(schedule), loot.pda.prizeVault(contest), loot.pda.lotteryStash(lottery)',
471
+ ' loot.decode("LootBox", data), loot.receipts(tx) accounts, and the receipts of a getTransaction result (none when meta.err is set)',
472
+ 'IDL: @xgamesfun/solana/idl.json (Anchor 1.x format).',
473
+ '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.',
474
+ '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: a failed transaction keeps its logs, and they can read as all-success.',
475
+ ].join('\n');
476
+ return { text: text + footer, ...PROGRAM_INFO };
477
+ },
478
+ },
479
+ ];