@flayerlabs/gamemode-gate 0.4.3 → 0.5.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.
Files changed (68) hide show
  1. package/DEPLOY.md +27 -3
  2. package/README.md +8 -3
  3. package/dist/chain/chains.d.ts +45 -6
  4. package/dist/chain/chains.d.ts.map +1 -1
  5. package/dist/chain/chains.js +56 -5
  6. package/dist/chain/chains.js.map +1 -1
  7. package/dist/chain/discover.d.ts +20 -2
  8. package/dist/chain/discover.d.ts.map +1 -1
  9. package/dist/chain/discover.js +74 -6
  10. package/dist/chain/discover.js.map +1 -1
  11. package/dist/chain/secure.d.ts +11 -1
  12. package/dist/chain/secure.d.ts.map +1 -1
  13. package/dist/chain/secure.js +67 -0
  14. package/dist/chain/secure.js.map +1 -1
  15. package/dist/claims.d.ts +1 -1
  16. package/dist/claims.d.ts.map +1 -1
  17. package/dist/claims.js +2 -2
  18. package/dist/claims.js.map +1 -1
  19. package/dist/demo.d.ts +1 -1
  20. package/dist/demo.d.ts.map +1 -1
  21. package/dist/demo.js +9 -9
  22. package/dist/demo.js.map +1 -1
  23. package/dist/economy.d.ts +25 -9
  24. package/dist/economy.d.ts.map +1 -1
  25. package/dist/economy.js +63 -22
  26. package/dist/economy.js.map +1 -1
  27. package/dist/index.d.ts +7 -6
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +4 -3
  30. package/dist/index.js.map +1 -1
  31. package/dist/ledger.d.ts +2 -2
  32. package/dist/ledger.d.ts.map +1 -1
  33. package/dist/ledger.js +16 -16
  34. package/dist/ledger.js.map +1 -1
  35. package/dist/main.d.ts +30 -5
  36. package/dist/main.d.ts.map +1 -1
  37. package/dist/main.js +158 -17
  38. package/dist/main.js.map +1 -1
  39. package/dist/room.d.ts +7 -4
  40. package/dist/room.d.ts.map +1 -1
  41. package/dist/room.js +31 -10
  42. package/dist/room.js.map +1 -1
  43. package/dist/server.d.ts +33 -3
  44. package/dist/server.d.ts.map +1 -1
  45. package/dist/server.js +93 -25
  46. package/dist/server.js.map +1 -1
  47. package/dist/standings.d.ts +34 -0
  48. package/dist/standings.d.ts.map +1 -0
  49. package/dist/standings.js +103 -0
  50. package/dist/standings.js.map +1 -0
  51. package/dist/store.d.ts +1 -1
  52. package/dist/store.d.ts.map +1 -1
  53. package/dist/store.js +21 -0
  54. package/dist/store.js.map +1 -1
  55. package/package.json +2 -2
  56. package/src/chain/chains.ts +82 -9
  57. package/src/chain/discover.ts +87 -7
  58. package/src/chain/secure.ts +86 -1
  59. package/src/claims.ts +2 -2
  60. package/src/demo.ts +10 -10
  61. package/src/economy.ts +83 -31
  62. package/src/index.ts +8 -5
  63. package/src/ledger.ts +18 -18
  64. package/src/main.ts +181 -19
  65. package/src/room.ts +41 -14
  66. package/src/server.ts +142 -35
  67. package/src/standings.ts +138 -0
  68. package/src/store.ts +21 -0
package/src/room.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Pool } from 'pg';
2
2
  import { Round } from '@flayerlabs/gamemode-spec/round';
3
3
  import type { Award, Command, Decision, GameModule, PlayerId, Refusal, RoundWindow } from '@flayerlabs/gamemode-spec';
4
- import type { LaunchContext, RoundTerms } from '@flayerlabs/gamemode-spec/live';
4
+ import { ETH_SPEND_TOKEN, type LaunchContext, type RoundTerms, type SpendToken } from '@flayerlabs/gamemode-spec/live';
5
5
  import { isRefusal } from '@flayerlabs/gamemode-spec';
6
6
  import { inTransaction, toBigInt } from './store.js';
7
7
 
@@ -52,10 +52,13 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
52
52
  config: Config;
