@xgamesfun/solana 0.24.0 → 0.25.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 (99) hide show
  1. package/DISCLAIMER.md +1 -1
  2. package/NOTICE +4 -4
  3. package/README.md +100 -52
  4. package/bin/xsolana.mjs +32 -11
  5. package/examples/blind-forge.json +2 -4
  6. package/examples/coin-flip-arena.json +2 -5
  7. package/examples/duel-arena.json +1 -4
  8. package/examples/fair-presale.json +1 -3
  9. package/examples/hero-trials.json +14 -2
  10. package/examples/ideas.json +788 -788
  11. package/examples/nft-raffle.json +1 -0
  12. package/examples/partner-vault.json +19 -3
  13. package/examples/sacrifice-raffle.json +2 -10
  14. package/examples/shelter-fair.json +1 -0
  15. package/examples/stakers-draw.json +4 -7
  16. package/examples/trade-in-shop.json +2 -3
  17. package/js/approve/details.mjs +117 -48
  18. package/js/approve/ed25519.mjs +5 -1
  19. package/js/approve/live-check.mjs +83 -26
  20. package/js/approve/page.html +217 -78
  21. package/js/approve/page.js +43 -15
  22. package/js/approve/server.mjs +276 -74
  23. package/js/chain/base58.js +7 -1
  24. package/js/chain/borsh.js +15 -11
  25. package/js/chain/bytes.js +11 -8
  26. package/js/chain/core.js +21 -5
  27. package/js/chain/curve.js +1 -1
  28. package/js/chain/lookup.js +118 -36
  29. package/js/chain/mint.js +6 -2
  30. package/js/chain/pda.js +2 -2
  31. package/js/chain/programs.js +6 -6
  32. package/js/chain/rpc.js +40 -14
  33. package/js/chain/token.js +13 -2
  34. package/js/chain/token2022.js +55 -16
  35. package/js/chain/tx.js +32 -22
  36. package/js/client/accounts.js +270 -80
  37. package/js/client/compressed.js +4 -3
  38. package/js/client/grant.js +165 -40
  39. package/js/client/idl/loot.js +1 -1
  40. package/js/client/idl/loot.json +104 -16
  41. package/js/client/ix.js +2121 -457
  42. package/js/client/pda.js +20 -18
  43. package/js/client/policy.js +12 -5
  44. package/js/client/random.js +2 -2
  45. package/js/client/sizes.js +220 -65
  46. package/js/client/traits.js +14 -5
  47. package/js/connect/connect.mjs +59 -17
  48. package/js/economy/check.mjs +3884 -690
  49. package/js/economy/cost.mjs +381 -57
  50. package/js/economy/examples.mjs +176 -35
  51. package/js/economy/explain.mjs +468 -110
  52. package/js/economy/guide.mjs +282 -59
  53. package/js/economy/legal.mjs +12 -6
  54. package/js/economy/live.mjs +62 -20
  55. package/js/economy/model.mjs +320 -97
  56. package/js/economy/plan.mjs +1022 -203
  57. package/js/economy/review.mjs +760 -0
  58. package/js/economy/schema.json +134 -372
  59. package/js/economy/secrets.mjs +17 -7
  60. package/js/economy/simulate.mjs +771 -178
  61. package/js/economy/trust.mjs +15 -0
  62. package/js/economy/txsize.mjs +15 -3
  63. package/js/economy/validate.mjs +61 -24
  64. package/js/index.js +1 -0
  65. package/js/mcp/server.mjs +43 -15
  66. package/js/mcp/tools.mjs +318 -80
  67. package/js/play/index.js +1960 -461
  68. package/js/play/keys.js +35 -9
  69. package/js/results/index.js +225 -32
  70. package/js/results/server.js +230 -0
  71. package/js/results/wire.js +64 -16
  72. package/package.json +6 -4
  73. package/release.json +5 -5
  74. package/skills/xsolana-economy/SKILL.md +52 -22
  75. package/skills/xsolana-economy/references/after-launch.md +32 -21
  76. package/skills/xsolana-economy/references/apps.md +47 -36
  77. package/skills/xsolana-economy/references/sentence-to-spec.md +149 -129
  78. package/skills/xsolana-economy/references/telling-the-person.md +15 -6
  79. package/skills/xsolana-economy/references/trust-and-cheating.md +126 -0
  80. package/types/chain/core.d.ts +1 -1
  81. package/types/chain/lookup.d.ts +1 -1
  82. package/types/client/accounts.d.ts +24 -3
  83. package/types/client/grant.d.ts +2 -2
  84. package/types/client/idl/loot.d.ts +22 -0
  85. package/types/client/ix.d.ts +54 -45
  86. package/types/client/pda.d.ts +3 -1
  87. package/types/economy/cost.d.mts +1 -1
  88. package/types/economy/explain.d.mts +3 -0
  89. package/types/economy/guide.d.mts +5 -0
  90. package/types/economy/model.d.mts +48 -3
  91. package/types/economy/plan.d.mts +44 -2
  92. package/types/economy/review.d.mts +45 -0
  93. package/types/economy/trust.d.mts +8 -0
  94. package/types/index.d.ts +1 -0
  95. package/types/mcp/tools.d.mts +62 -0
  96. package/types/play/index.d.ts +14 -25
  97. package/types/results/index.d.ts +8 -7
  98. package/types/results/server.d.ts +126 -0
  99. package/types/results/wire.d.ts +1 -1
