@xgamesfun/solana 0.0.0-stage → 0.24.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 (138) hide show
  1. package/DISCLAIMER.md +41 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +13 -0
  4. package/README.md +172 -2
  5. package/bin/xsolana.mjs +189 -0
  6. package/examples/artwork-auction.json +51 -0
  7. package/examples/blind-forge.json +128 -0
  8. package/examples/coin-flip-arena.json +103 -0
  9. package/examples/duel-arena.json +137 -0
  10. package/examples/elements-set.json +274 -0
  11. package/examples/event-badges.json +67 -0
  12. package/examples/expedition-guild.json +154 -0
  13. package/examples/fair-presale.json +48 -0
  14. package/examples/falling-drop.json +90 -0
  15. package/examples/guild-missions.json +137 -0
  16. package/examples/halving-seasons.json +107 -0
  17. package/examples/hero-trials.json +93 -0
  18. package/examples/ideas.json +789 -0
  19. package/examples/level-ladder.json +65 -0
  20. package/examples/merge-2048.json +128 -0
  21. package/examples/nft-raffle.json +33 -0
  22. package/examples/paper-hands-farm.json +44 -0
  23. package/examples/partner-vault.json +112 -0
  24. package/examples/sacrifice-raffle.json +118 -0
  25. package/examples/scorched-treasury.json +84 -0
  26. package/examples/shelter-fair.json +164 -0
  27. package/examples/spark-pity.json +153 -0
  28. package/examples/stakers-draw.json +118 -0
  29. package/examples/star-fleet.json +150 -0
  30. package/examples/starforce.json +116 -0
  31. package/examples/stud-farm.json +134 -0
  32. package/examples/team-vesting.json +70 -0
  33. package/examples/trade-in-shop.json +168 -0
  34. package/js/approve/details.mjs +263 -0
  35. package/js/approve/ed25519.mjs +36 -0
  36. package/js/approve/live-check.mjs +144 -0
  37. package/js/approve/page.html +86 -0
  38. package/js/approve/page.js +203 -0
  39. package/js/approve/server.mjs +726 -0
  40. package/js/chain/base58.js +73 -0
  41. package/js/chain/borsh.js +270 -0
  42. package/js/chain/bytes.js +141 -0
  43. package/js/chain/core.js +181 -0
  44. package/js/chain/curve.js +52 -0
  45. package/js/chain/lookup.js +279 -0
  46. package/js/chain/mint.js +96 -0
  47. package/js/chain/pda.js +66 -0
  48. package/js/chain/programs.js +102 -0
  49. package/js/chain/rpc.js +219 -0
  50. package/js/chain/sha256.js +68 -0
  51. package/js/chain/token.js +85 -0
  52. package/js/chain/token2022.js +247 -0
  53. package/js/chain/tx.js +224 -0
  54. package/js/client/accounts.js +679 -0
  55. package/js/client/compressed.js +46 -0
  56. package/js/client/grant.js +299 -0
  57. package/js/client/idl/loot.js +2 -0
  58. package/js/client/idl/loot.json +17206 -0
  59. package/js/client/index.js +10 -0
  60. package/js/client/ix.js +2887 -0
  61. package/js/client/pda.js +114 -0
  62. package/js/client/policy.js +69 -0
  63. package/js/client/random.js +70 -0
  64. package/js/client/sizes.js +297 -0
  65. package/js/client/traits.js +44 -0
  66. package/js/connect/connect.mjs +182 -0
  67. package/js/economy/check.mjs +2236 -0
  68. package/js/economy/cost.mjs +185 -0
  69. package/js/economy/examples.mjs +114 -0
  70. package/js/economy/explain.mjs +239 -0
  71. package/js/economy/guide.mjs +232 -0
  72. package/js/economy/legal.mjs +15 -0
  73. package/js/economy/live.mjs +130 -0
  74. package/js/economy/model.mjs +698 -0
  75. package/js/economy/plan.mjs +1155 -0
  76. package/js/economy/schema.json +3071 -0
  77. package/js/economy/secrets.mjs +60 -0
  78. package/js/economy/simulate.mjs +1019 -0
  79. package/js/economy/txsize.mjs +45 -0
  80. package/js/economy/validate.mjs +85 -0
  81. package/js/index.js +10 -0
  82. package/js/mcp/server.mjs +113 -0
  83. package/js/mcp/tools.mjs +485 -0
  84. package/js/play/index.js +2855 -0
  85. package/js/play/keys.js +167 -0
  86. package/js/results/index.js +184 -0
  87. package/js/results/wire.js +90 -0
  88. package/package.json +100 -4
  89. package/release.json +8 -0
  90. package/skills/xsolana-economy/SKILL.md +109 -0
  91. package/skills/xsolana-economy/references/after-launch.md +124 -0
  92. package/skills/xsolana-economy/references/apps.md +193 -0
  93. package/skills/xsolana-economy/references/sentence-to-spec.md +180 -0
  94. package/skills/xsolana-economy/references/telling-the-person.md +134 -0
  95. package/types/chain/base58.d.ts +10 -0
  96. package/types/chain/borsh.d.ts +23 -0
  97. package/types/chain/bytes.d.ts +45 -0
  98. package/types/chain/core.d.ts +52 -0
  99. package/types/chain/curve.d.ts +1 -0
  100. package/types/chain/lookup.d.ts +194 -0
  101. package/types/chain/mint.d.ts +25 -0
  102. package/types/chain/pda.d.ts +25 -0
  103. package/types/chain/programs.d.ts +65 -0
  104. package/types/chain/rpc.d.ts +68 -0
  105. package/types/chain/sha256.d.ts +1 -0
  106. package/types/chain/token.d.ts +141 -0
  107. package/types/chain/token2022.d.ts +53 -0
  108. package/types/chain/tx.d.ts +60 -0
  109. package/types/client/accounts.d.ts +318 -0
  110. package/types/client/compressed.d.ts +29 -0
  111. package/types/client/grant.d.ts +170 -0
  112. package/types/client/idl/loot.d.ts +4498 -0
  113. package/types/client/index.d.ts +9 -0
  114. package/types/client/ix.d.ts +5315 -0
  115. package/types/client/pda.d.ts +95 -0
  116. package/types/client/policy.d.ts +48 -0
  117. package/types/client/random.d.ts +26 -0
  118. package/types/client/sizes.d.ts +101 -0
  119. package/types/client/traits.d.ts +12 -0
  120. package/types/economy/check.d.mts +100 -0
  121. package/types/economy/cost.d.mts +69 -0
  122. package/types/economy/examples.d.mts +27 -0
  123. package/types/economy/explain.d.mts +19 -0
  124. package/types/economy/guide.d.mts +140 -0
  125. package/types/economy/legal.d.mts +12 -0
  126. package/types/economy/live.d.mts +124 -0
  127. package/types/economy/model.d.mts +431 -0
  128. package/types/economy/plan.d.mts +1014 -0
  129. package/types/economy/secrets.d.mts +4 -0
  130. package/types/economy/simulate.d.mts +164 -0
  131. package/types/economy/txsize.d.mts +17 -0
  132. package/types/economy/validate.d.mts +3 -0
  133. package/types/index.d.ts +9 -0
  134. package/types/mcp/tools.d.mts +635 -0
  135. package/types/play/index.d.ts +880 -0
  136. package/types/play/keys.d.ts +51 -0
  137. package/types/results/index.d.ts +155 -0
  138. package/types/results/wire.d.ts +122 -0
