@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
@@ -0,0 +1,302 @@
1
+ // A project pays its players' fees: a sponsor. The sponsor is a plain key on the project's own server, with no role in
2
+ // the program: it is the transaction's fee payer, and it lends the player SOL in the same transaction, the player
3
+ // giving back, last, all but what the step costs them beyond its price (the platform's player fee, the oracle's fee
4
+ // for a roll, and the rent of what the program opens or mints for them). It lends first and takes back after because a
5
+ // step can need more SOL midway than it costs (a wrapped-SOL account it opens and closes). A player with no SOL can then
6
+ // play with what they hold. The price itself is never sponsored.
7
+ //
8
+ // The app (`solanaApp({ sponsor })`) builds the transaction with the sponsor as payer, the player's wallet signs it,
9
+ // and the project's server checks it (`checkSponsored`) and signs it (`sponsorSigner`, `sponsorHandler`) before the
10
+ // app sends it. The server trusts nothing the page says: it reads the transaction itself and dry-runs it. What it
11
+ // holds to:
12
+ //
13
+ // - the sponsor appears in no instruction but its one loan, a plain transfer to a signer of the transaction (the
14
+ // player), and plain transfers back to it: no instruction can move the sponsor's SOL but the loan, sign as it, or
15
+ // name it otherwise;
16
+ // - at least one instruction is this project's (a loot instruction naming the project), and none pays the project's
17
+ // fee box or the oracle straight from the page;
18
+ // - no priority fee past the server's limit (none by default);
19
+ // - in the dry run, what the sponsor is out (its loan less what came back) leaves the player no richer than they began,
20
+ // and is at most what the step cost in fees and rent: what the project's fee box and the oracle took, and the rent of
21
+ // what the program's own moves opened (`sponsoredCost`: a loot account, a token account of a mint the step's loot
22
+ // instructions name, a Core asset when the page calls Core itself nowhere). An account the page opens for itself (a
23
+ // plain key, a token account of any mint) is never sponsored: the player could close it and keep the rent;
24
+ // - the loan and the network fee together are at most `maxLamports`, and the project's `budget` (per player, per day)
25
+ // agrees.
26
+ //
27
+ // What a sponsor can still lose: rent the program later gives back to the player (an order's, when it settles; a token
28
+ // account the player closes), up to `maxLamports` a transaction, and a fee for a transaction that passed its dry run
29
+ // but failed on chain. `budget` is where a project caps those.
30
+ //
31
+ // Browser-safe: the server side needs only an RPC and the sponsor's own `sign`, which the project's server provides
32
+ // (this package never holds a key).
33
+ import { readTable } from '../chain/lookup.js';
34
+ import { COMPUTE_BUDGET_PROGRAM, TOKEN_2022_PROGRAM, TOKEN_PROGRAM } from '../chain/pda.js';
35
+ import { transferLamports } from '../chain/programs.js';
36
+ import { encodeBase58 } from '../chain/base58.js';
37
+ import { fromBase64, toBase64 } from '../chain/bytes.js';
38
+ import { readMessage, readTransaction, withSignature } from '../chain/tx.js';
39
+ import { LOOT_PROGRAM, MPL_CORE_PROGRAM, pda, VRF_PROGRAM } from '../client/index.js';
40
+
41
+ const SYSTEM_PROGRAM = '11111111111111111111111111111111';
42
+ /** What a signature costs the fee payer, in lamports. */
43
+ export const SIGNATURE_LAMPORTS = 5_000n;
44
+ /** The most a sponsored transaction may cost the sponsor by default: its top-up and its network fee, 0.01 SOL. */
45
+ export const SPONSOR_MAX_LAMPORTS = 10_000_000n;
46
+
47
+ const lamportsOf = a => (a ? BigInt(a.lamports) : 0n);
48
+ const u64At = (data, at) => new DataView(data.buffer, data.byteOffset).getBigUint64(at, true);
49
+ const u32At = (data, at) => new DataView(data.buffer, data.byteOffset).getUint32(at, true);
50
+
51
+ /** The top-up: the sponsor's transfer to the player, first in the transaction. */
52
+ export const sponsorTopUp = (sponsor, player, lamports) => transferLamports(sponsor, player, BigInt(lamports));
53
+
54
+ /** The give-back: what the step didn't need of the top-up, from the player back to the sponsor, last in the transaction. */
55
+ export const sponsorGiveBack = (sponsor, player, lamports) => transferLamports(player, sponsor, BigInt(lamports));
56
+
57
+ /**
58
+ * What a step cost in fees and rent, from the accounts it names before and after a dry run (`pre`, `post`: one each
59
+ * per address, null when there is none): what the project's fee box and the oracle took, and the full rent of each
60
+ * account it opened that loot, Core or a token program owns. Only what the program's own moves open counts: a token
61
+ * account counts when its mint is named by one of the step's loot instructions (`lootNamed`), and a Core asset when no
62
+ * instruction of the step calls Core itself (`programs`: the programs its instructions call), so a player can't open
63
+ * accounts of their own on the sponsor and close them for the rent. `skip`: the payer and the player.
64
+ * @param {{ addresses: string[], pre: ({ owner: string, lamports: number | bigint, data?: Uint8Array } | null)[],
65
+ * post: ({ owner: string, lamports: number | bigint, data?: Uint8Array } | null)[], project: string, skip?: string[],
66
+ * programs?: string[], lootNamed?: string[] }} input
67
+ * @returns {bigint}
68
+ */
69
+ export function sponsoredCost({ addresses, pre, post, project, skip = [], programs = [], lootNamed = [] }) {
70
+ const feeBox = pda.fees(project);
71
+ const named = new Set(lootNamed);
72
+ let cost = 0n;
73
+ for (const [i, address] of addresses.entries()) {
74
+ if (skip.includes(address)) continue;
75
+ const before = pre[i];
76
+ const after = post[i];
77
+ if (!after) continue;
78
+ const grew = lamportsOf(after) - lamportsOf(before);
79
+ if (address === feeBox || after.owner === VRF_PROGRAM) {
80
+ cost += grew > 0n ? grew : 0n;
81
+ continue;
82
+ }
83
+ if (before) continue;
84
+ const opened =
85
+ after.owner === LOOT_PROGRAM ||
86
+ (after.owner === MPL_CORE_PROGRAM && !programs.includes(MPL_CORE_PROGRAM)) ||
87
+ ((after.owner === TOKEN_PROGRAM || after.owner === TOKEN_2022_PROGRAM) &&
88
+ after.data?.length >= 32 &&
89
+ named.has(encodeBase58(after.data.subarray(0, 32))));
90
+ if (opened) cost += lamportsOf(after);
91
+ }
92
+ return cost;
93
+ }
94
+
95
+ /** The programs a step's instructions call, and every account its loot instructions name: what `sponsoredCost` reads. */
96
+ export function stepShape(instructions) {
97
+ return {
98
+ programs: [...new Set(instructions.map(ix => ix.programId))],
99
+ lootNamed: [
100
+ ...new Set(instructions.filter(ix => ix.programId === LOOT_PROGRAM).flatMap(ix => ix.keys.map(k => k.pubkey))),
101
+ ],
102
+ };
103
+ }
104
+
105
+ /**
106
+ * The top-up a step needs: what it cost the player (`spend`, before any top-up) bounded by its fees and rent
107
+ * (`cost`), never below 0. A step that leaves the player richer, or costs nothing, needs none.
108
+ */
109
+ export const topUpFor = ({ spend, cost }) => {
110
+ const t = spend < cost ? spend : cost;
111
+ return t > 0n ? t : 0n;
112
+ };
113
+
114
+ /** The transaction's accounts in order: its static keys, then each lookup table's writable, then read-only ones. */
115
+ async function accountKeys(message, rpc) {
116
+ const writable = [];
117
+ const readonly = [];
118
+ for (const l of message.lookups) {
119
+ const table = readTable(await rpc.account(l.address));
120
+ if (!table || table.deactivationSlot !== null) throw new Error(`the lookup table ${l.address} is not active`);
121
+ const at = i => {
122
+ if (i >= table.addresses.length) throw new Error(`the lookup table ${l.address} has no address ${i}`);
123
+ return table.addresses[i];
124
+ };
125
+ writable.push(...l.writable.map(at));
126
+ readonly.push(...l.readonly.map(at));
127
+ }
128
+ return [...message.keys, ...writable, ...readonly];
129
+ }
130
+
131
+ /**
132
+ * Whether the project's sponsor should sign this transaction (a wire, the player's signature in it): read it, dry-run
133
+ * it, and hold it to the rules above. Returns { ok: true, player, topUp, cost, lamports, message } (`topUp`: what the
134
+ * player kept of it, the top-up less the give-back; `lamports`: what it costs the sponsor, that and the network fee)
135
+ * or { ok: false, reason } in words.
136
+ * @param {Uint8Array} wire
137
+ * @param {{ rpc: any, project: string, sponsor: string, maxLamports?: bigint | number, maxPriorityMicroLamports?: bigint | number,
138
+ * budget?: ((ask: { player: string, lamports: bigint, topUp: bigint }) => boolean | Promise<boolean>) | null }} options
139
+ */
140
+ export async function checkSponsored(
141
+ wire,
142
+ { rpc, project, sponsor, maxLamports = SPONSOR_MAX_LAMPORTS, maxPriorityMicroLamports = 0n, budget = null },
143
+ ) {
144
+ const no = reason => ({ ok: false, reason });
145
+ let t;
146
+ let m;
147
+ let keys;
148
+ try {
149
+ t = readTransaction(wire);
150
+ m = readMessage(t.message);
151
+ keys = await accountKeys(m, rpc);
152
+ } catch (e) {
153
+ return no(`It is not a transaction this server can read: ${e.message}.`);
154
+ }
155
+ if (keys[0] !== sponsor) return no('The sponsor is not its fee payer.');
156
+ const signers = keys.slice(0, m.header.numRequiredSignatures);
157
+ if (signers.length < 2) return no('Only the sponsor signs it: a sponsored transaction is a player’s.');
158
+ let topUp = null;
159
+ let back = 0n;
160
+ let ours = false;
161
+ let units = 200_000n * BigInt(m.instructions.length);
162
+ let price = 0n;
163
+ for (const ix of m.instructions) {
164
+ const program = keys[ix.programIndex];
165
+ const accounts = ix.accountIndexes.map(i => keys[i]);
166
+ if (program === sponsor) return no('It calls the sponsor as a program.');
167
+ const isTransfer = program === SYSTEM_PROGRAM && ix.data.length === 12 && u32At(ix.data, 0) === 2;
168
+ if (isTransfer && accounts[0] === sponsor && accounts.length === 2) {
169
+ if (topUp) return no('It tops up more than once.');
170
+ if (!signers.includes(accounts[1]) || accounts[1] === sponsor)
171
+ return no('Its top-up goes to someone who doesn’t sign it.');
172
+ topUp = { to: accounts[1], lamports: u64At(ix.data, 4) };
173
+ continue;
174
+ }
175
+ if (isTransfer && accounts[1] === sponsor && accounts[0] !== sponsor && accounts.length === 2) {
176
+ back += u64At(ix.data, 4);
177
+ continue;
178
+ }
179
+ if (accounts.includes(sponsor)) return no('The sponsor is named in an instruction other than the top-up.');
180
+ if (program === COMPUTE_BUDGET_PROGRAM && ix.data[0] === 2 && ix.data.length >= 5)
181
+ units = BigInt(u32At(ix.data, 1));
182
+ if (program === COMPUTE_BUDGET_PROGRAM && ix.data[0] === 3 && ix.data.length >= 9) price = u64At(ix.data, 1);
183
+ if (program === LOOT_PROGRAM && accounts.includes(project)) ours = true;
184
+ }
185
+ if (!ours) return no('It does nothing in this project.');
186
+ if (price > BigInt(maxPriorityMicroLamports)) return no('Its priority fee is more than this sponsor pays.');
187
+ const fee = SIGNATURE_LAMPORTS * BigInt(signers.length) + (units * price + 999_999n) / 1_000_000n;
188
+ const player = topUp?.to ?? signers.find(s => s !== sponsor);
189
+ const lent = topUp?.lamports ?? 0n;
190
+ if (lent + fee > BigInt(maxLamports))
191
+ return no(`It would cost the sponsor ${lent + fee} lamports, more than the ${maxLamports} it pays a transaction.`);
192
+ // What the sponsor is out once the player gives back what the step didn't need.
193
+ const net = lent > back ? lent - back : 0n;
194
+ const lamports = net + fee;
195
+ const addresses = [...new Set(keys)];
196
+ const at = address => addresses.indexOf(address);
197
+ let pre;
198
+ try {
199
+ pre = await rpc.accounts(addresses);
200
+ } catch (e) {
201
+ return no(`Its accounts could not be read: ${e.message}`);
202
+ }
203
+ const feeBox = pda.fees(project);
204
+ const steps = m.instructions.map(ix => ({
205
+ programId: keys[ix.programIndex],
206
+ keys: ix.accountIndexes.map(i => ({ pubkey: keys[i] })),
207
+ }));
208
+ // The fee box and the oracle are paid by the program's own moves, never straight from an instruction of the page's.
209
+ for (const ix of steps.filter(x => x.programId === SYSTEM_PROGRAM))
210
+ if (ix.keys.some(k => k.pubkey === feeBox || pre[at(k.pubkey)]?.owner === VRF_PROGRAM))
211
+ return no('It pays the fee box or the oracle straight from an instruction of its own.');
212
+ let post;
213
+ try {
214
+ post = (await rpc.simulate(wire, { accounts: addresses })).accounts;
215
+ } catch (e) {
216
+ return no(`It would fail: ${e.message}`);
217
+ }
218
+ const cost = sponsoredCost({ addresses, pre, post, project, skip: [sponsor, player], ...stepShape(steps) });
219
+ // A player who keeps none of the top-up (it was all given back) may end richer by what the step itself pays them
220
+ // (an order's rent, back at its settle): the sponsor paid only the fee.
221
+ if (net > 0n) {
222
+ if (lamportsOf(post[at(player)]) > lamportsOf(pre[at(player)]))
223
+ return no('Its top-up is more than the step costs the player: they would keep some.');
224
+ if (net > cost) return no('Its top-up is more than the step’s fees and rent: the price is the player’s.');
225
+ }
226
+ if (budget && !(await budget({ player, lamports, topUp: net })))
227
+ return no('This project’s sponsor has paid as much as it pays for now.');
228
+ return { ok: true, player, topUp: net, cost, lamports, message: t.message };
229
+ }
230
+
231
+ /**
232
+ * The project's server side: `sign(wire)` checks a transaction (`checkSponsored`) and, when it passes, returns it with
233
+ * the sponsor's signature; otherwise it throws the reason (`refused: true`). `sponsor.sign(message)`: the sponsor key's
234
+ * ed25519 signature of the message bytes, made by the project's server, which alone holds that key.
235
+ * @param {{ rpc: any, project: string, sponsor: { address: string, sign: (message: Uint8Array) => Uint8Array | Promise<Uint8Array> },
236
+ * maxLamports?: bigint | number, maxPriorityMicroLamports?: bigint | number,
237
+ * budget?: ((ask: { player: string, lamports: bigint, topUp: bigint }) => boolean | Promise<boolean>) | null }} options
238
+ */
239
+ export function sponsorSigner({ rpc, project, sponsor, ...limits }) {
240
+ return {
241
+ address: sponsor.address,
242
+ async sign(wire) {
243
+ const checked = await checkSponsored(wire, { rpc, project, sponsor: sponsor.address, ...limits });
244
+ if (!checked.ok) throw Object.assign(new Error(checked.reason), { refused: true });
245
+ return withSignature(wire, sponsor.address, await sponsor.sign(checked.message));
246
+ },
247
+ };
248
+ }
249
+
250
+ /**
251
+ * The sponsor as an HTTP endpoint (a Next.js route, a Worker): POST { transaction } (base64) answers { transaction }
252
+ * signed, or 403 { error } in words. `signer`: a `sponsorSigner`.
253
+ * @param {Request} request
254
+ * @param {{ address: string, sign: (wire: Uint8Array) => Promise<Uint8Array> }} signer
255
+ * @returns {Promise<Response>}
256
+ */
257
+ export async function sponsorHandler(request, signer) {
258
+ const answer = (body, status = 200) =>
259
+ new Response(JSON.stringify(body), {
260
+ status,
261
+ headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
262
+ });
263
+ if (request.method !== 'POST') return answer({ error: 'POST a transaction.' }, 405);
264
+ let wire;
265
+ try {
266
+ const body = await request.json();
267
+ wire = fromBase64(String(body.transaction));
268
+ if (wire.length > 1232) throw new Error('too long');
269
+ } catch {
270
+ return answer({ error: 'Send { "transaction": "<base64>" }.' }, 400);
271
+ }
272
+ try {
273
+ return answer({ transaction: toBase64(await signer.sign(wire)), sponsor: signer.address });
274
+ } catch (e) {
275
+ return answer({ error: e.message }, e.refused ? 403 : 500);
276
+ }
277
+ }
278
+
279
+ /**
280
+ * The app's side of a sponsor behind `url` (`sponsorHandler`): what `solanaApp({ sponsor })` takes. `address`: the
281
+ * sponsor's public key; `maxLamports`: the most it pays a transaction, as its server says.
282
+ * @param {{ url: string, address: string, maxLamports?: bigint | number, fetchImpl?: typeof fetch }} options
283
+ */
284
+ export function httpSponsor({ url, address, maxLamports = SPONSOR_MAX_LAMPORTS, fetchImpl = fetch }) {
285
+ return {
286
+ address,
287
+ maxLamports: BigInt(maxLamports),
288
+ async sign(wire) {
289
+ const r = await fetchImpl(url, {
290
+ method: 'POST',
291
+ headers: { 'content-type': 'application/json' },
292
+ body: JSON.stringify({ transaction: toBase64(wire) }),
293
+ });
294
+ const body = await r.json().catch(() => ({}));
295
+ if (!r.ok || !body.transaction)
296
+ throw Object.assign(new Error(body.error ?? `The sponsor answered ${r.status}.`), {
297
+ refused: r.status === 403,
298
+ });
299
+ return fromBase64(body.transaction);
300
+ },
301
+ };
302
+ }
@@ -1,5 +1,5 @@
1
1
  // `@xgamesfun/solana/results`: what a game uses with a results server, in a host server or in a player's game page.