package/js/mcp/tools.mjs CHANGED
@@ -15,6 +15,7 @@ import { cost, explainCost } from '../economy/cost.mjs';
15
15
  import { changedParts } from '../economy/live.mjs';
16
16
  import { example, examples, ideas, search, partsUsed } from '../economy/examples.mjs';
17
17
  import { explainEconomy } from '../economy/explain.mjs';
18
+ import { describeReview, review } from '../economy/review.mjs';
18
19
  import { CANNOT, catalog, guideText, PARTS } from '../economy/guide.mjs';
19
20
  import { model, rent as rentOf } from '../economy/model.mjs';
20
21
  import { plan } from '../economy/plan.mjs';
@@ -40,7 +41,7 @@ export function plainName(rel, paths = path) {
40
41
  const name = String(rel).split(paths.sep).join('/');
41
42
  return SAVE_NAME.test(name) ? name : null;
42
43
  }
43
- const NOT_PLAIN = (rel) => `${JSON.stringify(String(rel))} is not a plain file name`;
44
+ const NOT_PLAIN = rel => `${JSON.stringify(String(rel))} is not a plain file name`;
44
45
  const RENAME = 'use letters, digits and . _ - (and / for a folder), as in xsolana.json';
45
46
  /**
46
47
  * The command an owner runs to approve going live in their own wallet, pinned to this package's exact version, with
@@ -55,11 +56,17 @@ export function approveCommand(file = '', paths = path) {
55
56
  export const CLUSTER = 'devnet';
56
57
  export const DEVNET_RPC = 'https://api.devnet.solana.com';
57
58
  /** 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
+ export const PROGRAM_INFO = Object.freeze({
60
+ program: LOOT_PROGRAM,
61
+ cluster: CLUSTER,
62
+ version: VERSION,
63
+ idl: '@xgamesfun/solana/idl.json',
64
+ package: `@xgamesfun/solana@${PACKAGE_VERSION}`,
65
+ });
59
66
  const footer = `\n\nloot ${VERSION} · program ${LOOT_PROGRAM} · ${CLUSTER}`;
60
67
  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();
68
+ const nowOf = ctx => ctx?.now ?? Math.floor(Date.now() / 1000);
69
+ const cwdOf = ctx => ctx?.cwd ?? process.cwd();
63
70
  export const DEFAULT_FILE = 'xsolana.json';
64
71
 
65
72
  /** The real path of `p`, or of the nearest folder above it that exists, with the rest added back. */
@@ -79,22 +86,24 @@ function realPath(p) {
79
86
  export function projectFile(rel, ctx) {
80
87
  const root = realPath(path.resolve(cwdOf(ctx)));
81
88
  const file = path.resolve(root, String(rel));
82
- const inside = (p) => {
89
+ const inside = p => {
83
90
  const r = path.relative(root, p);
84
91
  return r !== '' && !r.startsWith('..') && !path.isAbsolute(r);
85
92
  };
86
- if (!inside(file) || !inside(realPath(file))) throw new Error(`${rel} is outside the project folder (${root}): a spec file must be inside it.`);
93
+ if (!inside(file) || !inside(realPath(file)))
94
+ throw new Error(`${rel} is outside the project folder (${root}): a spec file must be inside it.`);
87
95
  if (path.extname(file).toLowerCase() !== '.json') throw new Error(`${rel} is not a .json file: a spec file is JSON.`);
88
96
  return file;
89
97
  }
90
98
 
91
99
  /** The spec's own top-level keys (schema.json). */
92
100
  const SPEC_KEYS = new Set(Object.keys(SCHEMA.properties));
93
- const plainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
101
+ const plainObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
94
102
  /** 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);
103
+ const specOf = value =>
104
+ plainObject(value) && Object.keys(value).length === 1 && plainObject(value.economy) ? value.economy : value;
96
105
  /** 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);
106
+ const economyOf = value => (plainObject(value) && plainObject(value.economy) ? value.economy : value);
98
107
  /**
99
108
  * 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
109
  * checks has them), a currency if it names one (a spec of one token checks without: that token is its currency)
@@ -103,15 +112,30 @@ const economyOf = (value) => (plainObject(value) && plainObject(value.economy) ?
103
112
  */
104
113
  export function isSpec(value) {
105
114
  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));
115
+ return (
116
+ plainObject(s) &&
117
+ s.version === 2 &&
118
+ (s.currency === undefined || typeof s.currency === 'string') &&
119
+ Array.isArray(s.tokens) &&
120
+ Object.keys(s).every(k => SPEC_KEYS.has(k))
121
+ );
107
122
  }
