@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.
Files changed (112) hide show
  1. package/README.md +95 -52
  2. package/bin/xsolana.mjs +23 -8
  3. package/examples/blind-forge.json +2 -4
  4. package/examples/coin-flip-arena.json +4 -12
  5. package/examples/duel-arena.json +1 -4
  6. package/examples/elements-set.json +2 -9
  7. package/examples/expedition-guild.json +1 -8
  8. package/examples/fair-presale.json +1 -3
  9. package/examples/guild-missions.json +3 -10
  10. package/examples/halving-seasons.json +4 -9
  11. package/examples/hero-trials.json +15 -6
  12. package/examples/holder-den.json +38 -0
  13. package/examples/holders-drop.json +105 -0
  14. package/examples/ideas.json +788 -788
  15. package/examples/match-predictions.json +24 -0
  16. package/examples/merge-2048.json +5 -5
  17. package/examples/nft-raffle.json +1 -0
  18. package/examples/pack-league.json +108 -0
  19. package/examples/paper-hands-farm.json +4 -16
  20. package/examples/partner-vault.json +20 -7
  21. package/examples/sacrifice-raffle.json +2 -10
  22. package/examples/shelter-fair.json +1 -0
  23. package/examples/stakers-draw.json +4 -15
  24. package/examples/star-fleet.json +1 -8
  25. package/examples/starforce.json +6 -6
  26. package/examples/stride-sneakers.json +164 -0
  27. package/examples/trade-in-shop.json +2 -3
  28. package/js/approve/details.mjs +122 -52
  29. package/js/approve/ed25519.mjs +5 -1
  30. package/js/approve/live-check.mjs +83 -26
  31. package/js/approve/page.html +217 -78
  32. package/js/approve/page.js +43 -15
  33. package/js/approve/server.mjs +290 -73
  34. package/js/chain/base58.js +7 -1
  35. package/js/chain/borsh.js +24 -11
  36. package/js/chain/bytes.js +11 -8
  37. package/js/chain/core.js +35 -7
  38. package/js/chain/curve.js +1 -1
  39. package/js/chain/lookup.js +124 -36
  40. package/js/chain/mint.js +6 -2
  41. package/js/chain/pda.js +2 -2
  42. package/js/chain/programs.js +6 -6
  43. package/js/chain/rpc.js +44 -16
  44. package/js/chain/token.js +13 -2
  45. package/js/chain/token2022.js +55 -16
  46. package/js/chain/tx.js +32 -22
  47. package/js/client/accounts.js +653 -131
  48. package/js/client/compressed.js +4 -3
  49. package/js/client/grant.js +267 -83
  50. package/js/client/idl/loot.js +1 -1
  51. package/js/client/idl/loot.json +2406 -2176
  52. package/js/client/index.js +1 -0
  53. package/js/client/ix.js +3059 -664
  54. package/js/client/pda.js +39 -33
  55. package/js/client/policy.js +52 -24
  56. package/js/client/random.js +70 -0
  57. package/js/client/sizes.js +414 -16
  58. package/js/client/traits.js +14 -5
  59. package/js/connect/connect.mjs +59 -17
  60. package/js/economy/check.mjs +4153 -725
  61. package/js/economy/cost.mjs +403 -64
  62. package/js/economy/examples.mjs +176 -35
  63. package/js/economy/explain.mjs +466 -111
  64. package/js/economy/guide.mjs +296 -72
  65. package/js/economy/legal.mjs +12 -6
  66. package/js/economy/live.mjs +83 -19
  67. package/js/economy/model.mjs +462 -124
  68. package/js/economy/plan.mjs +1306 -219
  69. package/js/economy/schema.json +390 -484
  70. package/js/economy/secrets.mjs +17 -7
  71. package/js/economy/simulate.mjs +883 -223
  72. package/js/economy/txsize.mjs +15 -3
  73. package/js/economy/validate.mjs +61 -24
  74. package/js/mcp/server.mjs +42 -14
  75. package/js/mcp/tools.mjs +307 -88
  76. package/js/play/index.js +2864 -619
  77. package/js/play/keys.js +193 -0
  78. package/js/results/index.js +223 -30
  79. package/js/results/server.js +230 -0
  80. package/js/results/wire.js +64 -16
  81. package/package.json +6 -4
  82. package/release.json +5 -5
  83. package/skills/xsolana-economy/SKILL.md +39 -21
  84. package/skills/xsolana-economy/references/after-launch.md +58 -38
  85. package/skills/xsolana-economy/references/apps.md +97 -50
  86. package/skills/xsolana-economy/references/sentence-to-spec.md +148 -121
  87. package/skills/xsolana-economy/references/telling-the-person.md +61 -17
  88. package/types/chain/core.d.ts +4 -2
  89. package/types/chain/lookup.d.ts +5 -2
  90. package/types/chain/rpc.d.ts +3 -1
  91. package/types/client/accounts.d.ts +217 -56
  92. package/types/client/grant.d.ts +72 -27
  93. package/types/client/idl/loot.d.ts +602 -549
  94. package/types/client/index.d.ts +1 -0
  95. package/types/client/ix.d.ts +3884 -247
  96. package/types/client/pda.d.ts +22 -16
  97. package/types/client/policy.d.ts +39 -14
  98. package/types/client/random.d.ts +26 -0
  99. package/types/client/sizes.d.ts +93 -5
  100. package/types/economy/check.d.mts +5 -10
  101. package/types/economy/cost.d.mts +2 -1
  102. package/types/economy/guide.d.mts +5 -0
  103. package/types/economy/live.d.mts +16 -1
  104. package/types/economy/model.d.mts +151 -38
  105. package/types/economy/plan.d.mts +237 -194
  106. package/types/economy/simulate.d.mts +21 -13
  107. package/types/mcp/tools.d.mts +21 -13
  108. package/types/play/index.d.ts +453 -53
  109. package/types/play/keys.d.ts +51 -0
  110. package/types/results/index.d.ts +7 -6
  111. package/types/results/server.d.ts +126 -0
  112. 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
