@flayerlabs/gamemode-gate 0.3.1 → 0.4.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.
package/src/main.ts ADDED
@@ -0,0 +1,256 @@
1
+ import pg from 'pg';
2
+ import { createPublicClient, http, isAddress, type PublicClient } from 'viem';
3
+ import type { FastifyInstance } from 'fastify';
4
+ import type { GameModule } from '@flayerlabs/gamemode-spec';
5
+ import { createGate, type GateAnnouncement } from './server.js';
6
+ import { Ledger } from './ledger.js';
7
+ import { Claims } from './claims.js';
8
+ import { Sessions } from './sessions.js';
9
+ import { PayloadSigner } from './chain/signer.js';
10
+ import { Discovery } from './chain/discover.js';
11
+ import { Settlement } from './settlement.js';
12
+ import { migrate } from './store.js';
13
+ import { resolveEconomy } from './economy.js';
14
+ import { deploymentFor, type GateDeployment } from './chain/chains.js';
15
+ import { assertSecureConfig } from './chain/secure.js';
16
+
17
+ /**
18
+ * A production gate from an environment, assembled and checked.
19
+ *
20
+ * Until this file, every component existed and nothing joined them: the only `createGate` call
21
+ * sites were tests and the chain-free demo, so "run a gate against a real chain" meant writing this
22
+ * bootstrap yourself. A gate is still your game's own server — your repo imports your rules module
23
+ * and calls {@link startGate} — but the wiring, the address book and the refusals are the same for
24
+ * everyone, so they live here.
25
+ *
26
+ * Configuration is refused loudly rather than defaulted quietly. The failure mode this guards
27
+ * against is not a crash but a gate that boots, adopts nothing, and signs authorizations nothing
28
+ * accepts — see {@link assertSecureConfig} and the address-book note in chain/chains.ts.
29
+ */
30
+
31
+ type Env = Record<string, string | undefined>;
32
+
33
+ export interface ResolvedGateConfig {
34
+ chainId: number;
35
+ rpcUrl: string;
36
+ signerPrivateKey: `0x${string}`;
37
+ databaseUrl: string;
38
+ sessionSecret: string;
39
+ /** The page the player is looking at — the embedding site, not the game or this gate. */
40
+ signInDomain: string;
41
+ /** This gate's own public origin — the SIWE audience. Configured, never derived from a header. */
42
+ gateOrigin: string;
43
+ allowedOrigins: (string | RegExp)[];
44
+ port: number;
45
+ deployment: GateDeployment;
46
+ /** Announced to launch tooling; the creator's launch writes it into the pool. */
47
+ walletCapWei: bigint;
48
+ roundDurationMs: number;
49
+ minLobbyLeadMs: number;
50
+ gateEndsAtGraceS: number;
51
+ pointsPerDollar: number;
52
+ usdPerEth: number;
53
+ /** May lift the gate early or rotate the signer on-chain. Defaults to the signing key's address. */
54
+ settler: `0x${string}` | null;
55
+ }
56
+
57
+ function required(env: Env, name: string): string {
58
+ const value = env[name];
59
+ if (!value) throw new Error(`${name} is not set — the gate refuses to guess it`);
60
+ return value;
61
+ }
62
+
63
+ function integer(env: Env, name: string, fallback: number): number {
64
+ const raw = env[name];
65
+ if (raw === undefined || raw === '') return fallback;
66
+ const value = Number(raw);
67
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer`);
68
+ return value;
69
+ }
70
+
71
+ function address(env: Env, name: string): `0x${string}` | null {
72
+ const raw = env[name];
73
+ if (raw === undefined || raw === '') return null;
74
+ if (!isAddress(raw)) throw new Error(`${name} is not an address`);
75
+ return raw;
76
+ }
77
+
78
+ /**
79
+ * Read and validate the environment, pure so a test can feed it a plain object.
80
+ *
81
+ * The contract, all read here and nowhere else:
82
+ * CHAIN_ID required; must have an address-book entry unless both overrides are set
83
+ * RPC_URL required
84
+ * SIGNER_PRIVATE_KEY required; this gate's own key, never shared between environments
85
+ * DATABASE_URL required
86
+ * SESSION_SECRET required, at least 32 characters, not the demo fallback
87
+ * SIGN_IN_DOMAIN required; the embedding page the player sees (e.g. flaunch.gg)
88
+ * GATE_ORIGIN required; this gate's own public https origin (the SIWE audience)
89
+ * ALLOWED_ORIGINS required; comma-separated origins that may call this gate
90
+ * PORT default 8790
91
+ * POSITION_MANAGER optional address-book override
92
+ * SPEND_GATED_CALCULATOR optional address-book override
93
+ * FLAUNCH_VARIANT optional; only meaningful alongside the overrides
94
+ * SETTLER optional; defaults to the signing key's own address
95
+ * WALLET_CAP_WEI default 0.025 ETH
96
+ * ROUND_MS default 90000
97
+ * MIN_LOBBY_LEAD_MS default 60000
98
+ * GATE_ENDS_AT_GRACE_S default 10
99
+ * POINTS_PER_DOLLAR default 100
100
+ * USD_PER_ETH default 3000
101
+ */
102
+ export function resolveGateEnv(env: Env): ResolvedGateConfig {
103
+ const chainId = Number(required(env, 'CHAIN_ID'));
104
+ if (!Number.isSafeInteger(chainId) || chainId <= 0) throw new Error('CHAIN_ID must be a positive integer');
105
+
106
+ const recorded = deploymentFor(chainId);
107
+ const positionManager = address(env, 'POSITION_MANAGER') ?? recorded?.positionManager ?? null;
108
+ const spendGatedCalculator = address(env, 'SPEND_GATED_CALCULATOR') ?? recorded?.spendGatedCalculator ?? null;
109
+ if (!positionManager || !spendGatedCalculator) {
110
+ throw new Error(
111
+ `chain ${chainId} has no recorded deployment — set POSITION_MANAGER and SPEND_GATED_CALCULATOR ` +
112
+ `with addresses read back from that chain, or use a chain from the address book`,
113
+ );
114
+ }
115
+ const variantOverride = env['FLAUNCH_VARIANT'];
116
+ if (variantOverride !== undefined && variantOverride !== 'legacy11' && variantOverride !== 'current9') {
117
+ throw new Error(`FLAUNCH_VARIANT must be legacy11 or current9, not ${variantOverride}`);
118
+ }
119
+ const flaunchVariant = variantOverride ?? recorded?.flaunchVariant ?? 'legacy11';
120
+
121
+ const signerPrivateKey = required(env, 'SIGNER_PRIVATE_KEY');
122
+ if (!/^0x[0-9a-fA-F]{64}$/.test(signerPrivateKey)) {
123
+ throw new Error('SIGNER_PRIVATE_KEY must be a 32-byte hex key');
124
+ }
125
+
126
+ /**
127
+ * An entry may carry one `*` in its host — `https://*.flayer.dev` — because preview deployments
128
+ * mint a fresh subdomain per deploy, and a gate that names only yesterday's refuses today's
129
+ * embed with a CORS failure the page can only report as "stuck". The wildcard spans exactly one
130
+ * label: it must not make `https://*.dev` mean the whole internet by accident.
131
+ */
132
+ const allowedOrigins: (string | RegExp)[] = required(env, 'ALLOWED_ORIGINS')
133
+ .split(',')
134
+ .map((origin) => origin.trim())
135
+ .filter((origin) => origin.length > 0)
136
+ .map((origin) =>
137
+ origin.includes('*')
138
+ ? new RegExp(`^${origin.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[a-z0-9-]+')}$`)
139
+ : origin,
140
+ );
141
+ if (allowedOrigins.length === 0) throw new Error('ALLOWED_ORIGINS names no origins');
142
+
143
+ return {
144
+ chainId,
145
+ rpcUrl: required(env, 'RPC_URL'),
146
+ signerPrivateKey: signerPrivateKey as `0x${string}`,
147
+ databaseUrl: required(env, 'DATABASE_URL'),
148
+ sessionSecret: required(env, 'SESSION_SECRET'),
149
+ signInDomain: required(env, 'SIGN_IN_DOMAIN'),
150
+ gateOrigin: required(env, 'GATE_ORIGIN'),
151
+ allowedOrigins,
152
+ port: integer(env, 'PORT', 8790),
153
+ deployment: { positionManager, spendGatedCalculator, flaunchVariant },
154
+ walletCapWei: BigInt(env['WALLET_CAP_WEI'] ?? 25_000_000_000_000_000n.toString()),
155
+ roundDurationMs: integer(env, 'ROUND_MS', 90_000),
156
+ minLobbyLeadMs: integer(env, 'MIN_LOBBY_LEAD_MS', 60_000),
157
+ gateEndsAtGraceS: integer(env, 'GATE_ENDS_AT_GRACE_S', 10),
158
+ pointsPerDollar: integer(env, 'POINTS_PER_DOLLAR', 100),
159
+ usdPerEth: integer(env, 'USD_PER_ETH', 3_000),
160
+ settler: address(env, 'SETTLER'),
161
+ };
162
+ }
163
+
164
+ export interface StartedGate {
165
+ app: FastifyInstance;
166
+ port: number;
167
+ /** The address launches must name as their trusted signer for this gate to adopt them. */
168
+ signer: `0x${string}`;
169
+ pool: pg.Pool;
170
+ }
171
+
172
+ export interface StartGateOverrides {
173
+ env?: Env;
174
+ /** Injectable for tests; a real boot builds its own from RPC_URL. */
175
+ client?: PublicClient;
176
+ /** False builds and checks everything but never binds a port. */
177
+ listen?: boolean;
178
+ }
179
+
180
+ export async function startGate<Config, State, Event, Action, PublicView, PlayerView>(
181
+ game: GameModule<Config, State, Event, Action, PublicView, PlayerView>,
182
+ gameConfig: Config,
183
+ overrides: StartGateOverrides = {},
184
+ ): Promise<StartedGate> {
185
+ const config = resolveGateEnv(overrides.env ?? process.env);
186
+
187
+ // The same refusal the demo makes: an economy that cannot pay out is caught before anyone plays.
188
+ const economy = resolveEconomy({
189
+ maxPointsPerPlayer: game.rewardBounds(gameConfig).maxPointsPerPlayer,
190
+ walletCapWei: config.walletCapWei,
191
+ pointsPerDollar: config.pointsPerDollar,
192
+ usdPerEth: config.usdPerEth,
193
+ });
194
+ if (!economy.ok) {
195
+ throw new Error(
196
+ `That economy does not work: ${economy.problem}. Try ${economy.suggestedPointsPerDollar} points per dollar.`,
197
+ );
198
+ }
199
+
200
+ const client = overrides.client ?? createPublicClient({ transport: http(config.rpcUrl) });
201
+ await assertSecureConfig(client, {
202
+ chainId: config.chainId,
203
+ signerPrivateKey: config.signerPrivateKey,
204
+ spendGatedCalculator: config.deployment.spendGatedCalculator,
205
+ sessionSecret: config.sessionSecret,
206
+ });
207
+
208
+ const pool = new pg.Pool({ connectionString: config.databaseUrl });
209
+ await migrate(pool);
210
+
211
+ const ledger = new Ledger(pool);
212
+ const signer = new PayloadSigner(config.signerPrivateKey, config.chainId, config.deployment.spendGatedCalculator);
213
+ const settler = config.settler ?? signer.address;
214
+
215
+ const announce = (): GateAnnouncement => ({
216
+ chainId: config.chainId,
217
+ contracts: {
218
+ positionManager: config.deployment.positionManager,
219
+ spendGatedCalculator: config.deployment.spendGatedCalculator,
220
+ },
221
+ signer: signer.address,
222
+ settler,
223
+ walletCapWei: config.walletCapWei.toString(),
224
+ roundDurationMs: config.roundDurationMs,
225
+ minLobbyLeadMs: config.minLobbyLeadMs,
226
+ gateEndsAtGraceS: config.gateEndsAtGraceS,
227
+ flaunchVariant: config.deployment.flaunchVariant,
228
+ requiresEoa: false,
229
+ accepting: true,
230
+ publicLaunchesOpen: true,
231
+ privateLaunches: false,
232
+ });
233
+
234
+ const app = createGate({
235
+ pool,
236
+ game,
237
+ config: gameConfig,
238
+ pointsPerDollar: config.pointsPerDollar,
239
+ usdPerEth: config.usdPerEth,
240
+ sessions: new Sessions(config.sessionSecret, config.signInDomain, config.gateOrigin),
241
+ claims: new Claims(pool, ledger, signer),
242
+ discovery: new Discovery(client, {
243
+ positionManager: config.deployment.positionManager,
244
+ spendGatedCalculator: config.deployment.spendGatedCalculator,
245
+ signer: signer.address,
246
+ }),
247
+ settlement: new Settlement(pool, ledger, client, config.deployment.spendGatedCalculator),
248
+ allowedOrigins: config.allowedOrigins,
249
+ announce,
250
+ });
251
+
252
+ if (overrides.listen !== false) {
253
+ await app.listen({ port: config.port, host: '0.0.0.0' });
254
+ }
255
+ return { app, port: config.port, signer: signer.address, pool };
256
+ }
package/src/server.ts CHANGED
@@ -49,8 +49,23 @@ interface GateBaseOptions {
49
49
  settlement?: Settlement;
50
50
  /** How long charts and final spend reconciliation remain live after gameplay closes. */
51
51
  settlementTailMs?: number;
52
- /** Origins allowed to call this gate: the game's own, and nothing else. */
53
- allowedOrigins: string[];
52
+ /**
53
+ * Origins allowed to call this gate: the game's own and the pages that embed it, and nothing
54
+ * else. Regular expressions are for hosts that exist in families — preview deployments get a
55
+ * fresh subdomain per deploy, and an allowlist that names only yesterday's is a gate that
56
+ * silently refuses today's embed.
57
+ */
58
+ allowedOrigins: (string | RegExp)[];
59
+ /**
60
+ * What this gate tells launch tooling about itself, served at `GET /config` when present.
61
+ *
62
+ * A launch gates its pool by writing this gate's signer (and the rest of these facts) into its
63
+ * own on-chain parameters, so whoever builds that launch has to be able to ask. The flaunch
64
+ * create page is the caller — a different origin from the game's — which is why the route is
65
+ * served with a permissive CORS header rather than the gate-wide allowlist: everything in it is
66
+ * public by definition, since a launch publishes it on chain.
67
+ */
68
+ announce?: () => GateAnnouncement;
54
69
  /**
55
70
  * Decide whether a caller may open a session or spend a request, or refuse them.
56
71
  *
@@ -94,6 +109,39 @@ export interface GameServerGateOptions extends GateBaseOptions {
94
109
  gameServerOrigins?: readonly string[];
95
110
  }
96
111
 
112
+ /**
113
+ * The gate's public identity, in the exact shape the flaunch frontend's `GateServerConfig` already
114
+ * parses — served this way so the page that builds launches needs no second config type.
115
+ */
116
+ export interface GateAnnouncement {
117
+ chainId: number;
118
+ contracts: {
119
+ positionManager: `0x${string}`;
120
+ spendGatedCalculator: `0x${string}`;
121
+ };
122
+ /** The address launches must name as their trusted signer for this gate to adopt them. */
123
+ signer: `0x${string}`;
124
+ /** May lift the gate early or rotate the signer on-chain; the launch writes it into the pool. */
125
+ settler: `0x${string}`;
126
+ /** A decimal string — wei does not survive JSON as a number. */
127
+ walletCapWei: string;
128
+ roundDurationMs: number;
129
+ minLobbyLeadMs: number;
130
+ gateEndsAtGraceS: number;
131
+ flaunchVariant: 'legacy11' | 'current9';
132
+ requiresEoa: boolean;
133
+ accepting: boolean;
134
+ /**
135
+ * Always true for a gate built from this library: launching through it is permissionless by
136
+ * design — the chain itself authorizes a round (the launch names this gate's signer in public
137
+ * state), so there is no allowlist for this flag to guard. The field exists because the
138
+ * embedding page fails CLOSED without it.
139
+ */
140
+ publicLaunchesOpen: boolean;
141
+ /** This library has no private-lobby registrar; announced false so no page offers the toggle. */
142
+ privateLaunches: boolean;
143
+ }
144
+
97
145
  /** Where an admission check is being made. Enough to tell a sign-in from a spend. */
98
146
  export interface AdmissionRequest {
99
147
  /** `session` on sign-in, `action` on gameplay, `claim` when allowance is about to be committed. */
@@ -437,6 +485,51 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
437
485
  app.after(() => {
438
486
  app.get('/health', () => ({ ok: true }));
439
487
 
488
+ /**
489
+ * Lifetime usage totals, for the platform's developer dashboard.
490
+ *
491
+ * Served by every gate rather than behind an option, because the numbers already exist in the
492
+ * gate's own tables and this is the only place they exist at all — the platform never sees a
493
+ * play. Spend is `chain_spent_wei`, the chain-reconciled figure, not claims: an expired hold is
494
+ * not a purchase. `spendWeiTotal` is a decimal string because wei does not survive JSON as a
495
+ * number, and `lastRoundAt` is milliseconds, matching every timestamp a round carries.
496
+ *
497
+ * The permissive CORS header mirrors `/config`: the dashboard is a different origin from the
498
+ * game's, and every figure here is an aggregate a spectator could tally themselves.
499
+ */
500
+ app.get('/stats', async (_request, reply) => {
501
+ void reply.header('access-control-allow-origin', '*');
502
+ const { rows } = await pool.query<{
503
+ rounds_total: string;
504
+ players_total: string;
505
+ spend_wei_total: string;
506
+ last_round_at: string | null;
507
+ }>(
508
+ `select (select count(*) from rounds) as rounds_total,
509
+ (select count(distinct player) from balances) as players_total,
510
+ (select coalesce(sum(chain_spent_wei), 0)::text from balances) as spend_wei_total,
511
+ (select max(opens_at) from rounds) as last_round_at`,
512
+ );
513
+ const totals = rows[0]!;
514
+ return {
515
+ roundsTotal: Number(totals.rounds_total),
516
+ playersTotal: Number(totals.players_total),
517
+ spendWeiTotal: totals.spend_wei_total,
518
+ lastRoundAt: totals.last_round_at === null ? null : Number(totals.last_round_at),
519
+ };
520
+ });
521
+
522
+ if (options.announce) {
523
+ const announce = options.announce;
524
+ app.get('/config', (_request, reply) => {
525
+ // Simple GET, so this header alone is enough — no preflight to satisfy. The gate-wide CORS
526
+ // allowlist names only the game's origins; the launch page is a different origin and every
527
+ // fact here is public (a launch publishes them on chain), so `*` is honest rather than lax.
528
+ void reply.header('access-control-allow-origin', '*');
529
+ return announce();
530
+ });
531
+ }
532
+
440
533
  app.post('/session/challenge', async (request) => {
441
534
  const { address } = request.body as { address?: string };
442
535
  if (typeof address !== 'string') throw new NotSignedIn('a wallet address is required');