108
123
 
109
124
  // ---------------------------------------------------------------- where a spec comes from
110
125
 
111
126
  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, …).' },
127
+ spec: {
128
+ type: 'object',
129
+ description: 'An economy spec (version 2) given inline. Takes precedence over file and example.',
130
+ },
131
+ file: {
132
+ type: 'string',
133
+ description: `A JSON file holding the spec, relative to the project folder (default: ${DEFAULT_FILE} when it exists).`,
134
+ },
135
+ example: {
136
+ type: 'string',
137
+ description: 'A worked example by name, from xsolana_examples (coin-flip-arena, stud-farm, …).',
138
+ },
115
139
  };
116
140
 
117
141
  async function load(input, ctx) {
@@ -121,7 +145,9 @@ async function load(input, ctx) {
121
145
  const file = projectFile(rel, ctx);
122
146
  if (!existsSync(file)) {
123
147
  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.`);
148
+ throw new Error(
149
+ `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.`,
150
+ );
125
151
  }
126
152
  let value;
127
153
  try {
@@ -157,11 +183,13 @@ export function carryIds(previous, next) {
157
183
  const out = { ...next, live: previous.live };
158
184
  const carry = (list, fields) => {
159
185
  if (!next[list]) return;
160
- const old = new Map((previous[list] ?? []).map((p) => [p.key, p]));
161
- out[list] = next[list].map((p) => {
186
+ const old = new Map((previous[list] ?? []).map(p => [p.key, p]));
187
+ out[list] = next[list].map(p => {
162
188
  const was = old.get(p.key);
163
189
  if (!was) return p;
164
- const ids = Object.fromEntries(fields.filter((f) => was[f] !== undefined && p[f] === undefined).map((f) => [f, was[f]]));
190
+ const ids = Object.fromEntries(
191
+ fields.filter(f => was[f] !== undefined && p[f] === undefined).map(f => [f, was[f]]),
192
+ );
165
193
  return { ...p, ...ids };
166
194
  });
167
195
  };
@@ -179,7 +207,13 @@ export function carryIds(previous, next) {
179
207
  /** The legal flags to pass on before the person approves, as lines for an answer: [] when there are none. */
180
208
  function flagsToTell(economy, now) {
181
209
  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}`), ''] : [];
210
+ return flags.length
211
+ ? [
212
+ "If you haven't told the person these yet, say each once, in a plain sentence (the approval page shows them too, and asks them to tick that they read them; after that, only when they ask):",
213
+ ...flags.map(f => `- ${f.says}`),
214
+ '',
215
+ ]
216
+ : [];
183
217
  }
184
218
 
