@flayerlabs/gamemode-gate 0.6.0 → 0.7.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 (63) hide show
  1. package/DEPLOY.md +7 -1
  2. package/README.md +6 -4
  3. package/dist/chain/chains.d.ts.map +1 -1
  4. package/dist/chain/chains.js +3 -1
  5. package/dist/chain/chains.js.map +1 -1
  6. package/dist/chain/signer.d.ts +19 -13
  7. package/dist/chain/signer.d.ts.map +1 -1
  8. package/dist/chain/signer.js +11 -10
  9. package/dist/chain/signer.js.map +1 -1
  10. package/dist/claims.d.ts +14 -30
  11. package/dist/claims.d.ts.map +1 -1
  12. package/dist/claims.js +55 -76
  13. package/dist/claims.js.map +1 -1
  14. package/dist/demo.d.ts +1 -1
  15. package/dist/demo.d.ts.map +1 -1
  16. package/dist/demo.js +3 -3
  17. package/dist/demo.js.map +1 -1
  18. package/dist/index.d.ts +1 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +0 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/ledger.d.ts +39 -45
  23. package/dist/ledger.d.ts.map +1 -1
  24. package/dist/ledger.js +71 -172
  25. package/dist/ledger.js.map +1 -1
  26. package/dist/registry.d.ts +1 -1
  27. package/dist/registry.d.ts.map +1 -1
  28. package/dist/registry.js +4 -4
  29. package/dist/registry.js.map +1 -1
  30. package/dist/room.d.ts +9 -0
  31. package/dist/room.d.ts.map +1 -1
  32. package/dist/room.js +14 -0
  33. package/dist/room.js.map +1 -1
  34. package/dist/server.d.ts +7 -0
  35. package/dist/server.d.ts.map +1 -1
  36. package/dist/server.js +42 -35
  37. package/dist/server.js.map +1 -1
  38. package/dist/settlement.d.ts +0 -3
  39. package/dist/settlement.d.ts.map +1 -1
  40. package/dist/settlement.js +12 -44
  41. package/dist/settlement.js.map +1 -1
  42. package/dist/standings.d.ts.map +1 -1
  43. package/dist/standings.js +7 -1
  44. package/dist/standings.js.map +1 -1
  45. package/dist/store.d.ts +1 -1
  46. package/dist/store.d.ts.map +1 -1
  47. package/dist/store.js +38 -20
  48. package/dist/store.js.map +1 -1
  49. package/dist/version.js +2 -2
  50. package/package.json +2 -2
  51. package/src/chain/chains.ts +3 -1
  52. package/src/chain/signer.ts +24 -30
  53. package/src/claims.ts +54 -78
  54. package/src/demo.ts +5 -5
  55. package/src/index.ts +1 -2
  56. package/src/ledger.ts +100 -248
  57. package/src/registry.ts +6 -3
  58. package/src/room.ts +15 -0
  59. package/src/server.ts +41 -35
  60. package/src/settlement.ts +12 -43
  61. package/src/standings.ts +9 -1
  62. package/src/store.ts +38 -20
  63. package/src/version.ts +2 -2
package/src/server.ts CHANGED
@@ -5,6 +5,7 @@ import { isAddress } from 'viem';
5
5
  import type { Pool } from 'pg';
6
6
  import type { Command, GameModule, PlayerId } from '@flayerlabs/gamemode-spec';
7
7
  import { isRefusal } from '@flayerlabs/gamemode-spec';