53
53
  seed: number;
54
54
  window: RoundWindow;
55
- weiPerPoint: bigint;
56
- walletCapWei: bigint;
55
+ unitsPerPoint: bigint;
56
+ walletCap: bigint;
57
57
  pointsPerDollar: number;
58
- usdPerEth: number;
58
+ /** The token spend is measured in. Defaults to ETH — a flETH- or ETH-paired round. */
59
+ spendToken?: SpendToken;
60
+ /** The market price of one whole spend token, in dollars, at adoption. */
61
+ usdPerSpendToken: number;
59
62
  maxPointsPerPlayer: number;
60
63
  scoreAuthority: ScoreAuthority;
61
64
  poolId: string;
@@ -86,28 +89,39 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
86
89
  closesAt: round.window.closesAt,
87
90
  booksCloseAt,
88
91
  });
92
+ const spendToken: SpendToken = Object.freeze({ ...(round.spendToken ?? ETH_SPEND_TOKEN) });
89
93
  const terms: RoundTerms = Object.freeze({
90
94
  maxPointsPerPlayer: round.maxPointsPerPlayer,
91
- walletCapWei: round.walletCapWei,
92
- weiPerPoint: round.weiPerPoint,
95
+ walletCap: round.walletCap,
96
+ unitsPerPoint: round.unitsPerPoint,
93
97
  pointsPerDollar: round.pointsPerDollar,
94
- usdPerEth: round.usdPerEth,
98
+ usdPerSpendToken: round.usdPerSpendToken,
99
+ spendToken,
95
100
  });
96
101
  const started = Round.start(game, round.config, round.seed, round.window);
102
+ // The columns keep their original wei-flavoured names while the API does not. Renaming them
103
+ // would be a migration with no reader outside this file, so the divergence is deliberate:
104
+ // wei_per_point is unitsPerPoint, wallet_cap_wei is walletCap, and usd_per_spend_token is
105
+ // usdPerSpendToken. usd_per_eth is a legacy column, written for provenance only.
97
106
  await pool.query(
98
107
  `insert into rounds (
99
108
  id, game_id, wei_per_point, wallet_cap_wei, points_per_dollar, usd_per_eth,
109
+ spend_token, spend_symbol, spend_decimals, usd_per_spend_token,
100
110
  max_points_per_player, score_authority,
101
111
  pool_id, opens_at, closes_at, books_close_at,
102
112
  coin_address, coin_name, coin_symbol, coin_image_url, state, seq
103
- ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 0)`,
113
+ ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, 0)`,
104
114
  [
105
115
  round.id,
106
116
  game.id,
107
- round.weiPerPoint.toString(),
108
- round.walletCapWei.toString(),
117
+ round.unitsPerPoint.toString(),
118
+ round.walletCap.toString(),
109
119
  round.pointsPerDollar,
110
- round.usdPerEth,
120
+ round.usdPerSpendToken,
121
+ spendToken.address.toLowerCase(),
122
+ spendToken.symbol,
123
+ spendToken.decimals,
124
+ round.usdPerSpendToken,
111
125
  round.maxPointsPerPlayer,
112
126
  round.scoreAuthority,
113
127
  round.poolId,
@@ -155,12 +169,17 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
155
169
  wallet_cap_wei: string;
156
170
  points_per_dollar: number;
157
171
  usd_per_eth: number;
172
+ spend_token: string;
173
+ spend_symbol: string;
174
+ spend_decimals: number;
175
+ usd_per_spend_token: number | null;
158
176
  max_points_per_player: string;
159
177
  score_authority: ScoreAuthority;
160
178
  game_id: string;
161
179
  }>(
162
180
  `select state, seq, pool_id, opens_at, closes_at, books_close_at, wei_per_point,
163
181
  wallet_cap_wei, points_per_dollar, usd_per_eth,
182
+ spend_token, spend_symbol, spend_decimals, usd_per_spend_token,
164
183
  max_points_per_player, score_authority, game_id,
165
184
  coin_address, coin_name, coin_symbol, coin_image_url