185
219
  /**
@@ -191,8 +225,12 @@ function liveChange(economy) {
191
225
  const { only, fixed, removed, settings } = changedParts(economy);
192
226
  const notes = [
193
227
  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.` : '',
228
+ fixed.length
229
+ ? `\nThese can't change once live, and stay as they were: ${fixed.join(', ')} (a farm, an auction or a pool's token).`
230
+ : '',
231
+ removed.length
232
+ ? `\nTaken out of the spec but still on chain: ${removed.join(', ')}. To stop one, keep it and set "active": false.`
233
+ : '',
196
234
  ].join('');
197
235
  return { scope: { only, live: economy.live }, notes, settings };
198
236
  }
@@ -207,16 +245,26 @@ export const tools = [
207
245
  annotations: READ,
208
246
  description:
209
247
  '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 },
248
+ inputSchema: {
249
+ type: 'object',
250
+ properties: {
251
+ part: { type: 'string', enum: Object.keys(PARTS), description: 'One part, with its nested shapes.' },
252
+ },
253
+ additionalProperties: false,
254
+ },
211
255
  async handler({ part } = NO_OPTIONS) {
212
- return { text: guideText(part) + footer, parts: catalog(part), ...(part ? {} : { cannot: CANNOT }), ...PROGRAM_INFO };
256
+ return {
257
+ text: guideText(part) + footer,
258
+ parts: catalog(part),
259
+ ...(part ? {} : { cannot: CANNOT }),
260
+ ...PROGRAM_INFO,
261
+ };
213
262
  },
214
263
  },
215
264
  {
216
265
  name: 'xsolana_examples',
217
266
  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.`,
267
+ description: `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
268
  inputSchema: {
221
269
  type: 'object',
222
270
  properties: {
@@ -231,24 +279,53 @@ export const tools = [
231
279
  if (name) {
232
280
  const ex = example(name);
233
281
  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}` };
282
+ return {
283
+ name: ex.name,
284
+ spec: ex.spec,
285
+ checks: verdict.ok ? 'clean' : verdict.refusals.map(r => r.code),
286
+ 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}`,
287
+ };
235
288
  }
236
289
  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 };
290
+ const all = examples().map(({ name: n, spec }) => ({
291
+ name: n,
292
+ title: spec.name,
293
+ prompt: spec.prompt,
294
+ parts: partsUsed(spec),
295
+ }));
296
+ return {
297
+ examples: all,
298
+ ideas: { count: ideas().length },
299
+ text:
300
+ [
301
+ `${all.length} worked examples (xsolana_examples {"name": "<name>"} gives the whole spec):`,
302
+ ...all.map(e => `- ${e.name}: ${e.title}. ${e.prompt ?? ''} [${e.parts.join(', ')}]`),
303
+ `\nAnd ${ideas().length} rated economies: pass a query to search them.`,
304
+ ].join('\n') + footer,
305
+ };
239
306
  }
240
307
  const r = search(query, { limit, fit });
241
308
  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(', ')}]`),
309
+ r.examples.length
310
+ ? `Examples closest to “${query}” (xsolana_examples {"name": …} gives the whole spec):`
311
+ : `No worked example matches “${query}”. Start from the nearest idea below, or from xsolana_guide.`,
312
+ ...r.examples.map(e => `- ${e.name}: ${e.title}. ${e.prompt ?? ''} [${e.parts.join(', ')}]`),
244
313
  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');
314
+ ...r.hows.map(h => `- ${h.what}: ${h.how}.`),
315
+ r.ideas.length
316
+ ? '\nRated economies (rated against this version of loot: a rating is a hint, the parts in xsolana_guide decide):'
317
+ : '',
318
+ ...r.ideas.map(i => `- #${i.n} ${i.name} (${i.fit}): ${i.how}`),
319
+ r.limits.length
320
+ ? '\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”):'
321
+ : '',
322
+ ...r.limits.flatMap(l => [`- ${l.what}: ${l.why}.`, ...l.nearest.map((n, i) => ` ${i + 1}. ${n}`)]),
323
+ r.ideas.some(i => i.fit === 'cannot')
324
+ ? '\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.'
325
+ : '',
326
+ ]
327
+ .filter(Boolean)
328
+ .join('\n');
252
329
  return { examples: r.examples, ideas: r.ideas, hows: r.hows, limits: r.limits, text: text + footer };
253
330
  },
254
331
  },
@@ -262,8 +339,16 @@ export const tools = [
262
339
  const { economy, from } = await load(input, ctx);
263
340
  const verdict = check(economy, { now: nowOf(ctx) });
264
341
  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}` };
342
+ const tail = verdict.ok
343
+ ? `\nReady (${plural(verdict.warnings.length, 'warning')}). Next: xsolana_simulate and xsolana_explain, and tell the person what it means in plain words; before going live, xsolana_review.`
344
+ : `\n${plural(verdict.refusals.length, 'refusal')}: fix each as its fix says, then check again.`;
345
+ return {
346
+ ok: verdict.ok,
347
+ from,
348
+ refusals: verdict.refusals,
349
+ warnings: verdict.warnings,
350
+ text: `${head}${tail}${footer}`,
351
+ };
267
352
  },
268
353
  },
269
354
  {
@@ -271,7 +356,15 @@ export const tools = [
271
356
  annotations: READ,
272
357
  description:
273
358
  '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 },
359
+ inputSchema: {
360
+ type: 'object',
361
+ properties: {
362
+ ...SOURCE,
363
+ scenario: { type: 'object', description: 'What to assume instead of the spec’s scenario.' },
364
+ json: { type: 'boolean', description: 'Also return every row.' },
365
+ },
366
+ additionalProperties: false,
367
+ },
275
368
  async handler(input, ctx) {
276
369
  const { economy: e, from } = await load(input, ctx);
277
370
  const economy = input.scenario ? { ...e, scenario: { ...e.scenario, ...input.scenario } } : e;
@@ -279,32 +372,48 @@ export const tools = [
279
372
  if (!verdict.ok) return refused(verdict, from);
280
373
  const sim = simulate(economy, { now: nowOf(ctx) });
281
374
  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 })),
375
+ boxes: sim.boxes.map(b => ({
376
+ key: b.key,
377
+ returnPercent: b.returnPercent,
378
+ houseEdgePercent: b.houseEdgePercent,
379
+ })),
380
+ pools: sim.pools.map(p => ({ key: p.key, lasts: p.lasts, lastsDays: p.lastsDays, settlesAt: p.settlesAt })),
381
+ farms: sim.farms.map(f => ({ key: f.key, rewardsPerDay: f.rewardsPerDay, yearlyPercent: f.yearlyPercent })),
285
382
  platformSolPerDay: sim.perDay.platform.totalSol,
286
383
  };
287
- return { ok: true, from, summary, ...(input.json ? { simulation: sim } : {}), text: explain(sim, economy) + footer };
384
+ return {
385
+ ok: true,
386
+ from,
387
+ summary,
388
+ ...(input.json ? { simulation: sim } : {}),
389
+ text: explain(sim, economy) + footer,
390
+ };
288
391
  },
289
392
  },
290
393
  {
291
394
  name: 'xsolana_cost',
292
395
  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.',
396
+ description:
397
+ '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
398
  inputSchema: { type: 'object', properties: SOURCE, additionalProperties: false },
295
399
  async handler(input, ctx) {
296
400
  const { economy, from } = await load(input, ctx);
297
401
  const change = liveChange(economy);
298
402
  const c = cost(economy, { now: nowOf(ctx), ...change.scope });
299
403
  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') : '';
404
+ const head = change.scope.live
405
+ ? change.scope.only.size
406
+ ? `This economy is live: this prices only the change (${[...change.scope.only].join(', ')}).\n`
407
+ : 'This economy is live and nothing has changed since it went live: publishing costs nothing.\n'
408
+ : '';
301
409
  return { ok: true, from, setup: c.setup, actions: c.actions, fees: c.fees, text: head + explainCost(c) + footer };
302
410
  },
303
411
  },
304
412
  {
305
413
  name: 'xsolana_explain',
306
414
  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.',
415
+ description:
416
+ '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
417
  inputSchema: { type: 'object', properties: SOURCE, additionalProperties: false },
309
418
  async handler(input, ctx) {
310
419
  const { economy, from } = await load(input, ctx);
@@ -312,11 +421,40 @@ export const tools = [
312
421
  return { ...r, from, text: r.text + footer };
313
422
  },
314
423
  },
424
+ {
425
+ name: 'xsolana_review',
426
+ annotations: READ,
427
+ description:
428
+ 'Review an economy the way a cheater reads it, before it goes live (not a validity check: xsolana_check is that). For every route that pays or gives value it says who can trigger it, what the program enforces, what it trusts off the chain, and the most one cheater gets; then findings with stable ids and a severity; then the trade-off to show the person: for each grant paid on vouchers, whether its server decides, replays or only bounds the result (its "trust"), what a cheater gets at that level, and what the next level costs to build. Fix every high finding, declare "trust" honestly on every voucher grant, and tell the person in plain words which level each signed route uses and what a cheater gets, so they choose the trade-off. Give the spec inline (`spec`), a file, or an example. Answers { ok (false when a finding is high, or the spec does not check: then `refusals`), routes: [{ kind ("grant", "farm", "item", "box", "payment", "recipe", "lottery", "contest", "auction", "schedule", "hold", "decision"), key, name, trigger, enforced: [words], trusts: [words], trust ("decides" | "replays" | "bounds" | null), ceiling (words), feeds: ["farm:<key>", …: what its prize is staked in, crafted into or buys] }], findings: [{ id (R-…), severity ("high" | "medium" | "low" | "info"), route ("grant:<key>", "farm:<key>", … or "economy"), message, fix }], tradeoffs (markdown) }.',
429
+ inputSchema: { type: 'object', properties: SOURCE, additionalProperties: false },
430
+ async handler(input, ctx) {
431
+ const { economy, from } = await load(input, ctx);
432
+ const r = review(economy, { now: nowOf(ctx) });
433
+ if (r.refusals) return refused({ refusals: r.refusals, warnings: [] }, from);
434
+ const highs = r.findings.filter(f => f.severity === 'high').length;
435
+ const tail = highs
436
+ ? `\n${plural(highs, 'high finding')}: fix ${highs === 1 ? 'it' : 'them'} (or have the person accept ${highs === 1 ? 'it' : 'them'} in plain words) before going live, then review again.`
437
+ : '\nNo high finding. Tell the person, in plain words, which level each signed route uses and what a cheater gets (the trade-offs above).';
438
+ return {
439
+ ok: r.ok,
440
+ from,
441
+ routes: r.routes,
442
+ findings: r.findings,
443
+ tradeoffs: r.tradeoffs,
444
+ text: `${describeReview(r, economy.name)}${tail}${footer}`,
445
+ };
446
+ },
447
+ },
315
448
  {
316
449
  name: 'xsolana_save',
317
450
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
318
451
  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 },
452
+ inputSchema: {
453
+ type: 'object',
454
+ properties: { spec: SOURCE.spec, file: SOURCE.file },
455
+ required: ['spec'],
456
+ additionalProperties: false,
457
+ },
320
458
  async handler(input, ctx) {
321
459
  const rel = input.file ?? DEFAULT_FILE;
322
460
  let file;
@@ -326,8 +464,18 @@ export const tools = [
326
464
  return { ok: false, isError: true, text: `Not saved: ${e.message}` };
327
465
  }
328
466
  // 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).' };
467
+ if (plainName(rel) === null)
468
+ return {
469
+ ok: false,
470
+ isError: true,
471
+ text: `Not saved: ${NOT_PLAIN(rel)}. Use letters, digits and . _ - (and / for a folder), as in ${DEFAULT_FILE}.`,
472
+ };
473
+ if (findSecrets(input.spec).length)
474
+ return {
475
+ ok: false,
476
+ isError: true,
477
+ text: 'Not saved: the spec holds what looks like a private key or a seed phrase. Remove it (and treat that wallet as exposed).',
478
+ };
331
479
  let previous = null;
332
480
  if (existsSync(file)) {
333
481
  try {
@@ -337,42 +485,84 @@ export const tools = [
337
485
  }
338
486
  // Only a spec is ever overwritten: never another JSON file that happens to be there (a package.json has a
339
487
  // `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.` };
488
+ if (!isSpec(previous))
489
+ return {
490
+ ok: false,
491
+ isError: true,
492
+ text: `Not saved: ${rel} is there already and is not an economy spec. Pick another file name.`,
493
+ };
341
494
  }
342
495
  // (a spec wrapped under `economy`, given or already in the file, is read the same way xsolana_check reads it)
343
496
  const given = economyOf(input.spec);
344
497
  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.' : '';
498
+ const dropped =
499
+ given.live && !previous?.live
500
+ ? ' The live section given was not kept: only `xsolana approve` writes one, as what it sends lands.'
501
+ : '';
346
502
  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}` };
503
+ if (!verdict.ok)
504
+ return {
505
+ ...refused(verdict, 'the spec given'),
506
+ isError: true,
507
+ text: `Not saved.\n${refused(verdict, 'the spec given').text}`,
508
+ };
348
509
  // A folder that isn't there yet is made, inside the project folder (projectFile checked the whole path).
349
510
  try {
350
511
  await mkdir(path.dirname(file), { recursive: true });
351
512
  } catch (e) {
352
- return { ok: false, isError: true, text: `Not saved: the folder for ${rel} can’t be made (${e.code ?? 'error'}).` };
513
+ return {
514
+ ok: false,
515
+ isError: true,
516
+ text: `Not saved: the folder for ${rel} can’t be made (${e.code ?? 'error'}).`,
517
+ };
353
518
  }
354
519
  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}` };
520
+ return {
521
+ ok: true,
522
+ file: rel,
523
+ warnings: verdict.warnings,
524
+ 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}`,
525
+ };
356
526
  },
357
527
  },
358
528
  {
359
529
  name: 'xsolana_publish',
360
530
  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.`,
531
+ description: `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
532
  inputSchema: { type: 'object', properties: SOURCE, additionalProperties: false },
364
533
  async handler(input, ctx) {
365
534
  const { economy, from, file } = await load(input, ctx);
366
535
  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}` };
536
+ if (change.scope.live && !change.scope.only.size && change.settings.length)
537
+ return {
538
+ ok: false,
539
+ transactions: 0,
540
+ approvals: 0,
541
+ steps: [],
542
+ text: `${from} is live, and its parts are as they are on chain. ${settingsNote(change.settings, economy.live)}${footer}`,
543
+ };
544
+ if (change.scope.live && !change.scope.only.size)
545
+ return {
546
+ ok: true,
547
+ transactions: 0,
548
+ approvals: 0,
549
+ steps: [],
550
+ text: `${from} is live, and nothing in it has changed since it went live: there is nothing to publish.${change.notes}${footer}`,
551
+ };
369
552
  const p = plan(economy, { now: nowOf(ctx), ...change.scope });
370
553
  if (!p.ok) return refused(p, from);
371
554
  const c = cost(economy, { now: nowOf(ctx), ...change.scope });
372
- const steps = p.transactions.flatMap((t) => t.steps.map((s) => s.says));
555
+ const steps = p.transactions.flatMap(t => t.steps.map(s => s.says));
373
556
  // the file as the approve command names it, relative to the project folder (projectFile's), and plain (plainName)
374
557
  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}` };
558
+ if (plainName(target) === null)
559
+ return {
560
+ ok: false,
561
+ transactions: 0,
562
+ approvals: 0,
563
+ steps: [],
564
+ 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}`,
565
+ };
376
566
  const command = approveCommand(target);
377
567
  const text = [
378
568
  ...flagsToTell(economy, nowOf(ctx)),
@@ -381,21 +571,34 @@ export const tools = [
381
571
  '',
382
572
  `To put it live, the person runs this in a terminal in the project folder (not you): ${command}`,
383
573
  '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).',
574
+ 'The page also says 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). They tick that they read it, so it needs no repeating unless they ask.',
385
575
  ...(file ? [] : [`(Save it first with xsolana_save: the approval reads ${DEFAULT_FILE}.)`]),
386
576
  ...(change.notes ? [change.notes.trim()] : []),
387
577
  ].join('\n');
388
- return { ok: true, command, transactions: p.transactions.length, approvals: p.approvals, setupSol: c.setup.totalSol, steps, text: text + footer };
578
+ return {
579
+ ok: true,
580
+ command,
581
+ transactions: p.transactions.length,
582
+ approvals: p.approvals,
583
+ setupSol: c.setup.totalSol,
584
+ steps,
585
+ text: text + footer,
586
+ };
389
587
  },
390
588
  },
391
589
  {
392
590
  name: 'xsolana_status',
393
591
  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).',
592
+ description:
593
+ '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
594
  inputSchema: { type: 'object', properties: { file: SOURCE.file }, additionalProperties: false },
396
595
  async handler(input, ctx) {
397
596
  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}` };
597
+ if (!economy.live?.project)
598
+ return {
599
+ ok: false,
600
+ text: `${from} is not live yet (no "live" section). xsolana_publish says how to put it live.${footer}`,
601
+ };
399
602
  const m = model(economy);
400
603
  // Devnet only: a mainnet URL is refused, and so is a chain whose first block is mainnet's or testnet's.
401
604
  const rpc = (ctx?.rpc ?? rpcClient)(notMainnet(process.env.XSOLANA_RPC_URL || DEVNET_RPC));
@@ -403,38 +606,72 @@ export const tools = [
403
606
  const project = economy.live.project;
404
607
  const wanted = [
405
608
  ['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)]),
609
+ ...m.pools.map(p => [`pool:${p.key}`, pda.pool(project, p.index)]),
610
+ ...m.allBoxes.map(b => [b.shopOf ? `item:${b.shopOf} (shop)` : `box:${b.key}`, pda.box(project, b.id)]),
611
+ ...m.items.map(it => [`item:${it.key}`, pda.item(project, it.id)]),
612
+ ...m.farms.map(f => [`farm:${f.key}`, pda.farm(project, f.id)]),
613
+ ...m.lotteries.map(l => [`lottery:${l.key}`, pda.lottery(project, l.id)]),
614
+ ...m.lotteries
615
+ .filter(l => Number.isInteger(l.pot))
616
+ .map(l => [`lottery:${l.key} (pot ${l.pot})`, pda.lotteryPot(pda.lottery(project, l.id), l.pot)]),
617
+ ...m.schedules.map(s => [`schedule:${s.key}`, pda.schedule(project, s.id)]),
618
+ ...m.recipes.map(r => [`recipe:${r.key}`, pda.recipe(project, r.id)]),
619
+ ...m.grants.map(g => [`grant:${g.key}`, pda.grant(project, g.id)]),
620
+ ...m.stashes.map(s => [`stash:${s.key}`, pda.stash(project, s.id)]),
621
+ ...(economy.auctions ?? [])
622
+ .filter(a => a.seller)
623
+ .map(a => [`auction:${a.key}`, pda.auction(project, a.seller, a.id)]),
416
624
  ];
417
625
  const accounts = await rpc.accounts(wanted.map(([, a]) => a));
418
626
  const rows = wanted.map(([part, address], i) => {
419
627
  const a = accounts[i];
420
628
  if (!a) return { part, address, found: false };
421
629
  // 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;
630
+ if (part.startsWith('grant:'))
631
+ return {
632
+ part,
633
+ address,
634
+ found: true,
635
+ kind: 'Grant',
636
+ sol: Number(BigInt(a.lamports ?? 0) - rentOf(a.data?.length ?? 0)) / 1e9,
637
+ };
638
+ const tokenAmount =
639
+ a.data?.length >= 72 && (part.startsWith('pool:') || part.includes(' (pot '))
640
+ ? Number(new DataView(a.data.buffer, a.data.byteOffset).getBigUint64(64, true))
641
+ : undefined;
424
642
  const decoded = a.owner === LOOT_PROGRAM ? decodeAny(a.data) : null;
425
643
  // A stash: how many NFTs it still holds to give away.
426
644
  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 } : {}) };
645
+ return {
646
+ part,
647
+ address,
648
+ found: true,
649
+ ...(tokenAmount !== undefined ? { amount: tokenAmount } : {}),
650
+ ...(nfts !== undefined ? { nfts } : {}),
651
+ ...(decoded ? { kind: decoded.kind } : {}),
652
+ };
428
653
  });
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');
654
+ const missing = rows.filter(r => !r.found);
655
+ const text = [
656
+ `${economy.name ?? 'The economy'} on ${CLUSTER}: project ${project}.`,
657
+ ...rows.map(
658
+ r =>
659
+ `- ${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})`,
660
+ ),
661
+ missing.length
662
+ ? `\n${plural(missing.length, 'part')} not on chain: publish again (xsolana_publish) to add them.`
663
+ : '',
664
+ ]
665
+ .filter(Boolean)
666
+ .join('\n');
431
667
  return { ok: true, rows, text: text + footer };
432
668
  },
433
669
  },
434
670
  {
435
671
  name: 'xsolana_client',
436
672
  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.',
673
+ description:
674
+ '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
675
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
439
676
  async handler() {
440
677
  const text = [
@@ -471,6 +708,7 @@ export const tools = [
471
708
  ' loot.openListingIx / takeListingIx / closeListingIx, loot.listingTerms(listing), loot.pda.listing(project, asset), loot.renterOf(listing, now)',
472
709
  ' a take or a bid signs the record’s terms: loot.listingTerms / offerTerms / auctionTerms(record), the hash of its bytes as read, but what a take changes',
473
710
  ' loot.decodeRound(data), loot.partsOwed(round, entry), loot.commonOwed(round), loot.pullIx(...) a round’s parts, each pulled in any order (a place’s roll: loot.pullRolls, asked in the same transaction)',
711
+ ' loot.drawLotteryIx({ ..., lottery, round, owing }) a round paid from a pot first moves in what the round before left it (`lottery.owing`, its record as `owing`); loot.pda.lotteryPot(lottery, id), loot.potIdOf(terms), loot.leftoverTo(lottery, terms)',
474
712
  ' loot.decodeEnds(data), loot.endsToWalk(farm, ends, now), loot.farmNeedsWalk(farm, ends, now), loot.walkFarmParts({ project, id, farm, ends, now }), loot.pda.ends(farm) a farm’s record of when seats stop earning',
475
713
  ' loot.orderRefundable(order, slot, now), loot.rollAskable(order, slot, now), loot.lotteryDrawStuck(round, now), loot.lotteryCancellable(round, now) deadlines, by the chain’s clock',
476
714
  ' loot.runsDue(schedule, now, vaultBalance), loot.scheduleCatchesUp(schedule), loot.pda.vest(schedule), loot.pda.prizeVault(contest), loot.pda.lotteryStash(lottery), loot.pda.rewards(farm)',