@flayerlabs/gamemode-gate 0.5.4 → 0.5.6

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.
@@ -0,0 +1,194 @@
1
+ import { formatUnits, parseAbi, type PublicClient } from 'viem';
2
+ import type { SpendToken } from '@flayerlabs/gamemode-spec/live';
3
+ import type { AnnouncedSpendToken, PriceFor } from '../server.js';
4
+ import { resolveEconomy, unitsPerToken } from '../economy.js';
5
+
6
+ /**
7
+ * Prices for any paired token the chain itself has approved, so a gate runs rounds on every
8
+ * pairing the protocol allows without anyone listing it first.
9
+ *
10
+ * A v1.3 PositionManager names its `PairedTokenRegistry`, and the registry names the price
11
+ * calculator each approved token was registered with — the same quote a launch uses to size a
12
+ * coin's starting price. That calculator answers "how many tokens is one ETH", and the operator's
13
+ * `USD_PER_ETH` turns that into dollars. Nothing here is typed by a person: an unapproved token has
14
+ * no calculator and is refused, exactly as the launch itself would be.
15
+ *
16
+ * Quotes are cached briefly. Adoption happens once per launch, so the cache protects the RPC from a
17
+ * burst of `/config/spend-tokens` reads by launch tooling, not from adoption itself. A quote that
18
+ * cannot be read throws, and the server answers adoption with 503 rather than a made-up price.
19
+ */
20
+
21
+ const managerAbi = parseAbi(['function pairedTokenRegistry() view returns (address)']);
22
+ const registryAbi = parseAbi([
23
+ 'function isApproved(address token) view returns (bool)',
24
+ 'function tokenConfig(address token) view returns ((bool approved, uint8 tokenType, uint8 decimals, address underlying, address feeEscrow, address priceCalculator, uint256 minDistribute, uint256 bidWallThreshold))',
25
+ ]);
26
+ const calculatorAbi = parseAbi(['function ethToPairedToken(uint256 ethAmount) view returns (uint256)']);
27
+ const erc20Abi = parseAbi(['function symbol() view returns (string)', 'function decimals() view returns (uint8)']);
28
+
29
+ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
30
+
31
+ /** The subset of a viem public client this module needs, so tests can script it. */
32
+ export type RegistryReader = Pick<PublicClient, 'readContract'>;
33
+
34
+ export interface RegistryPricesOptions {
35
+ /** The manager whose registry decides which pairings exist. */
36
+ positionManager: `0x${string}`;
37
+ /** The chain's flETH wrapper, priced as ETH and never asked of the registry. */
38
+ fleth?: `0x${string}`;
39
+ /** Dollars per ETH; every registry quote is in ETH. */
40
+ usdPerEth: number;
41
+ /** How long a quote is reused, in milliseconds. */
42
+ ttlMs?: number;
43
+ /** Injectable for tests. */
44
+ now?: () => number;
45
+ }
46
+
47
+ export interface RegistryQuote {
48
+ /** Dollars per whole token, to eight significant digits. */
49
+ usdPerToken: number;
50
+ /** Whole tokens per ETH as the registry's calculator quoted them. */
51
+ tokensPerEth: number;
52
+ priceCalculator: `0x${string}`;
53
+ }
54
+
55
+ /** What `/config/spend-tokens/:address` needs to size a launch's cap. */
56
+ export interface AnnounceOptions {
57
+ maxPointsPerPlayer: number;
58
+ pointsPerDollar: number;
59
+ /** Percent added over the smallest cap a flawless round fits under. */
60
+ headroomPercent: number;
61
+ }
62
+
63
+ export class RegistryPrices {
64
+ private registry: Promise<`0x${string}` | null> | undefined;
65
+ private readonly quotes = new Map<string, { quote: RegistryQuote | null; at: number }>();
66
+ private readonly ttlMs: number;
67
+ private readonly now: () => number;
68
+
69
+ constructor(
70
+ private readonly client: RegistryReader,
71
+ private readonly options: RegistryPricesOptions,
72
+ ) {
73
+ this.ttlMs = options.ttlMs ?? 60_000;
74
+ this.now = options.now ?? Date.now;
75
+ }
76
+
77
+ /**
78
+ * The registry the manager names, or null for a manager that predates paired tokens. Read once:
79
+ * the address is immutable on chain, and a gate serves exactly one manager.
80
+ */
81
+ registryAddress(): Promise<`0x${string}` | null> {
82
+ this.registry ??= this.client
83
+ .readContract({ address: this.options.positionManager, abi: managerAbi, functionName: 'pairedTokenRegistry' })
84
+ .then((address) => (address.toLowerCase() === ZERO_ADDRESS ? null : address))
85
+ .catch(() => null);
86
+ return this.registry;
87
+ }
88
+
89
+ /** ETH by any name is the operator's `USD_PER_ETH`; it is never asked of the registry. */
90
+ private isEthEquivalent(address: string): boolean {
91
+ const lower = address.toLowerCase();
92
+ return lower === ZERO_ADDRESS || (this.options.fleth !== undefined && lower === this.options.fleth.toLowerCase());
93
+ }
94
+
95
+ /**
96
+ * The registry's quote for a token, or null when it is not approved or has no calculator.
97
+ * Throws when the chain cannot be read, so a caller refuses for now rather than for good.
98
+ */
99
+ async quote(address: string): Promise<RegistryQuote | null> {
100
+ const key = address.toLowerCase();
101
+ const cached = this.quotes.get(key);
102
+ if (cached && this.now() - cached.at < this.ttlMs) return cached.quote;
103
+ const quote = await this.read(address as `0x${string}`);
104
+ this.quotes.set(key, { quote, at: this.now() });
105
+ return quote;
106
+ }
107
+
108
+ private async read(token: `0x${string}`): Promise<RegistryQuote | null> {
109
+ const registry = await this.registryAddress();
110
+ if (!registry) return null;
111
+ const approved = await this.client.readContract({ address: registry, abi: registryAbi, functionName: 'isApproved', args: [token] });
112
+ if (!approved) return null;
113
+ const config = await this.client.readContract({ address: registry, abi: registryAbi, functionName: 'tokenConfig', args: [token] });
114
+ const priceCalculator = config.priceCalculator;
115
+ if (!priceCalculator || priceCalculator.toLowerCase() === ZERO_ADDRESS) return null;
116
+ const decimals = Number(config.decimals);
117
+ const perEth = await this.client.readContract({
118
+ address: priceCalculator,
119
+ abi: calculatorAbi,
120
+ functionName: 'ethToPairedToken',
121
+ args: [unitsPerToken(18)],
122
+ });
123
+ if (perEth <= 0n) return null;
124
+ // String-based division: no binary float noise in a whole-token quote.
125
+ const tokensPerEth = Number(formatUnits(perEth, decimals));
126
+ if (!Number.isFinite(tokensPerEth) || tokensPerEth <= 0) return null;
127
+ const usdPerToken = Number((this.options.usdPerEth / tokensPerEth).toPrecision(8));
128
+ return { usdPerToken, tokensPerEth, priceCalculator };
129
+ }
130
+
131
+ /** A {@link PriceFor}: dollars per whole token for any approved pairing, null for the rest. */
132
+ priceFor: PriceFor = async (token: SpendToken) => {
133
+ if (this.isEthEquivalent(token.address)) return null;
134
+ const quote = await this.quote(token.address);
135
+ return quote?.usdPerToken ?? null;
136
+ };
137
+
138
+ /**
139
+ * The entry launch tooling needs before a coin is paid for: the token as the chain describes
140
+ * it, the price the gate will fix a round at, and the smallest per-wallet cap a flawless round
141
+ * fits under plus the operator's headroom. Null for a token the registry does not approve.
142
+ */
143
+ async announce(address: `0x${string}`, options: AnnounceOptions): Promise<AnnouncedSpendToken | null> {
144
+ if (this.isEthEquivalent(address)) return null;
145
+ const quote = await this.quote(address);
146
+ if (!quote) return null;
147
+ const [symbol, decimals] = await Promise.all([
148
+ this.client.readContract({ address, abi: erc20Abi, functionName: 'symbol' }),
149
+ this.client.readContract({ address, abi: erc20Abi, functionName: 'decimals' }).then(Number),
150
+ ]);
151
+ const walletCap = minimumWalletCap(quote.usdPerToken, decimals, options);
152
+ if (walletCap === null) return null;
153
+ return {
154
+ address,
155
+ symbol,
156
+ decimals,
157
+ usdPerToken: quote.usdPerToken,
158
+ walletCap: withHeadroom(walletCap, options.headroomPercent).toString(),
159
+ };
160
+ }
161
+ }
162
+
163
+ /**
164
+ * The smallest per-wallet cap under which a flawless round fits, in the token's base units, or
165
+ * null when no cap makes the economy work at this price. Checked against {@link resolveEconomy}
166
+ * rather than trusted, because that function is the one place the rule is written down.
167
+ */
168
+ export function minimumWalletCap(
169
+ usdPerToken: number,
170
+ decimals: number,
171
+ options: Pick<AnnounceOptions, 'maxPointsPerPlayer' | 'pointsPerDollar'>,
172
+ ): bigint | null {
173
+ const pointsPerToken = Math.round(usdPerToken * options.pointsPerDollar);
174
+ if (pointsPerToken <= 0) return null;
175
+ const unitsPerPoint = unitsPerToken(decimals) / BigInt(pointsPerToken);
176
+ if (unitsPerPoint <= 0n) return null;
177
+ const minimum = BigInt(Math.floor(options.maxPointsPerPlayer)) * unitsPerPoint;
178
+ const fit = resolveEconomy({
179
+ maxPointsPerPlayer: options.maxPointsPerPlayer,
180
+ walletCap: minimum,
181
+ pointsPerDollar: options.pointsPerDollar,
182
+ usdPerSpendToken: usdPerToken,
183
+ decimals,
184
+ });
185
+ return fit.ok ? minimum : null;
186
+ }
187
+
188
+ /** A cap with the operator's percentage headroom, rounded down to base units. */
189
+ export function withHeadroom(cap: bigint, headroomPercent: number): bigint {
190
+ if (!Number.isSafeInteger(headroomPercent) || headroomPercent < 0) {
191
+ throw new Error('headroom percent must be a non-negative integer');
192
+ }
193
+ return (cap * BigInt(100 + headroomPercent)) / 100n;
194
+ }
package/src/economy.ts CHANGED
@@ -81,6 +81,16 @@ function resolveInputs(input: EconomyInput): { usdPerSpendToken: number; decimal
81
81
  * goes and reads this file.
82
82
  */
83
83
  export function resolveEconomy(input: EconomyInput): EconomyResult {
84
+ return evaluate(input, true);
85
+ }
86
+
87
+ /**
88
+ * The check itself. `suggest` is false while {@link minimumPointsPerDollar} is probing candidates:
89
+ * a refusal there must not go looking for a suggestion of its own, or a cap one unit short of
90
+ * fitting recurses until the stack gives out — which is how this function once took a gate down at
91
+ * boot instead of printing the number that fixes it.
92
+ */
93
+ function evaluate(input: EconomyInput, suggest: boolean): EconomyResult {
84
94
  const { maxPointsPerPlayer, walletCap, pointsPerDollar } = input;
85
95
  const { usdPerSpendToken, decimals } = resolveInputs(input);
86
96
 
@@ -125,7 +135,7 @@ export function resolveEconomy(input: EconomyInput): EconomyResult {
125
135
  return {
126
136
  ok: false,
127
137
  problem: 'one point would be worth more than a whole spend token, which cannot be what was meant',
128
- suggestedPointsPerDollar: minimumPointsPerDollar(input),
138
+ suggestedPointsPerDollar: suggest ? minimumPointsPerDollar(input) : pointsPerDollar,
129
139
  };
130
140
  }
131
141
 
@@ -144,7 +154,7 @@ export function resolveEconomy(input: EconomyInput): EconomyResult {
144
154
  return {
145
155
  ok: false,
146
156
  problem: 'a perfect round would earn more than this launch lets one wallet spend',
147
- suggestedPointsPerDollar: minimumPointsPerDollar(input),
157
+ suggestedPointsPerDollar: suggest ? minimumPointsPerDollar(input) : pointsPerDollar,
148
158
  };
149
159
  }
150
160
 
@@ -179,7 +189,7 @@ function minimumPointsPerDollar(input: EconomyInput): number {
179
189
  Number.isSafeInteger(decimals) && decimals >= 0 && decimals <= 36 ? unitsPerToken(decimals) : WEI_PER_ETH;
180
190
  let candidate = Math.ceil(Number(units / maxUnitsPerPoint) / usdPerSpendToken);
181
191
  for (let i = 0; i < 3; i++) {
182
- const result = resolveEconomy({ ...input, pointsPerDollar: candidate });
192
+ const result = evaluate({ ...input, pointsPerDollar: candidate }, false);
183
193
  if (result.ok) return candidate;
184
194
  candidate += 1;
185
195
  }
package/src/index.ts CHANGED
@@ -66,11 +66,14 @@ export type { VerifiedPlayerJoin, VerifyPlayerJoinTicketOptions } from './player
66
66
  */
67
67
  export { startGate, resolveGateEnv } from './main.js';
68
68
  export type { ResolvedGateConfig, SpendTokenPricing, StartedGate, StartGateOverrides } from './main.js';
69
- export type { GateAnnouncement } from './server.js';
69
+ export type { GateAnnouncement, ServedGateAnnouncement } from './server.js';
70
+ export { GATE_MARKER, GATE_VERSION } from './version.js';
70
71
  export { NotSignedIn } from './sessions.js';
71
72
  export { DEPLOYMENTS, PAIRED_DEPLOYMENTS, deploymentFor } from './chain/chains.js';
72
73
  export type { FlaunchVariant, GateDeployment } from './chain/chains.js';
73
74
  export { POSITION_MANAGER_ROLE, assertSecureConfig } from './chain/secure.js';
75
+ export { RegistryPrices, minimumWalletCap, withHeadroom } from './chain/registry-prices.js';
76
+ export type { AnnounceOptions, RegistryPricesOptions, RegistryQuote, RegistryReader } from './chain/registry-prices.js';
74
77
 
75
78
  export { createGateReadiness } from './readiness.js';
76
79
  export type { GateReadinessOptions } from './readiness.js';
package/src/main.ts CHANGED
@@ -15,6 +15,7 @@ import { migrate } from './store.js';
15
15
  import { resolveEconomy } from './economy.js';
16
16
  import { deploymentFor, type GateDeployment } from './chain/chains.js';
17
17
  import { assertSecureConfig } from './chain/secure.js';
18
+ import { RegistryPrices } from './chain/registry-prices.js';
18
19
 
19
20
  /**
20
21
  * A production gate from an environment, assembled and checked.
@@ -59,6 +60,12 @@ export interface ResolvedGateConfig {
59
60
  * refused at adoption — the gate does not guess a token's price.
60
61
  */
61
62
  spendTokens: ReadonlyMap<string, SpendTokenPricing>;
63
+ /**
64
+ * Percent added over the smallest cap a flawless round fits under when the gate sizes a cap for a
65
+ * registry-priced pairing (`/config/spend-tokens/:address`). Prices move between the quote and the
66
+ * launch; this is the room left for that.
67
+ */
68
+ pairedTokenCapHeadroomPercent: number;
62
69
  /** May lift the gate early or rotate the signer on-chain. Defaults to the signing key's address. */
63
70
  settler: `0x${string}` | null;
64
71
  }
@@ -167,7 +174,10 @@ function address(env: Env, name: string): `0x${string}` | null {
167
174
  * POINTS_PER_DOLLAR default 100
168
175
  * USD_PER_ETH default 3000; fractional allowed
169
176
  * SPEND_TOKENS optional JSON array of { address, usdPerToken, walletCap } — the non-ETH
170
- * paired tokens this gate runs rounds on. Unlisted tokens are refused.
177
+ * paired tokens this gate runs rounds on. Pins a price the registry
178
+ * would otherwise quote; tokens neither here nor approved on the
179
+ * manager's PairedTokenRegistry are refused.
180
+ * PAIRED_TOKEN_CAP_HEADROOM_PERCENT default 25; headroom on caps sized for registry-priced pairings
171
181
  */
172
182
  export function resolveGateEnv(env: Env): ResolvedGateConfig {
173
183
  const chainId = Number(required(env, 'CHAIN_ID'));
@@ -247,6 +257,7 @@ export function resolveGateEnv(env: Env): ResolvedGateConfig {
247
257
  pointsPerDollar: integer(env, 'POINTS_PER_DOLLAR', 100),
248
258
  usdPerEth: positiveNumber(env, 'USD_PER_ETH', 3_000),
249
259
  spendTokens: spendTokens(env),
260
+ pairedTokenCapHeadroomPercent: integer(env, 'PAIRED_TOKEN_CAP_HEADROOM_PERCENT', 25),
250
261
  settler: address(env, 'SETTLER'),
251
262
  };
252
263
  }
@@ -262,9 +273,11 @@ function isEthEquivalent(token: SpendToken, fleth: `0x${string}` | undefined): b
262
273
  * The price list a running gate answers adoption with.
263
274
  *
264
275
  * An override (an oracle, a test) is asked first; the env map second; ETH and its wrapper are the
265
- * operator's `USD_PER_ETH`; anything else is unpriced and the launch is refused.
276
+ * operator's `USD_PER_ETH`; then the manager's own PairedTokenRegistry, which prices every token the
277
+ * protocol has approved through the calculator it was registered with. Only a token none of them
278
+ * know is unpriced, and that launch is refused — it could not have been launched either.
266
279
  */
267
- function priceList(config: ResolvedGateConfig, override: PriceFor | undefined): PriceFor {
280
+ function priceList(config: ResolvedGateConfig, override: PriceFor | undefined, registry: RegistryPrices): PriceFor {
268
281
  return async (token) => {
269
282
  if (override) {
270
283
  const quoted = await override(token);
@@ -273,7 +286,7 @@ function priceList(config: ResolvedGateConfig, override: PriceFor | undefined):
273
286
  const listed = config.spendTokens.get(token.address.toLowerCase());
274
287
  if (listed) return listed.usdPerToken;
275
288
  if (isEthEquivalent(token, config.deployment.fleth)) return config.usdPerEth;
276
- return null;
289
+ return registry.priceFor(token);
277
290
  };
278
291
  }
279
292
 
@@ -365,6 +378,16 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
365
378
  });
366
379
  }
367
380
 
381
+ // Every pairing the protocol approves, priced by the calculator it was registered with. The
382
+ // registry is read from the manager, so nothing is configured and nothing goes stale when a new
383
+ // token is approved: the next launch on it is adopted like any other.
384
+ const registryPrices = new RegistryPrices(client, {
385
+ positionManager: config.deployment.positionManager,
386
+ ...(config.deployment.fleth ? { fleth: config.deployment.fleth } : {}),
387
+ usdPerEth: config.usdPerEth,
388
+ });
389
+ const pairedTokenRegistry = await registryPrices.registryAddress();
390
+
368
391
  const pool = new pg.Pool({ connectionString: config.databaseUrl });
369
392
  await migrate(pool);
370
393
 
@@ -387,6 +410,7 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
387
410
  gateEndsAtGraceS: config.gateEndsAtGraceS,
388
411
  flaunchVariant: config.deployment.flaunchVariant,
389
412
  ...(announcedSpendTokens.length > 0 ? { spendTokens: announcedSpendTokens } : {}),
413
+ ...(pairedTokenRegistry ? { pairedTokenRegistry } : {}),
390
414
  requiresEoa: false,
391
415
  accepting: true,
392
416
  publicLaunchesOpen: true,
@@ -399,7 +423,17 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
399
423
  config: gameConfig,
400
424
  pointsPerDollar: config.pointsPerDollar,
401
425
  usdPerEth: config.usdPerEth,
402
- priceFor: priceList(config, overrides.priceProvider),
426
+ priceFor: priceList(config, overrides.priceProvider, registryPrices),
427
+ announceSpendToken: async (address) => {
428
+ // An operator-pinned token is announced exactly as pinned; anything else is the registry's word.
429
+ const pinned = announcedSpendTokens.find((entry) => entry.address.toLowerCase() === address.toLowerCase());
430
+ if (pinned) return pinned;
431
+ return registryPrices.announce(address, {
432
+ maxPointsPerPlayer,
433
+ pointsPerDollar: config.pointsPerDollar,
434
+ headroomPercent: config.pairedTokenCapHeadroomPercent,
435
+ });
436
+ },
403
437
  sessions: new Sessions(config.sessionSecret, config.signInDomain, config.gateOrigin),
404
438
  claims: new Claims(pool, ledger, signer),
405
439
  discovery: new Discovery(client, {
@@ -0,0 +1,25 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { Pool } from 'pg';
3
+
4
+ export function playSessionId(roundId: string, player: string): string {
5
+ return createHash('sha256').update(JSON.stringify([roundId, player.toLowerCase()])).digest('hex');
6
+ }
7
+ export async function recordPlaySession(pool: Pool, roundId: string, player: string, at = Date.now()): Promise<void> {
8
+ // One accepted start per wallet and launch. Reconnects and repeated joins retain the first time.
9
+ await pool.query('insert into play_sessions (id, round_id, started_at) values ($1, $2, $3) on conflict do nothing', [playSessionId(roundId, player), roundId, at]);
10
+ }
11
+ export function parsePlayCursor(value: unknown): { at: number; id: string } | null {
12
+ if (value === undefined) return { at: 0, id: '' };
13
+ if (typeof value !== 'string' || value.length > 100) return null;
14
+ const match = /^(\d{1,16}):([a-f0-9]{64})$/.exec(value);
15
+ if (!match || !Number.isSafeInteger(Number(match[1]))) return null;
16
+ return { at: Number(match[1]), id: match[2]! };
17
+ }
18
+ export async function readPlaySessions(pool: Pool, cursor: { at: number; id: string }) {
19
+ const [events, coverage] = await Promise.all([
20
+ pool.query<{ id: string; coin: string; started_at: string }>(`select p.id, r.coin_address as coin, p.started_at from play_sessions p join rounds r on r.id = p.round_id where r.coin_address is not null and (p.started_at, p.id) > ($1::bigint, $2::text) order by p.started_at, p.id limit 250`, [cursor.at, cursor.id]),
21
+ pool.query<{ started_at: string }>('select started_at from play_tracking where id = true'),
22
+ ]);
23
+ const last = events.rows.at(-1);
24
+ return { trackingSince: Number(coverage.rows[0]?.started_at ?? Date.now()), sessions: events.rows.map(r => ({ id: r.id, coin: r.coin, startedAt: Number(r.started_at) })), nextCursor: events.rows.length === 250 && last ? `${last.started_at}:${last.id}` : null };
25
+ }
package/src/server.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';
2
2
  import websocket from '@fastify/websocket';
3
3
  import cors from '@fastify/cors';
4
+ import { isAddress } from 'viem';
4
5
  import type { Pool } from 'pg';
5
6
  import type { Command, GameModule, PlayerId } from '@flayerlabs/gamemode-spec';
6
7
  import { isRefusal } from '@flayerlabs/gamemode-spec';
@@ -20,6 +21,7 @@ import { Room, type ScoreAuthority } from './room.js';
20
21
  import { Standings } from './standings.js';
21
22
  import { CLAIM_AUTHORIZATION_TTL_MS, Claims, isClaimAmount, isClaimRequestId } from './claims.js';
22
23
  import { NotSignedIn, Sessions } from './sessions.js';
24
+ import { parsePlayCursor, readPlaySessions, recordPlaySession } from './play-sessions.js';
23
25
  import type { Discovery, Launch, PoolKey } from './chain/discover.js';
24
26
  import type { Settlement } from './settlement.js';
25
27
  import { resolveEconomy } from './economy.js';
@@ -28,6 +30,7 @@ import { hasBearerToken } from './bearer.js';
28
30
  import { GameServerAwards, type GameServerAwardRequest } from './game-server-awards.js';
29
31
  import { issuePlayerJoinTicket } from './player-join-tickets.js';
30
32
  import { gameServerOrigins } from './registry.js';
33
+ import { GATE_VERSION } from './version.js';
31
34
 
32
35
  /**
33
36
  * The gate's door.
@@ -56,6 +59,13 @@ interface GateBaseOptions {
56
59
  * refused: a gate that guessed a price would be authorising real money on a made-up number.
57
60
  */
58
61
  priceFor?: PriceFor;
62
+ /**
63
+ * Serves `GET /config/spend-tokens/:address`: the announced entry for one paired token — price,
64
+ * symbol, decimals and the per-wallet cap a launch on it should write — or null to answer 404 for
65
+ * a token this gate will not run rounds on. Lets launch tooling check any pairing before a coin
66
+ * is paid for, without the gate having listed it in `/config` up front.
67
+ */
68
+ announceSpendToken?: (address: `0x${string}`) => Promise<AnnouncedSpendToken | null>;
59
69
  /** Verifies from the chain that a coin really is a game launch. */
60
70
  discovery?: Discovery;
61
71
  /** Keeps the ledger's view of spending level with the chain's. */
@@ -166,6 +176,12 @@ export interface GateAnnouncement {
166
176
  * listed here, which is the fail-closed answer for a gate that has not been told the token's price.
167
177
  */
168
178
  spendTokens?: AnnouncedSpendToken[];
179
+ /**
180
+ * The manager's PairedTokenRegistry, when it has one. Its presence means this gate prices every
181
+ * token the registry approves, not only `spendTokens`: ask `/config/spend-tokens/:address` for the
182
+ * entry of any pairing before building a launch on it.
183
+ */
184
+ pairedTokenRegistry?: `0x${string}`;
169
185
  requiresEoa: boolean;
170
186
  accepting: boolean;
171
187
  /**
@@ -179,6 +195,15 @@ export interface GateAnnouncement {
179
195
  privateLaunches: boolean;
180
196
  }
181
197
 
198
+ /**
199
+ * What `GET /config` actually serves: the operator's {@link GateAnnouncement} plus the version of
200
+ * `@flayerlabs/gamemode-gate` answering, so the platform can tell whether a gate runs a current SDK.
201
+ */
202
+ export interface ServedGateAnnouncement extends GateAnnouncement {
203
+ /** The SemVer of the `@flayerlabs/gamemode-gate` build serving this route. */
204
+ gateVersion: string;
205
+ }
206
+
182
207
  /** Where an admission check is being made. Enough to tell a sign-in from a spend. */
183
208
  export interface AdmissionRequest {
184
209
  /** `session` on sign-in, `action` on gameplay, `claim` when allowance is about to be committed. */
@@ -627,9 +652,24 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
627
652
  total: row.total,
628
653
  })),
629
654
  lastRoundAt: totals.last_round_at === null ? null : Number(totals.last_round_at),
655
+ // The same version `/config` reports, so a dashboard already reading stats needs no second call.
656
+ gateVersion: GATE_VERSION,
630
657
  };
631
658
  });