2
- // Browser-safe: Web Crypto only. `server` is RESULTS_SERVER (the public one, on the games.fun play host), or another
2
+ // Browser-safe: Web Crypto only. `server` is RESULTS_SERVER (the public one, on the xgames.fun play host), or another
3
3
  // one's URL. The server confirms who won (from signatures rooted on the chain) and signs grant vouchers; winners claim
4
4
  // them with js/play (`claimWins`, `claimGrant`). The program pays a wallet each round once, ever, whatever is signed.
5
5
  //
@@ -40,7 +40,7 @@ export {
40
40
  rosterHash,
41
41
  } from './wire.js';
42
42
 
43
- /** games.fun's results server, on its play host (devnet until the program's audit). */
43
+ /** xgames.fun's results server, on its play host (devnet until the program's audit). */
44
44
  export const RESULTS_SERVER = 'https://play.xgames.fun/xsolana';
45
45
 
46
46
  const subtle = () => globalThis.crypto.subtle;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@xgamesfun/solana",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
4
4
  "mcpName": "io.github.XGamesFun/solana-kit",
5
- "description": "One shared Solana program for game and community economies, its JS client, and the tools that let an AI agent design, check, simulate and launch an economy on it.",
5
+ "description": "Loot boxes, gacha, shops, lotteries, auctions, NFT staking, crafting and rewards on Solana without writing a smart contract: one shared program, its JS client, a CLI, and an MCP server and skill so Claude Code, Codex or any AI agent can design, check, simulate and launch an economy.",
6
6
  "license": "Apache-2.0",
