@xgamesfun/solana 0.25.0 → 0.25.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/DISCLAIMER.md +1 -1
  2. package/NOTICE +4 -4
  3. package/README.md +81 -31
  4. package/bin/xsolana.mjs +19 -5
  5. package/examples/stakers-draw.json +1 -0
  6. package/js/client/index.js +1 -0
  7. package/js/client/kit.js +36 -0
  8. package/js/client/random.js +1 -1
  9. package/js/economy/check.mjs +11 -0
  10. package/js/economy/cost.mjs +1 -1
  11. package/js/economy/explain.mjs +10 -3
  12. package/js/economy/guide.mjs +1 -1
  13. package/js/economy/live.mjs +2 -1
  14. package/js/economy/review.mjs +760 -0
  15. package/js/economy/schema.json +8 -4
  16. package/js/economy/trust.mjs +15 -0
  17. package/js/index.js +1 -0
  18. package/js/mcp/http.mjs +136 -0
  19. package/js/mcp/server.mjs +34 -10
  20. package/js/mcp/tools.mjs +50 -2
  21. package/js/play/index.js +116 -2
  22. package/js/play/sponsor.js +302 -0
  23. package/js/results/index.js +2 -2
  24. package/package.json +35 -8
  25. package/release.json +1 -1
  26. package/skills/xsolana-economy/SKILL.md +32 -13
  27. package/skills/xsolana-economy/references/after-launch.md +2 -1
  28. package/skills/xsolana-economy/references/apps.md +28 -0
  29. package/skills/xsolana-economy/references/sentence-to-spec.md +1 -0
  30. package/skills/xsolana-economy/references/telling-the-person.md +6 -0
  31. package/skills/xsolana-economy/references/trust-and-cheating.md +126 -0
  32. package/types/client/index.d.ts +1 -0
  33. package/types/client/kit.d.ts +49 -0
  34. package/types/economy/cost.d.mts +1 -1
  35. package/types/economy/explain.d.mts +3 -0
  36. package/types/economy/plan.d.mts +14 -14
  37. package/types/economy/review.d.mts +45 -0
  38. package/types/economy/trust.d.mts +8 -0
  39. package/types/index.d.ts +1 -0
  40. package/types/mcp/http.d.mts +30 -0
  41. package/types/mcp/server.d.mts +321 -0
  42. package/types/mcp/tools.d.mts +71 -1
  43. package/types/play/index.d.ts +3 -1
  44. package/types/play/sponsor.d.ts +149 -0
  45. package/types/results/index.d.ts +1 -1
  46. package/examples/holder-den.json +0 -38
  47. package/examples/holders-drop.json +0 -105
  48. package/examples/match-predictions.json +0 -24
  49. package/examples/pack-league.json +0 -108
  50. package/examples/stride-sneakers.json +0 -164
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://raw.githubusercontent.com/XGamesFun/solana-kit/main/js/economy/schema.json",
4
4
  "title": "A loot economy (spec version 2)",
5
- "description": "One project's economy on the shared loot program: its currencies, collectibles, pools and the mechanics that move them. Kept as xsolana.json in a project (or as the `economy` section of a games.fun game). Amounts are in whole units of their token, percents are percents, durations are like \"7d\".",
5
+ "description": "One project's economy on the shared loot program: its currencies, collectibles, pools and the mechanics that move them. Kept as xsolana.json in a project (or as the `economy` section of an xgames.fun game). Amounts are in whole units of their token, percents are percents, durations are like \"7d\".",
6
6
  "type": "object",
7
7
  "required": ["version"],
8
8
  "additionalProperties": false,
@@ -363,7 +363,7 @@
363
363
  "type": "object",
364
364
  "required": ["key", "source"],
365
365
  "additionalProperties": false,