632
659
 
660
+ /**
661
+ * Accepted play sessions, for the same dashboard: one row per wallet and round, so a per-game
662
+ * play count survives the room ending. Wallets are hashed into the row ID and never returned.
663
+ * Rounds without a coin (practice and mock rooms) are left out. Keyset-paginated at 250 rows on
664
+ * `(started_at, id)`; the cursor is `${startedAt}:${id}` and a malformed one is a 400.
665
+ */
666
+ app.get('/stats/plays', async (request, reply) => {
667
+ void reply.header('access-control-allow-origin', '*');
668
+ const cursor = parsePlayCursor((request.query as { cursor?: unknown }).cursor);
669
+ if (!cursor) return reply.code(400).send({ message: 'invalid play cursor' });
670
+ return readPlaySessions(pool, cursor);
671
+ });
672
+
633
673
  /**
634
674
  * How a coin's round stands: every wallet that scored, ranked, with what the chain says each
635
675
  * spent. This is what the coin page shows once the game is over — and during it, as the running
@@ -655,8 +695,27 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
655
695
  // allowlist names only the game's origins; the launch page is a different origin and every
656
696
  // fact here is public (a launch publishes them on chain), so `*` is honest rather than lax.
657
697
  void reply.header('access-control-allow-origin', '*');
658
- return announce();
698
+ // `gateVersion` is added here rather than asked of `announce()`: the library knows what it is
699
+ // and an operator's announcement should not be able to misreport it.
700
+ const served: ServedGateAnnouncement = { ...announce(), gateVersion: GATE_VERSION };
701
+ return served;
659
702
  });
703
+ if (options.announceSpendToken) {
704
+ const announceSpendToken = options.announceSpendToken;
705
+ app.get<{ Params: { address: string } }>('/config/spend-tokens/:address', async (request, reply) => {
706
+ void reply.header('access-control-allow-origin', '*');
707
+ // A quote moves with the market and an approval can be revoked; nothing here may be cached
708
+ // past the response.
709
+ void reply.header('cache-control', 'no-store');
710
+ const { address } = request.params;
711
+ if (!isAddress(address, { strict: false })) {
712
+ return reply.code(400).send({ message: 'not a token address' });
713
+ }
714
+ const entry = await announceSpendToken(address as `0x${string}`);
715
+ if (!entry) return reply.code(404).send({ message: 'this gate does not run rounds on that token' });
716
+ return entry;
717
+ });
718
+ }
660
719
  }
661
720
 
662
721
  app.post('/session/challenge', async (request) => {
@@ -742,6 +801,7 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
742
801
  if (typeof audience !== 'string' || !awardEndpoint.ticketAudiences.has(audience)) {
743
802
  return reply.code(400).send({ message: 'that game server is not registered' });
744
803
  }
804
+ await recordPlaySession(pool, id, who);
745
805
  return issuePlayerJoinTicket({
746
806
  awardToken: awardEndpoint.token,
747
807
  player: who,
@@ -838,7 +898,9 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
838
898
 
839
899
  const id = (request.params as { id: string }).id;
840
900
  if (scoreAuthority === 'external-server') {
841
- player(request);
901
+ const who = player(request);
902
+ if (Date.now() >= room.window.closesAt) return reply.code(409).send({ message: 'that game has ended' });
903
+ await recordPlaySession(pool, id, who);
842
904
  return { joined: true };
843
905
  }
844
906
  const before = room.sequence();
@@ -850,7 +912,9 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
850
912
  at: Date.now(),
851
913
  });
852
914
  if (room.sequence() !== before) announce(id);
853
- return isRefusal(result) ? reply.code(409).send({ message: 'you cannot join right now' }) : { joined: true };
915
+ if (isRefusal(result)) return reply.code(409).send({ message: 'you cannot join right now' });
916
+ await recordPlaySession(pool, id, player(request));
917
+ return { joined: true };
854
918
  });
855
919
 
856
920
  app.post('/rounds/:id/actions', async (request, reply) => {
package/src/store.ts CHANGED
@@ -147,6 +147,21 @@ create table if not exists registrations (
147
147
  primary key (chain_id, coin)
148
148
  );
149
149
 
150
+ -- Accepted play sessions: one row per wallet and round, keyed by a hash of both so a wallet is never
151
+ -- stored in the clear. Independent of scoring and of websocket reconnects. play_tracking records
152
+ -- when this gate began counting, so a dashboard can tell "no plays" from "not yet tracked".
153
+ create table if not exists play_sessions (
154
+ id text primary key,
155
+ round_id text not null references rounds(id) on delete cascade,
156
+ started_at bigint not null
157
+ );
158
+ create index if not exists play_sessions_started_id_idx on play_sessions(started_at, id);
159
+ create table if not exists play_tracking (
160
+ id boolean primary key default true check (id),
161
+ started_at bigint not null
162
+ );
163
+ insert into play_tracking(id, started_at) values (true, (extract(epoch from clock_timestamp()) * 1000)::bigint) on conflict do nothing;
164
+
150
165
  -- What actually happened, in order. Not used to rebuild state — the snapshot does that — but it is
151
166
  -- the record of why a state looks the way it does, which is what an incident needs.
152
167
  create table if not exists commands (
package/src/version.ts ADDED
@@ -0,0 +1,5 @@
1
+ // Generated by scripts/write-version.mjs from package.json at build time. Do not edit.
2
+ /** The published version of @flayerlabs/gamemode-gate, as a literal so it survives any bundler. */
3
+ export const GATE_VERSION: string = "0.5.6";
4
+ /** `@flayerlabs/gamemode-gate@<version>`: the text a build of this package can be searched for. */
5
+ export const GATE_MARKER: string = "@flayerlabs/gamemode-gate@0.5.6";