@flayerlabs/gamemode-gate 0.4.3 → 0.5.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 (63) hide show
  1. package/DEPLOY.md +27 -3
  2. package/README.md +3 -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 +6 -6
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +3 -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 +63 -25
  46. package/dist/server.js.map +1 -1
  47. package/dist/store.d.ts +1 -1
  48. package/dist/store.d.ts.map +1 -1
  49. package/dist/store.js +21 -0
  50. package/dist/store.js.map +1 -1
  51. package/package.json +2 -2
  52. package/src/chain/chains.ts +82 -9
  53. package/src/chain/discover.ts +87 -7
  54. package/src/chain/secure.ts +86 -1
  55. package/src/claims.ts +2 -2
  56. package/src/demo.ts +10 -10
  57. package/src/economy.ts +83 -31
  58. package/src/index.ts +7 -5
  59. package/src/ledger.ts +18 -18
  60. package/src/main.ts +181 -19
  61. package/src/room.ts +41 -14
  62. package/src/server.ts +107 -35
  63. package/src/store.ts +21 -0
package/src/ledger.ts CHANGED
@@ -37,12 +37,12 @@ export interface BalanceKey {
37
37
 
38
38
  interface LedgerState {
39
39
  points: bigint;
40
- weiPerPoint: bigint;
40
+ unitsPerPoint: bigint;
41
41
  capWei: bigint;
42
42
  opensAt: number;
43
43
  closesAt: number;
44
44
  /** Authorisations still outstanding: issued, not yet expired, fate unknown. */
45
- heldWei: bigint;
45
+ held: bigint;
46
46
  /** What the chain says has actually been spent. */
47
47
  chainSpentWei: bigint;
48
48
  /**
@@ -60,9 +60,9 @@ interface LedgerState {
60
60
  * wallet spend. Issuing past the second does not overspend — the chain refuses it — it mints a
61
61
  * signature that reverts at the swap, which to a player is indistinguishable from being robbed.
62
62
  */
63
- function availableFrom({ points, weiPerPoint, capWei, heldWei, chainSpentWei }: LedgerState): bigint {
64
- const earnedWei = points * weiPerPoint;
65
- const ceiling = earnedWei < capWei ? earnedWei : capWei;
63
+ function availableFrom({ points, unitsPerPoint, capWei, held, chainSpentWei }: LedgerState): bigint {
64
+ const earned = points * unitsPerPoint;
65
+ const ceiling = earned < capWei ? earned : capWei;
66
66
 
67
67
  // Spent-on-chain PLUS still-outstanding, never the larger of the two.
68
68
  //
@@ -74,7 +74,7 @@ function availableFrom({ points, weiPerPoint, capWei, heldWei, chainSpentWei }:
74
74
  //
75
75
  // Adding them double-counts a hold whose swap has already landed, until that hold expires. That
76
76
  // errs towards authorising too little, which is the direction that costs nobody anything.
77
- const available = ceiling - (chainSpentWei + heldWei);
77
+ const available = ceiling - (chainSpentWei + held);
78
78
  return available > 0n ? available : 0n;
79
79
  }
80
80
 
@@ -186,11 +186,11 @@ export class Ledger {
186
186
  *
187
187
  * Never allowed to go backwards: a read from a lagging node must not undo what a later one saw.
188
188
  */
189
- async recordChainSpend(roundId: string, player: PlayerId, spentWei: bigint): Promise<boolean> {
189
+ async recordChainSpend(roundId: string, player: PlayerId, spent: bigint): Promise<boolean> {
190
190
  const result = await this.pool.query(
191
191
  `update balances set chain_spent_wei = greatest(chain_spent_wei, $3)
192
192
  where round_id = $1 and player = $2 and chain_spent_wei < $3`,
193
- [roundId, player, spentWei.toString()],
193
+ [roundId, player, spent.toString()],
194
194
  );
195
195
  return result.rowCount === 1;
196
196
  }
@@ -227,23 +227,23 @@ export class Ledger {
227
227
  return [...unique.values()];
228
228
  }
229
229
 
230
- async availableWei(roundId: string, player: PlayerId): Promise<bigint> {
231
- return (await this.economyBalance(roundId, player)).availableWei;
230
+ async available(roundId: string, player: PlayerId): Promise<bigint> {
231
+ return (await this.economyBalance(roundId, player)).available;
232
232
  }
233
233
 
234
234
  /** The authoritative balance snapshot, derived from the same state and ceiling as `claim`. */
235
235
  async economyBalance(roundId: string, player: PlayerId): Promise<EconomyBalance> {
236
236
  const state = await this.readState(this.pool, roundId, player);
237
237
  if (!state) {
238
- return { weiPerPoint: 0n, earnedWei: 0n, heldWei: 0n, spentWei: 0n, availableWei: 0n, holdExpiresAt: null };
238
+ return { unitsPerPoint: 0n, earned: 0n, held: 0n, spent: 0n, available: 0n, holdExpiresAt: null };
239
239
  }
240
240
 
241
241
  return {
242
- weiPerPoint: state.weiPerPoint,
243
- earnedWei: state.points * state.weiPerPoint,
244
- heldWei: state.heldWei,
245
- spentWei: state.chainSpentWei,
246
- availableWei: availableFrom(state),
242
+ unitsPerPoint: state.unitsPerPoint,
243
+ earned: state.points * state.unitsPerPoint,
244
+ held: state.held,
245
+ spent: state.chainSpentWei,
246
+ available: availableFrom(state),
247
247
  // Seconds on the way in, because that is what the contract checks against; milliseconds on
248
248
  // the way out, because that is what a browser counts down with.
249
249
  holdExpiresAt: state.holdExpiresAtSeconds === null ? null : state.holdExpiresAtSeconds * 1000,
@@ -285,11 +285,11 @@ export class Ledger {
285
285
  return {
286
286
  points: toBigInt(balance.rows[0]?.points),
287
287
  chainSpentWei: toBigInt(balance.rows[0]?.chain_spent_wei),
288
- weiPerPoint: toBigInt(row.wei_per_point),
288
+ unitsPerPoint: toBigInt(row.wei_per_point),
289
289
  capWei: toBigInt(row.wallet_cap_wei),
290
290
  opensAt: Number(row.opens_at),
291
291
  closesAt: Number(row.closes_at),
292
- heldWei: toBigInt(held.rows[0]?.total),
292
+ held: toBigInt(held.rows[0]?.total),
293
293
  holdExpiresAtSeconds: earliest == null ? null : Number(earliest),
294
294
  };
295
295
  }
package/src/main.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import pg from 'pg';
2
- import { createPublicClient, http, isAddress, type PublicClient } from 'viem';
2
+ import { createPublicClient, http, isAddress, parseAbi, type PublicClient } from 'viem';
3
3
  import type { FastifyInstance } from 'fastify';
4
4
  import type { GameModule } from '@flayerlabs/gamemode-spec';
5
- import { createGate, type GateAnnouncement } from './server.js';
5
+ import type { SpendToken } from '@flayerlabs/gamemode-spec/live';
6
+ import { createGate, type AnnouncedSpendToken, type GateAnnouncement, type PriceFor } from './server.js';
6
7
  import { Ledger } from './ledger.js';
7
8
  import { Claims } from './claims.js';
8
9
  import { Sessions } from './sessions.js';
@@ -44,16 +45,31 @@ export interface ResolvedGateConfig {
44
45
  port: number;
45
46
  deployment: GateDeployment;
46
47
  /** Announced to launch tooling; the creator's launch writes it into the pool. */
47
- walletCapWei: bigint;
48
+ walletCap: bigint;
48
49
  roundDurationMs: number;
49
50
  minLobbyLeadMs: number;
50
51
  gateEndsAtGraceS: number;
51
52
  pointsPerDollar: number;
53
+ /** The price of ETH; what ETH-paired rounds are priced at. Fractional values are fine. */
52
54
  usdPerEth: number;
55
+ /**
56
+ * The non-ETH paired tokens this gate will run rounds on, with the price it fixes them at and the
57
+ * per-wallet cap a launch on them should write. Keyed by lowercase address. Anything not here is
58
+ * refused at adoption — the gate does not guess a token's price.
59
+ */
60
+ spendTokens: ReadonlyMap<string, SpendTokenPricing>;
53
61
  /** May lift the gate early or rotate the signer on-chain. Defaults to the signing key's address. */
54
62
  settler: `0x${string}` | null;
55
63
  }
56
64
 
65
+ export interface SpendTokenPricing {
66
+ address: `0x${string}`;
67
+ /** Dollars per whole token. */
68
+ usdPerToken: number;
69
+ /** Per-wallet cap for a launch on this token, in its base units. */
70
+ walletCap: bigint;
71
+ }
72
+
57
73
  function required(env: Env, name: string): string {
58
74
  const value = env[name];
59
75
  if (!value) throw new Error(`${name} is not set — the gate refuses to guess it`);
@@ -68,6 +84,54 @@ function integer(env: Env, name: string, fallback: number): number {
68
84
  return value;
69
85
  }
70
86
 
87
+ /** A price or a rate: positive, finite, and allowed to be fractional — a dollar stablecoin is 1.0. */
88
+ function positiveNumber(env: Env, name: string, fallback: number): number {
89
+ const raw = env[name];
90
+ if (raw === undefined || raw === '') return fallback;
91
+ const value = Number(raw);
92
+ if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`);
93
+ return value;
94
+ }
95
+
96
+ /**
97
+ * `SPEND_TOKENS`: a JSON array of `{ address, usdPerToken, walletCap }`.
98
+ *
99
+ * Only what the chain cannot tell us: the price, and the cap the operator wants launches on this
100
+ * token to carry. Symbol and decimals are read from the token itself at boot — an operator-typed
101
+ * decimals is the exact mistake this whole feature exists to make impossible.
102
+ */
103
+ function spendTokens(env: Env): ReadonlyMap<string, SpendTokenPricing> {
104
+ const raw = env['SPEND_TOKENS'];
105
+ const out = new Map<string, SpendTokenPricing>();
106
+ if (raw === undefined || raw.trim() === '') return out;
107
+ let parsed: unknown;
108
+ try {
109
+ parsed = JSON.parse(raw);
110
+ } catch {
111
+ throw new Error('SPEND_TOKENS must be a JSON array of { address, usdPerToken, walletCap }');
112
+ }
113
+ if (!Array.isArray(parsed)) throw new Error('SPEND_TOKENS must be a JSON array');
114
+ for (const entry of parsed as unknown[]) {
115
+ const value = (entry ?? {}) as Record<string, unknown>;
116
+ // Casing is not checked: operators paste addresses from explorers and configs in every case,
117
+ // and a rejected checksum here would read as "wrong address" for a right one.
118
+ if (typeof value.address !== 'string' || !isAddress(value.address, { strict: false })) {
119
+ throw new Error('SPEND_TOKENS: every entry needs an address');
120
+ }
121
+ const usdPerToken = Number(value.usdPerToken);
122
+ if (!Number.isFinite(usdPerToken) || usdPerToken <= 0) {
123
+ throw new Error(`SPEND_TOKENS: ${value.address} needs a positive usdPerToken`);
124
+ }
125
+ if (typeof value.walletCap !== 'string' || !/^[1-9][0-9]*$/.test(value.walletCap)) {
126
+ throw new Error(`SPEND_TOKENS: ${value.address} needs walletCap as a decimal string of base units`);
127
+ }
128
+ const key = value.address.toLowerCase();
129
+ if (out.has(key)) throw new Error(`SPEND_TOKENS lists ${value.address} twice`);
130
+ out.set(key, { address: value.address as `0x${string}`, usdPerToken, walletCap: BigInt(value.walletCap) });
131
+ }
132
+ return out;
133
+ }
134
+
71
135
  function address(env: Env, name: string): `0x${string}` | null {
72
136
  const raw = env[name];
73
137
  if (raw === undefined || raw === '') return null;
@@ -88,23 +152,31 @@ function address(env: Env, name: string): `0x${string}` | null {
88
152
  * GATE_ORIGIN required; this gate's own public https origin (the SIWE audience)
89
153
  * ALLOWED_ORIGINS required; comma-separated origins that may call this gate
90
154
  * PORT default 8790
91
- * POSITION_MANAGER optional address-book override
155
+ * POSITION_MANAGER optional; selects a recorded stack on the chain (the legacy one when
156
+ * unset), or overrides the address for a chain with no address book
92
157
  * SPEND_GATED_CALCULATOR optional address-book override
93
- * FLAUNCH_VARIANT optional; only meaningful alongside the overrides
158
+ * POOL_SWAP optional address-book override; required for a v1_3 stack
159
+ * FLETH optional address-book override; the chain's flETH wrapper
160
+ * FLAUNCH_VARIANT optional; v1_2, multichain or v1_3
94
161
  * SETTLER optional; defaults to the signing key's own address
95
- * WALLET_CAP_WEI default 0.025 ETH
162
+ * WALLET_CAP_WEI default 0.025 ETH; the cap for ETH-paired launches
96
163
  * ROUND_MS default 90000
97
164
  * MIN_LOBBY_LEAD_MS default 60000
98
165
  * GATE_ENDS_AT_GRACE_S default 10
99
166
  * POINTS_PER_DOLLAR default 100
100
- * USD_PER_ETH default 3000
167
+ * USD_PER_ETH default 3000; fractional allowed
168
+ * SPEND_TOKENS optional JSON array of { address, usdPerToken, walletCap } — the non-ETH
169
+ * paired tokens this gate runs rounds on. Unlisted tokens are refused.
101
170
  */
102
171
  export function resolveGateEnv(env: Env): ResolvedGateConfig {
103
172
  const chainId = Number(required(env, 'CHAIN_ID'));
104
173
  if (!Number.isSafeInteger(chainId) || chainId <= 0) throw new Error('CHAIN_ID must be a positive integer');
105
174
 
106
- const recorded = deploymentFor(chainId);
107
- const positionManager = address(env, 'POSITION_MANAGER') ?? recorded?.positionManager ?? null;
175
+ const namedManager = address(env, 'POSITION_MANAGER');
176
+ // Naming a manager picks the recorded stack it belongs to — calculator and router with it — so a
177
+ // paired manager is never paired with the legacy calculator by a half-edited env file.
178
+ const recorded = deploymentFor(chainId, namedManager);
179
+ const positionManager = namedManager ?? recorded?.positionManager ?? null;
108
180
  const spendGatedCalculator = address(env, 'SPEND_GATED_CALCULATOR') ?? recorded?.spendGatedCalculator ?? null;
109
181
  if (!positionManager || !spendGatedCalculator) {
110
182
  throw new Error(
@@ -113,10 +185,20 @@ export function resolveGateEnv(env: Env): ResolvedGateConfig {
113
185
  );
114
186
  }
115
187
  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}`);
188
+ if (
189
+ variantOverride !== undefined &&
190
+ variantOverride !== 'v1_2' &&
191
+ variantOverride !== 'multichain' &&
192
+ variantOverride !== 'v1_3'
193
+ ) {
194
+ throw new Error(`FLAUNCH_VARIANT must be v1_2, multichain or v1_3, not ${variantOverride}`);
118
195
  }
119
- const flaunchVariant = variantOverride ?? recorded?.flaunchVariant ?? 'legacy11';
196
+ const flaunchVariant = variantOverride ?? recorded?.flaunchVariant ?? 'v1_2';
197
+ const poolSwap = address(env, 'POOL_SWAP') ?? recorded?.poolSwap ?? null;
198
+ if (flaunchVariant === 'v1_3' && !poolSwap) {
199
+ throw new Error('a v1_3 stack needs POOL_SWAP — the approved router a paired-token buy goes through');
200
+ }
201
+ const fleth = address(env, 'FLETH') ?? recorded?.fleth ?? null;
120
202
 
121
203
  const signerPrivateKey = required(env, 'SIGNER_PRIVATE_KEY');
122
204
  if (!/^0x[0-9a-fA-F]{64}$/.test(signerPrivateKey)) {
@@ -150,17 +232,50 @@ export function resolveGateEnv(env: Env): ResolvedGateConfig {
150
232
  gateOrigin: required(env, 'GATE_ORIGIN'),
151
233
  allowedOrigins,
152
234
  port: integer(env, 'PORT', 8790),
153
- deployment: { positionManager, spendGatedCalculator, flaunchVariant },
154
- walletCapWei: BigInt(env['WALLET_CAP_WEI'] ?? 25_000_000_000_000_000n.toString()),
235
+ deployment: {
236
+ positionManager,
237
+ spendGatedCalculator,
238
+ flaunchVariant,
239
+ ...(poolSwap ? { poolSwap } : {}),
240
+ ...(fleth ? { fleth } : {}),
241
+ },
242
+ walletCap: BigInt(env['WALLET_CAP_WEI'] ?? 25_000_000_000_000_000n.toString()),
155
243
  roundDurationMs: integer(env, 'ROUND_MS', 90_000),
156
244
  minLobbyLeadMs: integer(env, 'MIN_LOBBY_LEAD_MS', 60_000),
157
245
  gateEndsAtGraceS: integer(env, 'GATE_ENDS_AT_GRACE_S', 10),
158
246
  pointsPerDollar: integer(env, 'POINTS_PER_DOLLAR', 100),
159
- usdPerEth: integer(env, 'USD_PER_ETH', 3_000),
247
+ usdPerEth: positiveNumber(env, 'USD_PER_ETH', 3_000),
248
+ spendTokens: spendTokens(env),
160
249
  settler: address(env, 'SETTLER'),
161
250
  };
162
251
  }
163
252
 
253
+ const erc20 = parseAbi(['function symbol() view returns (string)', 'function decimals() view returns (uint8)']);
254
+
255
+ /** Whether a paired token is ETH by another name: native, or the chain's flETH wrapper. */
256
+ function isEthEquivalent(token: SpendToken, fleth: `0x${string}` | undefined): boolean {
257
+ return token.isNative || (fleth !== undefined && token.address.toLowerCase() === fleth.toLowerCase());
258
+ }
259
+
260
+ /**
261
+ * The price list a running gate answers adoption with.
262
+ *
263
+ * An override (an oracle, a test) is asked first; the env map second; ETH and its wrapper are the
264
+ * operator's `USD_PER_ETH`; anything else is unpriced and the launch is refused.
265
+ */
266
+ function priceList(config: ResolvedGateConfig, override: PriceFor | undefined): PriceFor {
267
+ return async (token) => {
268
+ if (override) {
269
+ const quoted = await override(token);
270
+ if (quoted !== null) return quoted;
271
+ }
272
+ const listed = config.spendTokens.get(token.address.toLowerCase());
273
+ if (listed) return listed.usdPerToken;
274
+ if (isEthEquivalent(token, config.deployment.fleth)) return config.usdPerEth;
275
+ return null;
276
+ };
277
+ }
278
+
164
279
  export interface StartedGate {
165
280
  app: FastifyInstance;
166
281
  port: number;
@@ -175,6 +290,11 @@ export interface StartGateOverrides {
175
290
  client?: PublicClient;
176
291
  /** False builds and checks everything but never binds a port. */
177
292
  listen?: boolean;
293
+ /**
294
+ * A price source consulted before `SPEND_TOKENS` — an oracle, or a test's constant. Answering
295
+ * null defers to the env list; a token neither knows is refused at adoption.
296
+ */
297
+ priceProvider?: PriceFor;
178
298
  }
179
299
 
180
300
  export async function startGate<Config, State, Event, Action, PublicView, PlayerView>(
@@ -183,13 +303,14 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
183
303
  overrides: StartGateOverrides = {},
184
304
  ): Promise<StartedGate> {
185
305
  const config = resolveGateEnv(overrides.env ?? process.env);
306
+ const maxPointsPerPlayer = game.rewardBounds(gameConfig).maxPointsPerPlayer;
186
307
 
187
308
  // The same refusal the demo makes: an economy that cannot pay out is caught before anyone plays.
188
309
  const economy = resolveEconomy({
189
- maxPointsPerPlayer: game.rewardBounds(gameConfig).maxPointsPerPlayer,
190
- walletCapWei: config.walletCapWei,
310
+ maxPointsPerPlayer,
311
+ walletCap: config.walletCap,
191
312
  pointsPerDollar: config.pointsPerDollar,
192
- usdPerEth: config.usdPerEth,
313
+ usdPerSpendToken: config.usdPerEth,
193
314
  });
194
315
  if (!economy.ok) {
195
316
  throw new Error(
@@ -203,8 +324,46 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
203
324
  signerPrivateKey: config.signerPrivateKey,
204
325
  spendGatedCalculator: config.deployment.spendGatedCalculator,
205
326
  sessionSecret: config.sessionSecret,
327
+ positionManager: config.deployment.positionManager,
328
+ flaunchVariant: config.deployment.flaunchVariant,
329
+ ...(config.deployment.poolSwap ? { poolSwap: config.deployment.poolSwap } : {}),
206
330
  });
207
331
 
332
+ // Each listed spend token: read what the chain says it is, and refuse an economy that does not
333
+ // fit its cap now, at boot, rather than at the first launch somebody has already paid for.
334
+ const announcedSpendTokens: AnnouncedSpendToken[] = [];
335
+ for (const pricing of config.spendTokens.values()) {
336
+ let symbol: string;
337
+ let decimals: number;
338
+ try {
339
+ [symbol, decimals] = await Promise.all([
340
+ client.readContract({ address: pricing.address, abi: erc20, functionName: 'symbol' }),
341
+ client.readContract({ address: pricing.address, abi: erc20, functionName: 'decimals' }).then(Number),
342
+ ]);
343
+ } catch {
344
+ throw new Error(`SPEND_TOKENS: ${pricing.address} does not answer symbol()/decimals() on chain ${config.chainId}`);
345
+ }
346
+ const fit = resolveEconomy({
347
+ maxPointsPerPlayer,
348
+ walletCap: pricing.walletCap,
349
+ pointsPerDollar: config.pointsPerDollar,
350
+ usdPerSpendToken: pricing.usdPerToken,
351
+ decimals,
352
+ });
353
+ if (!fit.ok) {
354
+ throw new Error(
355
+ `That economy does not work for ${symbol}: ${fit.problem}. Try ${fit.suggestedPointsPerDollar} points per dollar.`,
356
+ );
357
+ }
358
+ announcedSpendTokens.push({
359
+ address: pricing.address,
360
+ symbol,
361
+ decimals,
362
+ usdPerToken: pricing.usdPerToken,
363
+ walletCap: pricing.walletCap.toString(),
364
+ });
365
+ }
366
+
208
367
  const pool = new pg.Pool({ connectionString: config.databaseUrl });
209
368
  await migrate(pool);
210
369
 
@@ -217,14 +376,16 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
217
376
  contracts: {
218
377
  positionManager: config.deployment.positionManager,
219
378
  spendGatedCalculator: config.deployment.spendGatedCalculator,
379
+ ...(config.deployment.poolSwap ? { poolSwap: config.deployment.poolSwap } : {}),
220
380
  },
221
381
  signer: signer.address,
222
382
  settler,
223
- walletCapWei: config.walletCapWei.toString(),
383
+ walletCap: config.walletCap.toString(),
224
384
  roundDurationMs: config.roundDurationMs,
225
385
  minLobbyLeadMs: config.minLobbyLeadMs,
226
386
  gateEndsAtGraceS: config.gateEndsAtGraceS,
227
387
  flaunchVariant: config.deployment.flaunchVariant,
388
+ ...(announcedSpendTokens.length > 0 ? { spendTokens: announcedSpendTokens } : {}),
228
389
  requiresEoa: false,
229
390
  accepting: true,
230
391
  publicLaunchesOpen: true,
@@ -237,6 +398,7 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
237
398
  config: gameConfig,
238
399
  pointsPerDollar: config.pointsPerDollar,
239
400
  usdPerEth: config.usdPerEth,
401
+ priceFor: priceList(config, overrides.priceProvider),
240
402
  sessions: new Sessions(config.sessionSecret, config.signInDomain, config.gateOrigin),
241
403
  claims: new Claims(pool, ledger, signer),
242
404
  discovery: new Discovery(client, {
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,