@flayerlabs/gamemode-gate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +79 -0
  3. package/dist/chain/discover.d.ts +158 -0
  4. package/dist/chain/discover.d.ts.map +1 -0
  5. package/dist/chain/discover.js +169 -0
  6. package/dist/chain/discover.js.map +1 -0
  7. package/dist/chain/signer.d.ts +46 -0
  8. package/dist/chain/signer.d.ts.map +1 -0
  9. package/dist/chain/signer.js +60 -0
  10. package/dist/chain/signer.js.map +1 -0
  11. package/dist/claims.d.ts +62 -0
  12. package/dist/claims.d.ts.map +1 -0
  13. package/dist/claims.js +128 -0
  14. package/dist/claims.js.map +1 -0
  15. package/dist/demo.d.ts +40 -0
  16. package/dist/demo.d.ts.map +1 -0
  17. package/dist/demo.js +90 -0
  18. package/dist/demo.js.map +1 -0
  19. package/dist/economy.d.ts +46 -0
  20. package/dist/economy.d.ts.map +1 -0
  21. package/dist/economy.js +100 -0
  22. package/dist/economy.js.map +1 -0
  23. package/dist/game-registry.d.ts +17 -0
  24. package/dist/game-registry.d.ts.map +1 -0
  25. package/dist/game-registry.js +90 -0
  26. package/dist/game-registry.js.map +1 -0
  27. package/dist/index.d.ts +23 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +14 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/ledger.d.ts +87 -0
  32. package/dist/ledger.d.ts.map +1 -0
  33. package/dist/ledger.js +222 -0
  34. package/dist/ledger.js.map +1 -0
  35. package/dist/registry.d.ts +55 -0
  36. package/dist/registry.d.ts.map +1 -0
  37. package/dist/registry.js +164 -0
  38. package/dist/registry.js.map +1 -0
  39. package/dist/room.d.ts +98 -0
  40. package/dist/room.d.ts.map +1 -0
  41. package/dist/room.js +215 -0
  42. package/dist/room.js.map +1 -0
  43. package/dist/server.d.ts +92 -0
  44. package/dist/server.d.ts.map +1 -0
  45. package/dist/server.js +695 -0
  46. package/dist/server.js.map +1 -0
  47. package/dist/sessions.d.ts +40 -0
  48. package/dist/sessions.d.ts.map +1 -0
  49. package/dist/sessions.js +144 -0
  50. package/dist/sessions.js.map +1 -0
  51. package/dist/settlement.d.ts +24 -0
  52. package/dist/settlement.d.ts.map +1 -0
  53. package/dist/settlement.js +69 -0
  54. package/dist/settlement.js.map +1 -0
  55. package/dist/store.d.ts +24 -0
  56. package/dist/store.d.ts.map +1 -0
  57. package/dist/store.js +125 -0
  58. package/dist/store.js.map +1 -0
  59. package/dist/turnstile.d.ts +19 -0
  60. package/dist/turnstile.d.ts.map +1 -0
  61. package/dist/turnstile.js +61 -0
  62. package/dist/turnstile.js.map +1 -0
  63. package/package.json +54 -0
  64. package/src/chain/discover.ts +243 -0
  65. package/src/chain/signer.ts +118 -0
  66. package/src/claims.ts +140 -0
  67. package/src/demo.ts +149 -0
  68. package/src/economy.ts +126 -0
  69. package/src/game-registry.ts +107 -0
  70. package/src/index.ts +45 -0
  71. package/src/ledger.ts +355 -0
  72. package/src/registry.ts +212 -0
  73. package/src/room.ts +297 -0
  74. package/src/server.ts +833 -0
  75. package/src/sessions.ts +155 -0
  76. package/src/settlement.ts +72 -0
  77. package/src/store.ts +127 -0
  78. package/src/turnstile.ts +74 -0