366
- "description": "A currency. `sol`: SOL (players pay in wrapped SOL). `existing`: any mint by address, launched anywhere, classic or Token-2022, pump.fun included (games.fun takes no share of its fees). `project` (`game` is its older spelling): a token made when the economy goes live: the project mints it as its rules say (points, tickets, shards), or, with `supply`, a fixed supply minted once to the owner (a community token you keep).",
366
+ "description": "A currency. `sol`: SOL (players pay in wrapped SOL). `existing`: any mint by address, launched anywhere, classic or Token-2022, pump.fun included (xgames.fun takes no share of its fees). `project` (`game` is its older spelling): a token made when the economy goes live: the project mints it as its rules say (points, tickets, shards), or, with `supply`, a fixed supply minted once to the owner (a community token you keep).",
367
367
  "properties": {
368
368
  "key": {
369
369
  "$ref": "#/$defs/key"
@@ -397,8 +397,8 @@
397
397
  "$ref": "#/$defs/address"
398
398
  },
399
399
  "venue": {
400
- "enum": ["games.fun", "pump.fun", "meteora", "other"],
401
- "description": "Where the token trades, recorded for the app and not read or enforced by the tools: loot doesn't launch or trade tokens. A token launched elsewhere is `existing`, by its mint."
400
+ "enum": ["xgames.fun", "games.fun", "pump.fun", "meteora", "other"],
401
+ "description": "Where the token trades, recorded for the app and not read or enforced by the tools: loot doesn't launch or trade tokens. A token launched elsewhere is `existing`, by its mint. `games.fun` is the older spelling of `xgames.fun`."
402
402
  },
403
403
  "uri": {
404
404
  "$ref": "#/$defs/link"
@@ -2462,6 +2462,10 @@
2462
2462
  "type": "integer",
2463
2463
  "minimum": 1,
2464
2464
  "description": "About how many rounds each wallet will be paid over its life. Default 1."
2465
+ },
2466
+ "trust": {
2467
+ "enum": ["decides", "replays", "bounds"],
2468
+ "description": "What the server holding a voucher key checks before it signs, said honestly: \"decides\" (it runs the game or match itself; the page sends only inputs), \"replays\" (it runs the page's inputs again, from randomness it keeps, and reads the result), \"bounds\" (the page runs the game and reports; the server checks only pace, order and daily caps, so a script at human speed gets through). Never sent on chain and changes nothing that goes live: xsolana_review and xsolana_explain read it. Only on a grant with `vouchers` or `keys`."
2465
2469
  }
2466
2470
  }
2467
2471
  },
@@ -0,0 +1,15 @@
1
+ // How far a result a voucher's server signs can be trusted: the levels a grant declares in `trust`, and each in
2
+ // words. The spec's own note about its server: never sent on chain. Pure.
3
+
4
+ /** The trust levels a voucher grant declares, strongest first. */
5
+ export const TRUST_LEVELS = Object.freeze(['decides', 'replays', 'bounds']);
6
+
7
+ /** Each trust level in words: what the server does, and what a cheater still gets. */
8
+ export const TRUST_WORDS = Object.freeze({
9
+ decides:
10
+ 'the server decides the result itself (it runs the game or the match; the page sends only inputs): nothing the page says can forge one, though a bot that plays well still earns what a good player does',
11
+ replays:
12
+ 'the server runs the page’s inputs again and reads the result itself: a forged result fails, though a bot that plays well still earns, and one that knows the run’s randomness up front can search for the best run offline',
13
+ bounds:
14
+ 'the page runs the game and reports the result, and the server checks only its pace, order and daily caps: a script that waits as long as a person would gets every reward, up to the limits',
15
+ });
package/js/index.js CHANGED
@@ -4,6 +4,7 @@ export { simulate, explain } from './economy/simulate.mjs';
4
4
  export { plan, setupSteps, playSteps } from './economy/plan.mjs';
5
5
  export { cost, explainCost } from './economy/cost.mjs';
6
6
  export { explainEconomy } from './economy/explain.mjs';
7
+ export { review, describeReview, SEVERITIES, TRUST_LEVELS, TRUST_WORDS } from './economy/review.mjs';
7
8
  export { model } from './economy/model.mjs';
8
9
  export { prepare, recordLanded, changedParts } from './economy/live.mjs';
9
10
  export { guideText, catalog, CANNOT } from './economy/guide.mjs';
