@xgamesfun/solana 0.24.0 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DISCLAIMER.md +1 -1
- package/NOTICE +4 -4
- package/README.md +100 -52
- package/bin/xsolana.mjs +32 -11
- package/examples/blind-forge.json +2 -4
- package/examples/coin-flip-arena.json +2 -5
- package/examples/duel-arena.json +1 -4
- package/examples/fair-presale.json +1 -3
- package/examples/hero-trials.json +14 -2
- package/examples/ideas.json +788 -788
- package/examples/nft-raffle.json +1 -0
- package/examples/partner-vault.json +19 -3
- package/examples/sacrifice-raffle.json +2 -10
- package/examples/shelter-fair.json +1 -0
- package/examples/stakers-draw.json +4 -7
- package/examples/trade-in-shop.json +2 -3
- package/js/approve/details.mjs +117 -48
- package/js/approve/ed25519.mjs +5 -1
- package/js/approve/live-check.mjs +83 -26
- package/js/approve/page.html +217 -78
- package/js/approve/page.js +43 -15
- package/js/approve/server.mjs +276 -74
- package/js/chain/base58.js +7 -1
- package/js/chain/borsh.js +15 -11
- package/js/chain/bytes.js +11 -8
- package/js/chain/core.js +21 -5
- package/js/chain/curve.js +1 -1
- package/js/chain/lookup.js +118 -36
- package/js/chain/mint.js +6 -2
- package/js/chain/pda.js +2 -2
- package/js/chain/programs.js +6 -6
- package/js/chain/rpc.js +40 -14
- package/js/chain/token.js +13 -2
- package/js/chain/token2022.js +55 -16
- package/js/chain/tx.js +32 -22
- package/js/client/accounts.js +270 -80
- package/js/client/compressed.js +4 -3
- package/js/client/grant.js +165 -40
- package/js/client/idl/loot.js +1 -1
- package/js/client/idl/loot.json +104 -16
- package/js/client/ix.js +2121 -457
- package/js/client/pda.js +20 -18
- package/js/client/policy.js +12 -5
- package/js/client/random.js +2 -2
- package/js/client/sizes.js +220 -65
- package/js/client/traits.js +14 -5
- package/js/connect/connect.mjs +59 -17
- package/js/economy/check.mjs +3884 -690
- package/js/economy/cost.mjs +381 -57
- package/js/economy/examples.mjs +176 -35
- package/js/economy/explain.mjs +468 -110
- package/js/economy/guide.mjs +282 -59
- package/js/economy/legal.mjs +12 -6
- package/js/economy/live.mjs +62 -20
- package/js/economy/model.mjs +320 -97
- package/js/economy/plan.mjs +1022 -203
- package/js/economy/review.mjs +760 -0
- package/js/economy/schema.json +134 -372
- package/js/economy/secrets.mjs +17 -7
- package/js/economy/simulate.mjs +771 -178
- package/js/economy/trust.mjs +15 -0
- package/js/economy/txsize.mjs +15 -3
- package/js/economy/validate.mjs +61 -24
- package/js/index.js +1 -0
- package/js/mcp/server.mjs +43 -15
- package/js/mcp/tools.mjs +318 -80
- package/js/play/index.js +1960 -461
- package/js/play/keys.js +35 -9
- package/js/results/index.js +225 -32
- package/js/results/server.js +230 -0
- package/js/results/wire.js +64 -16
- package/package.json +6 -4
- package/release.json +5 -5
- package/skills/xsolana-economy/SKILL.md +52 -22
- package/skills/xsolana-economy/references/after-launch.md +32 -21
- package/skills/xsolana-economy/references/apps.md +47 -36
- package/skills/xsolana-economy/references/sentence-to-spec.md +149 -129
- package/skills/xsolana-economy/references/telling-the-person.md +15 -6
- package/skills/xsolana-economy/references/trust-and-cheating.md +126 -0
- package/types/chain/core.d.ts +1 -1
- package/types/chain/lookup.d.ts +1 -1
- package/types/client/accounts.d.ts +24 -3
- package/types/client/grant.d.ts +2 -2
- package/types/client/idl/loot.d.ts +22 -0
- package/types/client/ix.d.ts +54 -45
- package/types/client/pda.d.ts +3 -1
- package/types/economy/cost.d.mts +1 -1
- package/types/economy/explain.d.mts +3 -0
- package/types/economy/guide.d.mts +5 -0
- package/types/economy/model.d.mts +48 -3
- package/types/economy/plan.d.mts +44 -2
- package/types/economy/review.d.mts +45 -0
- package/types/economy/trust.d.mts +8 -0
- package/types/index.d.ts +1 -0
- package/types/mcp/tools.d.mts +62 -0
- package/types/play/index.d.ts +14 -25
- package/types/results/index.d.ts +8 -7
- package/types/results/server.d.ts +126 -0
- 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([
|
|
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 =
|
|
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(
|
|
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 {
|
|
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:
|
|
58
|
+
get: k => ls.getItem(k),
|
|
44
59
|
set: (k, v) => ls.setItem(k, v),
|
|
45
|
-
remove:
|
|
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, [
|
|
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(
|
|
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(), [])
|
|
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) {
|
package/js/results/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// `@xgamesfun/solana/results`: what a game uses with a results server, in a host server or in a player's game page.
|
|
2
|
-
// Browser-safe: Web Crypto only. `server` is RESULTS_SERVER (the public one, on the
|
|
2
|
+
// Browser-safe: Web Crypto only. `server` is RESULTS_SERVER (the public one, on the xgames.fun play host), or another
|
|
3
3
|
// one's URL. The server confirms who won (from signatures rooted on the chain) and signs grant vouchers; winners claim
|
|
4
4
|
// them with js/play (`claimWins`, `claimGrant`). The program pays a wallet each round once, ever, whatever is signed.
|
|
5
5
|
//
|
|
@@ -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 {
|
|
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
|
+
/** xgames.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 =
|
|
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 =
|
|
53
|
+
const seg = value => {
|
|
39
54
|
const text = String(value);
|
|
40
|
-
if (text === '' || text === '.' || text === '..')
|
|
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([
|
|
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
|
|
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,
|
|
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({
|
|
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)
|
|
108
|
-
|
|
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(
|
|
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({
|
|
130
|
-
|
|
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))
|
|
135
|
-
|
|
136
|
-
|
|
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}`, {
|
|
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
|
|
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(
|
|
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 ({
|
|
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(
|
|
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 ({
|
|
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({
|
|
168
|
-
|
|
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 }) =>
|
|
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 }) =>
|
|
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 ({
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
+
}
|