@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.
- package/DISCLAIMER.md +1 -1
- package/NOTICE +4 -4
- package/README.md +81 -31
- package/bin/xsolana.mjs +19 -5
- package/examples/stakers-draw.json +1 -0
- package/js/client/index.js +1 -0
- package/js/client/kit.js +36 -0
- package/js/client/random.js +1 -1
- package/js/economy/check.mjs +11 -0
- package/js/economy/cost.mjs +1 -1
- package/js/economy/explain.mjs +10 -3
- package/js/economy/guide.mjs +1 -1
- package/js/economy/live.mjs +2 -1
- package/js/economy/review.mjs +760 -0
- package/js/economy/schema.json +8 -4
- package/js/economy/trust.mjs +15 -0
- package/js/index.js +1 -0
- package/js/mcp/http.mjs +136 -0
- package/js/mcp/server.mjs +34 -10
- package/js/mcp/tools.mjs +50 -2
- package/js/play/index.js +116 -2
- package/js/play/sponsor.js +302 -0
- package/js/results/index.js +2 -2
- package/package.json +35 -8
- package/release.json +1 -1
- package/skills/xsolana-economy/SKILL.md +32 -13
- package/skills/xsolana-economy/references/after-launch.md +2 -1
- package/skills/xsolana-economy/references/apps.md +28 -0
- package/skills/xsolana-economy/references/sentence-to-spec.md +1 -0
- package/skills/xsolana-economy/references/telling-the-person.md +6 -0
- package/skills/xsolana-economy/references/trust-and-cheating.md +126 -0
- package/types/client/index.d.ts +1 -0
- package/types/client/kit.d.ts +49 -0
- package/types/economy/cost.d.mts +1 -1
- package/types/economy/explain.d.mts +3 -0
- package/types/economy/plan.d.mts +14 -14
- package/types/economy/review.d.mts +45 -0
- package/types/economy/trust.d.mts +8 -0
- package/types/index.d.ts +1 -0
- package/types/mcp/http.d.mts +30 -0
- package/types/mcp/server.d.mts +321 -0
- package/types/mcp/tools.d.mts +71 -1
- package/types/play/index.d.ts +3 -1
- package/types/play/sponsor.d.ts +149 -0
- package/types/results/index.d.ts +1 -1
- package/examples/holder-den.json +0 -38
- package/examples/holders-drop.json +0 -105
- package/examples/match-predictions.json +0 -24
- package/examples/pack-league.json +0 -108
- package/examples/stride-sneakers.json +0 -164
package/js/economy/schema.json
CHANGED
|
@@ -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
|
|
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 (
|
|
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';
|
package/js/mcp/http.mjs
ADDED
|
@@ -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
|
|
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 =
|
|
1454
|
+
oneSignature = !sponsor,
|
|
1341
1455
|
chosenRecipient = null,
|
|
1342
1456
|
listProof,
|
|
1343
1457
|
count = 1,
|