7
7
  "type": "module",
8
8
  "repository": {
@@ -15,13 +15,32 @@
15
15
  },
16
16
  "keywords": [
17
17
  "solana",
18
- "economy",
19
- "loot-box",
20
- "staking",
18
+ "solana-program",
19
+ "web3",
21
20
  "nft",
22
- "game",
21
+ "nft-staking",
22
+ "staking",
23
+ "loot-box",
24
+ "gacha",
25
+ "lottery",
26
+ "raffle",
27
+ "auction",
28
+ "crafting",
29
+ "game-economy",
30
+ "web3-gaming",
31
+ "gamefi",
32
+ "token",
33
+ "spl-token",
34
+ "metaplex-core",
35
+ "no-smart-contract",
36
+ "vrf",
23
37
  "mcp",
24
- "agent"
38
+ "mcp-server",
39
+ "model-context-protocol",
40
+ "claude-code",
41
+ "codex",
42
+ "ai-agent",
43
+ "agent-skill"
25
44
  ],
26
45
  "engines": {
27
46
  "node": ">=22"
@@ -43,13 +62,21 @@
43
62
  "types": "./types/play/index.d.ts",
44
63
  "default": "./js/play/index.js"
45
64
  },
65
+ "./sponsor": {
66
+ "types": "./types/play/sponsor.d.ts",
67
+ "default": "./js/play/sponsor.js"
68
+ },
46
69
  "./results": {
47
70
  "types": "./types/results/index.d.ts",
48
71
  "default": "./js/results/index.js"
49
72
  },