@@ -0,0 +1,136 @@
1
+ // The xsolana MCP server over HTTP (MCP's Streamable HTTP transport), for a hosted endpoint that chat apps add by URL
2
+ // (claude.ai, ChatGPT, Claude Desktop), where a local stdio server can't run. It speaks Request and Response, so a
3
+ // Next.js route handler, a Worker or `xsolana mcp --http` all serve it the same way.
4
+ //
5
+ // It is the stdio server's own handler (server.mjs `handle`) in remote mode: only the tools in REMOTE_TOOLS, every spec
6
+ // passed whole as an argument, no file read or written, nothing signed. Each request stands alone (no session), and
7
+ // every answer is one JSON body: no event stream, so GET is refused as the transport allows.
8
+ import { createServer } from 'node:http';
9
+ import { handle } from './server.mjs';
10
+
11
+ /** The largest request body read: a spec is a few kilobytes; a megabyte is far more than any. */
12
+ export const MAX_BODY_BYTES = 1_000_000;
13
+
14
+ const CORS = {
15
+ 'access-control-allow-origin': '*',
16
+ 'access-control-allow-methods': 'POST, OPTIONS',
17
+ 'access-control-allow-headers': 'content-type, accept, authorization, mcp-protocol-version, mcp-session-id',
18
+ 'access-control-max-age': '86400',
19
+ };
20
+
21
+ const json = (body, status = 200) =>
22
+ new Response(JSON.stringify(body), {
23
+ status,
24
+ headers: { 'content-type': 'application/json', 'cache-control': 'no-store', ...CORS },
25
+ });
26
+ const empty = (status, headers = {}) => new Response(null, { status, headers: { ...CORS, ...headers } });
27
+ const rpcError = (code, message, status = 400) => json({ jsonrpc: '2.0', id: null, error: { code, message } }, status);
28
+
29
+ /** The body as text, or null once it is longer than `limit` bytes. */
30
+ async function bodyText(request, limit) {
31
+ const said = Number(request.headers.get('content-length'));
32
+ if (said > limit) return null;
33
+ if (!request.body) return '';
34
+ const reader = request.body.getReader();
35
+ const chunks = [];
36
+ let size = 0;
37
+ for (;;) {
38
+ const { done, value } = await reader.read();
39
+ if (done) break;
40
+ size += value.length;
41
+ if (size > limit) {
42
+ await reader.cancel();
43
+ return null;
44
+ }
45
+ chunks.push(value);
46
+ }
47
+ const all = new Uint8Array(size);
48
+ let at = 0;
49
+ for (const c of chunks) {
50
+ all.set(c, at);
51
+ at += c.length;
52
+ }
53
+ return new TextDecoder().decode(all);
54
+ }
55
+
56
+ /**
57
+ * One HTTP request to the MCP endpoint, answered. `allowOrigin(origin)` decides which browser origins may call it (a
58
+ * request with no Origin header, from a server or a desktop app, always may); by default every origin, since the
59
+ * endpoint holds no session, no cookie and nothing private.
60
+ * @param {Request} request
61
+ * @param {{ allowOrigin?: (origin: string) => boolean, maxBodyBytes?: number, ctx?: object }} [options]
62
+ * @returns {Promise<Response>}
63
+ */
64
+ export async function mcpHttp(request, { allowOrigin = () => true, maxBodyBytes = MAX_BODY_BYTES, ctx = {} } = {}) {
65
+ const origin = request.headers.get('origin');
66
+ if (origin && !allowOrigin(origin)) return rpcError(-32000, 'This origin may not call this server.', 403);
67
+ if (request.method === 'OPTIONS') return empty(204);
68
+ if (request.method !== 'POST') return empty(405, { allow: 'POST, OPTIONS' });
69
+ const text = await bodyText(request, maxBodyBytes);
70
+ if (text === null) return rpcError(-32600, `The request is larger than ${maxBodyBytes} bytes.`, 413);
71
+ let message;
72
+ try {
73
+ message = JSON.parse(text);
74
+ } catch {
75
+ return rpcError(-32700, 'Not JSON.');
76
+ }
77
+ const answer = m =>
78
+ handle(m, { ...ctx, remote: true }).catch(e => ({
79
+ jsonrpc: '2.0',
80
+ id: m?.id ?? null,
81
+ error: { code: -32603, message: e.message },
82
+ }));
83
+ if (Array.isArray(message)) {
84
+ const replies = (await Promise.all(message.map(answer))).filter(Boolean);
85
+ return replies.length ? json(replies) : empty(202);
86
+ }
87
+ const reply = await answer(message);
88
+ // A notification or a client's response gets no body.
89
+ return reply ? json(reply) : empty(202);
90
+ }
91
+
92
+ /**
93
+ * Serves mcpHttp at `path` on this machine (`xsolana mcp --http`). It listens on 127.0.0.1 unless told otherwise, and
94
+ * then answers only pages on localhost: a site in the browser can't reach it by renaming its host (DNS rebinding).
95
+ * @returns {Promise<{ address(): { port: number } | string | null, close(done?: () => void): unknown }>} the node:http server
96
+ */
97
+ export function serveHttp({ port = 8787, host = '127.0.0.1', path = '/mcp' } = {}) {
98
+ const local = host === '127.0.0.1' || host === 'localhost' || host === '::1';
99
+ const allowOrigin = origin => {
100
+ if (!local) return true;
101
+ try {
102
+ return ['localhost', '127.0.0.1', '[::1]'].includes(new URL(origin).hostname);
103
+ } catch {
104
+ return false;
105
+ }
106
+ };
107
+ const server = createServer(async (req, res) => {
108
+ try {
109
+ const url = new URL(req.url ?? '/', 'http://localhost');
110
+ if (url.pathname !== path) {
111
+ res.writeHead(404, { 'content-type': 'text/plain' }).end(`The MCP endpoint is ${path}.`);
112
+ return;
113
+ }
114
+ const request = new Request(url, {
115
+ method: req.method,
116
+ headers: Object.entries(req.headers).flatMap(([k, v]) =>
117
+ v === undefined ? [] : (Array.isArray(v) ? v : [v]).map(one => [k, one]),
118
+ ),
119
+ body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req,
120
+ duplex: 'half',
121
+ });
122
+ const response = await mcpHttp(request, { allowOrigin });
123
+ res.writeHead(response.status, Object.fromEntries(response.headers));
124
+ res.end(response.body ? Buffer.from(await response.arrayBuffer()) : undefined);
125
+ } catch (e) {
126
+ res.writeHead(500, { 'content-type': 'text/plain' }).end(e.message);
127
+ }
128
+ });
129
+ return new Promise((resolve, reject) => {
130
+ server.once('error', reject);
131
+ server.listen(port, host, () => {
132
+ console.error(`xsolana MCP server over HTTP: http://${host}:${server.address().port}${path}`);
133
+ resolve(server);
134
+ });
135
+ });
136
+ }
package/js/mcp/server.mjs CHANGED
@@ -6,14 +6,35 @@
6
6
  //
