@xgamesfun/solana 0.25.1 → 0.25.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,9 @@
1
- # solana-kit
1
+ # solana-kit: Solana game and NFT economies without a smart contract
2
2
 
3
- The tools for **loot**, one shared Solana program for game and community economies. This repository is the npm
4
- package `@xgamesfun/solana`:
3
+ Loot boxes and gacha, shops, lotteries and raffles, auctions, NFT staking farms, crafting, quests and capped rewards on
4
+ Solana, written as a JSON spec instead of a new Anchor program. They all run on **loot**, one shared, open Solana
5
+ program, and an AI coding agent (Claude Code, Codex, Cursor or any MCP client) can design, check, simulate and price
6
+ one from a sentence. This repository is the npm package `@xgamesfun/solana`:
5
7
 
6
8
  - a JavaScript client with no Solana SDK (`@xgamesfun/solana/client`), a player module for apps (`/play`) and a results
7
9
  client (`/results`);
@@ -104,14 +106,29 @@ codex plugin marketplace add XGamesFun/solana-kit
104
106
  codex plugin add xsolana@xgamesfun
105
107
  ```
106
108
 
109
+ **Chat apps** (claude.ai, ChatGPT, Claude Desktop): add `https://xgames.fun/mcp` as a custom connector (an MCP server
110
+ by URL). It is the hosted server (below, "Over HTTP"): nothing to install, and going live stays your own approval on your
111
+ computer. More at [xgames.fun/solana](https://xgames.fun/solana).
112
+
107
113
  **Any agent with MCP** (Cursor, opencode, Cline…): run the server with
108
- `npx -y --package=@xgamesfun/solana@0.25.1 xsolana mcp`. Or let `npx -y @xgamesfun/solana@0.25.1 connect` register it, with
114
+ `npx -y --package=@xgamesfun/solana@0.25.3 xsolana mcp`. Or let `npx -y @xgamesfun/solana@0.25.3 connect` register it, with
109
115
  the skill, for Claude Code and Codex.
110
116
 
117
+ **The skill alone, in any agent that reads skills** (Claude Code, Codex, Copilot, Gemini CLI, Cursor, Windsurf, Cline,
118
+ opencode…): `npx skills add XGamesFun/solana-kit` finds it and installs it for the agents you have. Without the MCP
119
+ server it runs the same tools through `npx`. Each GitHub release also carries `xsolana-economy.zip`, the skill as one
120
+ file for apps that take a skill as an upload (the Claude and ChatGPT desktop apps).
121
+
122
+ **Over HTTP**: `npx -y --package=@xgamesfun/solana@0.25.3 xsolana mcp --http` serves the tools that read nothing but
123
+ their arguments (guide, examples, check, simulate, cost, explain, review, status, client) at
124
+ `http://127.0.0.1:8787/mcp`, MCP's Streamable HTTP transport. `@xgamesfun/solana/mcp/http` exports the same handler
125
+ (`mcpHttp(request) → response`) for a hosted endpoint: it reads no files, and the agent hands the person the spec and the
126
+ `approve` command to go live.
127
+
111
128
  Then ask for an economy, for example _"a fishing game where each cast costs 0.02 SOL, with a rare golden fish and a
112
129
  monthly draw"_.
113
130
 
114
- Going live is always yours: `npx -y @xgamesfun/solana@0.25.1 approve` opens a page on your computer where you connect a
131
+ Going live is always yours: `npx -y @xgamesfun/solana@0.25.3 approve` opens a page on your computer where you connect a
115
132
  devnet wallet and approve each transaction. The agent never signs and never asks for a key.
116
133
 
117
134
  ## Use it from a terminal
@@ -119,13 +136,13 @@ devnet wallet and approve each transaction. The agent never signs and never asks
119
136
  Every tool runs from the CLI too, on a spec file (`xsolana.json` by default):
120
137
 
121
138
  ```bash
122
- npx -y @xgamesfun/solana@0.25.1 examples shop with loot boxes # the nearest worked examples
123
- npx -y @xgamesfun/solana@0.25.1 check xsolana.json # refusals and warnings
124
- npx -y @xgamesfun/solana@0.25.1 simulate xsolana.json # where the money goes, day by day
125
- npx -y @xgamesfun/solana@0.25.1 cost xsolana.json # what going live and each action cost
126
- npx -y @xgamesfun/solana@0.25.1 explain xsolana.json # the economy in plain words
127
- npx -y @xgamesfun/solana@0.25.1 review xsolana.json # what a cheater gets on each route (exits 1 on a high finding)
128
- npx -y @xgamesfun/solana@0.25.1 approve xsolana.json # go live, approved in your own wallet
139
+ npx -y @xgamesfun/solana@0.25.3 examples shop with loot boxes # the nearest worked examples
140
+ npx -y @xgamesfun/solana@0.25.3 check xsolana.json # refusals and warnings
141
+ npx -y @xgamesfun/solana@0.25.3 simulate xsolana.json # where the money goes, day by day
142
+ npx -y @xgamesfun/solana@0.25.3 cost xsolana.json # what going live and each action cost
143
+ npx -y @xgamesfun/solana@0.25.3 explain xsolana.json # the economy in plain words
144
+ npx -y @xgamesfun/solana@0.25.3 review xsolana.json # what a cheater gets on each route (exits 1 on a high finding)
145
+ npx -y @xgamesfun/solana@0.25.3 approve xsolana.json # go live, approved in your own wallet
129
146
  ```
130
147
 
131
148
  `xsolana help` lists every command. Every command names the exact version, so `npx` runs that release and never whatever
@@ -142,6 +159,13 @@ import * as loot from '@xgamesfun/solana/client';
142
159
  const ix = loot.openBoxIx({ player, project, box, nonce }); // one builder per instruction, no Solana SDK
143
160
  ```
144
161
 
162
+ Players with no SOL can still play when the project sponsors their fees: `solanaApp({ sponsor })` has a key on the
163
+ project's own server pay each step's fees and rent (never its price), and `@xgamesfun/solana/sponsor` is that server's
164
+ side: `sponsorSigner` dry-runs and checks every transaction before it signs, under the project's own per-player budget.
165
+
166
+ Building on `@solana/kit`? `loot.toKitInstruction(ix)` gives any builder's instruction in Kit's shape
167
+ (`{ programAddress, accounts, data }`), and `loot.fromKitInstruction` turns one back.
168
+
145
169
  | Import | What |
146
170
  | -------------------------------- | ------------------------------------------------------------------------------------------- |
147
171
  | `@xgamesfun/solana` | the economy tools: `check`, `simulate`, `plan`, `cost`, `explainEconomy`, `review`, `model` |
@@ -155,10 +179,35 @@ const ix = loot.openBoxIx({ player, project, box, nonce }); // one builder per i
155
179
  The client, chain and play modules run in browsers and Node 22 or later, with no bundler required. The package
156
180
  ships TypeScript declarations for every entry point.
157
181
 
182
+ ## Questions people ask
183
+
184
+ **How do I make a loot box, gacha or pack opening on Solana without writing a smart contract?** Describe it as a box
185
+ in a spec (its price, its outcomes and their odds, what each one pays), run `xsolana check` and `xsolana simulate`, then
186
+ approve it in your wallet with `xsolana approve`. Rolls come from MagicBlock's VRF, and a buyer is refunded if the
187
+ oracle never answers. [`examples/`](examples) has whole worked economies.
188
+
189
+ **How do I add staking to an NFT collection, even one I didn't mint?** A staking farm takes any collection, with lock
190
+ tiers, boosts and fixed or shared reward rates. The project needs no authority over the NFTs: see
191
+ [`docs/nfts.md`](docs/nfts.md).
192
+
193
+ **Can I run a lottery, raffle or auction on Solana without deploying a program?** Yes: lotteries (including no-loss
194
+ ones), raffles with a Merkle allowlist, and auctions that refund the outbid at once are all routes in the same spec.
195
+
196
+ **Is there an MCP server or Claude Code plugin for building on Solana?** Yes: the `xsolana` plugin for Claude Code and
197
+ Codex, the hosted server at `https://xgames.fun/mcp` for chat apps, and `npx -y --package=@xgamesfun/solana@0.25.3 xsolana mcp` for any MCP client. It is listed in the MCP
198
+ Registry as `io.github.XGamesFun/solana-kit`.
199
+
200
+ **How is this different from writing my own Anchor program?** Nothing is written, deployed or upgraded per project:
201
+ every project's rules are checked by the one program, and its tests run every instruction against that program in
202
+ LiteSVM. The trade-off is that an economy can only be what the spec can say ([known limits](docs/program.md#known-limits)).
203
+
204
+ **Does it run on mainnet?** Not yet: devnet only until the program is independently audited.
205
+
158
206
  ## Documentation
159
207
 
160
208
  | | |
161
209
  | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
210
+ | [`docs/tutorial-loot-box.md`](docs/tutorial-loot-box.md) | a loot box from one sentence to going live, step by step |
162
211
  | [`docs/program.md`](docs/program.md) | the program: who signs, accounts, every instruction, fees, limits, receipts, costs |
163
212
  | [`docs/nfts.md`](docs/nfts.md) | NFTs and tokens, with and without the project's authority |
164
213
  | [`docs/harnesses.md`](docs/harnesses.md) | how the plugin reaches Claude Code and Codex, and the release checklist |
package/bin/xsolana.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // the same answer, for an agent (or a person) without MCP.
4
4
  //
5
5
  // xsolana mcp serve the tools over stdio (what a plugin's MCP config runs)
6
+ // xsolana mcp --http [--port n] [--host h] serve the hosted tools over HTTP at /mcp (127.0.0.1:8787 by default)
6
7
  // xsolana guide [part] [--json] what an economy can be made of, and what loot cannot do
7
8
  // xsolana examples [words…] [--json] the nearest worked examples and rated economies
8
9
  // xsolana check|simulate|cost|explain|review|publish|status [file] [--json] one tool on a spec file (default xsolana.json)
@@ -31,6 +32,7 @@ const USAGE = `xsolana: the tools for loot, one shared Solana program for game a
31
32
  xsolana status [file] a live economy on chain
32
33
  xsolana connect add the xsolana tools to Claude Code or Codex (--agent claude|codex, --scope user|project, --local, --remove, --dry-run)
33
34
  xsolana mcp serve the tools over MCP (stdio)
35
+ xsolana mcp --http serve the hosted tools over HTTP at http://127.0.0.1:8787/mcp (--port n, --host h)
34
36
  xsolana tool --list every tool; xsolana tool <name> '<json>' runs one (--args-file f)
35
37
  xsolana version this package's xsolana version and program
36
38
 
@@ -41,11 +43,11 @@ const USAGE = `xsolana: the tools for loot, one shared Solana program for game a
41
43
  class UsageError extends Error {}
42
44
 
43
45
  /** The flags that take a value: `--name value` or `--name=value`. */
44
- const VALUED = new Set(['--args-file', '--agent', '--scope', '--port']);
46
+ const VALUED = new Set(['--args-file', '--agent', '--scope', '--port', '--host']);
45
47
  /** The flags each command takes; any other is refused. */
46
48
  const TOOL_FLAGS = ['--json'];
47
49
  const FLAGS = {
48
- mcp: [],
50
+ mcp: ['--http', '--port', '--host'],
49
51
  guide: TOOL_FLAGS,
50
52
  examples: TOOL_FLAGS,
51
53
  check: TOOL_FLAGS,
@@ -119,6 +121,12 @@ async function main(argv) {
119
121
 
120
122
  switch (command) {
121
123
  case 'mcp': {
124
+ if (flags.has('--http')) {
125
+ const { serveHttp } = await import('../js/mcp/http.mjs');
126
+ await serveHttp({ port: portOf(values.get('--port')) ?? 8787, host: values.get('--host') ?? '127.0.0.1' });
127
+ break;
128
+ }
129
+ if (flags.has('--port') || flags.has('--host')) throw new UsageError('--port and --host go with --http.');
122
130
  const { serveStdio } = await import('../js/mcp/server.mjs');
123
131
  await serveStdio();
124
132
  break;
@@ -7,4 +7,5 @@ export * from './sizes.js';
7
7
  export * from './policy.js';
8
8
  export * from './compressed.js';
9
9
  export * from './traits.js';
10
+ export * from './kit.js';
10
11
  export * as random from './random.js';
@@ -0,0 +1,36 @@
1
+ // Instructions in @solana/kit's shape. The builders here return { programId, keys, data } (the shape of
2
+ // @solana/web3.js 1.x, which js/chain/tx.js compiles); an app built on @solana/kit takes { programAddress, accounts,
3
+ // data } instead, with each account's role as a number. These convert one to the other, with no dependency on Kit.
4
+
5
+ /** @solana/kit's AccountRole: bit 1 is a signer, bit 0 is writable. */
6
+ export const KIT_ACCOUNT_ROLE = Object.freeze({ READONLY: 0, WRITABLE: 1, READONLY_SIGNER: 2, WRITABLE_SIGNER: 3 });
7
+
8
+ /**
9
+ * One instruction from a builder here, as @solana/kit takes it: `appendTransactionMessageInstruction(toKitInstruction(ix), m)`.
10
+ * @param {{ programId: string, keys: { pubkey: string, isSigner: boolean, isWritable: boolean }[], data: Uint8Array }} ix
11
+ * @returns {{ programAddress: string, accounts: { address: string, role: number }[], data: Uint8Array }}
12
+ */
13
+ export function toKitInstruction(ix) {
14
+ return {
15
+ programAddress: ix.programId,
16
+ accounts: ix.keys.map(k => ({ address: k.pubkey, role: (k.isSigner ? 2 : 0) | (k.isWritable ? 1 : 0) })),
17
+ data: ix.data,
18
+ };
19
+ }
20
+
21
+ /**
22
+ * A @solana/kit instruction in the shape this package's transactions take (js/chain/tx.js, js/play).
23
+ * @param {{ programAddress: string, accounts?: { address: string, role: number }[], data?: Uint8Array }} ix
24
+ * @returns {{ programId: string, keys: { pubkey: string, isSigner: boolean, isWritable: boolean }[], data: Uint8Array }}
25
+ */
26
+ export function fromKitInstruction(ix) {
27
+ return {
28
+ programId: ix.programAddress,
29
+ keys: (ix.accounts ?? []).map(a => ({
30
+ pubkey: a.address,
31
+ isSigner: (a.role & 2) !== 0,
32
+ isWritable: (a.role & 1) !== 0,
33
+ })),
34
+ data: ix.data ?? new Uint8Array(),
35
+ };
36
+ }
@@ -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,7 +6,7 @@
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.',
@@ -14,6 +14,27 @@ export const INSTRUCTIONS = [
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
@@ -69,6 +69,22 @@ const nowOf = ctx => ctx?.now ?? Math.floor(Date.now() / 1000);
69
69
  const cwdOf = ctx => ctx?.cwd ?? process.cwd();
70
70
  export const DEFAULT_FILE = 'xsolana.json';
71
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
+
72
88
  /** The real path of `p`, or of the nearest folder above it that exists, with the rest added back. */
73
89
  function realPath(p) {
74
90
  try {
@@ -141,6 +157,11 @@ const SOURCE = {
141
157
  async function load(input, ctx) {
142
158
  if (input.spec) return { economy: economyOf(input.spec), from: 'the spec given' };
143
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
+ );
144
165
  const rel = input.file ?? DEFAULT_FILE;
145
166
  const file = projectFile(rel, ctx);
146
167
  if (!existsSync(file)) {
@@ -591,7 +612,7 @@ export const tools = [
591
612
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
592
613
  description:
593
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).',
594
- inputSchema: { type: 'object', properties: { file: SOURCE.file }, additionalProperties: false },
615
+ inputSchema: { type: 'object', properties: { spec: SOURCE.spec, file: SOURCE.file }, additionalProperties: false },
595
616
  async handler(input, ctx) {
596
617
  const { economy, from } = await load(input, ctx);
597
618
  if (!economy.live?.project)
@@ -683,6 +704,7 @@ export const tools = [
683
704
  ' `chosenRecipient` (buy, openBox, pay): the wallet (or collectible) the player names for a "chosen" share of the split (a referrer from the link)',
684
705
  ' app.lotteries(), app.buyTickets(key, n, { listing }) (or { maxPrice }: a ticket’s most, in base units),',
685
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',
686
708
  ' app.collectibles(), app.balances() what a player does. `listing`: the entry shop(), boxes(), lotteries() or recipes() showed the',
687
709
  ' player; its price (or version) is the most they pay: one that moved since is refused, never paid unseen',
688
710
  ' a prize (a grant, a lottery place, a contest win, a mission, a recipe) is given in the instruction that wins it,',
@@ -704,6 +726,7 @@ export const tools = [
704
726
  ' import * as loot from "@xgamesfun/solana/client";',
705
727
  ' loot.pda.project(owner, number), loot.pda.box(project, id), … every address the program derives',
706
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',
707
730
  ' loot.payNftIx({ player, project, asset, to }) pay with an NFT (burned, or given to `to`): the app admits by the receipt',
708
731
  ' loot.openListingIx / takeListingIx / closeListingIx, loot.listingTerms(listing), loot.pda.listing(project, asset), loot.renterOf(listing, now)',
709
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,