50
73
  "./idl.json": "./js/client/idl/loot.json",
51
74
  "./package.json": "./package.json",
52
- "./release.json": "./release.json"
75
+ "./release.json": "./release.json",
76
+ "./mcp/http": {
77
+ "types": "./types/mcp/http.d.mts",
78
+ "default": "./js/mcp/http.mjs"
79
+ }
53
80
  },
54
81
  "sideEffects": [
55
82
  "./bin/xsolana.mjs",
package/release.json CHANGED
@@ -4,5 +4,5 @@
4
4
  "sbfBytes": 1741840,
5
5
  "sbfSha256": "ea19d15d03211f7bd0b25179d51f75544342225127fcb631e687eae6750c9ba7",
6
6
  "idlSha256": "41e621990c17b8645f59bb2a170271645ad370d1c1bf9f1dbdd04f817b82d4cf",
7
- "clientSha256": "11270da0bd539b1cb79e8c61527e4b0686f2d03a2e2b617c823dcabf97f05f6e"
7
+ "clientSha256": "849106b5d1a022aac13e1208172945c4cee0c4a55cafbd5f1c77e1be6948de04"
8
8
  }
@@ -9,8 +9,8 @@ An economy is one JSON spec (`xsolana.json` in the project) on **loot**, a Solan
9
9
  writes or deploys a contract: the spec is checked, simulated and priced by the `xsolana_*` tools, and the project's