7
7
  // stdout is the protocol: everything else goes to stderr.
8
8
  import { validate } from '../economy/validate.mjs';
9
- import { PROGRAM_INFO, tools, VERSION } from './tools.mjs';
9
+ import { PACKAGE_VERSION, PROGRAM_INFO, REMOTE_TOOLS, tools, VERSION } from './tools.mjs';
10
10
 
11
11
  export const INSTRUCTIONS = [
12
12
  'loot is one shared Solana program for game and community economies (shops, loot boxes, lotteries, auctions, staking farms, crafting, contests, schedules, perks for holders of any NFT, capped rewards). An economy is a JSON spec, not a new smart contract: never write Anchor or Rust for what loot covers.',
13
- 'Start with xsolana_guide, then xsolana_examples with the person’s own words; write the spec; xsolana_check after every change; xsolana_simulate and xsolana_explain before telling them what it does; xsolana_save; xsolana_publish when they say to put it live (they approve in their own wallet: you never sign or ask for a key).',
13
+ 'Start with xsolana_guide, then xsolana_examples with the person’s own words; write the spec; xsolana_check after every change; xsolana_simulate and xsolana_explain before telling them what it does; xsolana_review before going live (fix its high findings, say honestly what each voucher server checks, and tell the person what a cheater gets); xsolana_save; xsolana_publish when they say to put it live (they approve in their own wallet: you never sign or ask for a key).',
14
14
  `Program ${PROGRAM_INFO.program} on ${PROGRAM_INFO.cluster} (loot ${VERSION}). Devnet only until audited.`,
15
15
  ].join(' ');
16
16
 
