@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
|
@@ -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
|
+
}
|
package/js/results/index.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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.
|
|
3
|
+
"version": "0.25.2",
|
|
4
4
|
"mcpName": "io.github.XGamesFun/solana-kit",
|
|
5
|
-
"description": "
|
|
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
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"staking",
|
|
18
|
+
"solana-program",
|
|
19
|
+
"web3",
|
|
21
20
|
"nft",
|
|
22
|
-
"
|
|
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
|
-
"
|
|
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": "
|
|
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.
|
|
13
|
-
or `npx -y @xgamesfun/solana@0.25.
|
|
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.
|
|
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
|
|
57
|
-
rewards for results small, and have a server
|
|
58
|
-
|
|
59
|
-
`
|
|
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. **
|
|
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.
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
|