10
10
  owner puts it live by approving in their own wallet. It runs on **devnet** until the program is audited.
11
11
 
12
- The tools are the `xsolana_*` MCP tools. Without MCP, the same from a terminal: `npx -y @xgamesfun/solana@0.25.0 <guide|examples|check|simulate|cost|explain|publish|status> [xsolana.json]`,
13
- or `npx -y @xgamesfun/solana@0.25.0 tool <name> '<json>'` for any tool (always with the exact version).
12
+ The tools are the `xsolana_*` MCP tools. Without MCP, the same from a terminal: `npx -y @xgamesfun/solana@0.25.2 <guide|examples|check|simulate|cost|explain|review|publish|status> [xsolana.json]`,
13
+ or `npx -y @xgamesfun/solana@0.25.2 tool <name> '<json>'` for any tool (always with the exact version).
14
14
 
15
15
  ## Rules (always)
16
16
 
@@ -41,7 +41,9 @@ or `npx -y @xgamesfun/solana@0.25.0 tool <name> '<json>'` for any tool (always w
41
41
  anything a limit, break the idea into its pieces (what players pay, what they get, what is random, what the chain
42
42
  can't see, what is held and for how long, who is paid) and find the part for each (`references/sentence-to-spec.md`).
43
43
  What the chain can't see (a match, a score, something done offline) is vouched for by a signer: a results server's voucher, a
44
- decider's key, a list. Build the combination and say which part does which piece. When the tools' words and the
44
+ decider's key, a list. The signer is not the end of the story: the program checks its signature and limits, never
45
+ what happened, so a signed result is worth what that server checks (it decides the result, replays it, or only
46
+ bounds what the page reports: rule 12). Build the combination and say which part does which piece. When the tools' words and the
45
47
  checker disagree (a field the schema takes that a sentence somewhere calls impossible), the checker and
46
48
  `xsolana_guide`'s parts decide: try it in a spec and run `xsolana_check`.
47
49
  A piece is a limit only when no part or combination does it. Then:
@@ -53,10 +55,12 @@ or `npx -y @xgamesfun/solana@0.25.0 tool <name> '<json>'` for any tool (always w
53
55
  table.
54
56
 
55
57
  10. **Design for many wallets and bots.** Wallets are free and bots play well: bound every reward with totals
56
- (`perDay`, `total`), gate open rewards on holding something or count people by vouchers (`perPerson`), keep token
57
- rewards for results small, and have a server check what the page claims (`@xgamesfun/solana/results`:
58
- `collectiblesOf`, `verifyProof`, `makeCodes`). The checker warns about the rest (`W-GRANT-TOTAL`, `W-GRANT-OPEN`,
59
- `W-FARM-MINT`); xsolana_guide's "Designing against farming" says why.
58
+ (`perDay`, `total`), gate open rewards on holding something or count people by vouchers (`perPerson`, with the
59
+ server naming an X account, never a wallet), keep token rewards for results small, and have a server decide or
60
+ replay a result worth paying rather than take the page's word (`@xgamesfun/solana/results`: `collectiblesOf`,
61
+ `verifyProof`, `makeCodes`). Keep entry fees and prize pools for results the server decides and for chance, and
62
+ give a minted farm an end. The checker warns about the rest (`W-GRANT-TOTAL`, `W-GRANT-OPEN`, `W-FARM-MINT`);
63
+ `references/trust-and-cheating.md` says why.
60
64
 
61
65
  11. **People finish what follows; build the button, not a server.** Every step after a player's ask (settle a roll,
62
66
  draw a lottery, pull its parts (each place, a refund, a share), refund a draw the oracle never answered, claim a
@@ -66,6 +70,14 @@ or `npx -y @xgamesfun/solana@0.25.0 tool <name> '<json>'` for any tool (always w
66
70
  nobody claims for a player. loot needs no server to finish anything. Never make a key in a page or a server for
67
71
  the owner to fund: whoever signs pays.
68
72
 
73
+ 12. **Review before going live, and let the person choose the trade-off.** Run `xsolana_review` before the owner
74
+ approves. Fix every high finding, or have the person accept it in plain words. Declare `"trust"` honestly on every
75
+ voucher grant: `"decides"` (the server runs the game or match; the page sends only inputs), `"replays"` (it re-runs
76
+ the page's inputs from randomness it keeps) or `"bounds"` (it takes the page's word within a pace and caps). Then
77
+ tell the person, for each signed route, which level it uses, what a cheater gets (the review's ceiling, with what
78
+ the prize goes on to earn), and what the stronger level would cost to build. They choose; you don't choose for
79
+ them by leaving it out. `references/trust-and-cheating.md` has the levels, the costs and the checklist.
80
+
69
81
  ## The loop
70
82
 
71
83
  1. **Read what an economy can be made of**: `xsolana_guide` (every part and field, who acts and what it costs, what
@@ -96,14 +108,17 @@ or `npx -y @xgamesfun/solana@0.25.0 tool <name> '<json>'` for any tool (always w
96
108
  (every value of a fresh item's `{attribute}`), a table's rent, and the collectible rent a pack's settle pays.
97
109
  6. **Iterate on what they say.** "Make legendaries 0.5%", "a bigger seed": change, check, simulate, explain again.
98
110
  7. **Save it**: `xsolana_save {"spec": …}` writes `xsolana.json`, only if it checks. A live file keeps its program numbers.
99
- 8. **When they say "put it live"**: `xsolana_publish` lists every step and its cost and gives the command. Tell them to
111
+ 8. **Review it before they approve**: `xsolana_review` (rule 12). Fix the high findings, declare `trust` on every
112
+ voucher grant, and tell the person which level each signed route uses, what a cheater gets and what the stronger
113
+ level would cost. Run it again after every change that touches a route that pays.
114
+ 9. **When they say "put it live"**: `xsolana_publish` lists every step and its cost and gives the command. Tell them to
100
115
  run it themselves in the project folder, exactly as `xsolana_publish` gives it (it names the exact version:
101
- `npx -y @xgamesfun/solana@0.25.0 approve xsolana.json`; never drop the version). It opens a page on their computer where
116
+ `npx -y @xgamesfun/solana@0.25.2 approve xsolana.json`; never drop the version). It opens a page on their computer where
102
117
  they connect a devnet wallet and approve; what lands is written back into `xsolana.json`. You publish nothing.
103
- 9. **After it is live**: `xsolana_status` shows each part on chain; read it before acting on what the person says
104
- happened. A later change is the same loop: save, publish, and the approval sends only what changed. What only the
105
- owner signs (open or decide a contest, pay out) goes on an owner page they approve in their own wallet:
106
- `references/after-launch.md`.
118
+ 10. **After it is live**: `xsolana_status` shows each part on chain; read it before acting on what the person says
119
+ happened. A later change is the same loop: save, publish, and the approval sends only what changed. What only the
120
+ owner signs (open or decide a contest, pay out) goes on an owner page they approve in their own wallet:
121
+ `references/after-launch.md`.
107
122
 
108
123
  ## Building the app around it
109
124
 
@@ -114,6 +129,9 @@ is due, list what a player owns). Pages call `claims()` when they open and show
114
129
  `references/apps.md` shows a whole page; `xsolana_client` gives the program and the calls. Never invent account layouts,
115
130
  instruction data or addresses, and never put a key or the authority's actions in a page players load.
116
131
  For staking an NFT of another collection, show what custody means and get explicit acceptance before sending.
132
+ Players with no SOL can still play when the project sponsors their fees: a key on the project's own server pays them,
133
+ never the price (`references/apps.md`, "Sponsoring players' fees"). Offer it when the person wants free first steps or
134
+ players new to Solana.
117
135
 
118
136
  ## More
119
137
 
@@ -121,3 +139,4 @@ For staking an NFT of another collection, show what custody means and get explic
121
139
  - Telling the person what they get, and what stays the project's job: `references/telling-the-person.md`
122
140
  - Building the app for a live economy: `references/apps.md`
123
141
  - After it goes live (who sends each step, owner pages, results from a game, a results server): `references/after-launch.md`
142
+ - What a server decides, replays or bounds, who gets paid, and the exploit checklist: `references/trust-and-cheating.md`
@@ -101,7 +101,8 @@ now })` for the player's own wallet and claims (`app.claimGrant(key, { voucher }
101
101
  (`perPerson`, with a person hash the voucher names: that needs an outside sign-in such as X, and a wallet-only
102
102
  player has none); it checks what a wallet holds on the chain, never the page's word (`collectiblesOf`, `holds`); and
103
103
  the grant's `perDay` and `total` bound everything whatever happens to a key. Keep token rewards for results small.
104
- - **Vouchers from a results server**: the grant names the server's public key in `vouchers`; the server checks the
104
+ - **Vouchers from a results server**: the grant names the server's public key in `vouchers`, and says in `trust`
105
+ what the server checks (`"decides"`, `"replays"` or `"bounds"`: `trust-and-cheating.md`); the server checks the
105
106
  game's result and signs a voucher (for a grant of tokens, with each winner's own `amount`, up to the grant's: rewards
106
107
  by rank); the winner's page claims with it (`app.claimGrant(key, { voucher })`).
107
108
  - The server can be the person's own game server (`signVoucher` from `@xgamesfun/solana/results`). It holds only that
@@ -198,6 +198,34 @@ assetId, tree }` after a `loot.proveLeafIx` in the same transaction. `app.stakeN
198
198
  `collectibles()`), one read at a time, and keep what is on screen when one read fails.