17
+ /**
18
+ * What a hosted server (js/mcp/http.mjs) tells the agent: the same work, with the spec passed whole in every call,
19
+ * and going live on the person's own computer, since a hosted server can neither write their files nor open a page there.
20
+ */
21
+ export const REMOTE_INSTRUCTIONS = [
22
+ INSTRUCTIONS.split(' Start with')[0],
23
+ 'Start with xsolana_guide, then xsolana_examples with the person’s own words; write the spec and pass it whole as "spec" to every tool (this hosted server reads no files); xsolana_check after every change; xsolana_simulate and xsolana_explain before telling them what it does; xsolana_review before going live (fix its high findings, say honestly what each voucher server checks, and tell the person what a cheater gets).',
24
+ `To go live, give the person the spec as a file named xsolana.json and this command, run in its folder: npx -y @xgamesfun/solana@${PACKAGE_VERSION} approve xsolana.json. It opens a page on their own computer where they approve each transaction in their own wallet. Nobody else signs, and never ask for a key. (xsolana_save and xsolana_publish, which tool texts may name, run only in the local server.)`,
25
+ `Program ${PROGRAM_INFO.program} on ${PROGRAM_INFO.cluster} (loot ${VERSION}). Devnet only until audited.`,
26
+ ].join(' ');
27
+
28
+ /** A tool as tools/list shows it; on a hosted server, without the `file` argument it can't take. */
29
+ function listed(t, remote) {
30
+ let inputSchema = t.inputSchema;
31
+ if (remote && inputSchema.properties?.file) {
32
+ const { file: _file, ...properties } = inputSchema.properties;
33
+ inputSchema = { ...inputSchema, properties };
34
+ }
35
+ return { name: t.name, description: t.description, inputSchema, annotations: t.annotations };
36
+ }
37
+
17
38
  /**
18
39
  * The protocol versions this server speaks, newest first. A client that asks for one of them gets it; any other ask
19
40
  * gets the newest, and the client decides whether it can go on (the spec's version negotiation).
@@ -36,6 +57,13 @@ export async function runTool(name, input = {}, ctx = {}) {
36
57
  text: `No tool ${name}.${near.length ? ` Did you mean ${near.join(', ')}?` : ''} Tools: ${tools.map(t => t.name).join(', ')}.`,
37
58
  };
38
59
  }
60
+ if (ctx.remote && !REMOTE_TOOLS.has(name))
61
+ return {
62
+ isError: true,
63
+ text: `${name} works on the person’s own files, so it runs only in the local server (npx -y --package=@xgamesfun/solana@${PACKAGE_VERSION} xsolana mcp).`,
64
+ };
65
+ if (ctx.remote && 'file' in input)
66
+ return { isError: true, text: 'This hosted server reads no files: pass the whole spec as "spec".' };
39
67
  const bad = inputProblems(tool.inputSchema, input);
40
68
  if (bad.length) return { isError: true, text: bad.join('; ') };
41
69
  try {
@@ -64,22 +92,18 @@ export async function handle(message, ctx = {}) {
64
92
  protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
65
93
  capabilities: { tools: {} },
66
94
  serverInfo: { name: 'xsolana', version: VERSION },
67
- instructions: INSTRUCTIONS,
95
+ instructions: ctx.remote ? REMOTE_INSTRUCTIONS : INSTRUCTIONS,
68
96
  });
69
97
  }
70
98
  case 'ping':
71
99
  return reply({});
72
100
  case 'tools/list':
73
101
  return reply({
74
- tools: tools.map(t => ({
75
- name: t.name,
76
- description: t.description,
77
- inputSchema: t.inputSchema,
78
- annotations: t.annotations,
79
- })),
102
+ tools: tools.filter(t => !ctx.remote || REMOTE_TOOLS.has(t.name)).map(t => listed(t, ctx.remote)),
80
103
  });
81
104
  case 'tools/call': {
82
- const result = await runTool(params?.name, params?.arguments ?? {}, { ...ctx, cwd: ctx.cwd ?? process.cwd() });
105
+ const input = params?.arguments ?? {};
106
+ const result = await runTool(params?.name, input, ctx.remote ? ctx : { ...ctx, cwd: ctx.cwd ?? process.cwd() });
83
107
  // A tool that failed says so (isError), and keeps whatever it found (a save's refusals, say) as structuredContent.
84
108
  const { text, isError, ...data } = jsonable(result);
85
109
  return reply({
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';
@@ -68,6 +69,22 @@ const nowOf = ctx => ctx?.now ?? Math.floor(Date.now() / 1000);
68
69
  const cwdOf = ctx => ctx?.cwd ?? process.cwd();
69
70
  export const DEFAULT_FILE = 'xsolana.json';
70
71
 
72
+ /**
73
+ * The tools a hosted server (js/mcp/http.mjs) offers: those that read nothing but their arguments and the chain.
74
+ * xsolana_save and xsolana_publish write and read the person's own files, so they run only on the person's machine.
75
+ */
76
+ export const REMOTE_TOOLS = new Set([
77
+ 'xsolana_guide',
78
+ 'xsolana_examples',
79
+ 'xsolana_check',
80
+ 'xsolana_simulate',
81
+ 'xsolana_cost',
82
+ 'xsolana_explain',
83
+ 'xsolana_review',
84
+ 'xsolana_status',
85
+ 'xsolana_client',
86
+ ]);
87
+
71
88
  /** The real path of `p`, or of the nearest folder above it that exists, with the rest added back. */
