@xgamesfun/solana 0.0.0-stage → 0.20.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 +41 -0
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +172 -2
- package/bin/xsolana.mjs +189 -0
- package/examples/artwork-auction.json +51 -0
- package/examples/blind-forge.json +128 -0
- package/examples/coin-flip-arena.json +108 -0
- package/examples/duel-arena.json +137 -0
- package/examples/elements-set.json +281 -0
- package/examples/event-badges.json +67 -0
- package/examples/expedition-guild.json +161 -0
- package/examples/fair-presale.json +48 -0
- package/examples/falling-drop.json +90 -0
- package/examples/guild-missions.json +144 -0
- package/examples/halving-seasons.json +112 -0
- package/examples/hero-trials.json +96 -0
- package/examples/ideas.json +789 -0
- package/examples/level-ladder.json +65 -0
- package/examples/merge-2048.json +128 -0
- package/examples/nft-raffle.json +33 -0
- package/examples/paper-hands-farm.json +56 -0
- package/examples/partner-vault.json +115 -0
- package/examples/sacrifice-raffle.json +118 -0
- package/examples/scorched-treasury.json +84 -0
- package/examples/shelter-fair.json +164 -0
- package/examples/spark-pity.json +153 -0
- package/examples/stakers-draw.json +125 -0
- package/examples/star-fleet.json +157 -0
- package/examples/starforce.json +116 -0
- package/examples/stud-farm.json +134 -0
- package/examples/team-vesting.json +70 -0
- package/examples/trade-in-shop.json +168 -0
- package/js/approve/details.mjs +262 -0
- package/js/approve/ed25519.mjs +36 -0
- package/js/approve/live-check.mjs +144 -0
- package/js/approve/page.html +86 -0
- package/js/approve/page.js +203 -0
- package/js/approve/server.mjs +711 -0
- package/js/chain/base58.js +73 -0
- package/js/chain/borsh.js +261 -0
- package/js/chain/bytes.js +141 -0
- package/js/chain/core.js +169 -0
- package/js/chain/curve.js +52 -0
- package/js/chain/lookup.js +273 -0
- package/js/chain/mint.js +96 -0
- package/js/chain/pda.js +66 -0
- package/js/chain/programs.js +102 -0
- package/js/chain/rpc.js +217 -0
- package/js/chain/sha256.js +68 -0
- package/js/chain/token.js +85 -0
- package/js/chain/token2022.js +247 -0
- package/js/chain/tx.js +224 -0
- package/js/client/accounts.js +347 -0
- package/js/client/compressed.js +46 -0
- package/js/client/grant.js +240 -0
- package/js/client/idl/loot.js +2 -0
- package/js/client/idl/loot.json +17064 -0
- package/js/client/index.js +9 -0
- package/js/client/ix.js +2156 -0
- package/js/client/pda.js +110 -0
- package/js/client/policy.js +48 -0
- package/js/client/sizes.js +54 -0
- package/js/client/traits.js +44 -0
- package/js/connect/connect.mjs +182 -0
- package/js/economy/check.mjs +1991 -0
- package/js/economy/cost.mjs +170 -0
- package/js/economy/examples.mjs +114 -0
- package/js/economy/explain.mjs +235 -0
- package/js/economy/guide.mjs +231 -0
- package/js/economy/legal.mjs +15 -0
- package/js/economy/live.mjs +107 -0
- package/js/economy/model.mjs +583 -0
- package/js/economy/plan.mjs +887 -0
- package/js/economy/schema.json +2923 -0
- package/js/economy/secrets.mjs +60 -0
- package/js/economy/simulate.mjs +952 -0
- package/js/economy/txsize.mjs +45 -0
- package/js/economy/validate.mjs +85 -0
- package/js/index.js +10 -0
- package/js/mcp/server.mjs +113 -0
- package/js/mcp/tools.mjs +479 -0
- package/js/play/index.js +2109 -0
- package/js/results/index.js +184 -0
- package/js/results/wire.js +90 -0
- package/package.json +100 -4
- package/release.json +8 -0
- package/skills/xsolana-economy/SKILL.md +105 -0
- package/skills/xsolana-economy/references/after-launch.md +114 -0
- package/skills/xsolana-economy/references/apps.md +157 -0
- package/skills/xsolana-economy/references/sentence-to-spec.md +172 -0
- package/skills/xsolana-economy/references/telling-the-person.md +93 -0
- package/types/chain/base58.d.ts +10 -0
- package/types/chain/borsh.d.ts +23 -0
- package/types/chain/bytes.d.ts +45 -0
- package/types/chain/core.d.ts +50 -0
- package/types/chain/curve.d.ts +1 -0
- package/types/chain/lookup.d.ts +191 -0
- package/types/chain/mint.d.ts +25 -0
- package/types/chain/pda.d.ts +25 -0
- package/types/chain/programs.d.ts +65 -0
- package/types/chain/rpc.d.ts +66 -0
- package/types/chain/sha256.d.ts +1 -0
- package/types/chain/token.d.ts +141 -0
- package/types/chain/token2022.d.ts +53 -0
- package/types/chain/tx.d.ts +60 -0
- package/types/client/accounts.d.ts +178 -0
- package/types/client/compressed.d.ts +29 -0
- package/types/client/grant.d.ts +125 -0
- package/types/client/idl/loot.d.ts +4467 -0
- package/types/client/index.d.ts +8 -0
- package/types/client/ix.d.ts +1687 -0
- package/types/client/pda.d.ts +91 -0
- package/types/client/policy.d.ts +23 -0
- package/types/client/sizes.d.ts +13 -0
- package/types/client/traits.d.ts +12 -0
- package/types/economy/check.d.mts +105 -0
- package/types/economy/cost.d.mts +68 -0
- package/types/economy/examples.d.mts +27 -0
- package/types/economy/explain.d.mts +19 -0
- package/types/economy/guide.d.mts +140 -0
- package/types/economy/legal.d.mts +12 -0
- package/types/economy/live.d.mts +109 -0
- package/types/economy/model.d.mts +363 -0
- package/types/economy/plan.d.mts +1013 -0
- package/types/economy/secrets.d.mts +4 -0
- package/types/economy/simulate.d.mts +156 -0
- package/types/economy/txsize.d.mts +17 -0
- package/types/economy/validate.d.mts +3 -0
- package/types/index.d.ts +9 -0
- package/types/mcp/tools.d.mts +627 -0
- package/types/play/index.d.ts +469 -0
- package/types/results/index.d.ts +155 -0
- package/types/results/wire.d.ts +122 -0
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.20.1",
|
|
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.20.1",
|
|
3
|
+
"programId": "9cwqM1TXytPnFZsLfmUyv2dg4uW6Ch9C6UJVPns7NFQ",
|
|
4
|
+
"sbfBytes": 1457936,
|
|
5
|
+
"sbfSha256": "358805a85fd32ba7b5b6bee9dd88e063bda6f854820d978265bca6683790aee7",
|
|
6
|
+
"idlSha256": "0f75fb422c2e8eca80cfd4fd5ccc1cd8805df9cc12e981bfd0297810c772aca2",
|
|
7
|
+
"clientSha256": "91537742722a9b31da8afd1f1a2af1bfeea389ad74981e740a41917f81ebecce"
|
|
8
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: xsolana-economy
|
|
3
|
+
description: Build a web3, token or NFT economy on Solana without writing a smart contract, on loot, one shared program every project uses. Covers shops, loot boxes and gacha, packs, random NFT and token prizes from a stash of NFTs the project holds, coin flips and prize pools, lotteries and raffles, auctions where the highest bid wins and the outbid get their money back, staking farms with lock tiers and NFT boosts, crafting and merging, seasons and emissions, prediction pools and contests, quests, airdrops and capped rewards, holder-gated drops, and perks or staking for any NFT, whether the project minted it or not. Turns a person's idea into a JSON spec, then checks, simulates and prices it, explains it in plain words, and hands the person the command that puts it live in their own wallet. Use whenever someone asks for anything onchain that players or members pay, earn, stake, open, craft or win, for a game, a community, a creator or a brand, and before writing any Anchor, Rust or Solidity for it.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Build an economy on loot
|
|
7
|
+
|
|
8
|
+
An economy is one JSON spec (`xsolana.json` in the project) on **loot**, a Solana program every project shares. Nobody
|
|
9
|
+
writes or deploys a contract: the spec is checked, simulated and priced by the `xsolana_*` tools, and the project's
|
|
10
|
+
owner puts it live by approving in their own wallet. It runs on **devnet** until the program is audited.
|
|
11
|
+
|
|
12
|
+
The tools are the `xsolana_*` MCP tools. Without MCP, the same from a terminal: `npx -y @xgamesfun/solana@0.20.1 <guide|examples|check|simulate|cost|explain|publish|status> [xsolana.json]`,
|
|
13
|
+
or `npx -y @xgamesfun/solana@0.20.1 tool <name> '<json>'` for any tool (always with the exact version).
|
|
14
|
+
|
|
15
|
+
## Rules (always)
|
|
16
|
+
|
|
17
|
+
1. **Never write, build or deploy a program.** No Anchor, Rust, Solidity or custom program, and no deploy: not by you,
|
|
18
|
+
not by a command or page you give the person, not even "just on devnet". Unaudited code holding people's money is
|
|
19
|
+
the risk loot exists to remove. If the idea needs something loot can't do, follow rule 9.
|
|
20
|
+
2. **You never sign, and you never ask for or handle a key.** No private key, seed phrase or keypair file: not in
|
|
21
|
+
chat, not in a spec, not in a file. Going live is the person running `xsolana approve` and approving in their own
|
|
22
|
+
wallet. If they paste a key, tell them to treat it as exposed and move its funds; write it nowhere.
|
|
23
|
+
3. **Devnet only.** Nothing here is real money yet; say so.
|
|
24
|
+
4. **The checker decides.** Never save or publish a spec with refusals. Fix each refusal the way its `fix` says.
|
|
25
|
+
5. **Numbers come from the tools.** What a box gives back, how long a pool lasts, what going live costs: quote
|
|
26
|
+
`xsolana_simulate`, `xsolana_cost` and `xsolana_explain`, never your own arithmetic. What a player pays is the tools'
|
|
27
|
+
"in all" number for that action (price, fee, oracle, network and rent that never comes back): quote it as is.
|
|
28
|
+
6. **Flag the law.** When the checker raises `W-CHANCE-PRIZE` (paid chance at money: boxes with prizes, lotteries,
|
|
29
|
+
contests), `W-CHANCE-ITEMS` (paid random collectibles a player can resell: loot boxes, gacha) or `W-REVENUE-SHARE` (stakers paid from sales), tell the person in plain words, every time, and never
|
|
30
|
+
call anything an investment, a return or passive income. For an auction, always say `W-AUCTION-SHILL`: anyone,
|
|
31
|
+
the seller included, can bid to push the price up. Before they go live, say as well that the program is not
|
|
32
|
+
independently audited and comes as is, with no warranty: they use it at their own risk, and making sure the
|
|
33
|
+
economy is legal where it is offered is theirs. Never promise it is safe, and never give legal advice.
|
|
34
|
+
7. **Take addresses from the tools, never from memory**: the program, its accounts, the IDL. Every answer names them.
|
|
35
|
+
8. **Public data only in a spec**: names, prices, percents, public addresses. Leave out `id`, `index`, `shopBox`,
|
|
36
|
+
`rewardsIndex` and `live`: they are written when it goes live.
|
|
37
|
+
9. **Compose before you say no.** Most ideas are several parts working together, not one part. Before calling
|
|
38
|
+
anything a limit, break the idea into its pieces (what players pay, what they get, what is random, what the chain
|
|
39
|
+
can't see, what is held and for how long, who is paid) and find the part for each (`references/sentence-to-spec.md`).
|
|
40
|
+
What the chain can't see (a match, a score, something done offline) is vouched for by a signer: a results server's voucher, a
|
|
41
|
+
decider's key, a list. Build the combination and say which part does which piece. When the tools' words and the
|
|
42
|
+
checker disagree (a field the schema takes that a sentence somewhere calls impossible), the checker and
|
|
43
|
+
`xsolana_guide`'s parts decide: try it in a spec and run `xsolana_check`.
|
|
44
|
+
A piece is a limit only when no part or combination does it. Then:
|
|
45
|
+
- say in one line what is missing and why;
|
|
46
|
+
- offer the nearest versions (`xsolana_examples` with the words of that piece returns them), best first, each with
|
|
47
|
+
what it gives up and its "in all" cost; recommend one, and build it;
|
|
48
|
+
- if they push back, offer the next version or a change that brings back what they miss (a price, a bundle, a
|
|
49
|
+
second step). Don't withdraw a draft that checks, and don't send them elsewhere while a version on loot is on the
|
|
50
|
+
table.
|
|
51
|
+
|
|
52
|
+
10. **People finish what follows; build the button, not a server.** Every step after a player's ask (settle a roll,
|
|
53
|
+
draw a lottery, complete its tickets and pay its places, refund a draw the oracle never answered, claim a win, end
|
|
54
|
+
an auction, take unbonded tokens out, run a sealed schedule, take a rental's NFT home) can be sent by anyone, and
|
|
55
|
+
the person who gains from it sends it from the page (`app.claims()` lists them, `app.claim(c)` sends one). A
|
|
56
|
+
grant's claim is the winner's alone: the winning wallet signs it, whatever proves it won; anyone may pay its fees,
|
|
57
|
+
nobody claims for a player. loot needs no server to finish anything. Never make a key in a page or a server for
|
|
58
|
+
the owner to fund: whoever signs pays.
|
|
59
|
+
|
|
60
|
+
## The loop
|
|
61
|
+
|
|
62
|
+
1. **Read what an economy can be made of**: `xsolana_guide` (every part and field, who acts and what it costs, what
|
|
63
|
+
loot cannot do). `{"part": "farms"}` for one part's nested shapes.
|
|
64
|
+
2. **Start from the nearest example**: `xsolana_examples {"query": "<the person's own words>"}`, then
|
|
65
|
+
`xsolana_examples {"name": "<example>"}` for a whole spec to copy. The rated economies say how each real economy
|
|
66
|
+
fits (great, some, a-lot, cannot) and what carries it.
|
|
67
|
+
3. **Write the spec** from their sentence: `references/sentence-to-spec.md` maps their words to parts. Keep their
|
|
68
|
+
sentence in `prompt`. Add a `scenario` with the counts they expect (how many opens a day).
|
|
69
|
+
4. **Check it**: `xsolana_check {"spec": …}`. Fix every refusal; every warning is something to tell the person, in a
|
|
70
|
+
sentence of its own.
|
|
71
|
+
5. **Simulate and explain it**: `xsolana_simulate`, then `xsolana_explain`. Tell the person, in a few short lines, what each
|
|
72
|
+
play costs and is worth, where the money goes, how the pools fill and drain, what going live costs, what the
|
|
73
|
+
authority must sign while it runs, and the flags: `references/telling-the-person.md`. Before they approve, say who
|
|
74
|
+
sends each step while it runs (a draw, a payout, a contest's result) and what happens if nobody does:
|
|
75
|
+
`references/after-launch.md`.
|
|
76
|
+
Prizes: give a known prize directly on whatever route wins it (a grant, a lottery place, a contest, a mission, a
|
|
77
|
+
recipe): one step, no oracle. Use a roll of a box only for chance. Every other shape (bundles, an NFT as the price,
|
|
78
|
+
stashes, gates, no-loss lotteries, amounts by rank) is in `references/sentence-to-spec.md` and `xsolana_guide`.
|
|
79
|
+
6. **Iterate on what they say.** "Make legendaries 0.5%", "a bigger seed": change, check, simulate, explain again.
|
|
80
|
+
7. **Save it**: `xsolana_save {"spec": …}` writes `xsolana.json`, only if it checks. A live file keeps its program numbers.
|
|
81
|
+
8. **When they say "put it live"**: `xsolana_publish` lists every step and its cost and gives the command. Tell them to
|
|
82
|
+
run it themselves in the project folder, exactly as `xsolana_publish` gives it (it names the exact version:
|
|
83
|
+
`npx -y @xgamesfun/solana@0.20.1 approve xsolana.json`; never drop the version). It opens a page on their computer where
|
|
84
|
+
they connect a devnet wallet and approve; what lands is written back into `xsolana.json`. You publish nothing.
|
|
85
|
+
9. **After it is live**: `xsolana_status` shows each part on chain; read it before acting on what the person says
|
|
86
|
+
happened. A later change is the same loop: save, publish, and the approval sends only what changed. What only the
|
|
87
|
+
owner signs (open or decide a contest, pay out) goes on an owner page they approve in their own wallet:
|
|
88
|
+
`references/after-launch.md`.
|
|
89
|
+
|
|
90
|
+
## Building the app around it
|
|
91
|
+
|
|
92
|
+
A game, a site or a bot calls the live economy with the player's own wallet, through the package's player module
|
|
93
|
+
(`@xgamesfun/solana/play`: buy, open a box and collect the roll, pay, stake, compound and withdraw, craft, bid and end an
|
|
94
|
+
auction, list an NFT for sale or for rent and read who rents it, raffle an NFT, claim what a player is owed, run what
|
|
95
|
+
is due, list what a player owns). Pages call `claims()` when they open and show each as a button.
|
|
96
|
+
`references/apps.md` shows a whole page; `xsolana_client` gives the program and the calls. Never invent account layouts,
|
|
97
|
+
instruction data or addresses, and never put a key or the authority's actions in a page players load.
|
|
98
|
+
For staking an NFT of another collection, show what custody means and get explicit acceptance before sending.
|
|
99
|
+
|
|
100
|
+
## More
|
|
101
|
+
|
|
102
|
+
- From a sentence to a spec, with a worked example: `references/sentence-to-spec.md`
|
|
103
|
+
- Telling the person what they get: `references/telling-the-person.md`
|
|
104
|
+
- Building the app for a live economy: `references/apps.md`
|
|
105
|
+
- After it goes live (who sends each step, owner pages, results from a game, a results server): `references/after-launch.md`
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# After it goes live: who does what
|
|
2
|
+
|
|
3
|
+
Going live is the start. Before the person approves, tell them who sends each step the economy needs while it runs,
|
|
4
|
+
and what happens if nobody does. Most failures in practice are a raffle nobody draws, a contest nobody decides, or a
|
|
5
|
+
winner nobody pays.
|
|
6
|
+
|
|
7
|
+
## Who sends each step
|
|
8
|
+
|
|
9
|
+
| Step | Who can send it | If nobody does |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| A box's roll and settle, a shop buy | the player, in their own purchase (`js/play` does both in one prompt) | anyone can settle a rolled order, a normal item too (the app's backend delivers it from the `OrderSettled` receipt of a finalized transaction whose `meta.err` is null, once per order: `loot.receipts(tx)` gives none for a transaction with an error, whose logs can still read as all-success; it reads the transaction again when its log is truncated); a roll that failed or never came refunds (`app.refund`) |
|
|
12
|
+
| A lottery draw, completing its contributions, paying its places | **anyone**: the page (`app.claims()` lists each available step), or a sweep. Paying a winner with no prize account opens it in the same transaction (the sender pays its rent) | selling stops at draw time. A result must be followed by completion of its contributions and payment of every place before the next round. A round nobody draws waits, with no deadline. If the oracle never answers a draw (7 days after it was asked for), anyone can cancel and refund each recorded buyer. An empty round moves on with the next ticket. A one-time lottery ends after its round |
|
|
13
|
+
| A schedule that is due (an emission, rent, a drip) | **anyone**: the page (`app.runSchedule`), a player, a sweep | it waits, and catches up one run at a time |
|
|
14
|
+
| Ending an auction | **anyone**: usually the winner, from the page | it waits: whenever it is ended, the lot goes to the highest bidder |
|
|
15
|
+
| A contest's entries back when it is off, or its winners' claims | **anyone** (each entry's money goes only to its player) | they wait; nothing is lost |
|
|
16
|
+
| Opening a contest | the **authority** (the owner's wallet by default) | nothing happens |
|
|
17
|
+
| Naming its winner, calling it off | the **authority**, or **anyone** sending a verdict one of its `deciders` signed (a results server's key: `decideIx` with `verdict`, after the Ed25519 check of `verdictBytes`) | after `resultDue` it is off by the clock and everyone claims back; a crowdfund short of its `minPot` when entries close is off at once |
|
|
18
|
+
| Moving tokens out of a pool, minting, a `decisions` payout, rewriting a collectible | the **authority** | nothing happens |
|
|
19
|
+
| A grant claim | the **winner** only, from the page: the winning wallet signs every claim, whatever proves it (a voucher, the list, an open grant); anyone may pay its fees, nobody claims for it | the prize waits for them |
|
|
20
|
+
| A prize given directly (a grant's, a lottery place's, a contest's, a mission's, a recipe's: an item, tokens, an NFT of a stash) | the winner's own claim (a grant's: the winner signs it), the place's payment, the winning entry's claim, the unstake or the craft: it is given in that transaction, the sender paying what it opens (a collectible's rent, the winner's token account) | a grant's claim or a craft of a prize it can't give is refused; a lottery's place, a contest's claim or a mission's end goes on without it (an NFT already given, an item sold out or switched off, a pool short pays what it holds) |
|
|
21
|
+
| A prize that is a roll of a box (a grant's, a lottery's place, a contest's winners, an auction's mystery lot, a mission's chest) | the winner's own claim (a grant's), the place's payment or the auction's end makes the winner's order (its rent goes back to the winner at settle); then **anyone** asks the oracle for the roll (`loot.askRollIx`, its fee the asker's) and **anyone** settles it (`loot.settleIx`), which hands over what it rolled, a stash's NFT included; `app.claims()` lists them | it waits, and is never refunded while it waits; asked and never answered, anyone asks again. What the box gives that has run out (an empty stash, a sold-out item) can't be won: the roll lands on its other outcomes, and a fixed prize its pool runs short of pays what the pool holds |
|
|
22
|
+
| Putting more NFTs in a stash, or taking one out | the **owner** (or the authority), holding them: add them to the stash's `nfts` and publish again (only the new ones go in), or `loot.stashIx` with `out` to any wallet | a stash nobody refills just runs out |
|
|
23
|
+
|
|
24
|
+
"Anyone" means the page can offer a button for it, and any player can press it. Build those buttons with `js/play`:
|
|
25
|
+
`app.claims()` lists what this player can finish now (a draw, its completion or cancellation, a payout, a refund, an
|
|
26
|
+
auction to end), and `app.claim(c)` sends one. A **sweep** is a server that sends overdue public steps for the
|
|
27
|
+
projects it watches. One may or may not be running for the person's project, so never rely on it: the page's buttons
|
|
28
|
+
are what finish them. A sweep never needs anyone's key but its own fee payer.
|
|
29
|
+
|
|
30
|
+
A lottery's ticket payments stay separate from the prize pot until the draw succeeds: cuts, referrals and burns run
|
|
31
|
+
after it. The buyer's share recipients are fixed when they buy; completing a successful contribution sends its shares
|
|
32
|
+
and its prize funding. Cancelling instead (only for a draw the oracle never answered) returns its entire ticket
|
|
33
|
+
payment and record rent to the recorded buyer, with no ticket split or burn; network and oracle fees stay spent. Prizes and
|
|
34
|
+
outside donations already in the prize pot stay there for the lottery; they are not ticket refunds. Each purchase is
|
|
35
|
+
a record of its own whose rent goes back to its buyer once it is completed (a winning one once its places are paid),
|
|
36
|
+
so a round has no limit on buyers.
|
|
37
|
+
|
|
38
|
+
Every account a payment goes to is opened by the transaction that pays it, and whoever sends it pays the rent: the
|
|
39
|
+
treasury's or a payee's account at a settle, a winner's at a payout, an outbid bidder's at the next bid. Nothing waits
|
|
40
|
+
on an account, and closing one (an unwrap of wrapped SOL) never stops a sale.
|
|
41
|
+
|
|
42
|
+
An NFT the project has no authority over is used as it is, with no import step, whatever its kind: Core, Token Metadata
|
|
43
|
+
(legacy, pNFT) or compressed (Bubblegum v2). Any of them can be staked, held, auctioned by the owner or authority,
|
|
44
|
+
and shown to gates and odds (a player auctions only an NFT of the project's own collection);
|
|
45
|
+
a compressed one is proven in the same transaction (`prove_leaf`, which the page does by itself from the RPC's DAS
|
|
46
|
+
methods). What the project writes about one (a level, a use) goes in its Sheet (`["sheet", project, nft]`), written by
|
|
47
|
+
the authority with `collectible` `Set`, by a farm's growth, or by anyone proving the traits the project vouched for
|
|
48
|
+
(`app.proveTraits`).
|
|
49
|
+
|
|
50
|
+
A new mission is a new farm: add it to the spec and publish again; `runs` ends it that long after it goes live. Near the end of a farm that shares its pool, fund it at least its release span (its longest lock, a day at least) before the end and refresh any seat you suspect is dead a span before it: what is not released by the end, and what a seat gives back after it, stays in the pool, the project's. If rewards cannot move, unstaking can explicitly forfeit them.
|
|
51
|
+
|
|
52
|
+
Changing a live box's roll (its outcomes, odds, `stockOdds`, `rollsOn`, `then`) fails every roll of it still waiting
|
|
53
|
+
(`W-LIVE-ROLLS`): a purchase gets its payment back, but a roll won on another route (a grant's, a mission's chest, a
|
|
54
|
+
lottery's place, an auction's lot) paid nothing in and refunds 0. Publish such a change once none waits, or make a new
|
|
55
|
+
box. Its price, rise, fall, split, switch, window and gate change freely. A set's way out (the recipe that unbinds it)
|
|
56
|
+
should take no `pay` line (`W-UNBIND-PAY`): a recipe with one waits while the project is paused or the platform
|
|
57
|
+
suspends it, and the pieces stay locked in their farm meanwhile.
|
|
58
|
+
|
|
59
|
+
Leaving a farm needs only the player's wallet, never a server's signature; what can still keep an NFT from coming
|
|
60
|
+
back is the NFT issuer's own controls (`telling-the-person.md`, NFT custody).
|
|
61
|
+
|
|
62
|
+
## The owner's own steps: an owner page
|
|
63
|
+
|
|
64
|
+
What only the authority can sign (open or decide a contest, pay out, move a pool) needs a page the owner opens and
|
|
65
|
+
approves in **their own wallet**, like the approve page. Build it with `@xgamesfun/solana/client` builders
|
|
66
|
+
(`openContestIx`, `decideIx`, `moveTokensIx`) and the wallet's `signTransaction`, served locally or on the same site
|
|
67
|
+
behind nothing more than the owner's wallet: the program checks the signature, so a page holds no secret.
|
|
68
|
+
|
|
69
|
+
- Show what each button does in words before the wallet asks ("Name the Owls the winner: the pot of 0.42 SOL is
|
|
70
|
+
shared by 7 backers; 5% goes to you").
|
|
71
|
+
- Never put an owner's or authority's key in a page, a file or a server, and never ask the person to export one.
|
|
72
|
+
|
|
73
|
+
## Results from a game: who won, and paying them
|
|
74
|
+
|
|
75
|
+
When a game (not the chain) knows who won a match, cleared a quest or placed in a tournament, pay through a **grant**
|
|
76
|
+
the winner claims themselves. The grant's limits (per wallet in all and per window, per person, per key, per day, in
|
|
77
|
+
total, between two dates) are the most anything can ever pay out, whatever happens to a server.
|
|
78
|
+
|
|
79
|
+
- **A list** (an airdrop, a known set of winners): the owner writes the wallets in the grant's `list`; no server.
|
|
80
|
+
- **Promo codes, claim links**: the grant's `keys` are the public keys of one keypair per code (made offline from each
|
|
81
|
+
code; only public keys go in the spec), with `limits.perKey: 1`. The player types the code; the page derives its
|
|
82
|
+
keypair, signs a voucher for the player's own wallet, and claims (`app.claimGrant(key, { voucher })`). A code pays
|
|
83
|
+
once in all; a signature someone copies pays only the wallet it names.
|
|
84
|
+
- **Show a badge, claim once**: an open grant (no `vouchers`, `keys` or `list`) with a `gate` and `limits.perWallet`:
|
|
85
|
+
any wallet that shows the collectible claims, and each collectible shown counts too. No server.
|
|
86
|
+
- **Vouchers from a results server**: the grant names the server's public key in `vouchers`; the server checks the
|
|
87
|
+
game's result and signs a voucher (for a grant of tokens, with each winner's own `amount`, up to the grant's: rewards
|
|
88
|
+
by rank); the winner's page claims with it (`app.claimGrant(key, { voucher })`).
|
|
89
|
+
- The server can be the person's own game server (`signVoucher` from `@xgamesfun/solana/results`). It holds only that
|
|
90
|
+
voucher key, never the owner's. Set the grant's `total` and `perWallet`: they bound what a stolen voucher key can
|
|
91
|
+
pay.
|
|
92
|
+
- Or a **results server** the person runs or names. It checks results signed by the game's host key, which the
|
|
93
|
+
owner certifies once by signing a text in their wallet; the game server and pages talk to it with
|
|
94
|
+
`@xgamesfun/solana/results` (`report`, `giveaways`, `draws`), and winners claim what it vouched for with
|
|
95
|
+
`app.claimWins({ server, token })` (`token`: the player's sign-in with that server, when it asks for one; a drop
|
|
96
|
+
of NFTs from a stash gives one only at a signed-in claim, first come first served). Its voucher key is per
|
|
97
|
+
project (`GET <server>/v1/voucher-key?game=<address>`): the project's address exists only once it is live, so go
|
|
98
|
+
live first, then add the grant with that key and publish again. A grant written before then is saved switched
|
|
99
|
+
off (`"active": false`, which `xsolana_check` reports as W-OFF): tell the person it waits for that key, and switch it
|
|
100
|
+
on when the key goes in. Never use a key from `/v1/health`.
|
|
101
|
+
- **A random draw nobody can rig** (a free draw for members): a results server's computed draws (drand), or a lottery.
|
|
102
|
+
|
|
103
|
+
Per wallet per day ("2 wins a day each") uses `limits.perWalletEach: { "count": 2, "every": "1d" }`;
|
|
104
|
+
`perDay` separately counts all wallets together. A wallet can claim at most 256 distinct signed voucher rounds
|
|
105
|
+
within one grant incarnation, in any order. Plan a new campaign when that bound would be exceeded; changing
|
|
106
|
+
limits or voucher keys does not clear its history. Closing and recreating a grant starts a new campaign with new
|
|
107
|
+
receipts and invalidates old vouchers. A list written in xsolana.json pays each wallet once per grant incarnation: editing it lets newly added wallets
|
|
108
|
+
claim, never a wallet already paid (for repeated payouts, list the wallet again under a new `round`, or use vouchers). A results server must read the current grant incarnation when accepting
|
|
109
|
+
a result and include it in the voucher it signs.
|
|
110
|
+
|
|
111
|
+
## Before you act on what the person says happened
|
|
112
|
+
|
|
113
|
+
"It's been an hour", "the Owls won", "the pool is empty": read the chain first (`xsolana_status`, or the account itself), and
|
|
114
|
+
say what you found. The chain's clock and balances decide what can be sent now.
|