199
199
  - **Errors** come back as plain sentences (a sold-out item, a gate not met, a price above the most they agreed to):
200
200
  show them.
201
+ - **Sponsoring players' fees** (players with no SOL, a free item or box as a first step): the project runs a sponsor,
202
+ a plain key on its own server with no role in the program, funded with SOL. The page passes
203
+ `solanaApp({ ..., sponsor: httpSponsor({ url: '/sponsor', address: SPONSOR_ADDRESS }) })`; each step then has the
204
+ sponsor pay the network fee and lend the player SOL for the step, the player giving back, in the same transaction,
205
+ all but what it costs in fees and rent (the player fee, the oracle's fee for a roll, the rent of what the program
206
+ opens or mints). The price is never sponsored: a priced step still takes the player's own SOL or tokens. The server
207
+ route is a few lines:
208
+
209
+ ```js
210
+ import { sponsorHandler, sponsorSigner } from '@xgamesfun/solana/sponsor';
211
+ const signer = sponsorSigner({
212
+ rpc, // the client's rpcClient, or any with accounts and simulate
213
+ project: spec.live.project,
214
+ sponsor: { address: SPONSOR_ADDRESS, sign: message => signWithServerKey(message) }, // the server's own ed25519 key
215
+ maxLamports: 10_000_000n, // the most one transaction costs it (0.01 SOL)
216
+ budget: ({ player, lamports }) => underTodaysCap(player, lamports), // the project's own per-player, per-day limit
217
+ });
218
+ export const POST = request => sponsorHandler(request, signer);
219
+ ```
220
+
221
+ The server reads and dry-runs every transaction before it signs (`checkSponsored`): the sponsor may only pay the fee
222
+ and the one loan, the transaction must be this project's, and the player may keep no more than the step's fees and
223
+ rent. What a sponsor can still lose is rent the program later gives back to the player (an order's, at its settle)
224
+ and fees for transactions that fail on chain, so always give it a `budget`, keep only a few days' worth of SOL in
225
+ it, and never make it the owner or the authority. Sponsored, a box's roll takes two prompts (the open, then the
226
+ settle) instead of one, and a step too big for one transaction goes as the player's own. Tell the person what a
227
+ day of their players costs the sponsor (`xsolana_cost`: the player fee, the oracle's fee and rent per step).
228
+
201
229
  - **Never in a page**: a private key, a keyed RPC link, or anything the authority signs (payouts, minting, rewriting
202
230
  a collectible). Those are the owner's or the authority's to sign in their own wallet, or on a server they run.
203
231
  - **Lower level**: `@xgamesfun/solana/client` has one builder per instruction and the account decoders, for what the
@@ -40,6 +40,7 @@ maps to a part of the spec (`xsolana_guide` has every field).
40
40
  | "the expedition brings back a chest", "missions drop random loot" | a farm tier's `prize`: `{ "gives": "roll", "box": "<box>" }`, rolled when the mission is completed (its lock stayed, its upkeep never ran dry, not forfeited) while the NFT goes home; a known reward (tokens, an item, an NFT) is given as it leaves |
41
41
  | "only pass holders can buy it", "members only" | a `gate` of the item alone: `{ "item": "pass" }` |
42
42
  | "only holders can open it", "level 5 and up" | a `gate`: `{ "item": "pass", "attribute": "level", "atLeast": 5 }` |
43
+ | "holders of any of our plots", "any of the three species", "gold or diamond tier" | a rule of several: `{ "items": ["oak", "pine", "birch"] }` (any of these items, in the game's own collection) or `{ "attribute": "tier", "oneOf": ["gold", "diamond"] }`, up to 16 each; it works wherever a rule does (a gate, a farm's gate or boost, odds, `rollsOn`, a recipe's `show`) |
43
44
  | "only these players", "just the two of them", "the tournament's entrants" | a `gate` of wallets: `{ "wallets": ["<address>", …] }` (a long list is a `list` gate instead, below) |
44
45
  | "stake to enter", "staking tiers for a token sale", "only people who lock 100 of our token can buy" | a farm of a token that nothing funds when staking pays nothing (no `seed`, no share or schedule into `"farm:<key>"`; `W-FARM-DRY` says so), and a `gate` `{ "staked": { "farm": "<farm of a token>", "atLeast": 100 } }`: the wallet's stake in that farm, counted only while its tier's lock runs and from the second after it is made (one stake can't move to another wallet and count again; a tier with no lock never passes, `W-STAKED`). The app passes the seat and the farm |
45
46
  | "allowlist", "whitelist of 5,000 wallets", "a snapshot of our holders", "early supporters only" | a `gate` `{ "list": ["<address>", …] }` of any size: the program keeps only its root, the page passes each player's proof (`W-LIST`); a new list is a new root, published again |
@@ -40,6 +40,12 @@ and the spec differ (a limit, an amount, a key not set yet), change one of them
40
40
  For a collectible they already hold: "It stays with the project until someone wins it or the auction ends."
41
41
  8. **Anything else the checker warned about**, in a sentence each (a mint that can freeze accounts, a free box bots
42
42
  can drain, a grant with no limits or no funds).
43
+ 9. **What a cheater gets**, from `xsolana_review`, before the owner approves: for each route paid on a server's
44
+ voucher, which level it uses (the server decides, replays or only bounds the result), what one cheater gets there
45
+ (its ceiling, and what the prize goes on to earn), and what the stronger level would cost to build: "A script that
46
+ waits as long as a player would gets each reward NFT once per wallet, and each then earns tokens every day with no
47
+ end; having the server replay each run would stop forged runs, and needs the game to be deterministic." Then each
48
+ high finding and its fix. The person chooses the trade-off (`trust-and-cheating.md`).
43
49
 
44
50
  ## What stays the project's job
45
51