@flayerlabs/gamemode-gate 0.4.2 → 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/server.ts CHANGED
@@ -8,10 +8,12 @@ import type {
8
8
  EconomyBalance,
9
9
  PresenceState,
10
10
  RoundTerms,
11
+ SpendToken,
11
12
  WireEconomyBalance,
12
13
  WireRoundTerms,
13
14
  } from '@flayerlabs/gamemode-spec/live';
14
- import { isReactionId, LIVE_PROTOCOL_VERSION } from '@flayerlabs/gamemode-spec/live';
15
+ import { ETH_SPEND_TOKEN, isReactionId, LIVE_PROTOCOL_VERSION } from '@flayerlabs/gamemode-spec/live';
16
+ import type { FlaunchVariant } from './chain/chains.js';
15
17
  import { Room, type ScoreAuthority } from './room.js';
16
18
  import { CLAIM_AUTHORIZATION_TTL_MS, Claims, isClaimAmount, isClaimRequestId } from './claims.js';
17
19
  import { NotSignedIn, Sessions } from './sessions.js';
@@ -42,7 +44,15 @@ interface GateBaseOptions {
42
44
  claims: Claims;
43
45
  /** How many points a dollar buys. The only economic number anyone sets by hand. */
44
46
  pointsPerDollar: number;
47
+ /** The price of ETH — what an ETH-paired round is priced at. */
45
48
  usdPerEth: number;
49
+ /**
50
+ * The price of one whole spend token, for a round whose coin is paired with something other
51
+ * than ETH — or null to refuse the launch as unpriced. Consulted at adoption with the token the
52
+ * chain reported. Omitted, only native ETH is priced (at `usdPerEth`) and every other pairing is
53
+ * refused: a gate that guessed a price would be authorising real money on a made-up number.
54
+ */
55
+ priceFor?: PriceFor;
46
56
  /** Verifies from the chain that a coin really is a game launch. */
47
57
  discovery?: Discovery;
48
58
  /** Keeps the ledger's view of spending level with the chain's. */
@@ -85,6 +95,20 @@ interface GateBaseOptions {
85
95
  reactionIds?: readonly string[];
86
96
  }
87
97
 
98
+ /** A price for one whole spend token in dollars, or null when this gate has none for it. */
99
+ export type PriceFor = (token: SpendToken) => Promise<number | null> | number | null;
100
+
101
+ /** One entry of a gate's announced price list — what launch tooling checks a pairing against. */
102
+ export interface AnnouncedSpendToken {
103
+ address: `0x${string}`;
104
+ symbol: string;
105
+ decimals: number;
106
+ /** The price this gate will fix rounds on that token at. */
107
+ usdPerToken: number;
108
+ /** The per-wallet cap the launch should write, in this token's base units. A decimal string. */
109
+ walletCap: string;
110
+ }
111
+
88
112
  /** Resolve one immutable rules config after the launch has been verified. */
89
113
  export type VerifiedLaunch = Readonly<Omit<Launch, 'poolKey'>> & { readonly poolKey: Readonly<PoolKey> };
90
114
  export type ConfigForLaunch<Config> = (launch: VerifiedLaunch) => Promise<Config>;
@@ -118,17 +142,25 @@ export interface GateAnnouncement {
118
142
  contracts: {
119
143
  positionManager: `0x${string}`;
120
144
  spendGatedCalculator: `0x${string}`;
145
+ /** The approved swap router a paired-token buy goes through. Present on a `v1_3` stack. */
146
+ poolSwap?: `0x${string}`;
121
147
  };
122
148
  /** The address launches must name as their trusted signer for this gate to adopt them. */
123
149
  signer: `0x${string}`;
124
150
  /** May lift the gate early or rotate the signer on-chain; the launch writes it into the pool. */
125
151
  settler: `0x${string}`;
126
- /** A decimal string — wei does not survive JSON as a number. */
127
- walletCapWei: string;
152
+ /** The per-wallet cap for an ETH-paired launch, in wei. A decimal string — wei does not survive JSON as a number. */
153
+ walletCap: string;
128
154
  roundDurationMs: number;
129
155
  minLobbyLeadMs: number;
130
156
  gateEndsAtGraceS: number;
131
- flaunchVariant: 'legacy11' | 'current9';
157
+ flaunchVariant: FlaunchVariant;
158
+ /**
159
+ * The non-ETH paired tokens this gate will run rounds on, each with the cap a launch on it should
160
+ * write. Absent or empty means ETH pairings only. Launch tooling refuses a pairing that is not
161
+ * listed here, which is the fail-closed answer for a gate that has not been told the token's price.
162
+ */
163
+ spendTokens?: AnnouncedSpendToken[];
132
164
  requiresEoa: boolean;
133
165
  accepting: boolean;
134
166
  /**
@@ -170,22 +202,26 @@ const ADMIT_ALL: Admit = () => ({ ok: true });
170
202
  /** What the socket says, which is only the truth when nothing is proxying in front of the gate. */
171
203
  const ipOf = (request: FastifyRequest): string | null => request.ip || null;
172
204
 
173
- /** Wei has no JSON form, so terms cross as decimal strings, exactly as the economy balance does. */
205
+ /** Bigint has no JSON form, so terms cross as decimal strings, exactly as the economy balance does. */
174
206
  const wireTerms = (terms: RoundTerms): WireRoundTerms => ({
175
207
  maxPointsPerPlayer: terms.maxPointsPerPlayer,
176
- walletCapWei: terms.walletCapWei.toString(),
177
- weiPerPoint: terms.weiPerPoint.toString(),
208
+ walletCap: terms.walletCap.toString(),
209
+ unitsPerPoint: terms.unitsPerPoint.toString(),
178
210
  pointsPerDollar: terms.pointsPerDollar,
179
- usdPerEth: terms.usdPerEth,
211
+ usdPerSpendToken: terms.usdPerSpendToken,
212
+ spendToken: terms.spendToken,
180
213
  });
181
214
 
215
+ /** Only native ETH is priced without being told: everything else needs a `priceFor`. */
216
+ const ethOnly = (usdPerEth: number): PriceFor => (token) => (token.isNative ? usdPerEth : null);
217
+
182
218
  /** The same encoding for the per-player balance, wherever it leaves the process. */
183
219
  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(),
220
+ unitsPerPoint: balance.unitsPerPoint.toString(),
221
+ earned: balance.earned.toString(),
222
+ held: balance.held.toString(),
223
+ spent: balance.spent.toString(),
224
+ available: balance.available.toString(),
189
225
  holdExpiresAt: balance.holdExpiresAt,
190
226
  });