package/src/demo.ts ADDED
@@ -0,0 +1,149 @@
1
+ import pg from 'pg';
2
+ import { privateKeyToAccount } from 'viem/accounts';
3
+ import type { FastifyInstance } from 'fastify';
4
+ import type { GameModule } from '@flayerlabs/gamemode-spec';
5
+ import { createGate } from './server.js';
6
+ import { Room } from './room.js';
7
+ import { Ledger } from './ledger.js';
8
+ import { Claims } from './claims.js';
9
+ import { Sessions } from './sessions.js';
10
+ import { PayloadSigner } from './chain/signer.js';
11
+ import { migrate } from './store.js';
12
+ import { resolveEconomy } from './economy.js';
13
+
14
+ /**
15
+ * A whole gate on a laptop, with the chain taken out.
16
+ *
17
+ * Real gate, real database, real socket, real EIP-712 signatures. What is faked is the one thing a
18
+ * laptop cannot provide: a chain. The wallet is a throwaway key and the purchase is recorded rather
19
+ * than submitted, so every step up to and including the signature is the genuine one — which is
20
+ * what makes this a playtest rather than a mock-up.
21
+ *
22
+ * This lived inside the quiz example, where no other game could reach it. Nothing about it was ever
23
+ * quiz-specific.
24
+ *
25
+ * Not for production, and it refuses to pretend otherwise: the signing key is a constant, the pool
26
+ * id is invented, and no chain is ever consulted.
27
+ */
28
+
29
+ /** A key nobody should ever fund. Constant so a restart does not change who you are. */
30
+ const DEMO_SIGNING_KEY = `0x${'11'.repeat(32)}` as const;
31
+ const DEMO_WALLET_KEY = `0x${'77'.repeat(32)}` as const;
32
+ const DEMO_POOL_ID = `0x${'ab'.repeat(32)}` as const;
33
+ const DEMO_CALCULATOR = `0x${'cd'.repeat(20)}` as const;
34
+
35
+ export interface DemoOptions<Config, State, Event, Action, PublicView, PlayerView> {
36
+ game: GameModule<Config, State, Event, Action, PublicView, PlayerView>;
37
+ config: Config;
38
+ /** Default `postgres://postgres:gamemode@127.0.0.1:55439/gamemode`, or `DATABASE_URL`. */
39
+ databaseUrl?: string;
40
+ /** How long before the round opens. Short, because someone is waiting. */
41
+ lobbyMs?: number;
42
+ roundMs?: number;
43
+ /** The cap a wallet may spend, as a real launch would set on chain. Default 0.025 ETH. */
44
+ walletCapWei?: bigint;
45
+ pointsPerDollar?: number;
46
+ usdPerEth?: number;
47
+ roundId?: string;
48
+ /** Exact origin where this demo gate will listen. Default `http://127.0.0.1:4000`. */
49
+ gateOrigin?: string;
50
+ }
51
+
52
+ export interface Demo {
53
+ app: FastifyInstance;
54
+ roundId: string;
55
+ opensAt: number;
56
+ /** The throwaway wallet standing in for the player's real one. */
57
+ walletAddress: `0x${string}`;
58
+ /** Everything the fake wallet was asked to buy, in order. Empty until somebody buys. */
59
+ purchases: { maxSpendWei: string; at: number }[];
60
+ pool: pg.Pool;
61
+ }
62
+
63
+ const ETH = 1_000_000_000_000_000_000n;
64
+
65
+ /**
66
+ * Boot a gate, a round and a stand-in wallet.
67
+ *
68
+ * The caller owns serving their own game's files and calling `listen`, because how a game is built
69
+ * is the one part of this that genuinely differs between games.
70
+ */
71
+ export async function createDemoGate<Config, State, Event, Action, PublicView, PlayerView>(
72
+ options: DemoOptions<Config, State, Event, Action, PublicView, PlayerView>,
73
+ ): Promise<Demo> {
74
+ const {
75
+ game,
76
+ config,
77
+ databaseUrl = process.env.DATABASE_URL ?? 'postgres://postgres:gamemode@127.0.0.1:55439/gamemode',
78
+ lobbyMs = 8_000,
79
+ roundMs = 10 * 60_000,
80
+ walletCapWei = ETH / 40n,
81
+ pointsPerDollar = 100,
82
+ usdPerEth = 3_000,
83
+ roundId = 'dev',
84
+ gateOrigin = 'http://127.0.0.1:4000',
85
+ } = options;
86
+
87
+ // The same check the real gate runs at boot. A demo that silently used an impossible economy
88
+ // would teach a builder that their numbers work when they do not.
89
+ const economy = resolveEconomy({
90
+ maxPointsPerPlayer: game.rewardBounds(config).maxPointsPerPlayer,
91
+ walletCapWei,
92
+ pointsPerDollar,
93
+ usdPerEth,
94
+ });
95
+ if (!economy.ok) {
96
+ throw new Error(
97
+ `That economy does not work: ${economy.problem}. Try ${economy.suggestedPointsPerDollar} points per dollar.`,
98
+ );
99
+ }
100
+
101
+ const pool = new pg.Pool({ connectionString: databaseUrl });
102
+ await migrate(pool);
103
+ // Same round id every run, so a reload is the same game rather than an accumulating pile.
104
+ await pool.query('delete from rounds where id = $1', [roundId]);
105
+
106
+ const opensAt = Date.now() + lobbyMs;
107
+ await Room.create(pool, game, {
108
+ id: roundId,
109
+ config,
110
+ seed: Math.floor(Math.random() * 1_000_000),
111
+ window: { opensAt, closesAt: opensAt + roundMs },
112
+ weiPerPoint: economy.economy.weiPerPoint,
113
+ walletCapWei,
114
+ poolId: DEMO_POOL_ID,
115
+ });
116
+
117
+ const app = createGate({
118
+ pool,
119
+ game,
120
+ config,
121
+ pointsPerDollar,
122
+ usdPerEth,
123
+ sessions: new Sessions(
124
+ process.env.SESSION_SECRET ?? 'dev-secret-that-is-long-enough-to-pass',
125
+ 'localhost',
126
+ gateOrigin,
127
+ ),
128
+ claims: new Claims(pool, new Ledger(pool), new PayloadSigner(DEMO_SIGNING_KEY, 8453, DEMO_CALCULATOR)),
129
+ // Anything, because this is a laptop and the browser is on some port nobody can predict.
130
+ allowedOrigins: true as unknown as string[],
131
+ });
132
+
133
+ const wallet = privateKeyToAccount(DEMO_WALLET_KEY);
134
+ const purchases: { maxSpendWei: string; at: number }[] = [];
135
+
136
+ // Standing in for the page a game is embedded in. In production this is flaunch.gg holding the
137
+ // player's real wallet; here the signature is real and the purchase is only written down.
138
+ app.get('/dev/wallet', () => ({ address: wallet.address }));
139
+ app.post('/dev/wallet/sign', async (request) => ({
140
+ signature: await wallet.signMessage({ message: (request.body as { message: string }).message }),
141
+ }));
142
+ app.post('/dev/wallet/buy', (request) => {
143
+ const { maxSpendWei } = request.body as { maxSpendWei: string };
144
+ purchases.push({ maxSpendWei, at: Date.now() });
145
+ return { spentWei: maxSpendWei };
146
+ });
147
+
148
+ return { app, roundId, opensAt, walletAddress: wallet.address, purchases, pool };
149
+ }
package/src/economy.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Turning points into money, and refusing a round whose sums do not work.
3
+ *
4
+ * This file exists because of a real incident. A gate ran with a per-wallet cap twenty times the
5
+ * value its own deployment guide prescribed, and over 48 hours roughly 2 ETH went to wallets whose
6
+ * scores were in the bot range. Nothing was broken: every detector worked. One number in an
7
+ * environment variable was wrong, and no code was in a position to notice.
8
+ *
9
+ * So the arithmetic lives here, runs at round creation with the actual values, and refuses. A
10
+ * creator sets one human number — how many points a dollar is worth — and never sees wei.
11
+ */
12
+
13
+ /** 1 ETH in wei. */
14
+ const WEI_PER_ETH = 1_000_000_000_000_000_000n;
15
+
16
+ export interface EconomyInput {
17
+ /** The most one player can earn, declared by the game itself via `rewardBounds`. */
18
+ maxPointsPerPlayer: number;
19
+ /** The pool's on-chain per-wallet ceiling. The chain enforces this whatever we do. */
20
+ walletCapWei: bigint;
21
+ /** The creator's one knob. 100 means 100 points is worth a dollar. */
22
+ pointsPerDollar: number;
23
+ usdPerEth: number;
24
+ }
25
+
26
+ export interface Economy {
27
+ weiPerPoint: bigint;
28
+ /** What a flawless round pays. Always at or under `walletCapWei`, or this would not exist. */
29
+ maxPayoutWei: bigint;
30
+ }
31
+
32
+ export type EconomyResult =
33
+ | { ok: true; economy: Economy }
34
+ | { ok: false; problem: string; suggestedPointsPerDollar: number };
35
+
36
+ /**
37
+ * Resolve the rate for one round, or refuse with the number that fixes it.
38
+ *
39
+ * The rule is single and total: **a flawless round must fit under the wallet cap.** If it does not,
40
+ * a player earns points the chain will not let them spend — the game shows a reward and the buy
41
+ * refuses it, which is the worst failure available because it looks like theft.
42
+ *
43
+ * Refusals carry the corrected `pointsPerDollar` rather than a diagnosis. An operator reading
44
+ * "raise it to 250" fixes this in one step; an operator reading "the cap binds below the ceiling"
45
+ * goes and reads this file.
46
+ */
47
+ export function resolveEconomy(input: EconomyInput): EconomyResult {
48
+ const { maxPointsPerPlayer, walletCapWei, pointsPerDollar, usdPerEth } = input;
49
+
50
+ if (!Number.isFinite(pointsPerDollar) || pointsPerDollar <= 0) {
51
+ return { ok: false, problem: 'points per dollar must be a positive number', suggestedPointsPerDollar: 100 };
52
+ }
53
+ if (!Number.isFinite(usdPerEth) || usdPerEth <= 0) {
54
+ return { ok: false, problem: 'the ETH price must be a positive number', suggestedPointsPerDollar: pointsPerDollar };
55
+ }
56
+ if (!Number.isFinite(maxPointsPerPlayer) || maxPointsPerPlayer <= 0) {
57
+ return {
58
+ ok: false,
59
+ problem: 'the game says a player can earn at most 0 points, so there is nothing to pay',
60
+ suggestedPointsPerDollar: pointsPerDollar,
61
+ };
62
+ }
63
+ if (walletCapWei <= 0n) {
64
+ return {
65
+ ok: false,
66
+ problem: 'this launch has no per-wallet spending cap, so nothing can be authorised',
67
+ suggestedPointsPerDollar: pointsPerDollar,
68
+ };
69
+ }
70
+
71
+ // How many points one ETH buys. Checked before it becomes a divisor: `pointsPerDollar` is
72
+ // guarded as positive above, but a small enough positive value still rounds this product to
73
+ // zero — and dividing by it threw `Division by zero` out of the one function whose entire job is
74
+ // to refuse a bad economy with a sentence an operator can act on.
75
+ const pointsPerEth = Math.round(usdPerEth * pointsPerDollar);
76
+ if (pointsPerEth <= 0) {
77
+ return {
78
+ ok: false,
79
+ problem: 'one point would be worth more than a whole ETH, which cannot be what was meant',
80
+ suggestedPointsPerDollar: minimumPointsPerDollar(input),
81
+ };
82
+ }
83
+
84
+ const weiPerPoint = WEI_PER_ETH / BigInt(pointsPerEth);
85
+ if (weiPerPoint <= 0n) {
86
+ return {
87
+ ok: false,
88
+ problem: 'each point would be worth less than the smallest unit of ETH',
89
+ suggestedPointsPerDollar: Math.max(1, Math.floor(pointsPerDollar / 1000)),
90
+ };
91
+ }
92
+
93
+ const maxPayoutWei = BigInt(Math.floor(maxPointsPerPlayer)) * weiPerPoint;
94
+ if (maxPayoutWei > walletCapWei) {
95
+ return {
96
+ ok: false,
97
+ problem: 'a perfect round would earn more than this launch lets one wallet spend',
98
+ suggestedPointsPerDollar: minimumPointsPerDollar(input),
99
+ };
100
+ }
101
+
102
+ return { ok: true, economy: { weiPerPoint, maxPayoutWei } };
103
+ }
104
+
105
+ /**
106
+ * The smallest `pointsPerDollar` that keeps a flawless round inside the cap.
107
+ *
108
+ * Rounded up, then nudged until it actually passes: the division that produces it floors, and a
109
+ * value that is correct to the last wei but fails the check is worse than useless in an error
110
+ * message. Two steps is enough for any input — the loop is a guard against an off-by-one in the
111
+ * rounding, not an iterative search.
112
+ */
113
+ function minimumPointsPerDollar(input: EconomyInput): number {
114
+ const { maxPointsPerPlayer, walletCapWei, usdPerEth } = input;
115
+ const points = BigInt(Math.floor(maxPointsPerPlayer));
116
+ const maxWeiPerPoint = walletCapWei / points;
117
+ if (maxWeiPerPoint <= 0n) return Math.ceil(maxPointsPerPlayer);
118
+
119
+ let candidate = Math.ceil(Number(WEI_PER_ETH / maxWeiPerPoint) / usdPerEth);
120
+ for (let i = 0; i < 3; i++) {
121
+ const result = resolveEconomy({ ...input, pointsPerDollar: candidate });
122
+ if (result.ok) return candidate;
123
+ candidate += 1;
124
+ }
125
+ return candidate;
126
+ }
@@ -0,0 +1,107 @@
1
+ import Fastify, { type FastifyInstance } from 'fastify';
2
+ import cors from '@fastify/cors';
3
+ import { createHash, timingSafeEqual } from 'node:crypto';
4
+ import type { Pool } from 'pg';
5
+ import { isAddress } from 'viem';
6
+ import {
7
+ InvalidRegistration,
8
+ RegistrationConflict,
9
+ disableGameRegistration,
10
+ pinGameRegistration,
11
+ resolveGameRegistration,
12
+ } from './registry.js';
13
+
14
+ export interface GameRegistryOptions {
15
+ pool: Pool;
16
+ /** The flaunch.gg origins allowed to resolve registrations in a browser. */
17
+ allowedOrigins: readonly string[];
18
+ /** Server-to-server bearer token held only by the Flaunch launch backend. */
19
+ writerToken: string;
20
+ }
21
+
22
+ const authorised = (header: string | undefined, token: string): boolean => {
23
+ const supplied = header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
24
+ const digest = (value: string): Buffer => createHash('sha256').update(value).digest();
25
+ return timingSafeEqual(digest(supplied), digest(token));
26
+ };
27
+
28
+ const routeChainId = (value: string): number | null => {
29
+ if (!/^[1-9][0-9]*$/.test(value)) return null;
30
+ const parsed = Number(value);
31
+ return Number.isSafeInteger(parsed) ? parsed : null;
32
+ };
33
+
34
+ const internalRoute = (
35
+ request: { headers: { authorization?: string | undefined }; params: unknown },
36
+ token: string,
37
+ ) => {
38
+ if (!authorised(request.headers.authorization, token)) {
39
+ return { ok: false as const, status: 401 as const, message: 'registry writer authentication failed' };
40
+ }
41
+ const { chainId: rawChainId, coin } = request.params as { chainId: string; coin: string };
42
+ const chainId = routeChainId(rawChainId);
43
+ return chainId === null
44
+ ? { ok: false as const, status: 400 as const, message: 'that is not a chain id' }
45
+ : { ok: true as const, chainId, coin };
46
+ };
47
+
48
+ /**
49
+ * The stable, Flaunch-owned public read service for chain + coin → game discovery.
50
+ *
51
+ * Creator gates cannot host this route: a page needs the registration to discover which creator
52
+ * gate to call. Registration writes remain a trusted launch-backend operation against the store.
53
+ */
54
+ export function createGameRegistry(options: GameRegistryOptions): FastifyInstance {
55
+ if (options.writerToken.length < 32) throw new RangeError('registry writer token must be at least 32 characters');
56
+ const app = Fastify({ bodyLimit: 1_024 });
57
+ void app.register(cors, { origin: [...options.allowedOrigins], methods: ['GET'] });
58
+
59
+ app.get('/games/:chainId/:coin', async (request, reply) => {
60
+ const { chainId: rawChainId, coin } = request.params as { chainId: string; coin: string };
61
+ const chainId = routeChainId(rawChainId);
62
+ if (chainId === null) {
63
+ return reply.code(400).send({ message: 'that is not a chain id' });
64
+ }
65
+ if (!isAddress(coin, { strict: false })) {
66
+ return reply.code(400).send({ message: 'that is not a coin address' });
67
+ }
68
+ const registration = await resolveGameRegistration(options.pool, chainId, coin);
69
+ return registration ?? reply.code(404).send({ message: 'that coin has no registered game' });
70
+ });
71
+
72
+ app.put('/internal/games/:chainId/:coin', async (request, reply) => {
73
+ const route = internalRoute(request, options.writerToken);
74
+ if (!route.ok) return reply.code(route.status).send({ message: route.message });
75
+ const body = (request.body ?? {}) as { gameId?: unknown; gateOrigin?: unknown; deployId?: unknown };
76
+ try {
77
+ const registration = await pinGameRegistration(options.pool, {
78
+ chainId: route.chainId,
79
+ coin: route.coin,
80
+ gameId: body.gameId as string,
81
+ gateOrigin: body.gateOrigin as string,
82
+ deployId: body.deployId as string,
83
+ });
84
+ return reply.send(registration);
85
+ } catch (error) {
86
+ if (error instanceof InvalidRegistration) return reply.code(400).send({ message: error.message });
87
+ if (error instanceof RegistrationConflict) {
88
+ return reply.code(409).send({ message: 'that coin already has a different game' });
89
+ }
90
+ throw error;
91
+ }
92
+ });
93
+
94
+ app.delete('/internal/games/:chainId/:coin', async (request, reply) => {
95
+ const route = internalRoute(request, options.writerToken);
96
+ if (!route.ok) return reply.code(route.status).send({ message: route.message });
97
+ try {
98
+ await disableGameRegistration(options.pool, route.chainId, route.coin);
99
+ return reply.code(204).send();
100
+ } catch (error) {
101
+ if (error instanceof InvalidRegistration) return reply.code(400).send({ message: error.message });
102
+ throw error;
103
+ }
104
+ });
105
+
106
+ return app;
107
+ }
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ /** Public server-side surface for a production Game Mode gate. */
2
+ export {
3
+ Claims,
4
+ DEFAULT_SETTLEMENT_TAIL_MS,
5
+ Room,
6
+ Sessions,
7
+ createGate,
8
+ } from './server.js';
9
+ export type {
10
+ Admission,
11
+ AdmissionRequest,
12
+ Admit,
13
+ ConfigForLaunch,
14
+ GateOptions,
15
+ VerifiedLaunch,
16
+ } from './server.js';
17
+ export { createTurnstileAdmit } from './turnstile.js';
18
+ export type { TurnstileAdmitOptions } from './turnstile.js';
19
+
20
+ export { Ledger } from './ledger.js';
21
+ export type { BalanceKey, Claim, ClaimResult } from './ledger.js';
22
+ export { CLAIM_AUTHORIZATION_TTL_MS } from './claims.js';
23
+ export { PayloadSigner, encodeHookData } from './chain/signer.js';
24
+ export type { SignedPayload } from './chain/signer.js';
25
+ export { Discovery, poolIdOf, verifyLaunch } from './chain/discover.js';
26
+ export type { ChainReader, Deployment, Launch, LaunchCheck, PoolKey, Refused } from './chain/discover.js';
27
+ export { Settlement } from './settlement.js';
28
+ export { resolveEconomy } from './economy.js';
29
+ export type { Economy, EconomyInput, EconomyResult } from './economy.js';
30
+ export { migrate } from './store.js';
31
+ export { createDemoGate } from './demo.js';
32
+ export type { Demo, DemoOptions } from './demo.js';
33
+ export {
34
+ InvalidRegistration,
35
+ RegistrationConflict,
36
+ checkRegistration,
37
+ disableGameRegistration,
38
+ gateOrigin,
39
+ pinGameRegistration,
40
+ resolveGameRegistration,
41
+ securityProfile,
42
+ } from './registry.js';
43
+ export type { Registration, SecurityProfile } from './registry.js';
44
+ export { createGameRegistry } from './game-registry.js';
45
+ export type { GameRegistryOptions } from './game-registry.js';