@xgamesfun/solana 0.20.1 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -52
- package/bin/xsolana.mjs +23 -8
- package/examples/blind-forge.json +2 -4
- package/examples/coin-flip-arena.json +4 -12
- package/examples/duel-arena.json +1 -4
- package/examples/elements-set.json +2 -9
- package/examples/expedition-guild.json +1 -8
- package/examples/fair-presale.json +1 -3
- package/examples/guild-missions.json +3 -10
- package/examples/halving-seasons.json +4 -9
- package/examples/hero-trials.json +15 -6
- package/examples/holder-den.json +38 -0
- package/examples/holders-drop.json +105 -0
- package/examples/ideas.json +788 -788
- package/examples/match-predictions.json +24 -0
- package/examples/merge-2048.json +5 -5
- package/examples/nft-raffle.json +1 -0
- package/examples/pack-league.json +108 -0
- package/examples/paper-hands-farm.json +4 -16
- package/examples/partner-vault.json +20 -7
- package/examples/sacrifice-raffle.json +2 -10
- package/examples/shelter-fair.json +1 -0
- package/examples/stakers-draw.json +4 -15
- package/examples/star-fleet.json +1 -8
- package/examples/starforce.json +6 -6
- package/examples/stride-sneakers.json +164 -0
- package/examples/trade-in-shop.json +2 -3
- package/js/approve/details.mjs +122 -52
- package/js/approve/ed25519.mjs +5 -1
- package/js/approve/live-check.mjs +83 -26
- package/js/approve/page.html +217 -78
- package/js/approve/page.js +43 -15
- package/js/approve/server.mjs +290 -73
- package/js/chain/base58.js +7 -1
- package/js/chain/borsh.js +24 -11
- package/js/chain/bytes.js +11 -8
- package/js/chain/core.js +35 -7
- package/js/chain/curve.js +1 -1
- package/js/chain/lookup.js +124 -36
- package/js/chain/mint.js +6 -2
- package/js/chain/pda.js +2 -2
- package/js/chain/programs.js +6 -6
- package/js/chain/rpc.js +44 -16
- package/js/chain/token.js +13 -2
- package/js/chain/token2022.js +55 -16
- package/js/chain/tx.js +32 -22
- package/js/client/accounts.js +653 -131
- package/js/client/compressed.js +4 -3
- package/js/client/grant.js +267 -83
- package/js/client/idl/loot.js +1 -1
- package/js/client/idl/loot.json +2406 -2176
- package/js/client/index.js +1 -0
- package/js/client/ix.js +3059 -664
- package/js/client/pda.js +39 -33
- package/js/client/policy.js +52 -24
- package/js/client/random.js +70 -0
- package/js/client/sizes.js +414 -16
- package/js/client/traits.js +14 -5
- package/js/connect/connect.mjs +59 -17
- package/js/economy/check.mjs +4153 -725
- package/js/economy/cost.mjs +403 -64
- package/js/economy/examples.mjs +176 -35
- package/js/economy/explain.mjs +466 -111
- package/js/economy/guide.mjs +296 -72
- package/js/economy/legal.mjs +12 -6
- package/js/economy/live.mjs +83 -19
- package/js/economy/model.mjs +462 -124
- package/js/economy/plan.mjs +1306 -219
- package/js/economy/schema.json +390 -484
- package/js/economy/secrets.mjs +17 -7
- package/js/economy/simulate.mjs +883 -223
- package/js/economy/txsize.mjs +15 -3
- package/js/economy/validate.mjs +61 -24
- package/js/mcp/server.mjs +42 -14
- package/js/mcp/tools.mjs +307 -88
- package/js/play/index.js +2864 -619
- package/js/play/keys.js +193 -0
- package/js/results/index.js +223 -30
- package/js/results/server.js +230 -0
- package/js/results/wire.js +64 -16
- package/package.json +6 -4
- package/release.json +5 -5
- package/skills/xsolana-economy/SKILL.md +39 -21
- package/skills/xsolana-economy/references/after-launch.md +58 -38
- package/skills/xsolana-economy/references/apps.md +97 -50
- package/skills/xsolana-economy/references/sentence-to-spec.md +148 -121
- package/skills/xsolana-economy/references/telling-the-person.md +61 -17
- package/types/chain/core.d.ts +4 -2
- package/types/chain/lookup.d.ts +5 -2
- package/types/chain/rpc.d.ts +3 -1
- package/types/client/accounts.d.ts +217 -56
- package/types/client/grant.d.ts +72 -27
- package/types/client/idl/loot.d.ts +602 -549
- package/types/client/index.d.ts +1 -0
- package/types/client/ix.d.ts +3884 -247
- package/types/client/pda.d.ts +22 -16
- package/types/client/policy.d.ts +39 -14
- package/types/client/random.d.ts +26 -0
- package/types/client/sizes.d.ts +93 -5
- package/types/economy/check.d.mts +5 -10
- package/types/economy/cost.d.mts +2 -1
- package/types/economy/guide.d.mts +5 -0
- package/types/economy/live.d.mts +16 -1
- package/types/economy/model.d.mts +151 -38
- package/types/economy/plan.d.mts +237 -194
- package/types/economy/simulate.d.mts +21 -13
- package/types/mcp/tools.d.mts +21 -13
- package/types/play/index.d.ts +453 -53
- package/types/play/keys.d.ts +51 -0
- package/types/results/index.d.ts +7 -6
- package/types/results/server.d.ts +126 -0
- package/types/results/wire.d.ts +1 -1
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// What a game's server checks before it pays or grants anything: which of the game's collectibles a wallet holds,
|
|
2
|
+
// that a wallet's owner signed a proof (a sign-in, a link to a player), and promo codes with keys nobody can guess.
|
|
3
|
+
// Browser-safe (Web Crypto and fetch only), so a Cloudflare Worker, a Node server or a page can use it alike; the
|
|
4
|
+
// page's own answers are never trusted for what a server pays.
|
|
5
|
+
//
|
|
6
|
+
// const mine = await collectiblesOf({ rpc: RPC_URL, spec, wallet }) // [{ asset, item, name, attributes, … }]
|
|
7
|
+
// if (await holds({ rpc: RPC_URL, spec, wallet, item: 'pass' })) … // a perk for holders
|
|
8
|
+
//
|
|
9
|
+
// const ask = proofRequest({ game, wallet, purpose: 'link to player 42' }) // the server keeps ask.nonce until ask.expires
|
|
10
|
+
// … the page has the wallet sign ask.text (signMessage) and sends back the signature …
|
|
11
|
+
// const proof = await verifyProof({ text: ask.text, signature, wallet, game, purpose: 'link to player 42' })
|
|
12
|
+
//
|
|
13
|
+
// const codes = makeCodes(50) // print these on flyers; keep them out of the repository
|
|
14
|
+
// spec.grants[i].keys = await codeAddresses(codes) // only public keys go in the spec
|
|
15
|
+
// const voucher = await codeVoucher({ code, game, grant: 3, incarnation, wallet, now }) // the player typed `code`
|
|
16
|
+
import { decodeBase58, encodeBase58, isAddress } from '../chain/base58.js';
|
|
17
|
+
import { toHex, utf8 } from '../chain/bytes.js';
|
|
18
|
+
import { MPL_CORE_PROGRAM, ownedAssetFilters, parseCoreAsset } from '../chain/core.js';
|
|
19
|
+
import { rpcClient } from '../chain/rpc.js';
|
|
20
|
+
import { sha256 } from '../chain/sha256.js';
|
|
21
|
+
import { signVoucher } from './index.js';
|
|
22
|
+
|
|
23
|
+
const subtle = () => globalThis.crypto.subtle;
|
|
24
|
+
const rpcOf = rpc => (typeof rpc === 'string' ? rpcClient(rpc) : rpc);
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------- what a wallet holds
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The game's collectibles `wallet` holds now, read from the chain (Metaplex Core assets of the live spec's collection
|
|
30
|
+
* and of the collections it names): [{ asset, item (its key in the spec, or null), itemId, name, uri, attributes,
|
|
31
|
+
* frozen }]. `rpc`: an RPC link or a `rpcClient`. Token Metadata and compressed NFTs are not read here.
|
|
32
|
+
*/
|
|
33
|
+
export async function collectiblesOf({ rpc, spec, wallet }) {
|
|
34
|
+
if (!isAddress(wallet)) throw new Error('Name the wallet (its public address).');
|
|
35
|
+
const collections = [spec?.live?.collection, ...(spec?.collections ?? [])].filter(Boolean);
|
|
36
|
+
if (!collections.length)
|
|
37
|
+
throw new Error('The spec names no collection: put it live first (its live section names the collection).');
|
|
38
|
+
const keyOf = new Map((spec.items ?? []).filter(it => Number.isInteger(it.id)).map(it => [it.id, it.key]));
|
|
39
|
+
const client = rpcOf(rpc);
|
|
40
|
+
const found = (
|
|
41
|
+
await Promise.all(collections.map(c => client.programAccounts(MPL_CORE_PROGRAM, ownedAssetFilters(wallet, c))))
|
|
42
|
+
).flat();
|
|
43
|
+
return found.map(({ pubkey, account }) => {
|
|
44
|
+
const a = parseCoreAsset(account.data);
|
|
45
|
+
const itemId = a.attributes?.item_id !== undefined ? Number(a.attributes.item_id) : null;
|
|
46
|
+
return { asset: pubkey, ...a, itemId, item: itemId === null ? null : (keyOf.get(itemId) ?? null) };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether `wallet` holds at least `atLeast` of the game's collectible `item` (its key in the spec), or of any of the
|
|
52
|
+
* game's collectibles when `item` is left out. With `attribute`, only those whose number under it is at least
|
|
53
|
+
* `minimum` count (a level, a rarity). → the count it holds when that is enough, else 0.
|
|
54
|
+
*/
|
|
55
|
+
export async function holds({ rpc, spec, wallet, item = null, atLeast = 1, attribute = null, minimum = 0 }) {
|
|
56
|
+
const mine = (await collectiblesOf({ rpc, spec, wallet })).filter(
|
|
57
|
+
c =>
|
|
58
|
+
(item === null || c.item === item) && (attribute === null || Number(c.attributes?.[attribute] ?? 0) >= minimum),
|
|
59
|
+
);
|
|
60
|
+
return mine.length >= atLeast ? mine.length : 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------- a wallet's signed proof
|
|
64
|
+
|
|
65
|
+
const PROOF_TITLE = 'loot wallet proof';
|
|
66
|
+
const PROOF_FIELDS = ['cluster', 'game', 'wallet', 'purpose', 'nonce', 'expires'];
|
|
67
|
+
const oneLine = (name, value) => {
|
|
68
|
+
if (/[\r\n]/.test(String(value))) throw new RangeError(`A proof's ${name} is one line.`);
|
|
69
|
+
return String(value);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The exact text a wallet signs to prove it is its owner's: fixed lines, so nothing else can be passed off as it. */
|
|
73
|
+
export const proofText = ({ cluster = 'devnet', game, wallet, purpose, nonce, expires }) =>
|
|
74
|
+
[
|
|
75
|
+
PROOF_TITLE,
|
|
76
|
+
...PROOF_FIELDS.map(f => `${f}: ${oneLine(f, { cluster, game, wallet, purpose, nonce, expires }[f])}`),
|
|
77
|
+
'',
|
|
78
|
+
'Signing this moves nothing.',
|
|
79
|
+
].join('\n');
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A proof to ask of `wallet`, made by the server: { text, nonce, expires }. The server keeps `nonce` until `expires`
|
|
83
|
+
* (unix seconds, `seconds` from now) and takes each nonce once, so a signed proof can't be replayed.
|
|
84
|
+
*/
|
|
85
|
+
export function proofRequest({
|
|
86
|
+
cluster = 'devnet',
|
|
87
|
+
game,
|
|
88
|
+
wallet,
|
|
89
|
+
purpose = 'sign in',
|
|
90
|
+
seconds = 300,
|
|
91
|
+
now = Math.floor(Date.now() / 1000),
|
|
92
|
+
}) {
|
|
93
|
+
if (!isAddress(wallet)) throw new Error('Name the wallet (its public address).');
|
|
94
|
+
const nonce = toHex(globalThis.crypto.getRandomValues(new Uint8Array(16)));
|
|
95
|
+
const expires = Math.floor(now) + Math.floor(seconds);
|
|
96
|
+
return { text: proofText({ cluster, game, wallet, purpose, nonce, expires }), nonce, expires };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A proof text's fields, or null when it is not one. */
|
|
100
|
+
export function readProof(text) {
|
|
101
|
+
const lines = String(text).split('\n');
|
|
102
|
+
if (
|
|
103
|
+
lines[0] !== PROOF_TITLE ||
|
|
104
|
+
lines.length !== PROOF_FIELDS.length + 3 ||
|
|
105
|
+
lines.at(-1) !== 'Signing this moves nothing.'
|
|
106
|
+
)
|
|
107
|
+
return null;
|
|
108
|
+
const out = {};
|
|
109
|
+
for (const [i, f] of PROOF_FIELDS.entries()) {
|
|
110
|
+
const prefix = `${f}: `;
|
|
111
|
+
if (!lines[i + 1].startsWith(prefix)) return null;
|
|
112
|
+
out[f] = lines[i + 1].slice(prefix.length);
|
|
113
|
+
}
|
|
114
|
+
out.expires = Number(out.expires);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Checks a wallet's signed proof: the text is a proof for this `game` (and `purpose`, when given), for `wallet`, not
|
|
120
|
+
* expired at `now`, and `signature` (base58, or 64 bytes) is that wallet's Ed25519 signature of it. → the proof's
|
|
121
|
+
* fields ({ wallet, nonce, expires, … }), or throws in words. Taking each nonce once is the caller's: check
|
|
122
|
+
* `fields.nonce` against the ones the server handed out and not yet used.
|
|
123
|
+
*/
|
|
124
|
+
export async function verifyProof({
|
|
125
|
+
text,
|
|
126
|
+
signature,
|
|
127
|
+
wallet,
|
|
128
|
+
game,
|
|
129
|
+
purpose = null,
|
|
130
|
+
cluster = 'devnet',
|
|
131
|
+
now = Math.floor(Date.now() / 1000),
|
|
132
|
+
}) {
|
|
133
|
+
const fields = readProof(text);
|
|
134
|
+
if (!fields) throw new Error('That is not a wallet proof.');
|
|
135
|
+
if (fields.cluster !== cluster) throw new Error(`The proof is for ${fields.cluster}, not ${cluster}.`);
|
|
136
|
+
if (game !== undefined && fields.game !== String(game)) throw new Error('The proof is for another game.');
|
|
137
|
+
if (wallet !== undefined && fields.wallet !== wallet) throw new Error('The proof names another wallet.');
|
|
138
|
+
if (purpose !== null && fields.purpose !== purpose) throw new Error('The proof is for something else.');
|
|
139
|
+
if (!(fields.expires > now)) throw new Error('The proof has expired: ask for a new one.');
|
|
140
|
+
const sig = typeof signature === 'string' ? decodeBase58(signature) : Uint8Array.from(signature ?? []);
|
|
141
|
+
if (sig.length !== 64) throw new Error('A signature is 64 bytes.');
|
|
142
|
+
const key = await subtle().importKey('raw', decodeBase58(fields.wallet), { name: 'Ed25519' }, false, ['verify']);
|
|
143
|
+
if (!(await subtle().verify({ name: 'Ed25519' }, key, sig, utf8(text))))
|
|
144
|
+
throw new Error("The signature is not that wallet's.");
|
|
145
|
+
return fields;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------- promo codes
|
|
149
|
+
|
|
150
|
+
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
151
|
+
const CODE_DOMAIN = 'loot-promo-code-v1';
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `n` fresh promo codes, each `bits` of randomness (80 by default, 16 characters of Crockford base32, in groups of 4).
|
|
155
|
+
* A code's key is in the spec for anyone to read, so a short code can be found by trying them all offline: fewer than
|
|
156
|
+
* 64 bits is refused.
|
|
157
|
+
*/
|
|
158
|
+
export function makeCodes(n, { bits = 80, group = 4 } = {}) {
|
|
159
|
+
if (!(bits >= 64))
|
|
160
|
+
throw new RangeError(
|
|
161
|
+
'A promo code needs at least 64 bits (80 by default): its key is public, so a short code can be guessed offline.',
|
|
162
|
+
);
|
|
163
|
+
const chars = Math.ceil(bits / 5);
|
|
164
|
+
return Array.from({ length: n }, () => {
|
|
165
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(chars));
|
|
166
|
+
const text = Array.from(bytes, b => CROCKFORD[b & 31]).join('');
|
|
167
|
+
return group ? text.match(new RegExp(`.{1,${group}}`, 'g')).join('-') : text;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A code as typed, read the forgiving way (Crockford): case, spaces and dashes don't matter, O reads 0, I and L read 1. */
|
|
172
|
+
export const normalizeCode = code =>
|
|
173
|
+
String(code).toUpperCase().replace(/[\s-]/g, '').replace(/O/g, '0').replace(/[IL]/g, '1');
|
|
174
|
+
|
|
175
|
+
/** The key a code stands for: { address, sign } (Ed25519, its seed sha256 of the domain and the code). */
|
|
176
|
+
export async function codeKey(code, { domain = CODE_DOMAIN } = {}) {
|
|
177
|
+
const normalized = normalizeCode(code);
|
|
178
|
+
if (!normalized || [...normalized].some(c => !CROCKFORD.includes(c))) throw new Error('That is not a code.');
|
|
179
|
+
const seed = sha256(utf8(`${domain}\n${normalized}`));
|
|
180
|
+
const pkcs8 = new Uint8Array([
|
|
181
|
+
0x30,
|
|
182
|
+
0x2e,
|
|
183
|
+
0x02,
|
|
184
|
+
0x01,
|
|
185
|
+
0x00,
|
|
186
|
+
0x30,
|
|
187
|
+
0x05,
|
|
188
|
+
0x06,
|
|
189
|
+
0x03,
|
|
190
|
+
0x2b,
|
|
191
|
+
0x65,
|
|
192
|
+
0x70,
|
|
193
|
+
0x04,
|
|
194
|
+
0x22,
|
|
195
|
+
0x04,
|
|
196
|
+
0x20,
|
|
197
|
+
...seed,
|
|
198
|
+
]);
|
|
199
|
+
const privateKey = await subtle().importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
|
|
200
|
+
const jwk = await subtle().exportKey('jwk', privateKey);
|
|
201
|
+
const raw = Uint8Array.from(atob(jwk.x.replace(/-/g, '+').replace(/_/g, '/')), ch => ch.charCodeAt(0));
|
|
202
|
+
return {
|
|
203
|
+
address: encodeBase58(raw),
|
|
204
|
+
sign: async message => new Uint8Array(await subtle().sign({ name: 'Ed25519' }, privateKey, message)),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The public keys for a grant's `keys`, one per code, in order. */
|
|
209
|
+
export const codeAddresses = async (codes, options) =>
|
|
210
|
+
Promise.all(codes.map(async c => (await codeKey(c, options)).address));
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A voucher a typed code signs for `wallet` (the page, or a server, makes it): the grant pays it once per code with
|
|
214
|
+
* `limits.perKey: 1`. Its round is the code's own, so one wallet can't use one code twice either. `incarnation` is the
|
|
215
|
+
* grant's, read from the chain; `now` the cluster's clock.
|
|
216
|
+
*/
|
|
217
|
+
export async function codeVoucher({
|
|
218
|
+
code,
|
|
219
|
+
game,
|
|
220
|
+
grant,
|
|
221
|
+
incarnation,
|
|
222
|
+
wallet,
|
|
223
|
+
now,
|
|
224
|
+
seconds = 86_400,
|
|
225
|
+
domain = CODE_DOMAIN,
|
|
226
|
+
}) {
|
|
227
|
+
const key = await codeKey(code, { domain });
|
|
228
|
+
const round = toHex(sha256(utf8(`${domain}\nround\n${key.address}`))).slice(0, 32);
|
|
229
|
+
return signVoucher({ signer: key, game, grant, incarnation, wallet, round, now, seconds });
|
|
230
|
+
}
|
package/js/results/wire.js
CHANGED
|
@@ -11,7 +11,10 @@ export const CLUSTERS = Object.freeze({ devnet: 0, mainnet: 1 });
|
|
|
11
11
|
export const CERT_KINDS = Object.freeze({
|
|
12
12
|
host: { title: 'loot host key', fields: ['cluster', 'game', 'host', 'until'] },
|
|
13
13
|
revoke: { title: 'loot revoke host key', fields: ['cluster', 'game', 'host'] },
|
|
14
|
-
draw: {
|
|
14
|
+
draw: {
|
|
15
|
+
title: 'loot draw',
|
|
16
|
+
fields: ['cluster', 'game', 'draw', 'engine', 'winners', 'prizes', 'payout', 'identity', 'slug', 'rates'],
|
|
17
|
+
},
|
|
15
18
|
lock: { title: 'loot draw lock', fields: ['cluster', 'game', 'draw', 'roster', 'players', 'beacon'] },
|
|
16
19
|
entry: { title: 'loot entry', fields: ['cluster', 'game', 'draw'] },
|
|
17
20
|
giveaway: { title: 'loot giveaway host', fields: ['cluster', 'game', 'who', 'slug', 'grant', 'until'] },
|
|
@@ -20,13 +23,16 @@ export const CERT_KINDS = Object.freeze({
|
|
|
20
23
|
|
|
21
24
|
/** One field's value, on its own line: a value with a line break in it (which would read as another field) is refused. */
|
|
22
25
|
const field = (name, value) => {
|
|
23
|
-
if (/[\r\n]/.test(String(value)))
|
|
26
|
+
if (/[\r\n]/.test(String(value)))
|
|
27
|
+
throw new RangeError(`A certificate's ${name} is one line: ${JSON.stringify(String(value))} has a line break.`);
|
|
24
28
|
return value;
|
|
25
29
|
};
|
|
26
|
-
const write = (kind, values) =>
|
|
30
|
+
const write = (kind, values) =>
|
|
31
|
+
[CERT_KINDS[kind].title, ...CERT_KINDS[kind].fields.map(f => `${f}: ${field(f, values[f])}`)].join('\n');
|
|
27
32
|
|
|
28
33
|
/** The text the owner signs to let `host` report `game`'s results until `until` (unix seconds). */
|
|
29
|
-
export const hostCertText = ({ cluster = 'devnet', game, host, until }) =>
|
|
34
|
+
export const hostCertText = ({ cluster = 'devnet', game, host, until }) =>
|
|
35
|
+
write('host', { cluster, game, host, until });
|
|
30
36
|
/** The text the owner signs to take `host` back. */
|
|
31
37
|
export const revokeText = ({ cluster = 'devnet', game, host }) => write('revoke', { cluster, game, host });
|
|
32
38
|
|
|
@@ -38,10 +44,33 @@ export const revokeText = ({ cluster = 'devnet', game, host }) => write('revoke'
|
|
|
38
44
|
* `slug`: the game's play-host name (its tokens' audience), or '-'; `rates`: the sha256 of the engine's rates (canonical
|
|
39
45
|
* JSON), or 'default'.
|
|
40
46
|
*/
|
|
41
|
-
export const drawText = ({
|
|
42
|
-
|
|
47
|
+
export const drawText = ({
|
|
48
|
+
cluster = 'devnet',
|
|
49
|
+
game,
|
|
50
|
+
draw,
|
|
51
|
+
engine,
|
|
52
|
+
winners,
|
|
53
|
+
prizes = [],
|
|
54
|
+
payout = 'vouchers',
|
|
55
|
+
identity = 'linked',
|
|
56
|
+
slug = '-',
|
|
57
|
+
rates = 'default',
|
|
58
|
+
}) =>
|
|
59
|
+
write('draw', {
|
|
60
|
+
cluster,
|
|
61
|
+
game,
|
|
62
|
+
draw,
|
|
63
|
+
engine,
|
|
64
|
+
winners,
|
|
65
|
+
prizes: prizes.length ? prizes.map(p => `${p.place}:${p.grant}`).join(' ') : 'none',
|
|
66
|
+
payout,
|
|
67
|
+
identity,
|
|
68
|
+
slug,
|
|
69
|
+
rates,
|
|
70
|
+
});
|
|
43
71
|
/** The text the organizer signs to lock a draw's players (`roster`: rosterHash) to a beacon round still to come. */
|
|
44
|
-
export const lockText = ({ cluster = 'devnet', game, draw, roster, players, beacon, net = 'quicknet' }) =>
|
|
72
|
+
export const lockText = ({ cluster = 'devnet', game, draw, roster, players, beacon, net = 'quicknet' }) =>
|
|
73
|
+
write('lock', { cluster, game, draw, roster, players, beacon: `${net} ${beacon}` });
|
|
45
74
|
/** The text a player's wallet signs to enter a draw (identity 'wallet'). */
|
|
46
75
|
export const entryText = ({ cluster = 'devnet', game, draw }) => write('entry', { cluster, game, draw });
|
|
47
76
|
|
|
@@ -49,20 +78,31 @@ export const entryText = ({ cluster = 'devnet', game, draw }) => write('entry',
|
|
|
49
78
|
* The text the owner (or authority) signs to appoint a giveaway host: `who` is "x:<X account id>" (the host signs in
|
|
50
79
|
* with that X account through the play host) or a wallet address; their giveaways pay from grant `grant` alone.
|
|
51
80
|
*/
|
|
52
|
-
export const giveawayHostText = ({ cluster = 'devnet', game, who, slug, grant, until }) =>
|
|
81
|
+
export const giveawayHostText = ({ cluster = 'devnet', game, who, slug, grant, until }) =>
|
|
82
|
+
write('giveaway', { cluster, game, who, slug, grant, until });
|
|
53
83
|
/** The text the owner (or authority) signs to take a giveaway host back. */
|
|
54
|
-
export const revokeGiveawayText = ({ cluster = 'devnet', game, who }) =>
|
|
84
|
+
export const revokeGiveawayText = ({ cluster = 'devnet', game, who }) =>
|
|
85
|
+
write('revokeGiveaway', { cluster, game, who });
|
|
55
86
|
|
|
56
87
|
/** A draw's players, as one hash: each `id<TAB>wallet`, sorted by id, one a line; sha256, hex. */
|
|
57
88
|
export async function rosterHash(entries) {
|
|
58
|
-
const text = [...entries]
|
|
59
|
-
|
|
89
|
+
const text = [...entries]
|
|
90
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
91
|
+
.map(e => `${e.id}\t${e.wallet}`)
|
|
92
|
+
.join('\n');
|
|
93
|
+
return Array.from(new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', utf8(text))), b =>
|
|
94
|
+
b.toString(16).padStart(2, '0'),
|
|
95
|
+
).join('');
|
|
60
96
|
}
|
|
61
97
|
|
|
62
98
|
/** JSON with every object's keys sorted: one text for one value, so its hash means one thing. */
|
|
63
99
|
export function canonicalJson(value) {
|
|
64
100
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
65
|
-
if (value && typeof value === 'object')
|
|
101
|
+
if (value && typeof value === 'object')
|
|
102
|
+
return `{${Object.keys(value)
|
|
103
|
+
.sort()
|
|
104
|
+
.map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`)
|
|
105
|
+
.join(',')}}`;
|
|
66
106
|
return JSON.stringify(value);
|
|
67
107
|
}
|
|
68
108
|
|
|
@@ -74,9 +114,17 @@ export function canonicalJson(value) {
|
|
|
74
114
|
* place past 255 and more than 255 winners are refused, never written as something else.
|
|
75
115
|
*/
|
|
76
116
|
export function resultBytes({ cluster = 'devnet', game, round, endedAt, winners }) {
|
|
77
|
-
if (!Object.hasOwn(CLUSTERS, cluster))
|
|
78
|
-
|
|
79
|
-
|
|
117
|
+
if (!Object.hasOwn(CLUSTERS, cluster))
|
|
118
|
+
throw new RangeError(
|
|
119
|
+
`Unknown cluster ${JSON.stringify(cluster)}: a result names ${Object.keys(CLUSTERS).join(' or ')}.`,
|
|
120
|
+
);
|
|
121
|
+
const roundAt =
|
|
122
|
+
typeof round === 'string' && /^[0-9a-f]{32}$/i.test(round)
|
|
123
|
+
? fromHex(round)
|
|
124
|
+
: round instanceof Uint8Array && round.length === 16
|
|
125
|
+
? round
|
|
126
|
+
: null;
|
|
127
|
+
if (!roundAt) throw new RangeError("A result's round is 16 bytes (32 hex characters).");
|
|
80
128
|
if (!Array.isArray(winners) || winners.length > 255) throw new RangeError('A result names at most 255 winners.');
|
|
81
129
|
return concat(
|
|
82
130
|
utf8(RESULT_DOMAIN),
|
|
@@ -85,6 +133,6 @@ export function resultBytes({ cluster = 'devnet', game, round, endedAt, winners
|
|
|
85
133
|
roundAt,
|
|
86
134
|
uintLE(BigInt(endedAt), 8),
|
|
87
135
|
Uint8Array.of(winners.length),
|
|
88
|
-
...winners.map(
|
|
136
|
+
...winners.map(w => concat(addressBytes(w.wallet), uintLE(w.place, 1), uintLE(w.grant ?? NO_GRANT, 2))),
|
|
89
137
|
);
|
|
90
138
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xgamesfun/solana",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"mcpName": "io.github.XGamesFun/solana-kit",
|
|
5
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.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -71,10 +71,11 @@
|
|
|
71
71
|
],
|
|
72
72
|
"scripts": {
|
|
73
73
|
"test": "node --test \"js/test/**/*.test.mjs\"",
|
|
74
|
-
"lint": "eslint .",
|
|
74
|
+
"lint": "eslint . && prettier --check .",
|
|
75
|
+
"format": "prettier --write .",
|
|
75
76
|
"types": "tsc -p tsconfig.types.json",
|
|
76
77
|
"fixtures": "node scripts/fetch-fixtures.mjs",
|
|
77
|
-
"check": "node scripts/fetch-fixtures.mjs --check && node scripts/sync-skills.mjs --check && node scripts/release-manifest.mjs --check &&
|
|
78
|
+
"check": "node scripts/fetch-fixtures.mjs --check && node scripts/sync-skills.mjs --check && node scripts/release-manifest.mjs --check && npm run lint && npm test",
|
|
78
79
|
"check:idl": "node scripts/sync-idl.mjs --check",
|
|
79
80
|
"sync-idl": "node scripts/sync-idl.mjs",
|
|
80
81
|
"sync-skills": "node scripts/sync-skills.mjs",
|
|
@@ -85,10 +86,11 @@
|
|
|
85
86
|
"@eslint/js": "10.0.1",
|
|
86
87
|
"@modelcontextprotocol/sdk": "1.32.2",
|
|
87
88
|
"ajv": "8.20.0",
|
|
88
|
-
"eslint": "10.
|
|
89
|
+
"eslint": "10.14.0",
|
|
89
90
|
"globals": "17.13.0",
|
|
90
91
|
"litesvm": "1.6.1",
|
|
91
92
|
"playwright-core": "1.64.1",
|
|
93
|
+
"prettier": "3.9.10",
|
|
92
94
|
"typescript": "7.0.2"
|
|
93
95
|
},
|
|
94
96
|
"bin": {
|
package/release.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.25.0",
|
|
3
3
|
"programId": "9cwqM1TXytPnFZsLfmUyv2dg4uW6Ch9C6UJVPns7NFQ",
|
|
4
|
-
"sbfBytes":
|
|
5
|
-
"sbfSha256": "
|
|
6
|
-
"idlSha256": "
|
|
7
|
-
"clientSha256": "
|
|
4
|
+
"sbfBytes": 1741840,
|
|
5
|
+
"sbfSha256": "ea19d15d03211f7bd0b25179d51f75544342225127fcb631e687eae6750c9ba7",
|
|
6
|
+
"idlSha256": "41e621990c17b8645f59bb2a170271645ad370d1c1bf9f1dbdd04f817b82d4cf",
|
|
7
|
+
"clientSha256": "11270da0bd539b1cb79e8c61527e4b0686f2d03a2e2b617c823dcabf97f05f6e"
|
|
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.
|
|
13
|
-
or `npx -y @xgamesfun/solana@0.
|
|
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).
|
|
14
14
|
|
|
15
15
|
## Rules (always)
|
|
16
16
|
|
|
@@ -25,15 +25,18 @@ or `npx -y @xgamesfun/solana@0.20.1 tool <name> '<json>'` for any tool (always w
|
|
|
25
25
|
5. **Numbers come from the tools.** What a box gives back, how long a pool lasts, what going live costs: quote
|
|
26
26
|
`xsolana_simulate`, `xsolana_cost` and `xsolana_explain`, never your own arithmetic. What a player pays is the tools'
|
|
27
27
|
"in all" number for that action (price, fee, oracle, network and rent that never comes back): quote it as is.
|
|
28
|
-
6. **Flag the law.**
|
|
29
|
-
contests), `W-CHANCE-ITEMS` (paid random collectibles a player can resell: loot boxes, gacha) or
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
6. **Flag the law once.** The first time the checker raises `W-CHANCE-PRIZE` (paid chance at money: boxes with
|
|
29
|
+
prizes, lotteries, contests), `W-CHANCE-ITEMS` (paid random collectibles a player can resell: loot boxes, gacha) or
|
|
30
|
+
`W-REVENUE-SHARE` (stakers paid from sales), say it in one plain sentence. Don't repeat it in later answers, or add
|
|
31
|
+
legal notes to pages and apps you build, unless the person asks or a flag of a new kind appears. When they ask about
|
|
32
|
+
the law, answer what you know and leave the rest to their lawyer: you are not one. Never call anything an
|
|
33
|
+
investment, a return or passive income, and never promise it is safe. The approval page shows the flags and that
|
|
34
|
+
the program is not independently audited and comes as is, and asks the owner to tick that they read them, so going
|
|
35
|
+
live needs nothing more from you. For an auction, say once how bidding works (`W-AUCTION-SHILL`): anyone, the
|
|
36
|
+
seller included, can bid to push the price up.
|
|
34
37
|
7. **Take addresses from the tools, never from memory**: the program, its accounts, the IDL. Every answer names them.
|
|
35
38
|
8. **Public data only in a spec**: names, prices, percents, public addresses. Leave out `id`, `index`, `shopBox`,
|
|
36
|
-
|
|
39
|
+
and `live`: they are written when it goes live.
|
|
37
40
|
9. **Compose before you say no.** Most ideas are several parts working together, not one part. Before calling
|
|
38
41
|
anything a limit, break the idea into its pieces (what players pay, what they get, what is random, what the chain
|
|
39
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`).
|
|
@@ -49,13 +52,19 @@ or `npx -y @xgamesfun/solana@0.20.1 tool <name> '<json>'` for any tool (always w
|
|
|
49
52
|
second step). Don't withdraw a draft that checks, and don't send them elsewhere while a version on loot is on the
|
|
50
53
|
table.
|
|
51
54
|
|
|
52
|
-
10. **
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
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.
|
|
60
|
+
|
|
61
|
+
11. **People finish what follows; build the button, not a server.** Every step after a player's ask (settle a roll,
|
|
62
|
+
draw a lottery, pull its parts (each place, a refund, a share), refund a draw the oracle never answered, claim a
|
|
63
|
+
win, end an auction, run a sealed schedule, take a rental's NFT home) can be sent by the person who
|
|
64
|
+
gains from it, from the page (`app.claims()` lists them, `app.claim(c)` sends one). A
|
|
65
|
+
grant's claim is the winner's alone: the winning wallet signs it, whatever proves it won; anyone may pay its fees,
|
|
66
|
+
nobody claims for a player. loot needs no server to finish anything. Never make a key in a page or a server for
|
|
67
|
+
the owner to fund: whoever signs pays.
|
|
59
68
|
|
|
60
69
|
## The loop
|
|
61
70
|
|
|
@@ -74,13 +83,22 @@ or `npx -y @xgamesfun/solana@0.20.1 tool <name> '<json>'` for any tool (always w
|
|
|
74
83
|
sends each step while it runs (a draw, a payout, a contest's result) and what happens if nobody does:
|
|
75
84
|
`references/after-launch.md`.
|
|
76
85
|
Prizes: give a known prize directly on whatever route wins it (a grant, a lottery place, a contest, a mission, a
|
|
77
|
-
recipe): one step, no oracle. Use a roll of a box only for chance.
|
|
78
|
-
|
|
86
|
+
recipe): one step, no oracle. Use a roll of a box only for chance. A pack of cards is one box with `slots` (five
|
|
87
|
+
draws an open, a guaranteed rare), opened several at once from the page; cards whose traits differ are a fresh
|
|
88
|
+
item drawing from `tables`. Every other shape (bundles, an NFT as the price, stashes, gates, no-loss lotteries,
|
|
89
|
+
amounts by rank) is in `references/sentence-to-spec.md` and `xsolana_guide`.
|
|
90
|
+
A lottery always names where its places are paid from, and there is no default: ask the person if their words
|
|
91
|
+
don't say. `"pot": 0` keeps a pot from round to round: a seed, sponsors, shares of other sales and what earlier
|
|
92
|
+
rounds left all count, and the next round starts with what the round before left (a jackpot that grows, a no-loss
|
|
93
|
+
lottery). `"rest": "<destination>"` pays each round from its own ticket money alone, which nothing from outside can
|
|
94
|
+
change, and sends what the places leave there (`"treasury"`, a wallet, `"burn"`; never a pool or `"chosen"`).
|
|
95
|
+
Then say what stays the project's job (`references/telling-the-person.md`): the files behind every item's link
|
|
96
|
+
(every value of a fresh item's `{attribute}`), a table's rent, and the collectible rent a pack's settle pays.
|
|
79
97
|
6. **Iterate on what they say.** "Make legendaries 0.5%", "a bigger seed": change, check, simulate, explain again.
|
|
80
98
|
7. **Save it**: `xsolana_save {"spec": …}` writes `xsolana.json`, only if it checks. A live file keeps its program numbers.
|
|
81
99
|
8. **When they say "put it live"**: `xsolana_publish` lists every step and its cost and gives the command. Tell them to
|
|
82
100
|
run it themselves in the project folder, exactly as `xsolana_publish` gives it (it names the exact version:
|
|
83
|
-
`npx -y @xgamesfun/solana@0.
|
|
101
|
+
`npx -y @xgamesfun/solana@0.25.0 approve xsolana.json`; never drop the version). It opens a page on their computer where
|
|
84
102
|
they connect a devnet wallet and approve; what lands is written back into `xsolana.json`. You publish nothing.
|
|
85
103
|
9. **After it is live**: `xsolana_status` shows each part on chain; read it before acting on what the person says
|
|
86
104
|
happened. A later change is the same loop: save, publish, and the approval sends only what changed. What only the
|
|
@@ -90,7 +108,7 @@ or `npx -y @xgamesfun/solana@0.20.1 tool <name> '<json>'` for any tool (always w
|
|
|
90
108
|
## Building the app around it
|
|
91
109
|
|
|
92
110
|
A game, a site or a bot calls the live economy with the player's own wallet, through the package's player module
|
|
93
|
-
(`@xgamesfun/solana/play`: buy, open a box and collect the roll, pay, stake, compound and
|
|
111
|
+
(`@xgamesfun/solana/play`: buy, open a box and collect the roll, pay, stake, compound and unstake, craft, bid and end an
|
|
94
112
|
auction, list an NFT for sale or for rent and read who rents it, raffle an NFT, claim what a player is owed, run what
|
|
95
113
|
is due, list what a player owns). Pages call `claims()` when they open and show each as a button.
|
|
96
114
|
`references/apps.md` shows a whole page; `xsolana_client` gives the program and the calls. Never invent account layouts,
|
|
@@ -100,6 +118,6 @@ For staking an NFT of another collection, show what custody means and get explic
|
|
|
100
118
|
## More
|
|
101
119
|
|
|
102
120
|
- From a sentence to a spec, with a worked example: `references/sentence-to-spec.md`
|
|
103
|
-
- Telling the person what they get: `references/telling-the-person.md`
|
|
121
|
+
- Telling the person what they get, and what stays the project's job: `references/telling-the-person.md`
|
|
104
122
|
- Building the app for a live economy: `references/apps.md`
|
|
105
123
|
- After it goes live (who sends each step, owner pages, results from a game, a results server): `references/after-launch.md`
|