8
+ import { MAX_GAME_SERVER_ORIGINS } from '@flayerlabs/gamemode-spec/embed';
8
9
  import type {
9
10
  EconomyBalance,
10
11
  PresenceState,
@@ -19,7 +20,7 @@ import { ETH_SPEND_TOKEN, isReactionId, LIVE_PROTOCOL_VERSION } from '@flayerlab
19
20
  import type { FlaunchVariant } from './chain/chains.js';
20
21
  import { Room, type ScoreAuthority } from './room.js';
21
22
  import { Standings } from './standings.js';
22
- import { CLAIM_AUTHORIZATION_TTL_MS, Claims, isClaimAmount, isClaimRequestId } from './claims.js';
23
+ import { Claims, isClaimAmount, isClaimRequestId } from './claims.js';
23
24
  import { NotSignedIn, Sessions } from './sessions.js';
24
25
  import { parsePlayCursor, readPlaySessions, recordPlaySession } from './play-sessions.js';
25
26
  import type { Discovery, Launch, PoolKey } from './chain/discover.js';
@@ -269,10 +270,8 @@ const ethOnly = (usdPerEth: number): PriceFor => (token) => (token.isNative ? us
269
270
  const wireEconomy = (balance: EconomyBalance): WireEconomyBalance => ({
270
271
  unitsPerPoint: balance.unitsPerPoint.toString(),
271
272
  earned: balance.earned.toString(),
272
- held: balance.held.toString(),
273
273
  spent: balance.spent.toString(),
274
274
  available: balance.available.toString(),
275
- holdExpiresAt: balance.holdExpiresAt,
276
275
  });
277
276
 
278
277
  /** How often each open room is nudged so a deadline nobody is watching still fires. */
@@ -295,7 +294,14 @@ const MAX_AUTHORITATIVE_BUFFERED_BYTES = 1_024 * 1_024;
295
294
  const TERMINAL_DRAIN_DEADLINE_MS = 1_000;
296
295
  /** Reactions are also bounded across a whole room, not only per sending socket. */
297
296
  const MAX_ROOM_REACTIONS_PER_SECOND = 20;
298
- export const DEFAULT_SETTLEMENT_TAIL_MS = CLAIM_AUTHORIZATION_TTL_MS;
297
+ /**
298
+ * How long after `closesAt` the books stay open for chain reconciliation.
299
+ *
300
+ * Independent of any authorisation lifetime: a signed ceiling lasts until the books close, and a
301
+ * buy included in the last seconds of play still needs two confirmations and a reconcile pass to
302
+ * reach the standings.
303
+ */
304
+ export const DEFAULT_SETTLEMENT_TAIL_MS = 5 * 60_000;
299
305
 
300
306
  type AdoptionResult<Config, State, Event, Action, PublicView, PlayerView> =
301
307
  | { ok: true; room: Room<Config, State, Event, Action, PublicView, PlayerView>; created: boolean }
@@ -367,7 +373,7 @@ export function createGameServerGate(options: GameServerGateOptions): FastifyIns
367
373
  typeof maximum === 'function'
368
374
  ? async (launch) => ({ maxPointsPerPlayer: await maximum(launch) })
369
375
  : { maxPointsPerPlayer: maximum };
370
- const ticketAudiences = new Set([options.sessions.origin, ...gameServerOrigins(options.gameServerOrigins)]);
376
+ const ticketAudiences = new Set([options.sessions.origin, ...gameServerOrigins(options.gameServerOrigins, MAX_GAME_SERVER_ORIGINS)]);
371
377
  const { gameId, maxPointsPerPlayer: _maximum, awardToken, gameServerOrigins: _origins, ...base } = options;
372
378
  return createGateRuntime(
373
379
  { ...base, game: gameServerModule(gameId), config },
@@ -390,8 +396,8 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
390
396
  throw new RangeError('reactionIds must contain only valid reaction identifiers');
391
397
  }
392
398
  const settlementTailMs = options.settlementTailMs ?? DEFAULT_SETTLEMENT_TAIL_MS;
393
- if (!Number.isSafeInteger(settlementTailMs) || settlementTailMs < CLAIM_AUTHORIZATION_TTL_MS) {
394
- throw new RangeError(`settlementTailMs must be at least ${CLAIM_AUTHORIZATION_TTL_MS}`);
399
+ if (!Number.isSafeInteger(settlementTailMs) || settlementTailMs <= 0) {
400
+ throw new RangeError('settlementTailMs must be a positive number of milliseconds');
395
401
  }
396
402
  // A body limit costs nothing and is the cheapest protection against an endpoint that parses.
397
403
  const app = Fastify({ logger: process.env.GATE_LOG === '1', bodyLimit: 64 * 1024 });
@@ -626,8 +632,8 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
626
632
  *
627
633
  * Served by every gate rather than behind an option, because the numbers already exist in the
628
634
  * gate's own tables and this is the only place they exist at all — the platform never sees a
629
- * play. Spend is `chain_spent_wei`, the chain-reconciled figure, not claims: an expired hold is
630
- * not a purchase. `spendWeiTotal` is a decimal string because wei does not survive JSON as a
635
+ * play. Spend is `chain_spent_wei`, the chain-reconciled figure, not authorisations: a signed
636
+ * ceiling is not a purchase. `spendWeiTotal` is a decimal string because wei does not survive JSON as a
631
637
  * number, and `lastRoundAt` is milliseconds, matching every timestamp a round carries.
632
638
  *
633
639
  * The permissive CORS header mirrors `/config`: the dashboard is a different origin from the
@@ -880,7 +886,7 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
880
886
  return reply.code(result.refuse === 'award.invalid' ? 400 : 404).send({ refuse: result.refuse });
881
887
  }
882
888
 
883
- // `held` moves on its own when an authorisation expires, so this answer is only true at the
889
+ // `spent` rises whenever settlement sees a purchase land, so this answer is only true at the
884
890
  // moment it is read. Points are monotone; the wei beside them are not.
885
891
  const balance = await claims.economyBalance(result.standing.roundId, result.standing.player);
886
892
  return reply
@@ -962,18 +968,17 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
962
968
  const who = player(request);
963
969
  const room = await roomFor(id);
964
970
  if (!room) return reply.code(404).send({ message: 'that game is not running' });
965
- const { maxSpendWei, requestId } = request.body as { maxSpendWei?: string; requestId?: unknown };
971
+ const { spend, requestId } = request.body as { spend?: string; requestId?: unknown };
966
972
  let upTo: bigint;
967
973
  try {
968
- upTo = BigInt(maxSpendWei ?? '0');
974
+ upTo = BigInt(spend ?? '0');
969
975
  } catch {
970
976
  return reply.code(400).send({ message: 'that amount was not understood' });
971
977
  }
972
978
  if (!isClaimAmount(upTo)) return reply.code(400).send({ message: 'that amount was not understood' });
973
979
  // Required, not defaulted. Minting an id here for a caller that omitted one makes every retry a
974
- // fresh claim, which is precisely the double-hold idempotency exists to prevent — and it fails
975
- // silently, for the one caller that needed it most. Nothing is live, so there is no old client
976
- // to keep working.
980
+ // fresh signature, and a retry must receive the same bytes it was first given — otherwise the
981
+ // trusted page cannot tell a replayed request from a new one.
977
982
  if (!isClaimRequestId(requestId)) {
978
983
  return reply.code(400).send({ message: 'that request was not understood' });
979
984
  }
@@ -991,10 +996,12 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
991
996
  buyer: payload.buyer,
992
997
  poolId: payload.poolId,
993
998
  deadline: payload.deadline.toString(),
994
- maxSpendWei: payload.maxSpendWei.toString(),
995
- nonce: payload.nonce.toString(),
999
+ spendCeilingWei: payload.spendCeilingWei.toString(),
996
1000
  signature: payload.signature,
997
1001
  signer: payload.signer,
1002
+ // Not signed: the exact-in amount for THIS buy, clamped to what was available. The page
1003
+ // spends exactly this; the calculator enforces the ceiling above.
1004
+ spend: authorised.spend.toString(),
998
1005
  // The embed passes this opaque value to the Flaunch buy call. Game code never reconstructs
999
1006
  // the ABI and therefore cannot accidentally sign or submit a different transaction shape.
1000
1007
  hookData: encodeHookData(payload),
@@ -1090,10 +1097,8 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
1090
1097
  : {
1091
1098
  unitsPerPoint: room.terms.unitsPerPoint,
1092
1099
  earned: 0n,
1093
- held: 0n,
1094
1100
  spent: 0n,
1095
1101
  available: 0n,
1096
- holdExpiresAt: null,
1097
1102
  };
1098
1103
  const economy = wireEconomy(balance);
1099
1104
  return {
@@ -1284,23 +1289,14 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
1284
1289
  try {
1285
1290
  const now = Date.now();
1286
1291
  const sweeping = Boolean(settlement && now - reconciledAt >= RECONCILE_MS);
1287
- // Authorisations nobody spent come back to the player who earned them.
1288
- // A chain-backed gate expires holds atomically with confirmed spend, never ahead of a
1289
- // lagging/failed RPC read. Demo and legacy adapters retain their existing expiry path.
1290
- const released = settlement?.usesConfirmedSnapshots ? [] : await claims.releaseExpired().catch((error) => {
1291
- app.log.error(error);
1292
- return [];
1293
- });
1294
- for (const balance of released) announcePlayer(balance.roundId, balance.player);
1295
-
1296
1292
  for (const [id, room] of rooms) {
1297
- // Gameplay is over, but a buzzer-time authorisation and the chart remain honourable through
1298
- // the independent books tail. A process can wake for the first time after this tail (for
1293
+ // Gameplay is over, but a buzzer-time buy and the chart remain honourable through the
1294
+ // independent books tail. A process can wake for the first time after this tail (for
1299
1295
  // example after a stall or restart), so persist every due game wake before deleting it.
1300
1296
  if (now > room.launch.booksCloseAt) {
1301
- // Confirmations may trail a last-second claim's deadline. Keep background accounting
1302
- // alive at the normal cadence until holds settle; socket deadlines remain independent.
1303
- if (settlement?.usesConfirmedSnapshots && !sweeping) continue;
1297
+ // The last reconcile must run at the normal cadence, not on every tick, and a room is
1298
+ // torn down only after one clean pass so the final standings carry every landed buy.
1299
+ if (settlement && !sweeping) continue;
1304
1300
  try {
1305
1301
  if (room.nextWakeAt() !== null) await room.tick(Math.min(now, room.window.closesAt));
1306
1302
  } catch (error) {
@@ -1312,8 +1308,18 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
1312
1308
  if (settlement) {
1313
1309
  const final = await settlement.reconcile(id).catch(() => ({ read: 0, failed: 1, changed: [] }));
1314
1310
  if (final.failed > 0) app.log.error(`${id}: ${final.failed} final spend reads failed`);
1315
- if (settlement.usesConfirmedSnapshots &&
1316
- (final.failed > 0 || await settlement.hasPendingClaims(id).catch(() => true))) continue;
1311
+ if (final.failed > 0) continue;
1312
+ }
1313
+ // The round is now proven final. Persist that before anything is torn down, so a page
1314
+ // reading the standings can tell "ended, still settling" from "final" — and so the
1315
+ // 4410 close below is never sent for a round the database still calls unsettled.
1316
+ try {
1317
+ await room.markSettled(now);
1318
+ } catch (error) {
1319
+ // Retain the room and retry: the standings would otherwise never be marked final,
1320
+ // and the sockets would be told the round ended before the database agreed.
1321
+ app.log.error(error);
1322
+ continue;
1317
1323
  }
1318
1324
  for (const watcher of watchers.get(id) ?? []) {
1319
1325
  if (
package/src/settlement.ts CHANGED
@@ -7,19 +7,17 @@ import type { ChainReader } from './chain/discover.js';
7
7
  /**
8
8
  * Keeping the ledger honest about what was actually spent.
9
9
  *
10
- * The obvious design is to watch for a swap and match it back to the authorisation that permitted
11
- * it. That turns out to be both harder and less useful than it sounds: the event carries the buyer
12
- * and the amount but not the nonce, and a wallet can submit through a relay, so correlating them is
13
- * guesswork.
10
+ * The calculator keeps one cumulative figure per pool per wallet, `walletSpentWei`, and it is the
11
+ * only figure that is true: it is what the contract itself enforces the signed ceiling against.
12
+ * Settlement reads it at a confirmed block and records it, never backwards. There is nothing to
13
+ * match back to an authorisation — a signed ceiling is not consumed, so it has no fate to prove.
14
14
  *
15
- * Read cumulative spend and exact authorisation consumption from the same block. Equal amounts
16
- * are not proof that a hold was consumed. A browser receipt is not a reason to release a hold.
15
+ * A buy that lands after `closesAt` still needs to be folded into `spent` for the standings, which
16
+ * is why reconciliation runs through `booksCloseAt` and the room waits for one clean final pass.
17
17
  */
18
18
 
19
19
  const spendGatedAbi = parseAbi([
20
20
  'function walletSpentWei(bytes32 _poolId, address _wallet) view returns (uint256)',
21
- 'function hashSpendAuthorization(address,bytes32,uint256,uint256,uint256) view returns (bytes32)',
22
- 'function isAuthorizationUsed(bytes32) view returns (bool)',
23
21
  ]);
24
22
 
25
23
  export class Settlement {
@@ -30,16 +28,6 @@ export class Settlement {
30
28
  private readonly calculator: `0x${string}`,
31
29
  ) {}
32
30
 
33
- /** Expiry must use the same confirmed chain snapshot as spend when snapshot reads are available. */
34
- get usesConfirmedSnapshots(): boolean { return typeof this.client.getBlock === 'function'; }
35
-
36
- async hasPendingClaims(roundId: string): Promise<boolean> {
37
- const result = await this.pool.query<{ pending: boolean }>(
38
- `select exists(select 1 from claims where round_id = $1 and status = 'held') as pending`, [roundId],
39
- );
40
- return result.rows[0]?.pending ?? false;
41
- }
42
-
43
31
  /**
44
32
  * Bring one round's view of spending up to date with the chain.
45
33
  *
@@ -61,14 +49,12 @@ export class Settlement {
61
49
 
62
50
  for (const player of players) {
63
51
  try {
64
- // Two blocks behind the observed head, then verify that block is still canonical before
65
- // committing. This follows chain confirmations, not wallet UI completion. Read-only
66
- // legacy adapters retain conservative expiry-only holds until upgraded to a full client.
52
+ // Two blocks behind the observed head when the client can name blocks, so a reorg of the
53
+ // tip cannot record a spend that then vanishes. A read-only legacy adapter reads the tip;
54
+ // `recordChainSpend` never goes backwards, so the worst case is a figure that arrives early.
67
55
  const head = this.client.getBlock ? await this.client.getBlock({ blockTag: 'latest' }) : null;
68
- const block = head?.number != null && head.number >= 2n
69
- ? await this.client.getBlock!({ blockNumber: head.number - 2n }) : null;
70
- if (this.client.getBlock && (block?.number !== head!.number! - 2n || !block?.hash || typeof block.timestamp !== 'bigint')) throw new Error('No confirmed block');
71
- const atBlock = block ? { blockNumber: block.number! } : {};
56
+ if (this.client.getBlock && (head?.number == null || head.number < 2n)) throw new Error('No confirmed block');
57
+ const atBlock = head?.number != null ? { blockNumber: head.number - 2n } : {};
72
58
  const spent = (await this.client.readContract({
73
59
  address: this.calculator,
74
60
  abi: spendGatedAbi,
@@ -77,24 +63,7 @@ export class Settlement {
77
63
  ...atBlock,
78
64
  })) as bigint;
79
65
  if (typeof spent !== 'bigint' || spent < 0n) throw new Error('Invalid chain spend');
80
- const consumed: bigint[] = [];
81
- if (block) {
82
- const outstanding = await this.ledger.outstandingClaims(roundId, player);
83
- for (const claim of outstanding) {
84
- if (claim.deadline < block.timestamp) continue;
85
- const digest = await this.client.readContract({ address: this.calculator, abi: spendGatedAbi,
86
- functionName: 'hashSpendAuthorization',
87
- args: [player as `0x${string}`, poolId as `0x${string}`, claim.deadline, claim.wei, claim.nonce], ...atBlock });
88
- if (typeof digest !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(digest)) throw new Error('Invalid digest');
89
- const used = await this.client.readContract({ address: this.calculator, abi: spendGatedAbi,
90
- functionName: 'isAuthorizationUsed', args: [digest], ...atBlock });
91
- if (typeof used !== 'boolean') throw new Error('Invalid consumption proof');
92
- if (used) consumed.push(claim.nonce);
93
- }
94
- const canonical = await this.client.getBlock!({ blockNumber: block.number! });
95
- if (canonical.hash !== block.hash) throw new Error('Settlement block changed');
96
- }
97
- if (await this.ledger.reconcileSpend(roundId, player, spent, consumed, block?.timestamp)) changed.push(player);
66
+ if (await this.ledger.recordChainSpend(roundId, player, spent)) changed.push(player);
98
67
  read += 1;
99
68
  } catch {
100
69
  // One unreachable read is not a reason to stop the round, but it is counted. Swallowed
package/src/standings.ts CHANGED
@@ -43,13 +43,15 @@ export class Standings {
43
43
  coin_symbol: string;
44
44
  opens_at: string;
45
45
  closes_at: string;
46
+ books_close_at: string;
47
+ settled_at: string | null;
46
48
  spend_token: string;
47
49
  spend_symbol: string;
48
50
  spend_decimals: number;
49
51
  usd_per_spend_token: number | null;
50
52
  usd_per_eth: number;
51
53
  }>(
52
- `select id, pool_id, coin_address, coin_symbol, opens_at, closes_at,
54
+ `select id, pool_id, coin_address, coin_symbol, opens_at, closes_at, books_close_at, settled_at,
53
55
  spend_token, spend_symbol, spend_decimals, usd_per_spend_token, usd_per_eth
54
56
  from rounds
55
57
  where lower(coin_address) = $1 and game_id = $2
@@ -78,6 +80,9 @@ export class Standings {
78
80
  ]);
79
81
 
80
82
  const window = { opensAt: Number(round.opens_at), closesAt: Number(round.closes_at) };
83
+ // `settled_at` is stamped by the gate's ticker after its last clean reconcile, so it is the one
84
+ // fact that separates "ended, still settling" from "final". The phase is clock-based and cannot.
85
+ const settledAt = round.settled_at === null ? null : Number(round.settled_at);
81
86
  return {
82
87
  roundId: round.id,
83
88
  poolId: round.pool_id,
@@ -85,6 +90,9 @@ export class Standings {
85
90
  symbol: round.coin_symbol,
86
91
  phase: roundPhaseAt(window, now),
87
92
  ...window,
93
+ booksCloseAt: Number(round.books_close_at),
94
+ settledAt,
95
+ final: settledAt !== null,
88
96
  spendToken: {
89
97
  address: round.spend_token,
90
98
  symbol: round.spend_symbol,
package/src/store.ts CHANGED
@@ -95,6 +95,12 @@ alter table rounds
95
95
  alter table rounds
96
96
  add column if not exists position_manager text check (position_manager <> '');
97
97
 
98
+ -- Round finalization (0.7.0). The moment the gate finished its last clean settlement pass past
99
+ -- books_close_at, in ms since the epoch: from here chain_spent_wei cannot move. Null while a round is still
100
+ -- settling, and on rounds a gate below 0.7.0 closed — nothing can reconstruct it for those.
101
+ alter table rounds
102
+ add column if not exists settled_at bigint;
103
+
98
104
  create table if not exists balances (
99
105
  round_id text not null references rounds(id) on delete cascade,
100
106
  player text not null,
@@ -105,27 +111,32 @@ create table if not exists balances (
105
111
  primary key (round_id, player)
106
112
  );
107
113
 
108
- -- One row per authorisation ever issued. Nothing is deleted: a signature that exists in the world
109
- -- cannot be un-issued, so the ledger records its fate instead of forgetting it.
110
- create table if not exists claims (
111
- nonce bigint generated always as identity primary key,
112
- round_id text not null references rounds(id) on delete cascade,
113
- player text not null,
114
- request_id varchar(128) not null check (request_id <> ''),
115
- requested_wei numeric(78,0) not null check (requested_wei > 0),
116
- wei numeric(78,0) not null check (wei > 0 and wei <= requested_wei),
117
- -- Held or released, and nothing else. Whether a swap LANDED is the chain's to say, not ours.
118
- status text not null check (status in ('held', 'released')),
119
- deadline bigint not null,
120
- -- Persisted before a payload leaves the gate. Until signing completes, both values are null.
121
- signature text,
122
- signer text,
123
- created_at timestamptz not null default now(),
124
- constraint claims_by_request unique (round_id, player, request_id),
125
- constraint claims_signature_pair check ((signature is null) = (signer is null))
114
+ -- One row per signed authorisation, kept only so a retried or replica-raced request returns the
115
+ -- exact bytes already handed out. Nothing here is a reservation: the signature carries a
116
+ -- cumulative ceiling the calculator compares against its own walletSpentWei, so an unused one
117
+ -- costs nothing and there is no fate to record.
118
+ create table if not exists authorisations (
119
+ round_id text not null references rounds(id) on delete cascade,
120
+ player text not null,
121
+ request_id varchar(128) not null check (request_id <> ''),
122
+ -- What the caller asked for, so a retry with a different amount is refused, not silently served.
123
+ requested_wei numeric(78,0) not null check (requested_wei > 0),
124
+ -- The signed field: min(earned, wallet cap) at issue, in the round's spend-token base units.
125
+ spend_ceiling_wei numeric(78,0) not null check (spend_ceiling_wei > 0),
126
+ -- The exact-in amount the caller intended, clamped to what was available. Echoed, not signed.
127
+ spend_wei numeric(78,0) not null check (spend_wei > 0 and spend_wei <= spend_ceiling_wei),
128
+ deadline bigint not null,
129
+ signature text not null,
130
+ signer text not null,
131
+ created_at timestamptz not null default now(),
132
+ primary key (round_id, player, request_id)
126
133
  );
127
134
 
128
- create index if not exists claims_by_player on claims (round_id, player) where status = 'held';
135
+ -- 0.7.0 hard cutover. Holds no longer exist, and every row the old table could carry was signed
136
+ -- under the version 1 calculator domain that a 0.7.0 gate cannot serve. Dropping it is the honest
137
+ -- migration; a gate should not be upgraded until every live round has passed its books close.
138
+ drop index if exists claims_by_player;
139
+ drop table if exists claims;
129
140
 
130
141
  -- Trusted creator servers use one stable event id for every score increment. The event and the
131
142
  -- balance update commit together, so a retry can recover the first result without paying twice.
@@ -149,10 +160,17 @@ create table if not exists registrations (
149
160
  game_server_origins text[] not null default '{}'::text[],
150
161
  disabled_at timestamptz,
151
162
  updated_at timestamptz not null default now(),
152
- constraint registrations_game_server_origins_bounded check (cardinality(game_server_origins) <= 4),
163
+ constraint registrations_game_server_origins_bounded check (cardinality(game_server_origins) <= 32),
153
164
  primary key (chain_id, coin)
154
165
  );
155
166
 
167
+ -- 0.7.1 raised the reviewed origin bound from four to thirty-two: twenty game servers plus the
168
+ -- other-chain gate origins a platform folds into the same list. The create table above only runs
169
+ -- for a new database, so rewrite the check in place for a gate that already has the table.
170
+ alter table registrations drop constraint if exists registrations_game_server_origins_bounded;
171
+ alter table registrations
172
+ add constraint registrations_game_server_origins_bounded check (cardinality(game_server_origins) <= 32);
173
+
156
174
  -- Accepted play sessions: one row per wallet and round, keyed by a hash of both so a wallet is never
157
175
  -- stored in the clear. Independent of scoring and of websocket reconnects. play_tracking records
158
176
  -- when this gate began counting, so a dashboard can tell "no plays" from "not yet tracked".
package/src/version.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // Generated by scripts/write-version.mjs from package.json at build time. Do not edit.
2
2
  /** The published version of @flayerlabs/gamemode-gate, as a literal so it survives any bundler. */
3
- export const GATE_VERSION: string = "0.6.0";
3
+ export const GATE_VERSION: string = "0.7.1";
4
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.6.0";
5
+ export const GATE_MARKER: string = "@flayerlabs/gamemode-gate@0.7.1";