@xgamesfun/solana 0.24.0 → 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 (92) hide show
  1. package/README.md +94 -51
  2. package/bin/xsolana.mjs +23 -8
  3. package/examples/blind-forge.json +2 -4
  4. package/examples/coin-flip-arena.json +2 -5
  5. package/examples/duel-arena.json +1 -4
  6. package/examples/fair-presale.json +1 -3
  7. package/examples/hero-trials.json +14 -2
  8. package/examples/holder-den.json +38 -0
  9. package/examples/holders-drop.json +105 -0
  10. package/examples/ideas.json +788 -788
  11. package/examples/match-predictions.json +24 -0
  12. package/examples/nft-raffle.json +1 -0
  13. package/examples/pack-league.json +108 -0
  14. package/examples/partner-vault.json +19 -3
  15. package/examples/sacrifice-raffle.json +2 -10
  16. package/examples/shelter-fair.json +1 -0
  17. package/examples/stakers-draw.json +3 -7
  18. package/examples/stride-sneakers.json +164 -0
  19. package/examples/trade-in-shop.json +2 -3
  20. package/js/approve/details.mjs +117 -48
  21. package/js/approve/ed25519.mjs +5 -1
  22. package/js/approve/live-check.mjs +83 -26
  23. package/js/approve/page.html +217 -78
  24. package/js/approve/page.js +43 -15
  25. package/js/approve/server.mjs +276 -74
  26. package/js/chain/base58.js +7 -1
  27. package/js/chain/borsh.js +15 -11
  28. package/js/chain/bytes.js +11 -8
  29. package/js/chain/core.js +21 -5
  30. package/js/chain/curve.js +1 -1
  31. package/js/chain/lookup.js +118 -36
  32. package/js/chain/mint.js +6 -2
  33. package/js/chain/pda.js +2 -2
  34. package/js/chain/programs.js +6 -6
  35. package/js/chain/rpc.js +40 -14
  36. package/js/chain/token.js +13 -2
  37. package/js/chain/token2022.js +55 -16
  38. package/js/chain/tx.js +32 -22
  39. package/js/client/accounts.js +270 -80
  40. package/js/client/compressed.js +4 -3
  41. package/js/client/grant.js +165 -40
  42. package/js/client/idl/loot.js +1 -1
  43. package/js/client/idl/loot.json +104 -16
  44. package/js/client/ix.js +2121 -457
  45. package/js/client/pda.js +20 -18
  46. package/js/client/policy.js +12 -5
  47. package/js/client/random.js +1 -1
  48. package/js/client/sizes.js +220 -65
  49. package/js/client/traits.js +14 -5
  50. package/js/connect/connect.mjs +59 -17
  51. package/js/economy/check.mjs +3873 -690
  52. package/js/economy/cost.mjs +380 -56
  53. package/js/economy/examples.mjs +176 -35
  54. package/js/economy/explain.mjs +461 -110
  55. package/js/economy/guide.mjs +282 -59
  56. package/js/economy/legal.mjs +12 -6
  57. package/js/economy/live.mjs +60 -19
  58. package/js/economy/model.mjs +320 -97
  59. package/js/economy/plan.mjs +1022 -203
  60. package/js/economy/schema.json +127 -369
  61. package/js/economy/secrets.mjs +17 -7
  62. package/js/economy/simulate.mjs +771 -178
  63. package/js/economy/txsize.mjs +15 -3
  64. package/js/economy/validate.mjs +61 -24
  65. package/js/mcp/server.mjs +42 -14
  66. package/js/mcp/tools.mjs +293 -80
  67. package/js/play/index.js +1960 -461
  68. package/js/play/keys.js +35 -9
  69. package/js/results/index.js +223 -30
  70. package/js/results/server.js +230 -0
  71. package/js/results/wire.js +64 -16
  72. package/package.json +6 -4
  73. package/release.json +5 -5
  74. package/skills/xsolana-economy/SKILL.md +30 -16
  75. package/skills/xsolana-economy/references/after-launch.md +30 -20
  76. package/skills/xsolana-economy/references/apps.md +47 -36
  77. package/skills/xsolana-economy/references/sentence-to-spec.md +148 -129
  78. package/skills/xsolana-economy/references/telling-the-person.md +9 -6
  79. package/types/chain/core.d.ts +1 -1
  80. package/types/chain/lookup.d.ts +1 -1
  81. package/types/client/accounts.d.ts +24 -3
  82. package/types/client/grant.d.ts +2 -2
  83. package/types/client/idl/loot.d.ts +22 -0
  84. package/types/client/ix.d.ts +54 -45
  85. package/types/client/pda.d.ts +3 -1
  86. package/types/economy/guide.d.mts +5 -0
  87. package/types/economy/model.d.mts +48 -3
  88. package/types/economy/plan.d.mts +44 -2
  89. package/types/play/index.d.ts +8 -19
  90. package/types/results/index.d.ts +7 -6
  91. package/types/results/server.d.ts +126 -0
  92. package/types/results/wire.d.ts +1 -1