+ }
@@ -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: { title: 'loot draw', fields: ['cluster', 'game', 'draw', 'engine', 'winners', 'prizes', 'payout', 'identity', 'slug', 'rates'] },
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))) throw new RangeError(`A certificate's ${name} is one line: ${JSON.stringify(String(value))} has a line break.`);
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) => [CERT_KINDS[kind].title, ...CERT_KINDS[kind].fields.map((f) => `${f}: ${field(f, values[f])}`)].join('\n');
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 }) => write('host', { cluster, 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 = ({ cluster = 'devnet', game, draw, engine, winners, prizes = [], payout = 'vouchers', identity = 'linked', slug = '-', rates = 'default' }) =>
42
- write('draw', { cluster, game, draw, engine, winners, prizes: prizes.length ? prizes.map((p) => `${p.place}:${p.grant}`).join(' ') : 'none', payout, identity, slug, rates });
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' }) => write('lock', { cluster, game, draw, roster, players, beacon: `${net} ${beacon}` });
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 }) => write('giveaway', { cluster, 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 }) => write('revokeGiveaway', { cluster, 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].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).map((e) => `${e.id}\t${e.wallet}`).join('\n');
59
- return Array.from(new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', utf8(text))), (b) => b.toString(16).padStart(2, '0')).join('');
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') return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
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)) throw new RangeError(`Unknown cluster ${JSON.stringify(cluster)}: a result names ${Object.keys(CLUSTERS).join(' or ')}.`);
78
- const roundAt = typeof round === 'string' && /^[0-9a-f]{32}$/i.test(round) ? fromHex(round) : round instanceof Uint8Array && round.length === 16 ? round : null;
79
- if (!roundAt) throw new RangeError('A result\'s round is 16 bytes (32 hex characters).');
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((w) => concat(addressBytes(w.wallet), uintLE(w.place, 1), uintLE(w.grant ?? NO_GRANT, 2))),
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.20.1",
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 && eslint . && npm test",
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.13.0",
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.20.1",
2
+ "version": "0.25.0",
3
3
  "programId": "9cwqM1TXytPnFZsLfmUyv2dg4uW6Ch9C6UJVPns7NFQ",
4
- "sbfBytes": 1457936,
5
- "sbfSha256": "358805a85fd32ba7b5b6bee9dd88e063bda6f854820d978265bca6683790aee7",
6
- "idlSha256": "0f75fb422c2e8eca80cfd4fd5ccc1cd8805df9cc12e981bfd0297810c772aca2",
7
- "clientSha256": "91537742722a9b31da8afd1f1a2af1bfeea389ad74981e740a41917f81ebecce"
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.20.1 <guide|examples|check|simulate|cost|explain|publish|status> [xsolana.json]`,
13
- or `npx -y @xgamesfun/solana@0.20.1 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.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.** When the checker raises `W-CHANCE-PRIZE` (paid chance at money: boxes with prizes, lotteries,
29
- contests), `W-CHANCE-ITEMS` (paid random collectibles a player can resell: loot boxes, gacha) or `W-REVENUE-SHARE` (stakers paid from sales), tell the person in plain words, every time, and never
30
- call anything an investment, a return or passive income. For an auction, always say `W-AUCTION-SHILL`: anyone,
31
- the seller included, can bid to push the price up. Before they go live, say as well that the program is not
32
- independently audited and comes as is, with no warranty: they use it at their own risk, and making sure the
33
- economy is legal where it is offered is theirs. Never promise it is safe, and never give legal advice.
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
- `rewardsIndex` and `live`: they are written when it goes live.
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. **People finish what follows; build the button, not a server.** Every step after a player's ask (settle a roll,
53
- draw a lottery, complete its tickets and pay its places, refund a draw the oracle never answered, claim a win, end
54
- an auction, take unbonded tokens out, run a sealed schedule, take a rental's NFT home) can be sent by anyone, and
55
- the person who gains from it sends it from the page (`app.claims()` lists them, `app.claim(c)` sends one). A
56
- grant's claim is the winner's alone: the winning wallet signs it, whatever proves it won; anyone may pay its fees,
57
- nobody claims for a player. loot needs no server to finish anything. Never make a key in a page or a server for
58
- the owner to fund: whoever signs pays.
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. Every other shape (bundles, an NFT as the price,
78
- stashes, gates, no-loss lotteries, amounts by rank) is in `references/sentence-to-spec.md` and `xsolana_guide`.
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.20.1 approve xsolana.json`; never drop the version). It opens a page on their computer where
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 withdraw, craft, bid and end an
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`