@@ -0,0 +1,167 @@
1
+ // The throwaway keys js/play settles with (the key job rule, AGENTS.md), made so a page that closes in the middle of a
2
+ // job can find them again and finish it, or send their SOL back.
3
+ //
4
+ // A key is named by the account its job is about (an order, a hold). With a wallet that signs messages, every key comes
5
+ // from one signature of the wallet's over a fixed text naming the project and the wallet, asked once a session: the
6
+ // key's seed is sha256("loot settle key" | that signature | the name). Ed25519 signatures are deterministic, so after a
7
+ // reload the same signature gives the same keys. A wallet that signs no messages, or whose signature of the text
8
+ // changes from one session to the next (some MPC wallets), gets a random key instead, kept in the page's storage
9
+ // encrypted with a device key the page can use but never read out (WebCrypto, IndexedDB); with no such storage, in
10
+ // memory only, as before.
11
+ import { addressBytes, encodeBase58 } from '../chain/base58.js';
12
+ import { concat, fromBase64, randomBytes, toBase64, toHex, utf8 } from '../chain/bytes.js';
13
+ import { sha256 } from '../chain/sha256.js';
14
+
15
+ /** The PKCS #8 header of a raw 32-byte Ed25519 seed (RFC 8410). */
16
+ const PKCS8_ED25519 = Uint8Array.from([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20]);
17
+
18
+ const DERIVED = 'derived';
19
+ const fromBase64Url = (text) => fromBase64(text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '='));
20
+
21
+ /** An Ed25519 key from its 32-byte seed: { address, sign(message) }. */
22
+ export async function keyFromSeed(seed, name = null) {
23
+ const subtle = globalThis.crypto.subtle;
24
+ const pkcs8 = concat(PKCS8_ED25519, seed);
25
+ const jwk = await subtle.exportKey('jwk', await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']));
26
+ const signer = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign']);
27
+ return { address: encodeBase58(fromBase64Url(jwk.x)), name, sign: async (message) => new Uint8Array(await subtle.sign('Ed25519', signer, message)) };
28
+ }
29
+
30
+ /** The text a wallet signs once a session for its settle keys. It moves nothing and says so. */
31
+ export const settleKeysText = (project, wallet) =>
32
+ `loot: the keys that settle your orders\nproject ${project}\nwallet ${wallet}\n\nSigning this moves nothing. A page that closes before it settles makes the same key again from it, to finish or send your SOL back.`;
33
+
34
+ /**
35
+ * The page's storage of keys that can't be made again (`localStorage`), and a device key that encrypts them, kept in
36
+ * IndexedDB as a non-extractable AES-GCM key. Null where either is missing (then keys live in memory only).
37
+ */
38
+ export function browserStore() {
39
+ const ls = globalThis.localStorage;
40
+ if (!ls || !globalThis.indexedDB) return null;
41
+ let device = null;
42
+ return {
43
+ get: (k) => ls.getItem(k),
44
+ set: (k, v) => ls.setItem(k, v),
45
+ remove: (k) => ls.removeItem(k),
46
+ keys: () => Array.from({ length: ls.length }, (_, i) => ls.key(i)).filter(Boolean),
47
+ cryptoKey: () => (device ??= deviceKey().catch(() => null)),
48
+ };
49
+ }
50
+
51
+ async function deviceKey() {
52
+ const db = await new Promise((resolve, reject) => {
53
+ const r = globalThis.indexedDB.open('loot-keys', 1);
54
+ r.onupgradeneeded = () => r.result.createObjectStore('keys');
55
+ r.onsuccess = () => resolve(r.result);
56
+ r.onerror = () => reject(r.error);
57
+ });
58
+ const found = await new Promise((resolve, reject) => {
59
+ const r = db.transaction('keys').objectStore('keys').get('device');
60
+ r.onsuccess = () => resolve(r.result ?? null);
61
+ r.onerror = () => reject(r.error);
62
+ });
63
+ if (found) return found;
64
+ const key = await globalThis.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
65
+ await new Promise((resolve, reject) => {
66
+ const tx = db.transaction('keys', 'readwrite');
67
+ tx.objectStore('keys').put(key, 'device');
68
+ tx.oncomplete = () => resolve();
69
+ tx.onerror = () => reject(tx.error);
70
+ });
71
+ return key;
72
+ }
73
+
74
+ /**
75
+ * The keys of `wallet` ({ address, signMessage? }) in `project`. `store`: `browserStore()`'s shape ({ get, set, remove,
76
+ * keys, cryptoKey }), or null. `key(name)` makes the key named by an address (asking the wallet for its signature the
77
+ * first time in a session); `again(name)` makes it again for a job left unfinished, or finds it stored, or null;
78
+ * `stored()` lists the names of keys stored; `forget(name)` drops a stored key once its job is done and it is empty.
79
+ * `how()` says how keys are made: 'derived', 'stored' or 'memory'.
80
+ */
81
+ export function keyring({ wallet, project, store = null }) {
82
+ let root = null;
83
+ const prefix = () => `loot-key:${project}:${wallet.address}:`;
84
+ const checkName = () => `loot-key-check:${project}:${wallet.address}`;
85
+ const safe = (f, fallback = null) => {
86
+ try {
87
+ return f();
88
+ } catch {
89
+ return fallback;
90
+ }
91
+ };
92
+
93
+ /** The wallet's signature of `settleKeysText`, once a session; null when it signs no messages, refuses, or signs the
94
+ * text differently from an earlier session (then nothing it signed could be made again). */
95
+ function signature() {
96
+ if (!wallet?.signMessage) return Promise.resolve(null);
97
+ root ??= (async () => {
98
+ const sig = new Uint8Array(await wallet.signMessage(utf8(settleKeysText(project, wallet.address))));
99
+ if (sig.length !== 64) return null;
100
+ const mark = toHex(sha256(sha256(sig))).slice(0, 32);
101
+ const had = store ? safe(() => store.get(checkName())) : null;
102
+ if (had && had !== mark) return null;
103
+ if (!had && store) safe(() => store.set(checkName(), mark));
104
+ return sig;
105
+ })().catch(() => null);
106
+ return root;
107
+ }
108
+
109
+ const derived = (sig, name) => keyFromSeed(sha256(concat(utf8('loot settle key'), sig, addressBytes(name))), name);
110
+
111
+ async function seal(seed) {
112
+ const device = await store?.cryptoKey?.();
113
+ if (!device) return null;
114
+ const iv = randomBytes(12);
115
+ const sealed = new Uint8Array(await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, device, seed));
116
+ return toBase64(concat(iv, sealed));
117
+ }
118
+
119
+ async function open(text) {
120
+ const device = await store?.cryptoKey?.();
121
+ if (!device || !text) return null;
122
+ const bytes = fromBase64(text);
123
+ return new Uint8Array(await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv: bytes.subarray(0, 12) }, device, bytes.subarray(12)));
124
+ }
125
+
126
+ return {
127
+ async how() {
128
+ if (await signature()) return 'derived';
129
+ return store && (await store.cryptoKey?.()) ? 'stored' : 'memory';
130
+ },
131
+
132
+ /** The key named `name` (an address) for a new job. */
133
+ async key(name) {
134
+ const sig = await signature();
135
+ if (sig) {
136
+ // Its name is kept, so `recover` finds it once its job's account is gone.
137
+ if (store) safe(() => store.set(prefix() + name, DERIVED));
138
+ return derived(sig, name);
139
+ }
140
+ const seed = randomBytes(32);
141
+ const sealed = store ? await seal(seed).catch(() => null) : null;
142
+ if (sealed) safe(() => store.set(prefix() + name, sealed));
143
+ return keyFromSeed(seed, name);
144
+ },
145
+
146
+ /** The key named `name` again, for a job left unfinished: made again from the wallet's signature, or read from
147
+ * storage; null when neither can. `ask`: false to make it only from a signature already given this session. */
148
+ async again(name, { ask = true } = {}) {
149
+ const sig = ask || root ? await signature() : null;
150
+ if (sig) return derived(sig, name);
151
+ const text = store ? safe(() => store.get(prefix() + name)) : null;
152
+ const seed = text && text !== DERIVED ? await open(text).catch(() => null) : null;
153
+ return seed ? keyFromSeed(seed, name) : null;
154
+ },
155
+
156
+ /** The names of the keys this page stored for the wallet (none when keys are made from its signature). */
157
+ stored() {
158
+ if (!store) return [];
159
+ const p = prefix();
160
+ return safe(() => store.keys(), []).filter((k) => k.startsWith(p)).map((k) => k.slice(p.length));
161
+ },
162
+
163
+ forget(name) {
164
+ if (store && name) safe(() => store.remove(prefix() + name));
165
+ },
166
+ };
167
+ }
@@ -0,0 +1,184 @@
1
+ // `@xgamesfun/solana/results`: what a game uses with a results server, in a host server or in a player's game page.
2
+ // Browser-safe: Web Crypto only. `server` is RESULTS_SERVER (the public one, on the games.fun play host), or another
3
+ // one's URL. The server confirms who won (from signatures rooted on the chain) and signs grant vouchers; winners claim
4
+ // them with js/play (`claimWins`, `claimGrant`). The program pays a wallet each round once, ever, whatever is signed.
5
+ //
6
+ // A host server (the project's own):
7
+ // const key = await newKey() // once; keep key.secret in the server's secret store
8
+ // hostCertText({ game, host: key.address, until }) → the owner signs it in their wallet, once
9
+ // await report({ server, cert, signer: key, game, round: newRound(), endedAt, winners: [{ wallet, place, grant }] })
10
+ //
11
+ // A host server that pays its winners itself, with no server of ours in the way (the owner names the host key among
12
+ // the grant's voucher keys; the program checks every voucher):
13
+ // const voucher = await signVoucher({ signer: key, game, grant: 1, incarnation, wallet, round, now: clusterNow })
14
+ // the winner's page: app.claimGrant('prize', { voucher })
15
+ //
16
+ // A giveaway host (a player the owner appointed, signed in with the play host; their page):
17
+ // const session = await newKey()
18
+ // await giveaways.open({ server, game, cert: appointment, token, giveaway: newRound(), winners: 3, endsBy, session })
19
+ // … the game is played …
20
+ // await giveaways.report({ server, game, giveaway, session, endedAt, winners: [{ wallet, place }], grant })
21
+ import { decodeBase58, encodeBase58 } from '../chain/base58.js';
22
+ import { toHex } from '../chain/bytes.js';
23
+ import { pda, voucherBytes } from '../client/index.js';
24
+ import { canonicalJson, drawText, entryText, lockText, resultBytes, rosterHash } from './wire.js';
25
+
26
+ export { canonicalJson, CERT_KINDS, CLUSTERS, drawText, entryText, giveawayHostText, hostCertText, lockText, NO_GRANT, RESULT_DOMAIN, resultBytes, revokeGiveawayText, revokeText, rosterHash } from './wire.js';
27
+
28
+ /** games.fun's results server, on its play host (devnet until the program's audit). */
29
+ export const RESULTS_SERVER = 'https://play.xgames.fun/xsolana';
30
+
31
+ const subtle = () => globalThis.crypto.subtle;
32
+ /** A value as a query's value: encoded, so a `&`, `#` or `/` in it is part of the value. */
33
+ const q = (value) => encodeURIComponent(String(value));
34
+ /**
35
+ * A value as one segment of a URL's path: encoded, so a `/` or `?` in it stays inside the segment. One that is empty,
36
+ * `.` or `..` (which a URL reads as a step up the path, encoded or not) is refused.
37
+ */
38
+ const seg = (value) => {
39
+ const text = String(value);
40
+ if (text === '' || text === '.' || text === '..') throw new RangeError(`${JSON.stringify(text)} can't be a part of a path.`);
41
+ return encodeURIComponent(text);
42
+ };
43
+
44
+ /** A fresh round id: 16 random bytes, in hex. Make it when the round starts. */
45
+ export const newRound = () => toHex(globalThis.crypto.getRandomValues(new Uint8Array(16)));
46
+
47
+ /**
48
+ * A fresh Ed25519 key: { address, sign(bytes) → Promise<64 bytes>, secret }. `secret` is its 32-byte seed in base58,
49
+ * for a host server to keep in its secret store; a page's session key never needs it.
50
+ */
51
+ export async function newKey() {
52
+ const pair = await subtle().generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
53
+ const jwk = await subtle().exportKey('jwk', pair.privateKey);
54
+ return keyOf(pair.privateKey, base64url(jwk.x), jwk.d);
55
+ }
56
+
57
+ /** The key a host server kept (`secret`: its seed in base58). */
58
+ export async function keyFromSecret(secret) {
59
+ const seed = decodeBase58(secret);
60
+ if (seed.length !== 32) throw new Error('A host key secret is 32 bytes in base58.');
61
+ const pkcs8 = new Uint8Array([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, ...seed]);
62
+ const privateKey = await subtle().importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
63
+ const jwk = await subtle().exportKey('jwk', privateKey);
64
+ return keyOf(privateKey, base64url(jwk.x), jwk.d);
65
+ }
66
+
67
+ function keyOf(privateKey, publicBytes, d) {
68
+ return {
69
+ address: encodeBase58(publicBytes),
70
+ secret: encodeBase58(base64url(d)),
71
+ sign: async (message) => new Uint8Array(await subtle().sign({ name: 'Ed25519' }, privateKey, message)),
72
+ };
73
+ }
74
+
75
+ function base64url(text) {
76
+ const bin = atob(text.replace(/-/g, '+').replace(/_/g, '/'));
77
+ return Uint8Array.from(bin, (c) => c.charCodeAt(0));
78
+ }
79
+
80
+ /** One signer's signature of a result: { key, signature }. `signer` is a key from newKey, or anything with sign(). */
81
+ export async function signResult(signer, result, cluster = 'devnet') {
82
+ return { key: signer.address, signature: encodeBase58(await signer.sign(resultBytes({ ...result, cluster }))) };
83
+ }
84
+
85
+ /**
86
+ * A host server reports a round. `cert`: the owner's { text, signature } certifying `signer`, the host key. Retrying
87
+ * with the same result is safe: it is the same answer. Returns the server's answer, { result, vouchers }, or throws its
88
+ * { code, why }.
89
+ */
90
+ export async function report({ server, cert, signer, game, round, endedAt, winners, cluster = 'devnet', fetchImpl = globalThis.fetch }) {
91
+ const result = { game, round, endedAt, winners };
92
+ const all = [await signResult(signer, result, cluster)];
93
+ const response = await fetchImpl(`${server.replace(/\/$/, '')}/v1/results`, {
94
+ method: 'POST',
95
+ headers: { 'content-type': 'application/json' },
96
+ body: JSON.stringify({ ...result, cert, signatures: all }),
97
+ });
98
+ return answerOf(response);
99
+ }
100
+
101
+ /**
102
+ * A server's answer: its JSON body when the status is a success. Otherwise it throws the server's own { code, why }
103
+ * when the body says them, or the status in words (an HTML error page from a proxy, an empty body).
104
+ */
105
+ async function answerOf(response) {
106
+ const answer = await response.json().catch(() => null);
107
+ if (!response.ok) throw Object.assign(new Error(answer?.error?.why ?? `The results server answered ${response.status}${response.statusText ? ` ${response.statusText}` : ''}.`), { code: answer?.error?.code, status: response.status });
108
+ if (answer === null || typeof answer !== 'object') throw Object.assign(new Error(`The results server answered ${response.status} with no JSON.`), { status: response.status });
109
+ return answer;
110
+ }
111
+
112
+ /** A wallet's vouchers for a round it won: [{ grant, publicKey, signature, round, expires, … }]. */
113
+ export async function fetchVouchers({ server, game, round, wallet, fetchImpl = globalThis.fetch }) {
114
+ const response = await fetchImpl(`${server.replace(/\/$/, '')}/v1/vouchers?game=${q(game)}&round=${q(round)}&wallet=${q(wallet)}`);
115
+ return (await answerOf(response)).vouchers;
116
+ }
117
+
118
+ /**
119
+ * A loot grant voucher the host key signs itself, for a host server whose key the grant names (no results server
120
+ * involved): the program checks it and pays only `wallet`, within the grant's limits. `round` is the result's round id
121
+ * (16 bytes hex): the program pays a wallet each round once, ever, so the voucher may be signed again, any time, and
122
+ * still pays once. `incarnation` is the grant's (`Grant.incarnation`): a voucher pays only the grant it was signed
123
+ * for, never one written again at the same address. `now` is the cluster's clock (unix s); the voucher lives
124
+ * `seconds` from it, 30 days at most. `amount`: the tokens this claim gives, in base units, for a grant of a fixed amount
125
+ * of tokens (a rank's reward), at most the grant's; 0 (the default) gives the grant's prize as it is.
126
+ * Returns { wallet, grant, incarnation, publicKey, signature, round, expires, personHash, amount } for js/play claimGrant,
127
+ * plain JSON (`incarnation` and `amount` as decimal text), so a server can send it as it is.
128
+ */
129
+ export async function signVoucher({ signer, game, grant, incarnation, wallet, round, now, seconds = 7 * 86_400, personHash = null, amount = 0n }) {
130
+ if (!(Number(seconds) > 0)) throw new RangeError('A voucher lives some seconds from now (`seconds` above 0): one that has run out already pays nothing.');
131
+ if (Number(seconds) > 30 * 86_400) throw new Error('A voucher lives 30 days at most.');
132
+ // The cluster's clock comes as a number or a bigint (an RPC's, a decoded Clock's); `expires` is a number either way.
133
+ const expires = Math.floor(typeof now === 'bigint' ? Number(now) : now) + Number(seconds);
134
+ if (!Number.isSafeInteger(expires)) throw new RangeError('A voucher needs the cluster\'s clock (`now`, unix seconds).');
135
+ const message = voucherBytes({ grant: pda.grant(game, grant), incarnation, wallet, personHash, round, expires, amount: BigInt(amount) });
136
+ return { wallet, grant, incarnation: String(BigInt(incarnation)), publicKey: signer.address, signature: encodeBase58(await signer.sign(message)), round, expires, personHash: personHash ?? '0'.repeat(64), amount: String(BigInt(amount)) };
137
+ }
138
+
139
+ /** Posts JSON to the server and answers its body, or throws its { code, why }. */
140
+ async function post(server, path, body, fetchImpl) {
141
+ const response = await fetchImpl(`${server.replace(/\/$/, '')}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
142
+ return answerOf(response);
143
+ }
144
+
145
+ /** Gets JSON from the server and answers its body, or throws its { code, why }. */
146
+ const fetchJson = async (server, path, fetchImpl) => answerOf(await fetchImpl(`${server.replace(/\/$/, '')}${path}`));
147
+
148
+ /**
149
+ * A draw, from the organizer's side and the players'. `signMessage(text) → signature bytes` is the organizer's wallet
150
+ * (its signMessage) or a host key's sign; `by` its address; `cert` the owner's host certificate when `by` is a host key.
151
+ */
152
+ export const draws = {
153
+ open: async ({ server, signMessage, by, cert = null, rates = null, fetchImpl = globalThis.fetch, ...spec }) => {
154
+ const ratesHash = rates ? Array.from(new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalJson(rates)))), (b) => b.toString(16).padStart(2, '0')).join('') : 'default';
155
+ const text = drawText({ ...spec, rates: ratesHash });
156
+ return post(server, '/v1/draws', { text, signature: encodeBase58(await signMessage(new TextEncoder().encode(text))), by, ...(cert ? { cert } : {}), ...(rates ? { rates } : {}) }, fetchImpl);
157
+ },
158
+ /** A player enters with their play-host token, or with their wallet's signature (`wallet`, `signMessage`). */
159
+ enter: async ({ server, game, draw, token = null, wallet = null, signMessage = null, cluster = 'devnet', fetchImpl = globalThis.fetch }) => {
160
+ if (token) return post(server, `/v1/draws/${seg(game)}/${seg(draw)}/entries`, { token }, fetchImpl);
161
+ const text = entryText({ cluster, game, draw });
162
+ return post(server, `/v1/draws/${seg(game)}/${seg(draw)}/entries`, { wallet, text, signature: encodeBase58(await signMessage(new TextEncoder().encode(text))) }, fetchImpl);
163
+ },
164
+ /** Locks the players the server holds now to `beacon`, a drand round still to come. Checks their hash itself. */
165
+ lock: async ({ server, game, draw, beacon, signMessage, by, cert = null, cluster = 'devnet', net = 'quicknet', fetchImpl = globalThis.fetch }) => {
166
+ const { draw: view } = await fetchJson(server, `/v1/draws/${seg(game)}/${seg(draw)}`, fetchImpl);
167
+ const text = lockText({ cluster, game, draw, roster: await rosterHash(view.entries), players: view.entries.length, beacon, net });
168
+ return post(server, `/v1/draws/${seg(game)}/${seg(draw)}/lock`, { text, signature: encodeBase58(await signMessage(new TextEncoder().encode(text))), by, ...(cert ? { cert } : {}) }, fetchImpl);
169
+ },
170
+ /** The draw as anyone may check it; settled, with its result, once its beacon round is out. */
171
+ get: async ({ server, game, draw, fetchImpl = globalThis.fetch }) => (await fetchJson(server, `/v1/draws/${seg(game)}/${seg(draw)}`, fetchImpl)).draw,
172
+ };
173
+
174
+ /** A giveaway, from its appointed host's page. */
175
+ export const giveaways = {
176
+ /** Opens one: `cert` the owner's appointment, `token` the host's play-host sign-in, `session` a key from newKey(). */
177
+ open: async ({ server, game, cert, token, giveaway, winners, endsBy, session, fetchImpl = globalThis.fetch }) => post(server, `/v1/giveaways/${seg(game)}`, { cert, token, giveaway, winners, endsBy, session: session.address }, fetchImpl),
178
+ /** Reports its result, signed by its session key. `grant`: the host's grant (each winner is paid from it). */
179
+ report: async ({ server, game, giveaway, session, endedAt, winners, grant, cluster = 'devnet', fetchImpl = globalThis.fetch }) => {
180
+ const signed = winners.map((w) => ({ wallet: w.wallet, place: w.place, grant }));
181
+ const signature = encodeBase58(await session.sign(resultBytes({ cluster, game, round: giveaway, endedAt, winners: signed })));
182
+ return post(server, `/v1/giveaways/${seg(game)}/${seg(giveaway)}/result`, { endedAt, winners: winners.map(({ wallet, place }) => ({ wallet, place })), signature }, fetchImpl);
183
+ },
184
+ };
@@ -0,0 +1,90 @@
1
+ // The exact bytes and texts a results server checks (the public one runs at https://play.xgames.fun/xsolana), shared
2
+ // with whoever signs them: a host server, a player's game page, an owner's wallet. Browser-safe: no Node imports.
3
+ import { addressBytes } from '../chain/base58.js';
4
+ import { concat, fromHex, uintLE, utf8 } from '../chain/bytes.js';
5
+
6
+ export const RESULT_DOMAIN = 'loot-result-v1';
7
+ export const NO_GRANT = 0xffff;
8
+ export const CLUSTERS = Object.freeze({ devnet: 0, mainnet: 1 });
9
+
10
+ /** Each certificate's first line, and the lines that follow it, in order. */
11
+ export const CERT_KINDS = Object.freeze({
12
+ host: { title: 'loot host key', fields: ['cluster', 'game', 'host', 'until'] },
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'] },
15
+ lock: { title: 'loot draw lock', fields: ['cluster', 'game', 'draw', 'roster', 'players', 'beacon'] },
16
+ entry: { title: 'loot entry', fields: ['cluster', 'game', 'draw'] },
17
+ giveaway: { title: 'loot giveaway host', fields: ['cluster', 'game', 'who', 'slug', 'grant', 'until'] },
18
+ revokeGiveaway: { title: 'loot revoke giveaway host', fields: ['cluster', 'game', 'who'] },
19
+ });
20
+
21
+ /** 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
+ 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.`);
24
+ return value;
25
+ };
26
+ const write = (kind, values) => [CERT_KINDS[kind].title, ...CERT_KINDS[kind].fields.map((f) => `${f}: ${field(f, values[f])}`)].join('\n');
27
+
28
+ /** 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 });
30
+ /** The text the owner signs to take `host` back. */
31
+ export const revokeText = ({ cluster = 'devnet', game, host }) => write('revoke', { cluster, game, host });
32
+
33
+ /**
34
+ * The text the organizer (the game's owner or authority, or a host key they certified) signs to open a draw: a round
35
+ * whose winners an engine computes from the locked players and a future beacon value. `prizes`: [{ place, grant }];
36
+ * `payout`: 'vouchers' (the server vouches for each winner) or 'list' (the owner writes the winners' list on the grant);
37
+ * `identity`: 'linked' (a play-host sign-in linked to X, one entry a person) or 'wallet' (a wallet's own signature);
38
+ * `slug`: the game's play-host name (its tokens' audience), or '-'; `rates`: the sha256 of the engine's rates (canonical
39
+ * JSON), or 'default'.
40
+ */
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 });
43
+ /** 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}` });
45
+ /** The text a player's wallet signs to enter a draw (identity 'wallet'). */
46
+ export const entryText = ({ cluster = 'devnet', game, draw }) => write('entry', { cluster, game, draw });
47
+
48
+ /**
49
+ * The text the owner (or authority) signs to appoint a giveaway host: `who` is "x:<X account id>" (the host signs in
50
+ * with that X account through the play host) or a wallet address; their giveaways pay from grant `grant` alone.
51
+ */
52
+ export const giveawayHostText = ({ cluster = 'devnet', game, who, slug, grant, until }) => write('giveaway', { cluster, game, who, slug, grant, until });
53
+ /** 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 });
55
+
56
+ /** A draw's players, as one hash: each `id<TAB>wallet`, sorted by id, one a line; sha256, hex. */
57
+ 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('');
60
+ }
61
+
62
+ /** JSON with every object's keys sorted: one text for one value, so its hash means one thing. */
63
+ export function canonicalJson(value) {
64
+ 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(',')}}`;
66
+ return JSON.stringify(value);
67
+ }
68
+
69
+ /**
70
+ * The bytes a result's signers sign:
71
+ * "loot-result-v1" | cluster (1) | game (32) | round (16) | endedAt (u64 LE)
72
+ * | winners (u8 count; each wallet (32) | place (u8) | grant id (u16 LE, 0xffff: none))
73
+ * Every field has one length, so one set of bytes means one result: an unknown cluster, a round that is not 16 bytes, a
74
+ * place past 255 and more than 255 winners are refused, never written as something else.
75
+ */
76
+ 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).');
80
+ if (!Array.isArray(winners) || winners.length > 255) throw new RangeError('A result names at most 255 winners.');
81
+ return concat(
82
+ utf8(RESULT_DOMAIN),
83
+ Uint8Array.of(CLUSTERS[cluster]),
84
+ addressBytes(game),
85
+ roundAt,
86
+ uintLE(BigInt(endedAt), 8),
87
+ Uint8Array.of(winners.length),
88
+ ...winners.map((w) => concat(addressBytes(w.wallet), uintLE(w.place, 1), uintLE(w.grant ?? NO_GRANT, 2))),
89
+ );
90
+ }
package/package.json CHANGED
@@ -1,6 +1,102 @@
1
1
  {
2
2
  "name": "@xgamesfun/solana",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.24.0",
4
+ "mcpName": "io.github.XGamesFun/solana-kit",
5
+ "description": "One shared Solana program for game and community economies, its JS client, and the tools that let an AI agent design, check, simulate and launch an economy on it.",
6
+ "license": "Apache-2.0",
7
+ "type": "module",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/XGamesFun/solana-kit.git"
11
+ },
12
+ "homepage": "https://github.com/XGamesFun/solana-kit#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/XGamesFun/solana-kit/issues"
15
+ },
16
+ "keywords": [
17
+ "solana",
18
+ "economy",
19
+ "loot-box",
20
+ "staking",
21
+ "nft",
22
+ "game",
23
+ "mcp",
24
+ "agent"
25
+ ],
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "exports": {
30
+ ".": {
31
+ "types": "./types/index.d.ts",
32
+ "default": "./js/index.js"
33
+ },
34
+ "./client": {
35
+ "types": "./types/client/index.d.ts",
36
+ "default": "./js/client/index.js"
37
+ },
38
+ "./chain/*": {
39
+ "types": "./types/chain/*.d.ts",
40
+ "default": "./js/chain/*.js"
41
+ },
42
+ "./play": {
43
+ "types": "./types/play/index.d.ts",
44
+ "default": "./js/play/index.js"
45
+ },
46
+ "./results": {
47
+ "types": "./types/results/index.d.ts",
48
+ "default": "./js/results/index.js"
49
+ },
50
+ "./idl.json": "./js/client/idl/loot.json",
51
+ "./package.json": "./package.json",
52
+ "./release.json": "./release.json"
53
+ },
54
+ "sideEffects": [
55
+ "./bin/xsolana.mjs",
56
+ "./js/approve/page.js"
57
+ ],
58
+ "files": [
59
+ "js/",
60
+ "!js/test/",
61
+ "!js/sim/",
62
+ "types/",
63
+ "release.json",
64
+ "bin/",
65
+ "skills/",
66
+ "examples/*.json",
67
+ "README.md",
68
+ "LICENSE",
69
+ "NOTICE",
70
+ "DISCLAIMER.md"
71
+ ],
72
+ "scripts": {
73
+ "test": "node --test \"js/test/**/*.test.mjs\"",
74
+ "lint": "eslint .",
75
+ "types": "tsc -p tsconfig.types.json",
76
+ "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:idl": "node scripts/sync-idl.mjs --check",
79
+ "sync-idl": "node scripts/sync-idl.mjs",
80
+ "sync-skills": "node scripts/sync-skills.mjs",
81
+ "release:manifest": "node scripts/release-manifest.mjs",
82
+ "prepack": "node scripts/release-manifest.mjs --check && tsc -p tsconfig.types.json"
83
+ },
84
+ "devDependencies": {
85
+ "@eslint/js": "10.0.1",
86
+ "@modelcontextprotocol/sdk": "1.32.2",
87
+ "ajv": "8.20.0",
88
+ "eslint": "10.13.0",
89
+ "globals": "17.13.0",
90
+ "litesvm": "1.6.1",
91
+ "playwright-core": "1.64.1",
92
+ "typescript": "7.0.2"
93
+ },
94
+ "bin": {
95
+ "xsolana": "bin/xsolana.mjs"
96
+ },
97
+ "publishConfig": {
98
+ "access": "public",
99
+ "provenance": true
100
+ },
101
+ "types": "./types/index.d.ts"
102
+ }
package/release.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "version": "0.24.0",
3
+ "programId": "9cwqM1TXytPnFZsLfmUyv2dg4uW6Ch9C6UJVPns7NFQ",
4
+ "sbfBytes": 1727272,
5
+ "sbfSha256": "1bf01fbceb390c8888921df95f711d32e805554401e2c128e83eb16fcb11098c",
6
+ "idlSha256": "32dc0d5ed56fa7fb2ef8e90ef8d96d1d8bbe940b624d3d5b636933af20ae56b4",
7
+ "clientSha256": "f2a0f00386a08ecbd1fd939d53889d1a7a151997bf16684c641dd98a838b8d72"
8
+ }