package/js/play/keys.js CHANGED
@@ -13,18 +13,33 @@ import { concat, fromBase64, randomBytes, toBase64, toHex, utf8 } from '../chain
13
13
  import { sha256 } from '../chain/sha256.js';
14
14
 
15
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]);
16
+ const PKCS8_ED25519 = Uint8Array.from([
17
+ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
18
+ ]);
17
19
 
18
20
  const DERIVED = 'derived';
19
- const fromBase64Url = (text) => fromBase64(text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '='));
21
+ const fromBase64Url = text =>
22
+ fromBase64(
23
+ text
24
+ .replace(/-/g, '+')
25
+ .replace(/_/g, '/')
26
+ .padEnd(Math.ceil(text.length / 4) * 4, '='),
27
+ );
20
28
 
21
29
  /** An Ed25519 key from its 32-byte seed: { address, sign(message) }. */
22
30
  export async function keyFromSeed(seed, name = null) {
23
31
  const subtle = globalThis.crypto.subtle;
24
32
  const pkcs8 = concat(PKCS8_ED25519, seed);
25
- const jwk = await subtle.exportKey('jwk', await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']));
33
+ const jwk = await subtle.exportKey(
34
+ 'jwk',
35
+ await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']),
36
+ );
26
37
  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)) };
38
+ return {
39
+ address: encodeBase58(fromBase64Url(jwk.x)),
40
+ name,
41
+ sign: async message => new Uint8Array(await subtle.sign('Ed25519', signer, message)),
42
+ };
28
43
  }
29
44
 
30
45
  /** The text a wallet signs once a session for its settle keys. It moves nothing and says so. */
@@ -40,9 +55,9 @@ export function browserStore() {
40
55
  if (!ls || !globalThis.indexedDB) return null;
41
56
  let device = null;
42
57
  return {
43
- get: (k) => ls.getItem(k),
58
+ get: k => ls.getItem(k),
44
59
  set: (k, v) => ls.setItem(k, v),
45
- remove: (k) => ls.removeItem(k),
60
+ remove: k => ls.removeItem(k),
46
61
  keys: () => Array.from({ length: ls.length }, (_, i) => ls.key(i)).filter(Boolean),
47
62
  cryptoKey: () => (device ??= deviceKey().catch(() => null)),
48
63
  };
@@ -61,7 +76,10 @@ async function deviceKey() {
61
76
  r.onerror = () => reject(r.error);
62
77
  });
63
78
  if (found) return found;
64
- const key = await globalThis.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
79
+ const key = await globalThis.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, [
80
+ 'encrypt',
81
+ 'decrypt',
82
+ ]);
65
83
  await new Promise((resolve, reject) => {
66
84
  const tx = db.transaction('keys', 'readwrite');
67
85
  tx.objectStore('keys').put(key, 'device');
@@ -120,7 +138,13 @@ export function keyring({ wallet, project, store = null }) {
120
138
  const device = await store?.cryptoKey?.();
121
139
  if (!device || !text) return null;
122
140
  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)));
141
+ return new Uint8Array(
142
+ await globalThis.crypto.subtle.decrypt(
143
+ { name: 'AES-GCM', iv: bytes.subarray(0, 12) },
144
+ device,
145
+ bytes.subarray(12),
146
+ ),
147
+ );
124
148
  }
125
149
 