72
89
  function realPath(p) {
73
90
  try {
@@ -140,6 +157,11 @@ const SOURCE = {
140
157
  async function load(input, ctx) {
141
158
  if (input.spec) return { economy: economyOf(input.spec), from: 'the spec given' };
142
159
  if (input.example) return { economy: example(input.example).spec, from: `the example ${input.example}` };
160
+ // A hosted server (js/mcp/http.mjs) reads no file: the machine it runs on is not the person's.
161
+ if (ctx?.remote)
162
+ throw new Error(
163
+ 'This hosted server reads no files: pass the whole spec as "spec" (or a worked example by name as "example").',
164
+ );
143
165
  const rel = input.file ?? DEFAULT_FILE;
144
166
  const file = projectFile(rel, ctx);
145
167
  if (!existsSync(file)) {
@@ -339,7 +361,7 @@ export const tools = [
339
361
  const verdict = check(economy, { now: nowOf(ctx) });
340
362
  const head = verdict.refusals.length || verdict.warnings.length ? describe(verdict) : 'No problems found.';
341
363
  const tail = verdict.ok
342
- ? `\nReady (${plural(verdict.warnings.length, 'warning')}). Next: xsolana_simulate and xsolana_explain, and tell the person what it means in plain words.`
364
+ ? `\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.`
343
365
  : `\n${plural(verdict.refusals.length, 'refusal')}: fix each as its fix says, then check again.`;
344
366
  return {
345
367
  ok: verdict.ok,
@@ -420,6 +442,30 @@ export const tools = [
420
442
  return { ...r, from, text: r.text + footer };
421
443
  },
422
444
  },
445
+ {
446
+ name: 'xsolana_review',
447
+ annotations: READ,
448
+ description:
449
+ '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) }.',
450
+ inputSchema: { type: 'object', properties: SOURCE, additionalProperties: false },
451
+ async handler(input, ctx) {
452
+ const { economy, from } = await load(input, ctx);
453
+ const r = review(economy, { now: nowOf(ctx) });
454
+ if (r.refusals) return refused({ refusals: r.refusals, warnings: [] }, from);
455
+ const highs = r.findings.filter(f => f.severity === 'high').length;
456
+ const tail = highs
457
+ ? `\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.`
458
+ : '\nNo high finding. Tell the person, in plain words, which level each signed route uses and what a cheater gets (the trade-offs above).';
459
+ return {
460
+ ok: r.ok,
461
+ from,
462
+ routes: r.routes,
463
+ findings: r.findings,
464
+ tradeoffs: r.tradeoffs,
465
+ text: `${describeReview(r, economy.name)}${tail}${footer}`,
466
+ };
467
+ },
468
+ },
423
469
  {
424
470
  name: 'xsolana_save',
425
471
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
@@ -566,7 +612,7 @@ export const tools = [
566
612
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
567
613
  description:
568
614
  '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).',
569
- inputSchema: { type: 'object', properties: { file: SOURCE.file }, additionalProperties: false },
615
+ inputSchema: { type: 'object', properties: { spec: SOURCE.spec, file: SOURCE.file }, additionalProperties: false },
570
616
  async handler(input, ctx) {
571
617
  const { economy, from } = await load(input, ctx);
572
618
  if (!economy.live?.project)
@@ -658,6 +704,7 @@ export const tools = [
658
704
  ' `chosenRecipient` (buy, openBox, pay): the wallet (or collectible) the player names for a "chosen" share of the split (a referrer from the link)',
659
705
  ' app.lotteries(), app.buyTickets(key, n, { listing }) (or { maxPrice }: a ticket’s most, in base units),',
660
706
  ' 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),',
707
+ ' solanaApp({ ..., sponsor: httpSponsor({ url, address }) }) the project pays players’ fees (never the price) from a key on its own server: @xgamesfun/solana/sponsor sponsorSigner + sponsorHandler there',
661
708
  ' app.collectibles(), app.balances() what a player does. `listing`: the entry shop(), boxes(), lotteries() or recipes() showed the',
662
709
  ' player; its price (or version) is the most they pay: one that moved since is refused, never paid unseen',
663
710
  ' a prize (a grant, a lottery place, a contest win, a mission, a recipe) is given in the instruction that wins it,',
@@ -679,6 +726,7 @@ export const tools = [
679
726
  ' import * as loot from "@xgamesfun/solana/client";',
680
727
  ' loot.pda.project(owner, number), loot.pda.box(project, id), … every address the program derives',
681
728
  ' loot.openBoxIx({ player, project, box, nonce }), loot.buyIx({ player, project, box, items }) … one builder per instruction; each returns { programId, keys, data }',
729
+ ' loot.toKitInstruction(ix) a builder’s instruction in @solana/kit’s shape ({ programAddress, accounts, data }), for an app built on Kit; loot.fromKitInstruction back',
682
730
  ' loot.payNftIx({ player, project, asset, to }) pay with an NFT (burned, or given to `to`): the app admits by the receipt',
683
731
  ' loot.openListingIx / takeListingIx / closeListingIx, loot.listingTerms(listing), loot.pda.listing(project, asset), loot.renterOf(listing, now)',
684
732
  ' 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',
package/js/play/index.js CHANGED
@@ -57,6 +57,20 @@ import {
57
57
  import { compileMessage, readTransaction, wireTransaction } from '../chain/tx.js';
58
58
  import * as loot from '../client/index.js';
59
59
  import { browserStore, keyring } from './keys.js';
60
+ import { SPONSOR_MAX_LAMPORTS, sponsoredCost, sponsorGiveBack, sponsorTopUp, stepShape, topUpFor } from './sponsor.js';
61
+
62
+ export {
63
+ checkSponsored,
64
+ httpSponsor,
65
+ sponsoredCost,
66
+ sponsorGiveBack,
67
+ sponsorHandler,
68
+ sponsorSigner,
69
+ sponsorTopUp,
70
+ stepShape,
71
+ topUpFor,
72
+ SPONSOR_MAX_LAMPORTS,
73
+ } from './sponsor.js';
60
74
 
61
75
  // The options of a call made without any: read-only, and typed so the generated declarations take any option.
62
76
  /** A lottery's steps in `claims()`: each depends on the one before, so `claimAll` takes one of a lottery's a call. */
@@ -190,6 +204,7 @@ export function solanaApp({
190
204
  programs = {},
191
205
  now = () => Math.floor(Date.now() / 1000),
192
206
  keyStore = browserStore(),
207
+ sponsor = null,
193
208
  }) {
194
209
  const live = spec?.live;
195
210
  if (!live?.project) throw new Error('This economy is not live yet: its xsolana.json has no "live" section.');
@@ -591,6 +606,15 @@ export function solanaApp({
591
606
  // A compressed NFT's record is closed in the transaction that used it, its prover signing: its rent comes back.
592
607
  instructions = loot.withLeafCloses(instructions);
593
608
  if (!keepWrapped) instructions = await unwrapAfter(instructions);
609
+ if (sponsor) {
610
+ const sponsored = await sendSponsored(instructions, extra);
611
+ if (sponsored) return sponsored;
612
+ }
613
+ return sendUnsponsored(instructions, extra);
614
+ }
615
+
616
+ /** The player's step as they pay for it themselves: their wallet the fee payer (`sendFitting`). */
617
+ async function sendUnsponsored(instructions, extra = []) {
594
618
  const sign = async message => {
595
619
  const { signers } = readTransaction(wireTransaction(message));
596
620
  const sigs = await Promise.all(
@@ -616,6 +640,79 @@ export function solanaApp({
616
640
 
617
641
  const sendNow = (...args) => send(...args);
618
642
 
643
+ /** The most the sponsor pays a transaction (its top-up and network fee), as the app was told. */
644
+ // It lends the player that, less room for the network fee, and takes back what the step didn't need.
645
+ const sponsorMax = sponsor ? BigInt(sponsor.maxLamports ?? SPONSOR_MAX_LAMPORTS) - SIGNATURE_FEE * 4n : 0n;
646
+
647
+ /** Every account a list of instructions names, its programs too. */
648
+ const namedIn = instructions => [
649
+ ...new Set(instructions.flatMap(ix => [ix.programId, ...ix.keys.map(k => k.pubkey)])),
650
+ ];
651
+
652
+ /**
653
+ * The player's step with the sponsor paying (`sponsor`, js/play/sponsor.js): the sponsor the fee payer and the
654
+ * player topped up first by what the step costs them in fees and rent (a dry run, as the sponsor's server will
655
+ * check it), the player's wallet signing, then the sponsor's server. Null when it can't be sponsored as one
656
+ * transaction (it needs a lookup table of its own, or proofs sent first), or the sponsor refused it and the player
657
+ * pays instead (`sponsor.playerPaysIfRefused`, the default): the step then goes as the player's own.
658
+ */
659
+ async function sendSponsored(instructions, extra) {
660
+ const me = wallet.address;
661
+ const tables = await standing();
662
+ const { blockhash, lastValidBlockHeight } = await client.latestBlockhash();
663
+ // Lent the most, the step is tried; then sent lent the same, the player giving back last all but what it costs in
664
+ // fees and rent: a step can need more SOL midway than it costs (a wrapped-SOL account it opens and closes).
665
+ const compile = (back = 0n) =>
666
+ compileFitting({
667
+ payer: sponsor.address,
668
+ blockhash,
669
+ instructions: [
670
+ sponsorTopUp(sponsor.address, me, sponsorMax),
671
+ ...instructions,
672
+ ...(back > 0n ? [sponsorGiveBack(sponsor.address, me, back)] : []),
673
+ ],
674
+ tables,
675
+ });
676
+ const tried = compile();
677
+ if (!tried) return null;
678
+ const addresses = namedIn([sponsorTopUp(sponsor.address, me, 0n), ...instructions]);
679
+ const pre = await client.accounts(addresses);
680
+ // A step loot refuses is refused in words; one that fails otherwise (the sponsor short of SOL) goes as the player's.
681
+ const run = await client
682
+ .simulate(wireTransaction(tried), { accounts: addresses, replaceBlockhash: true })
683
+ .catch(e => (loot.lootError(e?.logs ?? []) ? inWords(e) : null));
684
+ if (!run) return null;
685
+ const post = run.accounts;
686
+ const at = a => addresses.indexOf(a);
687
+ const balance = a => (a ? BigInt(a.lamports) : 0n);
688
+ const cost = sponsoredCost({
689
+ addresses,
690
+ pre,
691
+ post,
692
+ project,
693
+ skip: [sponsor.address, me],
694
+ ...stepShape(instructions),
695
+ });
696
+ const spend = balance(pre[at(me)]) + sponsorMax - balance(post[at(me)]);
697
+ const message = compile(sponsorMax - topUpFor({ spend, cost }));
698
+ if (!message) return null;
699
+ const { signers } = readTransaction(wireTransaction(message));
700
+ const sigs = await Promise.all(
701
+ signers.map(async s => extra.find(k => k.address === s)?.sign(message) ?? new Uint8Array(64)),
702
+ );
703
+ const signed = await wallet.signTransaction(wireTransaction(message, sigs));
704
+ let wire;
705
+ try {
706
+ wire = await sponsor.sign(signed);
707
+ } catch (e) {
708
+ if (sponsor.playerPaysIfRefused === false) throw e;
709
+ return null;
710
+ }
711
+ const signature = await client.send(wire).catch(inWords);
712
+ await client.confirm?.(signature, { lastValidBlockHeight });
713
+ return signature;
714
+ }
715
+
619
716
  /**
620
717
  * Several independent steps of the player's, each one transaction (`lists`: their instructions), signed in one wallet
621
718
  * prompt when the wallet signs several at once (`wallet.signTransactions`), then sent in order; one by one otherwise,
@@ -630,7 +727,7 @@ export function solanaApp({
630
727
  let signed = null;
631
728
  let messages = [];
632
729
  let lastValidBlockHeight = null;
633
- if (wallet?.signTransactions && lists.length > 1) {
730
+ if (wallet?.signTransactions && lists.length > 1 && !sponsor) {
634
731
  const latest = await client.latestBlockhash();
635
732
  lastValidBlockHeight = latest.lastValidBlockHeight;
636
733
  const tables = await standing();
@@ -791,6 +888,12 @@ export function solanaApp({
791
888
  * them), and the accounts `watch` as they would be after: { ok, accounts, error }, `error` loot's when it refused
792
889
  * ({ code, name, msg }, `loot.lootError`). */
793
890
  async function dryRun(payer, instructions, watch = []) {
891
+ // A sponsored player's step is tried as it will be sent: the sponsor pays, and tops the player up (here by the
892
+ // most it pays, so a player with no SOL is tried as one the sponsor covers).
893
+ if (sponsor && payer === wallet?.address) {
894
+ instructions = [sponsorTopUp(sponsor.address, payer, sponsorMax), ...instructions];
895
+ payer = sponsor.address;
896
+ }
794
897
  const { blockhash } = await client.latestBlockhash();
795
898
  const message = compileFitting({ payer, blockhash, instructions, tables: await standing() });
796
899
  if (!message) return { ok: false, accounts: [], error: null };
@@ -848,6 +951,17 @@ export function solanaApp({
848
951
  return order.status === 'Waiting' && order.player === wallet.address ? [[address, order]] : [];
849
952
  });
850
953
  if (!waiting.length) return { signature: await send(instructions), rolls: [] };
954
+ // Sponsored, a settle key isn't funded (a plain account the player could empty is never sponsored): the step asks
955
+ // for each roll, and the player's wallet settles it once it is answered (one more prompt, sponsored too).
956
+ if (sponsor) {
957
+ const asked = [...instructions];
958
+ for (const [orderAddress, order] of waiting) asked.push(...(await asksFor(wallet.address, orderAddress, order)));
959
+ const tried = await dryRun(wallet.address, asked);
960
+ const signature = await send(tried.ok ? asked : instructions);
961
+ const rolls = [];
962
+ if (tried.ok) for (const [orderAddress] of waiting) rolls.push(await collect(orderAddress, { opened: true }));
963
+ return { signature, rolls };
964
+ }
851
965
  const asks = [];
852
966
  const funds = [];
853
967
  const burners = [];
@@ -1337,7 +1451,7 @@ export function solanaApp({
1337
1451
  on = null,
1338
1452
  maxPrice,
1339
1453
  listing,
1340
- oneSignature = true,
1454
+ oneSignature = !sponsor,
1341
1455
  chosenRecipient = null,
1342
1456
  listProof,
1343
1457
  count = 1,