166
185
  from rounds where id = $1`,
@@ -183,10 +202,18 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
183
202
  });
184
203
  const terms: RoundTerms = Object.freeze({
185
204
  maxPointsPerPlayer: Number(row.max_points_per_player),
186
- walletCapWei: toBigInt(row.wallet_cap_wei),
187
- weiPerPoint: toBigInt(row.wei_per_point),
205
+ walletCap: toBigInt(row.wallet_cap_wei),
206
+ unitsPerPoint: toBigInt(row.wei_per_point),
188
207
  pointsPerDollar: Number(row.points_per_dollar),
189
- usdPerEth: Number(row.usd_per_eth),
208
+ spendToken: Object.freeze({
209
+ address: row.spend_token,
210
+ symbol: row.spend_symbol,
211
+ decimals: Number(row.spend_decimals),
212
+ isNative: row.spend_token.toLowerCase() === ETH_SPEND_TOKEN.address,
213
+ }),
214
+ // usd_per_eth is the legacy column: on a row written before the spend-token columns existed
215
+ // it holds that round's ETH price, which is exactly its spend token's price.
216
+ usdPerSpendToken: row.usd_per_spend_token === null ? Number(row.usd_per_eth) : Number(row.usd_per_spend_token),
190
217
  });
191
218
  return new Room(
192
219
  pool,
package/src/server.ts CHANGED
@@ -7,12 +7,17 @@ import { isRefusal } from '@flayerlabs/gamemode-spec';
7
7
  import type {
8
8
  EconomyBalance,
9
9
  PresenceState,
10
+ RoundStandings,
10
11
  RoundTerms,
12
+ SpendToken,
11
13
  WireEconomyBalance,
14
+ WireRoundStandings,
12
15
  WireRoundTerms,
13
16
  } from '@flayerlabs/gamemode-spec/live';
14
- import { isReactionId, LIVE_PROTOCOL_VERSION } from '@flayerlabs/gamemode-spec/live';
17
+ import { ETH_SPEND_TOKEN, isReactionId, LIVE_PROTOCOL_VERSION } from '@flayerlabs/gamemode-spec/live';
18
+ import type { FlaunchVariant } from './chain/chains.js';
15
19
  import { Room, type ScoreAuthority } from './room.js';
20
+ import { Standings } from './standings.js';
16
21
  import { CLAIM_AUTHORIZATION_TTL_MS, Claims, isClaimAmount, isClaimRequestId } from './claims.js';
17
22
  import { NotSignedIn, Sessions } from './sessions.js';
18
23
  import type { Discovery, Launch, PoolKey } from './chain/discover.js';
@@ -42,7 +47,15 @@ interface GateBaseOptions {
42
47
  claims: Claims;
43
48
  /** How many points a dollar buys. The only economic number anyone sets by hand. */
44
49
  pointsPerDollar: number;
50
+ /** The price of ETH — what an ETH-paired round is priced at. */
45
51
  usdPerEth: number;
52
+ /**
53
+ * The price of one whole spend token, for a round whose coin is paired with something other
54
+ * than ETH — or null to refuse the launch as unpriced. Consulted at adoption with the token the
55
+ * chain reported. Omitted, only native ETH is priced (at `usdPerEth`) and every other pairing is
56
+ * refused: a gate that guessed a price would be authorising real money on a made-up number.
57
+ */
58
+ priceFor?: PriceFor;
46
59
  /** Verifies from the chain that a coin really is a game launch. */
47
60
  discovery?: Discovery;
48
61
  /** Keeps the ledger's view of spending level with the chain's. */
@@ -85,6 +98,20 @@ interface GateBaseOptions {
85
98
  reactionIds?: readonly string[];
86
99
  }
87
100
 
101
+ /** A price for one whole spend token in dollars, or null when this gate has none for it. */
102
+ export type PriceFor = (token: SpendToken) => Promise<number | null> | number | null;
103
+
104
+ /** One entry of a gate's announced price list — what launch tooling checks a pairing against. */
105
+ export interface AnnouncedSpendToken {
106
+ address: `0x${string}`;
107
+ symbol: string;
108
+ decimals: number;
109
+ /** The price this gate will fix rounds on that token at. */
110
+ usdPerToken: number;
111
+ /** The per-wallet cap the launch should write, in this token's base units. A decimal string. */
112
+ walletCap: string;
113
+ }
114
+
88
115
  /** Resolve one immutable rules config after the launch has been verified. */
89
116
  export type VerifiedLaunch = Readonly<Omit<Launch, 'poolKey'>> & { readonly poolKey: Readonly<PoolKey> };
90
117
  export type ConfigForLaunch<Config> = (launch: VerifiedLaunch) => Promise<Config>;
@@ -118,17 +145,25 @@ export interface GateAnnouncement {
118
145
  contracts: {
119
146
  positionManager: `0x${string}`;
120
147
  spendGatedCalculator: `0x${string}`;
148
+ /** The approved swap router a paired-token buy goes through. Present on a `v1_3` stack. */
149
+ poolSwap?: `0x${string}`;
121
150
  };
122
151
  /** The address launches must name as their trusted signer for this gate to adopt them. */
123
152
  signer: `0x${string}`;
124
153
  /** May lift the gate early or rotate the signer on-chain; the launch writes it into the pool. */
125
154
  settler: `0x${string}`;
126
- /** A decimal string — wei does not survive JSON as a number. */
127
- walletCapWei: string;
155
+ /** The per-wallet cap for an ETH-paired launch, in wei. A decimal string — wei does not survive JSON as a number. */
156
+ walletCap: string;
128
157
  roundDurationMs: number;
129
158
  minLobbyLeadMs: number;
130
159
  gateEndsAtGraceS: number;
131
- flaunchVariant: 'legacy11' | 'current9';
160
+ flaunchVariant: FlaunchVariant;
161
+ /**
162
+ * The non-ETH paired tokens this gate will run rounds on, each with the cap a launch on it should
163
+ * write. Absent or empty means ETH pairings only. Launch tooling refuses a pairing that is not
164
+ * listed here, which is the fail-closed answer for a gate that has not been told the token's price.
165
+ */
166
+ spendTokens?: AnnouncedSpendToken[];
132
167
  requiresEoa: boolean;
133
168
  accepting: boolean;
134
169
  /**
@@ -170,22 +205,32 @@ const ADMIT_ALL: Admit = () => ({ ok: true });
170
205
  /** What the socket says, which is only the truth when nothing is proxying in front of the gate. */
171
206
  const ipOf = (request: FastifyRequest): string | null => request.ip || null;
172
207
 
173
- /** Wei has no JSON form, so terms cross as decimal strings, exactly as the economy balance does. */
208
+ /** Bigint has no JSON form, so terms cross as decimal strings, exactly as the economy balance does. */
174
209
  const wireTerms = (terms: RoundTerms): WireRoundTerms => ({
175
210
  maxPointsPerPlayer: terms.maxPointsPerPlayer,
176
- walletCapWei: terms.walletCapWei.toString(),
177
- weiPerPoint: terms.weiPerPoint.toString(),
211
+ walletCap: terms.walletCap.toString(),
212
+ unitsPerPoint: terms.unitsPerPoint.toString(),
178
213
  pointsPerDollar: terms.pointsPerDollar,
179
- usdPerEth: terms.usdPerEth,
214
+ usdPerSpendToken: terms.usdPerSpendToken,
215
+ spendToken: terms.spendToken,
180
216
  });
181
217
 
218
+ /** Standings leave the process the same way every other amount does: base units as a string. */
219
+ const wireStandings = (standings: RoundStandings): WireRoundStandings => ({
220
+ ...standings,
221
+ players: standings.players.map((line) => ({ ...line, spent: line.spent.toString() })),
222
+ });
223
+
224
+ /** Only native ETH is priced without being told: everything else needs a `priceFor`. */
225
+ const ethOnly = (usdPerEth: number): PriceFor => (token) => (token.isNative ? usdPerEth : null);
226
+
182
227
  /** The same encoding for the per-player balance, wherever it leaves the process. */
183
228
  const wireEconomy = (balance: EconomyBalance): WireEconomyBalance => ({
184
- weiPerPoint: balance.weiPerPoint.toString(),
185
- earnedWei: balance.earnedWei.toString(),
186
- heldWei: balance.heldWei.toString(),
187
- spentWei: balance.spentWei.toString(),
188
- availableWei: balance.availableWei.toString(),
229
+ unitsPerPoint: balance.unitsPerPoint.toString(),
230
+ earned: balance.earned.toString(),
231
+ held: balance.held.toString(),
232
+ spent: balance.spent.toString(),
233
+ available: balance.available.toString(),
189
234
  holdExpiresAt: balance.holdExpiresAt,
190
235
  });
191
236
 
@@ -326,6 +371,7 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
326
371
  >();
327
372
 
328
373
  const playerSocketKey = (id: string, player: PlayerId): string => `${id}:${player}`;
374
+ const standings = new Standings(pool, game.id);
329
375
 
330
376
  void app.register(cors, { origin: options.allowedOrigins });
331
377
  void app.register(websocket, { options: { maxPayload: 1_024 } });
@@ -389,11 +435,27 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
389
435
  app.log.error(error);
390
436
  return { ok: false, status: 503, message: 'this game is still being prepared' };
391
437
  }
438
+ // The token the chain measures this pool's spend in decides the round's units. A launch this
439
+ // gate has no price for is refused rather than priced as ETH: on a six-decimal token that guess
440
+ // authorises a trillion times the intended spend.
441
+ const spendToken = launch.spendToken ?? ETH_SPEND_TOKEN;
442
+ let usdPerSpendToken: number | null;
443
+ try {
444
+ usdPerSpendToken = await (options.priceFor ?? ethOnly(options.usdPerEth))(spendToken);
445
+ } catch (error) {
446
+ app.log.error(error);
447
+ return { ok: false, status: 503, message: 'this game is still being prepared' };
448
+ }
449
+ if (usdPerSpendToken === null || !Number.isFinite(usdPerSpendToken) || usdPerSpendToken <= 0) {
450
+ app.log.error(`${launch.coinAddress}: no price for spend token ${spendToken.symbol} (${spendToken.address})`);
451
+ return { ok: false, status: 409, message: `this game is not set up for ${spendToken.symbol} yet` };
452
+ }
392
453
  const economy = resolveEconomy({
393
454
  maxPointsPerPlayer,
394
- walletCapWei: launch.walletCapWei,
455
+ walletCap: launch.walletCap,
395
456
  pointsPerDollar: options.pointsPerDollar,
396
- usdPerEth: options.usdPerEth,
457
+ usdPerSpendToken,
458
+ decimals: spendToken.decimals,
397
459
  });
398
460
  if (!economy.ok) {
399
461
  app.log.error(
@@ -409,10 +471,11 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
409
471
  config,
410
472
  seed: Number(BigInt(launch.poolId) % 2147483647n),
411
473
  window: { opensAt: launch.opensAt, closesAt: launch.closesAt },
412
- weiPerPoint: economy.economy.weiPerPoint,
474
+ unitsPerPoint: economy.economy.unitsPerPoint,
413
475
  pointsPerDollar: economy.economy.pointsPerDollar,
414
- usdPerEth: economy.economy.usdPerEth,
415
- walletCapWei: launch.walletCapWei,
476
+ spendToken,
477
+ usdPerSpendToken: economy.economy.usdPerSpendToken,
478
+ walletCap: launch.walletCap,
416
479
  maxPointsPerPlayer,
417
480
  scoreAuthority,
418
481
  poolId: launch.poolId,
@@ -499,26 +562,63 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
499
562
  */
500
563
  app.get('/stats', async (_request, reply) => {
501
564
  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
- );
565
+ // Spend is summed per spend token: base units of two different tokens do not add. The flat
566
+ // `spendWeiTotal` keeps its original meaning — wei, across ETH rounds — for dashboards built
567
+ // against it; `spendByToken` is the honest figure for a gate that runs more than one pairing.
568
+ const [{ rows }, byToken] = await Promise.all([
569
+ pool.query<{
570
+ rounds_total: string;
571
+ players_total: string;
572
+ spend_wei_total: string;
573
+ last_round_at: string | null;
574
+ }>(
575
+ `select (select count(*) from rounds) as rounds_total,
576
+ (select count(distinct player) from balances) as players_total,
577
+ (select coalesce(sum(b.chain_spent_wei), 0)::text
578
+ from balances b join rounds r on r.id = b.round_id
579
+ where r.spend_decimals = 18) as spend_wei_total,
580
+ (select max(opens_at) from rounds) as last_round_at`,
581
+ ),
582
+ pool.query<{ spend_token: string; spend_symbol: string; spend_decimals: number; total: string }>(
583
+ `select r.spend_token, r.spend_symbol, r.spend_decimals, coalesce(sum(b.chain_spent_wei), 0)::text as total
584
+ from rounds r left join balances b on b.round_id = r.id
585
+ group by r.spend_token, r.spend_symbol, r.spend_decimals
586
+ order by r.spend_token`,
587
+ ),
588
+ ]);
513
589
  const totals = rows[0]!;
514
590
  return {
515
591
  roundsTotal: Number(totals.rounds_total),
516
592
  playersTotal: Number(totals.players_total),
517
593
  spendWeiTotal: totals.spend_wei_total,
594
+ spendByToken: byToken.rows.map((row) => ({
595
+ address: row.spend_token,
596
+ symbol: row.spend_symbol,
597
+ decimals: Number(row.spend_decimals),
598
+ total: row.total,
599
+ })),
518
600
  lastRoundAt: totals.last_round_at === null ? null : Number(totals.last_round_at),
519
601
  };
520
602
  });
521
603
 
604
+ /**
605
+ * How a coin's round stands: every wallet that scored, ranked, with what the chain says each
606
+ * spent. This is what the coin page shows once the game is over — and during it, as the running
607
+ * order — so it is keyed by the one thing that page holds, the coin's address.
608
+ *
609
+ * A read, and deliberately not through `roomFor`: that resurrects rooms, advances game state and
610
+ * answers nothing once the books close, none of which a table should do. Public for the same
611
+ * reason `/stats` is — a spectator on the live socket could tally every number here — and served
612
+ * with the same header, because the coin page is a different origin from the game's.
613
+ */
614
+ app.get('/coins/:coin/rankings', async (request, reply) => {
615
+ void reply.header('access-control-allow-origin', '*');
616
+ const found = await standings.forCoin((request.params as { coin: string }).coin);
617
+ if (!found) return reply.code(404).send({ message: 'that coin has no game' });
618
+ // Points are monotone within a round; the spend beside them is the chain's latest word.
619
+ return reply.header('cache-control', 'no-store').send(wireStandings(found));
620
+ });
621
+
522
622
  if (options.announce) {
523
623
  const announce = options.announce;
524
624
  app.get('/config', (_request, reply) => {
@@ -672,7 +772,7 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
672
772
  return reply.code(result.refuse === 'award.invalid' ? 400 : 404).send({ refuse: result.refuse });
673
773
  }
674
774
 
675
- // `heldWei` moves on its own when an authorisation expires, so this answer is only true at the
775
+ // `held` moves on its own when an authorisation expires, so this answer is only true at the
676
776
  // moment it is read. Points are monotone; the wei beside them are not.
677
777
  const balance = await claims.economyBalance(result.standing.roundId, result.standing.player);
678
778
  return reply
@@ -688,11 +788,18 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
688
788
  const before = room.sequence();
689
789
  await room.tick(Date.now());
690
790
  if (room.sequence() !== before) announce(id);
791
+ // Who is here rides the round read so a lobby can show its seats without a socket of its own.
792
+ // Connection is decided by the sockets this process holds, which is the only place it is known.
793
+ const { roster, rosterTotal } = await standings.rosterFor(id, (who) =>
794
+ activePlayerSockets.has(playerSocketKey(id, who)),
795
+ );
691
796
  return {
692
797
  sequence: room.sequence(),
693
798
  view: room.publicView(),
694
799
  launch: room.launch,
695
800
  terms: wireTerms(room.terms),
801
+ roster,
802
+ rosterTotal,
696
803
  };
697
804
  });
698
805
 
@@ -869,11 +976,11 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
869
976
  const balance = viewer
870
977
  ? await claims.economyBalance(id, viewer)
871
978
  : {
872
- weiPerPoint: room.terms.weiPerPoint,
873
- earnedWei: 0n,
874
- heldWei: 0n,
875
- spentWei: 0n,
876
- availableWei: 0n,
979
+ unitsPerPoint: room.terms.unitsPerPoint,
980
+ earned: 0n,
981
+ held: 0n,
982
+ spent: 0n,
983
+ available: 0n,
877
984
  holdExpiresAt: null,
878
985
  };
879
986
  const economy = wireEconomy(balance);
@@ -0,0 +1,138 @@
1
+ import type { Pool } from 'pg';
2
+ import { isAddress } from 'viem';
3
+ import type { PlayerId } from '@flayerlabs/gamemode-spec';
4
+ import {
5
+ ETH_SPEND_TOKEN,
6
+ ROSTER_MAX_PLAYERS,
7
+ STANDINGS_MAX_PLAYERS,
8
+ roundPhaseAt,
9
+ type RoundRosterEntry,
10
+ type RoundStandings,
11
+ } from '@flayerlabs/gamemode-spec/live';
12
+ import { toBigInt } from './store.js';
13
+
14
+ /**
15
+ * The public reads of who played a round and how they did.
16
+ *
17
+ * Reads only, and never through a Room: loading one resurrects a round, advances its state and
18
+ * answers nothing once the books close — none of which a spectator's table should do. Everything
19
+ * here comes straight from `rounds` and `balances`, the same rows the ledger writes.
20
+ *
21
+ * Scoped by game, so another game's round sharing this database reads as no round at all rather
22
+ * than as somebody else's standings.
23
+ */
24
+ export class Standings {
25
+ constructor(
26
+ private readonly pool: Pool,
27
+ private readonly gameId: string,
28
+ ) {}
29
+
30
+ /**
31
+ * The standings of the latest round this gate ran for a coin, or null when it ran none.
32
+ *
33
+ * Latest by opening time: a coin can only carry one live round, and a page asking about a coin
34
+ * wants the round people remember, not the first one ever adopted.
35
+ */
36
+ async forCoin(coin: unknown, now: number = Date.now()): Promise<RoundStandings | null> {
37
+ if (typeof coin !== 'string' || !isAddress(coin, { strict: false })) return null;
38
+
39
+ const rounds = await this.pool.query<{
40
+ id: string;
41
+ pool_id: string;
42
+ coin_address: string | null;
43
+ coin_symbol: string;
44
+ opens_at: string;
45
+ closes_at: string;
46
+ spend_token: string;
47
+ spend_symbol: string;
48
+ spend_decimals: number;
49
+ usd_per_spend_token: number | null;
50
+ usd_per_eth: number;
51
+ }>(
52
+ `select id, pool_id, coin_address, coin_symbol, opens_at, closes_at,
53
+ spend_token, spend_symbol, spend_decimals, usd_per_spend_token, usd_per_eth
54
+ from rounds
55
+ where lower(coin_address) = $1 and game_id = $2
56
+ order by opens_at desc
57
+ limit 1`,
58
+ [coin.toLowerCase(), this.gameId],
59
+ );
60
+ const round = rounds.rows[0];
61
+ if (!round) return null;
62
+
63
+ // ORDER BY is qualified on purpose. A bare `points` there would bind to the OUTPUT column —
64
+ // the `::text` cast that shares its name — and sort as strings, ranking 99 above 100. The
65
+ // qualified name is the only spelling Postgres reads as the bigint underneath.
66
+ const [players, total] = await Promise.all([
67
+ this.pool.query<{ player: string; points: string; chain_spent_wei: string }>(
68
+ `select player, points::text, chain_spent_wei::text
69
+ from balances
70
+ where round_id = $1
71
+ order by balances.points desc, balances.chain_spent_wei desc, balances.player asc
72
+ limit $2`,
73
+ [round.id, STANDINGS_MAX_PLAYERS],
74
+ ),
75
+ this.pool.query<{ total: string }>(`select count(*)::text as total from balances where round_id = $1`, [
76
+ round.id,
77
+ ]),
78
+ ]);
79
+
80
+ const window = { opensAt: Number(round.opens_at), closesAt: Number(round.closes_at) };
81
+ return {
82
+ roundId: round.id,
83
+ poolId: round.pool_id,
84
+ coinAddress: round.coin_address,
85
+ symbol: round.coin_symbol,
86
+ phase: roundPhaseAt(window, now),
87
+ ...window,
88
+ spendToken: {
89
+ address: round.spend_token,
90
+ symbol: round.spend_symbol,
91
+ decimals: Number(round.spend_decimals),
92
+ isNative: round.spend_token.toLowerCase() === ETH_SPEND_TOKEN.address,
93
+ },
94
+ // usd_per_eth is the legacy column: on a row written before the spend-token columns existed
95
+ // it holds that round's ETH price, which is exactly its spend token's price.
96
+ usdPerSpendToken:
97
+ round.usd_per_spend_token === null ? Number(round.usd_per_eth) : Number(round.usd_per_spend_token),
98
+ players: players.rows.map((row, index) => ({
99
+ rank: index + 1,
100
+ player: row.player,
101
+ points: Number(row.points),
102
+ spent: toBigInt(row.chain_spent_wei),
103
+ })),
104
+ playersTotal: Number(total.rows[0]?.total ?? 0),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Who holds a seat in a round: every wallet with a balance row, best first, and whether each is
110
+ * connected right now. Connection is the caller's to know — it owns the sockets.
111
+ */
112
+ async rosterFor(
113
+ roundId: string,
114
+ connected: (player: PlayerId) => boolean,
115
+ ): Promise<{ roster: RoundRosterEntry[]; rosterTotal: number }> {
116
+ const [seats, total] = await Promise.all([
117
+ this.pool.query<{ player: string; points: string }>(
118
+ `select player, points::text
119
+ from balances
120
+ where round_id = $1
121
+ order by balances.points desc, balances.player asc
122
+ limit $2`,
123
+ [roundId, ROSTER_MAX_PLAYERS],
124
+ ),
125
+ this.pool.query<{ total: string }>(`select count(*)::text as total from balances where round_id = $1`, [
126
+ roundId,
127
+ ]),
128
+ ]);
129
+ return {
130
+ roster: seats.rows.map((row) => ({
131
+ player: row.player,
132
+ points: Number(row.points),
133
+ connected: connected(row.player),
134
+ })),
135
+ rosterTotal: Number(total.rows[0]?.total ?? 0),
136
+ };
137
+ }
138
+ }
package/src/store.ts CHANGED
@@ -18,6 +18,15 @@ create table if not exists rounds (
18
18
  -- was priced at.
19
19
  points_per_dollar double precision not null check (points_per_dollar > 0),
20
20
  usd_per_eth double precision not null check (usd_per_eth > 0),
21
+ -- The token spend is measured in: the coin's paired token, in whose base units every *_wei column
22
+ -- of this round is denominated. Defaults describe ETH, which is what every round was before the
23
+ -- columns existed, so a pre-existing row reads back exactly as it was priced. usd_per_eth holds
24
+ -- the rate normalised to eighteen decimals (see economy.ts); usd_per_spend_token the token's own
25
+ -- price, null on rows that predate it (for which usd_per_eth is that price).
26
+ spend_token text not null default '0x0000000000000000000000000000000000000000' check (spend_token <> ''),
27
+ spend_symbol text not null default 'ETH' check (spend_symbol <> ''),
28
+ spend_decimals integer not null default 18 check (spend_decimals between 0 and 36),
29
+ usd_per_spend_token double precision check (usd_per_spend_token > 0),
21
30
  max_points_per_player bigint not null check (max_points_per_player > 0),
22
31
  score_authority text not null check (score_authority in ('rules', 'external-server')),
23
32
  pool_id text not null,
@@ -68,6 +77,18 @@ alter table rounds alter column score_authority set not null;
68
77
  alter table rounds alter column points_per_dollar set not null;
69
78
  alter table rounds alter column usd_per_eth set not null;
70
79
 
80
+ -- Paired-token rounds (0.5.0). Every earlier round was an ETH round, so the defaults ARE the
81
+ -- backfill: Postgres fills a new not-null column with its default, and ETH/18 is exactly what those
82
+ -- rows were priced in. No guard is needed — nothing here has to be guessed.
83
+ alter table rounds
84
+ add column if not exists spend_token text not null default '0x0000000000000000000000000000000000000000' check (spend_token <> '');
85
+ alter table rounds
86
+ add column if not exists spend_symbol text not null default 'ETH' check (spend_symbol <> '');
87
+ alter table rounds
88
+ add column if not exists spend_decimals integer not null default 18 check (spend_decimals between 0 and 36);
89
+ alter table rounds
90
+ add column if not exists usd_per_spend_token double precision check (usd_per_spend_token > 0);
91
+
71
92
  create table if not exists balances (
72
93
  round_id text not null references rounds(id) on delete cascade,
73
94
  player text not null,