126
150
  return {
@@ -157,7 +181,9 @@ export function keyring({ wallet, project, store = null }) {
157
181
  stored() {
158
182
  if (!store) return [];
159
183
  const p = prefix();
160
- return safe(() => store.keys(), []).filter((k) => k.startsWith(p)).map((k) => k.slice(p.length));
184
+ return safe(() => store.keys(), [])
185
+ .filter(k => k.startsWith(p))
186
+ .map(k => k.slice(p.length));
161
187
  },
162
188
 
163
189
  forget(name) {
@@ -23,21 +23,37 @@ import { toHex } from '../chain/bytes.js';
23
23
  import { pda, voucherBytes } from '../client/index.js';
24
24
  import { canonicalJson, drawText, entryText, lockText, resultBytes, rosterHash } from './wire.js';
25
25
 
26
- export { canonicalJson, CERT_KINDS, CLUSTERS, drawText, entryText, giveawayHostText, hostCertText, lockText, NO_GRANT, RESULT_DOMAIN, resultBytes, revokeGiveawayText, revokeText, rosterHash } from './wire.js';
26
+ export {
27
+ canonicalJson,
28
+ CERT_KINDS,
29
+ CLUSTERS,
30
+ drawText,
31
+ entryText,
32
+ giveawayHostText,
33
+ hostCertText,
34
+ lockText,
35
+ NO_GRANT,
36
+ RESULT_DOMAIN,
37
+ resultBytes,
38
+ revokeGiveawayText,
39
+ revokeText,
40
+ rosterHash,
41
+ } from './wire.js';
27
42
 
28
43
  /** games.fun's results server, on its play host (devnet until the program's audit). */
29
44
  export const RESULTS_SERVER = 'https://play.xgames.fun/xsolana';
30
45
 
31
46
  const subtle = () => globalThis.crypto.subtle;
32
47
  /** 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));
48
+ const q = value => encodeURIComponent(String(value));
34
49
  /**
35
50
  * 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
51
  * `.` or `..` (which a URL reads as a step up the path, encoded or not) is refused.
37
52
  */
38
- const seg = (value) => {
53
+ const seg = value => {
39
54
  const text = String(value);
40
- if (text === '' || text === '.' || text === '..') throw new RangeError(`${JSON.stringify(text)} can't be a part of a path.`);
55
+ if (text === '' || text === '.' || text === '..')
56
+ throw new RangeError(`${JSON.stringify(text)} can't be a part of a path.`);
41
57
  return encodeURIComponent(text);
42
58
  };
43
59
 
@@ -58,7 +74,25 @@ export async function newKey() {
58
74
  export async function keyFromSecret(secret) {
59
75
  const seed = decodeBase58(secret);
60
76
  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]);
77
+ const pkcs8 = new Uint8Array([
78
+ 0x30,
79
+ 0x2e,
80
+ 0x02,
81
+ 0x01,
82
+ 0x00,
83
+ 0x30,
84
+ 0x05,
85
+ 0x06,
86
+ 0x03,
87
+ 0x2b,
88
+ 0x65,
89
+ 0x70,
90
+ 0x04,
91
+ 0x22,
92
+ 0x04,
93
+ 0x20,
94
+ ...seed,
95
+ ]);
62
96
  const privateKey = await subtle().importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
63
97
  const jwk = await subtle().exportKey('jwk', privateKey);
64
98
  return keyOf(privateKey, base64url(jwk.x), jwk.d);
@@ -68,13 +102,13 @@ function keyOf(privateKey, publicBytes, d) {
68
102
  return {
69
103
  address: encodeBase58(publicBytes),
70
104
  secret: encodeBase58(base64url(d)),
71
- sign: async (message) => new Uint8Array(await subtle().sign({ name: 'Ed25519' }, privateKey, message)),
105
+ sign: async message => new Uint8Array(await subtle().sign({ name: 'Ed25519' }, privateKey, message)),
72
106
  };
73
107
  }
74
108
 
75
109
  function base64url(text) {
76
110
  const bin = atob(text.replace(/-/g, '+').replace(/_/g, '/'));
77
- return Uint8Array.from(bin, (c) => c.charCodeAt(0));
111
+ return Uint8Array.from(bin, c => c.charCodeAt(0));
78
112
  }
79
113
 
80
114
  /** One signer's signature of a result: { key, signature }. `signer` is a key from newKey, or anything with sign(). */
@@ -87,7 +121,17 @@ export async function signResult(signer, result, cluster = 'devnet') {
87
121
  * with the same result is safe: it is the same answer. Returns the server's answer, { result, vouchers }, or throws its
88
122
  * { code, why }.
89
123
  */
90
- export async function report({ server, cert, signer, game, round, endedAt, winners, cluster = 'devnet', fetchImpl = globalThis.fetch }) {
124
+ export async function report({
125
+ server,
126
+ cert,
127
+ signer,
128
+ game,
129
+ round,
130
+ endedAt,
131
+ winners,
132
+ cluster = 'devnet',
133
+ fetchImpl = globalThis.fetch,
134
+ }) {
91
135
  const result = { game, round, endedAt, winners };
92
136
  const all = [await signResult(signer, result, cluster)];
93
137
  const response = await fetchImpl(`${server.replace(/\/$/, '')}/v1/results`, {
@@ -104,14 +148,26 @@ export async function report({ server, cert, signer, game, round, endedAt, winne
104
148
  */
105
149
  async function answerOf(response) {
106
150
  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 });
151
+ if (!response.ok)
152
+ throw Object.assign(
153
+ new Error(
154
+ answer?.error?.why ??
155
+ `The results server answered ${response.status}${response.statusText ? ` ${response.statusText}` : ''}.`,
156
+ ),
157
+ { code: answer?.error?.code, status: response.status },
158
+ );
159
+ if (answer === null || typeof answer !== 'object')
160
+ throw Object.assign(new Error(`The results server answered ${response.status} with no JSON.`), {
161
+ status: response.status,
162
+ });
109
163
  return answer;
110
164
  }
111
165
 
112
166
  /** A wallet's vouchers for a round it won: [{ grant, publicKey, signature, round, expires, … }]. */
113
167
  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)}`);
168
+ const response = await fetchImpl(
169
+ `${server.replace(/\/$/, '')}/v1/vouchers?game=${q(game)}&round=${q(round)}&wallet=${q(wallet)}`,
170
+ );
115
171
  return (await answerOf(response)).vouchers;
116
172
  }
117
173
 
@@ -126,19 +182,56 @@ export async function fetchVouchers({ server, game, round, wallet, fetchImpl = g
126
182
  * Returns { wallet, grant, incarnation, publicKey, signature, round, expires, personHash, amount } for js/play claimGrant,
127
183
  * plain JSON (`incarnation` and `amount` as decimal text), so a server can send it as it is.
128
184
  */
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.');
185
+ export async function signVoucher({
186
+ signer,
187
+ game,
188
+ grant,
189
+ incarnation,
190
+ wallet,
191
+ round,
192
+ now,
193
+ seconds = 7 * 86_400,
194
+ personHash = null,
195
+ amount = 0n,
196
+ }) {
197
+ if (!(Number(seconds) > 0))
198
+ throw new RangeError(
199
+ 'A voucher lives some seconds from now (`seconds` above 0): one that has run out already pays nothing.',
200
+ );
131
201
  if (Number(seconds) > 30 * 86_400) throw new Error('A voucher lives 30 days at most.');
132
202
  // 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
203
  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)) };
204
+ if (!Number.isSafeInteger(expires))
205
+ throw new RangeError("A voucher needs the cluster's clock (`now`, unix seconds).");
206
+ const message = voucherBytes({
207
+ grant: pda.grant(game, grant),
208
+ incarnation,
209
+ wallet,
210
+ personHash,
211
+ round,
212
+ expires,
213
+ amount: BigInt(amount),
214
+ });
215
+ return {
216
+ wallet,
217
+ grant,
218
+ incarnation: String(BigInt(incarnation)),
219
+ publicKey: signer.address,
220
+ signature: encodeBase58(await signer.sign(message)),
221
+ round,
222
+ expires,
223
+ personHash: personHash ?? '0'.repeat(64),
224
+ amount: String(BigInt(amount)),
225
+ };
137
226
  }
138
227
 
139
228
  /** Posts JSON to the server and answers its body, or throws its { code, why }. */
140
229
  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) });
230
+ const response = await fetchImpl(`${server.replace(/\/$/, '')}${path}`, {
231
+ method: 'POST',
232
+ headers: { 'content-type': 'application/json' },
233
+ body: JSON.stringify(body),
234
+ });
142
235
  return answerOf(response);
143
236
  }
144
237
 
@@ -151,34 +244,134 @@ const fetchJson = async (server, path, fetchImpl) => answerOf(await fetchImpl(`$
151
244
  */
152
245
  export const draws = {
153
246
  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';
247
+ const ratesHash = rates
248
+ ? Array.from(
249
+ new Uint8Array(
250
+ await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalJson(rates))),
251
+ ),
252
+ b => b.toString(16).padStart(2, '0'),
253
+ ).join('')
254
+ : 'default';
155
255
  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);
256
+ return post(
257
+ server,
258
+ '/v1/draws',
259
+ {
260
+ text,
261
+ signature: encodeBase58(await signMessage(new TextEncoder().encode(text))),
262
+ by,
263
+ ...(cert ? { cert } : {}),
264
+ ...(rates ? { rates } : {}),
265
+ },
266
+ fetchImpl,
267
+ );
157
268
  },
158
269
  /** 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 }) => {
270
+ enter: async ({
271
+ server,
272
+ game,
273
+ draw,
274
+ token = null,
275
+ wallet = null,
276
+ signMessage = null,
277
+ cluster = 'devnet',
278
+ fetchImpl = globalThis.fetch,
279
+ }) => {
160
280
  if (token) return post(server, `/v1/draws/${seg(game)}/${seg(draw)}/entries`, { token }, fetchImpl);
161
281
  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);
282
+ return post(
283
+ server,
284
+ `/v1/draws/${seg(game)}/${seg(draw)}/entries`,
285
+ { wallet, text, signature: encodeBase58(await signMessage(new TextEncoder().encode(text))) },
286
+ fetchImpl,
287
+ );
163
288
  },
164
289
  /** 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 }) => {
290
+ lock: async ({
291
+ server,
292
+ game,
293
+ draw,
294
+ beacon,
295
+ signMessage,
296
+ by,
297
+ cert = null,
298
+ cluster = 'devnet',
299
+ net = 'quicknet',
300
+ fetchImpl = globalThis.fetch,
301
+ }) => {
166
302
  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);
303
+ const text = lockText({
304
+ cluster,
305
+ game,
306
+ draw,
307
+ roster: await rosterHash(view.entries),
308
+ players: view.entries.length,
309
+ beacon,
310
+ net,
311
+ });
312
+ return post(
313
+ server,
314
+ `/v1/draws/${seg(game)}/${seg(draw)}/lock`,
315
+ {
316
+ text,
317
+ signature: encodeBase58(await signMessage(new TextEncoder().encode(text))),
318
+ by,
319
+ ...(cert ? { cert } : {}),
320
+ },
321
+ fetchImpl,
322
+ );
169
323
  },
170
324
  /** 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,
325
+ get: async ({ server, game, draw, fetchImpl = globalThis.fetch }) =>
326
+ (await fetchJson(server, `/v1/draws/${seg(game)}/${seg(draw)}`, fetchImpl)).draw,
172
327
  };
173
328
 
174
329
  /** A giveaway, from its appointed host's page. */
175
330
  export const giveaways = {
176
331
  /** 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),
332
+ open: async ({ server, game, cert, token, giveaway, winners, endsBy, session, fetchImpl = globalThis.fetch }) =>
333
+ post(
334
+ server,
335
+ `/v1/giveaways/${seg(game)}`,
336
+ { cert, token, giveaway, winners, endsBy, session: session.address },
337
+ fetchImpl,
338
+ ),
178
339
  /** 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);
340
+ report: async ({
341
+ server,
342
+ game,
343
+ giveaway,
344
+ session,
345
+ endedAt,
346
+ winners,
347
+ grant,
348
+ cluster = 'devnet',
349
+ fetchImpl = globalThis.fetch,
350
+ }) => {
351
+ const signed = winners.map(w => ({ wallet: w.wallet, place: w.place, grant }));
352
+ const signature = encodeBase58(
353
+ await session.sign(resultBytes({ cluster, game, round: giveaway, endedAt, winners: signed })),
354
+ );
355
+ return post(
356
+ server,
357
+ `/v1/giveaways/${seg(game)}/${seg(giveaway)}/result`,
358
+ { endedAt, winners: winners.map(({ wallet, place }) => ({ wallet, place })), signature },
359
+ fetchImpl,
360
+ );
183
361
  },
184
362
  };
363
+
364
+ // What a game's server checks: a wallet's collectibles, a wallet's signed proof, promo codes (server.js).
365
+ export {
366
+ codeAddresses,
367
+ codeKey,
368
+ codeVoucher,
369
+ collectiblesOf,
370
+ holds,
371
+ makeCodes,
372
+ normalizeCode,
373
+ proofRequest,
374
+ proofText,
375
+ readProof,
376
+ verifyProof,
377
+ } from './server.js';
@@ -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
+ }