191
227
 
@@ -389,11 +425,27 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
389
425
  app.log.error(error);
390
426
  return { ok: false, status: 503, message: 'this game is still being prepared' };
391
427
  }
428
+ // The token the chain measures this pool's spend in decides the round's units. A launch this
429
+ // gate has no price for is refused rather than priced as ETH: on a six-decimal token that guess
430
+ // authorises a trillion times the intended spend.
431
+ const spendToken = launch.spendToken ?? ETH_SPEND_TOKEN;
432
+ let usdPerSpendToken: number | null;
433
+ try {
434
+ usdPerSpendToken = await (options.priceFor ?? ethOnly(options.usdPerEth))(spendToken);
435
+ } catch (error) {
436
+ app.log.error(error);
437
+ return { ok: false, status: 503, message: 'this game is still being prepared' };
438
+ }
439
+ if (usdPerSpendToken === null || !Number.isFinite(usdPerSpendToken) || usdPerSpendToken <= 0) {
440
+ app.log.error(`${launch.coinAddress}: no price for spend token ${spendToken.symbol} (${spendToken.address})`);
441
+ return { ok: false, status: 409, message: `this game is not set up for ${spendToken.symbol} yet` };
442
+ }
392
443
  const economy = resolveEconomy({
393
444
  maxPointsPerPlayer,
394
- walletCapWei: launch.walletCapWei,
445
+ walletCap: launch.walletCap,
395
446
  pointsPerDollar: options.pointsPerDollar,
396
- usdPerEth: options.usdPerEth,
447
+ usdPerSpendToken,
448
+ decimals: spendToken.decimals,
397
449
  });
398
450
  if (!economy.ok) {
399
451
  app.log.error(
@@ -409,10 +461,11 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
409
461
  config,
410
462
  seed: Number(BigInt(launch.poolId) % 2147483647n),
411
463
  window: { opensAt: launch.opensAt, closesAt: launch.closesAt },
412
- weiPerPoint: economy.economy.weiPerPoint,
464
+ unitsPerPoint: economy.economy.unitsPerPoint,
413
465
  pointsPerDollar: economy.economy.pointsPerDollar,
414
- usdPerEth: economy.economy.usdPerEth,
415
- walletCapWei: launch.walletCapWei,
466
+ spendToken,
467
+ usdPerSpendToken: economy.economy.usdPerSpendToken,
468
+ walletCap: launch.walletCap,
416
469
  maxPointsPerPlayer,
417
470
  scoreAuthority,
418
471
  poolId: launch.poolId,
@@ -499,22 +552,41 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
499
552
  */
500
553
  app.get('/stats', async (_request, reply) => {
501
554
  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
- );
555
+ // Spend is summed per spend token: base units of two different tokens do not add. The flat
556
+ // `spendWeiTotal` keeps its original meaning — wei, across ETH rounds — for dashboards built
557
+ // against it; `spendByToken` is the honest figure for a gate that runs more than one pairing.
558
+ const [{ rows }, byToken] = await Promise.all([
559
+ pool.query<{
560
+ rounds_total: string;
561
+ players_total: string;
562
+ spend_wei_total: string;
563
+ last_round_at: string | null;
564
+ }>(
565
+ `select (select count(*) from rounds) as rounds_total,
566
+ (select count(distinct player) from balances) as players_total,
567
+ (select coalesce(sum(b.chain_spent_wei), 0)::text
568
+ from balances b join rounds r on r.id = b.round_id
569
+ where r.spend_decimals = 18) as spend_wei_total,
570
+ (select max(opens_at) from rounds) as last_round_at`,
571
+ ),
572
+ pool.query<{ spend_token: string; spend_symbol: string; spend_decimals: number; total: string }>(
573
+ `select r.spend_token, r.spend_symbol, r.spend_decimals, coalesce(sum(b.chain_spent_wei), 0)::text as total
574
+ from rounds r left join balances b on b.round_id = r.id
575
+ group by r.spend_token, r.spend_symbol, r.spend_decimals
576
+ order by r.spend_token`,
577
+ ),
578
+ ]);
513
579
  const totals = rows[0]!;
514
580
  return {
515
581
  roundsTotal: Number(totals.rounds_total),
516
582
  playersTotal: Number(totals.players_total),
517
583
  spendWeiTotal: totals.spend_wei_total,
584
+ spendByToken: byToken.rows.map((row) => ({
585
+ address: row.spend_token,
586
+ symbol: row.spend_symbol,
587
+ decimals: Number(row.spend_decimals),
588
+ total: row.total,
589
+ })),
518
590
  lastRoundAt: totals.last_round_at === null ? null : Number(totals.last_round_at),
519
591
  };
520
592
  });
@@ -672,7 +744,7 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
672
744
  return reply.code(result.refuse === 'award.invalid' ? 400 : 404).send({ refuse: result.refuse });
673
745
  }
674
746
 
675
- // `heldWei` moves on its own when an authorisation expires, so this answer is only true at the
747
+ // `held` moves on its own when an authorisation expires, so this answer is only true at the
676
748
  // moment it is read. Points are monotone; the wei beside them are not.
677
749
  const balance = await claims.economyBalance(result.standing.roundId, result.standing.player);
678
750
  return reply
@@ -869,11 +941,11 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
869
941
  const balance = viewer
870
942
  ? await claims.economyBalance(id, viewer)
871
943
  : {
872
- weiPerPoint: room.terms.weiPerPoint,
873
- earnedWei: 0n,
874
- heldWei: 0n,
875
- spentWei: 0n,
876
- availableWei: 0n,
944
+ unitsPerPoint: room.terms.unitsPerPoint,
945
+ earned: 0n,
946
+ held: 0n,
947
+ spent: 0n,
948
+ available: 0n,
877
949
  holdExpiresAt: null,
878
950
  };
879
951
  const economy = wireEconomy(balance);
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,