@flayerlabs/gamemode-gate 0.5.3 → 0.5.5

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.
@@ -1,3 +1,4 @@
1
+ import { type PublicClient } from 'viem';
1
2
  import type { Pool } from 'pg';
2
3
  import type { PlayerId } from '@flayerlabs/gamemode-spec';
3
4
  import type { Ledger } from './ledger.js';
@@ -7,7 +8,10 @@ export declare class Settlement {
7
8
  private readonly ledger;
8
9
  private readonly client;
9
10
  private readonly calculator;
10
- constructor(pool: Pool, ledger: Ledger, client: ChainReader, calculator: `0x${string}`);
11
+ constructor(pool: Pool, ledger: Ledger, client: ChainReader & Partial<Pick<PublicClient, 'getBlock'>>, calculator: `0x${string}`);
12
+ /** Expiry must use the same confirmed chain snapshot as spend when snapshot reads are available. */
13
+ get usesConfirmedSnapshots(): boolean;
14
+ hasPendingClaims(roundId: string): Promise<boolean>;
11
15
  /**
12
16
  * Bring one round's view of spending up to date with the chain.
13
17
  *
@@ -1 +1 @@
1
- {"version":3,"file":"settlement.d.ts","sourceRoot":"","sources":["../src/settlement.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,IAAI,CAAC;AAC/B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAmBvD,qBAAa,UAAU;IAEnB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;gBAHV,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,KAAK,MAAM,EAAE;IAG5C;;;;;;OAMG;IACG,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,QAAQ,EAAE,CAAA;KAAE,CAAC;CAiCjG"}
1
+ {"version":3,"file":"settlement.d.ts","sourceRoot":"","sources":["../src/settlement.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,YAAY,EAAE,MAAM,MAAM,CAAC;AACnD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,IAAI,CAAC;AAC/B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAoBvD,qBAAa,UAAU;IAEnB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;gBAHV,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC,EAC7D,UAAU,EAAE,KAAK,MAAM,EAAE;IAG5C,oGAAoG;IACpG,IAAI,sBAAsB,IAAI,OAAO,CAAuD;IAEtF,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAOzD;;;;;;OAMG;IACG,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,QAAQ,EAAE,CAAA;KAAE,CAAC;CA2DjG"}
@@ -7,12 +7,13 @@ import { parseAbi } from 'viem';
7
7
  * and the amount but not the nonce, and a wallet can submit through a relay, so correlating them is
8
8
  * guesswork.
9
9
  *
10
- * The contract already keeps the number that matters. `walletSpentWei(poolId, wallet)` is what it
11
- * enforces against, so reading it is reading the truth rather than inferring it. No matching, no
12
- * event log to replay, and nothing to get subtly wrong.
10
+ * Read cumulative spend and exact authorisation consumption from the same block. Equal amounts
11
+ * are not proof that a hold was consumed. A browser receipt is not a reason to release a hold.
13
12
  */
14
13
  const spendGatedAbi = parseAbi([
15
14
  'function walletSpentWei(bytes32 _poolId, address _wallet) view returns (uint256)',
15
+ 'function hashSpendAuthorization(address,bytes32,uint256,uint256,uint256) view returns (bytes32)',
16
+ 'function isAuthorizationUsed(bytes32) view returns (bool)',
16
17
  ]);
17
18
  export class Settlement {
18
19
  pool;
@@ -25,6 +26,12 @@ export class Settlement {
25
26
  this.client = client;
26
27
  this.calculator = calculator;
27
28
  }
29
+ /** Expiry must use the same confirmed chain snapshot as spend when snapshot reads are available. */
30
+ get usesConfirmedSnapshots() { return typeof this.client.getBlock === 'function'; }
31
+ async hasPendingClaims(roundId) {
32
+ const result = await this.pool.query(`select exists(select 1 from claims where round_id = $1 and status = 'held') as pending`, [roundId]);
33
+ return result.rows[0]?.pending ?? false;
34
+ }
28
35
  /**
29
36
  * Bring one round's view of spending up to date with the chain.
30
37
  *
@@ -45,13 +52,47 @@ export class Settlement {
45
52
  const changed = [];
46
53
  for (const player of players) {
47
54
  try {
55
+ // Two blocks behind the observed head, then verify that block is still canonical before
56
+ // committing. This follows chain confirmations, not wallet UI completion. Read-only
57
+ // legacy adapters retain conservative expiry-only holds until upgraded to a full client.
58
+ const head = this.client.getBlock ? await this.client.getBlock({ blockTag: 'latest' }) : null;
59
+ const block = head?.number != null && head.number >= 2n
60
+ ? await this.client.getBlock({ blockNumber: head.number - 2n }) : null;
61
+ if (this.client.getBlock && (block?.number !== head.number - 2n || !block?.hash || typeof block.timestamp !== 'bigint'))
62
+ throw new Error('No confirmed block');
63
+ const atBlock = block ? { blockNumber: block.number } : {};
48
64
  const spent = (await this.client.readContract({
49
65
  address: this.calculator,
50
66
  abi: spendGatedAbi,
51
67
  functionName: 'walletSpentWei',
52
68
  args: [poolId, player],
69
+ ...atBlock,
53
70
  }));
54
- if (spent > 0n && await this.ledger.recordChainSpend(roundId, player, spent))
71
+ if (typeof spent !== 'bigint' || spent < 0n)
72
+ throw new Error('Invalid chain spend');
73
+ const consumed = [];
74
+ if (block) {
75
+ const outstanding = await this.ledger.outstandingClaims(roundId, player);
76
+ for (const claim of outstanding) {
77
+ if (claim.deadline < block.timestamp)
78
+ continue;
79
+ const digest = await this.client.readContract({ address: this.calculator, abi: spendGatedAbi,
80
+ functionName: 'hashSpendAuthorization',
81
+ args: [player, poolId, claim.deadline, claim.wei, claim.nonce], ...atBlock });
82
+ if (typeof digest !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(digest))
83
+ throw new Error('Invalid digest');
84
+ const used = await this.client.readContract({ address: this.calculator, abi: spendGatedAbi,
85
+ functionName: 'isAuthorizationUsed', args: [digest], ...atBlock });
86
+ if (typeof used !== 'boolean')
87
+ throw new Error('Invalid consumption proof');
88
+ if (used)
89
+ consumed.push(claim.nonce);
90
+ }
91
+ const canonical = await this.client.getBlock({ blockNumber: block.number });
92
+ if (canonical.hash !== block.hash)
93
+ throw new Error('Settlement block changed');
94
+ }
95
+ if (await this.ledger.reconcileSpend(roundId, player, spent, consumed, block?.timestamp))
55
96
  changed.push(player);
56
97
  read += 1;
57
98
  }
@@ -1 +1 @@
1
- {"version":3,"file":"settlement.js","sourceRoot":"","sources":["../src/settlement.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,MAAM,CAAC;AAMhC;;;;;;;;;;;GAWG;AAEH,MAAM,aAAa,GAAG,QAAQ,CAAC;IAC7B,kFAAkF;CACnF,CAAC,CAAC;AAEH,MAAM,OAAO,UAAU;IAEF;IACA;IACA;IACA;IAJnB,YACmB,IAAU,EACV,MAAc,EACd,MAAmB,EACnB,UAAyB;QAHzB,SAAI,GAAJ,IAAI,CAAM;QACV,WAAM,GAAN,MAAM,CAAQ;QACd,WAAM,GAAN,MAAM,CAAa;QACnB,eAAU,GAAV,UAAU,CAAe;IACzC,CAAC;IAEJ;;;;;;OAMG;IACH,KAAK,CAAC,SAAS,CAAC,OAAe;QAC7B,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CAAsB,0CAA0C,EAAE;YACnG,OAAO;SACR,CAAC,CAAC;QACH,MAAM,MAAM,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC;QACtC,IAAI,CAAC,MAAM;YAAE,OAAO,EAAE,IAAI,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC;QAExD,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;QACrD,IAAI,IAAI,GAAG,CAAC,CAAC;QACb,IAAI,MAAM,GAAG,CAAC,CAAC;QACf,MAAM,OAAO,GAAe,EAAE,CAAC;QAE/B,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;YAC7B,IAAI,CAAC;gBACH,MAAM,KAAK,GAAG,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC;oBAC5C,OAAO,EAAE,IAAI,CAAC,UAAU;oBACxB,GAAG,EAAE,aAAa;oBAClB,YAAY,EAAE,gBAAgB;oBAC9B,IAAI,EAAE,CAAC,MAAuB,EAAE,MAAuB,CAAC;iBACzD,CAAC,CAAW,CAAC;gBAEd,IAAI,KAAK,GAAG,EAAE,IAAI,MAAM,IAAI,CAAC,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC;oBAAE,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;gBACnG,IAAI,IAAI,CAAC,CAAC;YACZ,CAAC;YAAC,MAAM,CAAC;gBACP,uFAAuF;gBACvF,0FAA0F;gBAC1F,sFAAsF;gBACtF,gBAAgB;gBAChB,MAAM,IAAI,CAAC,CAAC;YACd,CAAC;QACH,CAAC;QACD,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;IACnC,CAAC;CACF"}
1
+ {"version":3,"file":"settlement.js","sourceRoot":"","sources":["../src/settlement.ts"],"names":[],"mappings":"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"}
package/dist/store.d.ts CHANGED
@@ -6,7 +6,7 @@ import type { Pool, PoolClient } from 'pg';
6
6
  * table is how a schema drifts: one adds a column, the other's migration never runs, and the
7
7
  * failure appears somewhere neither of them looks.
8
8
  */
9
- export declare const SCHEMA = "\ncreate table if not exists rounds (\n id text primary key,\n game_id text not null check (game_id <> ''),\n wei_per_point numeric(78,0) not null check (wei_per_point > 0),\n wallet_cap_wei numeric(78,0) not null check (wallet_cap_wei > 0),\n -- The two settings that produced wei_per_point, frozen beside it. They are gate-process config,\n -- so an operator who restarts with a new ETH price must not retroactively change what a round\n -- was priced at.\n points_per_dollar double precision not null check (points_per_dollar > 0),\n usd_per_eth double precision not null check (usd_per_eth > 0),\n -- The token spend is measured in: the coin's paired token, in whose base units every *_wei column\n -- of this round is denominated. Defaults describe ETH, which is what every round was before the\n -- columns existed, so a pre-existing row reads back exactly as it was priced. usd_per_eth holds\n -- the rate normalised to eighteen decimals (see economy.ts); usd_per_spend_token the token's own\n -- price, null on rows that predate it (for which usd_per_eth is that price).\n spend_token text not null default '0x0000000000000000000000000000000000000000' check (spend_token <> ''),\n spend_symbol text not null default 'ETH' check (spend_symbol <> ''),\n spend_decimals integer not null default 18 check (spend_decimals between 0 and 36),\n usd_per_spend_token double precision check (usd_per_spend_token > 0),\n max_points_per_player bigint not null check (max_points_per_player > 0),\n score_authority text not null check (score_authority in ('rules', 'external-server')),\n pool_id text not null,\n opens_at bigint not null,\n closes_at bigint not null,\n books_close_at bigint not null,\n coin_address text,\n coin_name text not null,\n coin_symbol text not null,\n coin_image_url text,\n -- The game's own state, opaque to the platform. Written after every command, so a restart\n -- resumes from here rather than replaying history.\n state jsonb not null,\n -- Commands applied so far. Doubles as the cursor a reconnecting client resumes from.\n seq integer not null default 0 check (seq >= 0),\n constraint rounds_window check (opens_at < closes_at),\n constraint rounds_books_tail check (books_close_at >= closes_at)\n);\n\n-- A developer may already have run the first published schema without creating any rounds. Add\n-- the new authority fields in place for that empty setup. Existing round data needs a deliberate\n-- reset because its original reward bound cannot be reconstructed safely.\nalter table rounds\n add column if not exists game_id text check (game_id <> '');\nalter table rounds\n add column if not exists max_points_per_player bigint check (max_points_per_player > 0);\nalter table rounds\n add column if not exists score_authority text check (score_authority in ('rules', 'external-server'));\nalter table rounds\n add column if not exists points_per_dollar double precision check (points_per_dollar > 0);\nalter table rounds\n add column if not exists usd_per_eth double precision check (usd_per_eth > 0);\ndo $$\nbegin\n if exists (\n select 1 from rounds\n where game_id is null or max_points_per_player is null or score_authority is null\n or points_per_dollar is null or usd_per_eth is null\n ) then\n raise exception 'pre-release round rows cannot be upgraded safely'\n using hint = 'Delete the pre-release round data, then run migrate(pool) again.';\n end if;\nend\n$$;\nalter table rounds alter column game_id set not null;\nalter table rounds alter column max_points_per_player set not null;\nalter table rounds alter column score_authority set not null;\nalter table rounds alter column points_per_dollar set not null;\nalter table rounds alter column usd_per_eth set not null;\n\n-- Paired-token rounds (0.5.0). Every earlier round was an ETH round, so the defaults ARE the\n-- backfill: Postgres fills a new not-null column with its default, and ETH/18 is exactly what those\n-- rows were priced in. No guard is needed \u2014 nothing here has to be guessed.\nalter table rounds\n add column if not exists spend_token text not null default '0x0000000000000000000000000000000000000000' check (spend_token <> '');\nalter table rounds\n add column if not exists spend_symbol text not null default 'ETH' check (spend_symbol <> '');\nalter table rounds\n add column if not exists spend_decimals integer not null default 18 check (spend_decimals between 0 and 36);\nalter table rounds\n add column if not exists usd_per_spend_token double precision check (usd_per_spend_token > 0);\n\ncreate table if not exists balances (\n round_id text not null references rounds(id) on delete cascade,\n player text not null,\n points bigint not null default 0 check (points >= 0),\n -- What the chain says this wallet has already spent on this pool. The contract keeps this and\n -- enforces it whatever we believe, so it is the only figure that is actually true.\n chain_spent_wei numeric(78,0) not null default 0 check (chain_spent_wei >= 0),\n primary key (round_id, player)\n);\n\n-- One row per authorisation ever issued. Nothing is deleted: a signature that exists in the world\n-- cannot be un-issued, so the ledger records its fate instead of forgetting it.\ncreate table if not exists claims (\n nonce bigint generated always as identity primary key,\n round_id text not null references rounds(id) on delete cascade,\n player text not null,\n request_id varchar(128) not null check (request_id <> ''),\n requested_wei numeric(78,0) not null check (requested_wei > 0),\n wei numeric(78,0) not null check (wei > 0 and wei <= requested_wei),\n -- Held or released, and nothing else. Whether a swap LANDED is the chain's to say, not ours.\n status text not null check (status in ('held', 'released')),\n deadline bigint not null,\n -- Persisted before a payload leaves the gate. Until signing completes, both values are null.\n signature text,\n signer text,\n created_at timestamptz not null default now(),\n constraint claims_by_request unique (round_id, player, request_id),\n constraint claims_signature_pair check ((signature is null) = (signer is null))\n);\n\ncreate index if not exists claims_by_player on claims (round_id, player) where status = 'held';\n\n-- Trusted creator servers use one stable event id for every score increment. The event and the\n-- balance update commit together, so a retry can recover the first result without paying twice.\ncreate table if not exists award_events (\n round_id text not null references rounds(id) on delete cascade,\n event_id varchar(128) not null check (event_id <> ''),\n player text not null,\n points bigint not null check (points > 0),\n total_points bigint not null check (total_points >= points),\n created_at timestamptz not null default now(),\n primary key (round_id, event_id)\n);\n\n-- Which game a coin plays. Addresses are chain-local, so the pin is one row per (chain, coin).\ncreate table if not exists registrations (\n chain_id bigint not null check (chain_id > 0),\n coin text not null check (coin <> ''),\n game_id text not null check (game_id <> ''),\n gate_origin text not null check (gate_origin <> ''),\n deploy_id text not null check (deploy_id <> ''),\n game_server_origins text[] not null default '{}'::text[],\n disabled_at timestamptz,\n updated_at timestamptz not null default now(),\n constraint registrations_game_server_origins_bounded check (cardinality(game_server_origins) <= 4),\n primary key (chain_id, coin)\n);\n\n-- What actually happened, in order. Not used to rebuild state \u2014 the snapshot does that \u2014 but it is\n-- the record of why a state looks the way it does, which is what an incident needs.\ncreate table if not exists commands (\n round_id text not null references rounds(id) on delete cascade,\n seq integer not null check (seq > 0),\n command jsonb not null,\n primary key (round_id, seq)\n);\n";
9
+ export declare const SCHEMA = "\ncreate table if not exists rounds (\n id text primary key,\n game_id text not null check (game_id <> ''),\n wei_per_point numeric(78,0) not null check (wei_per_point > 0),\n wallet_cap_wei numeric(78,0) not null check (wallet_cap_wei > 0),\n -- The two settings that produced wei_per_point, frozen beside it. They are gate-process config,\n -- so an operator who restarts with a new ETH price must not retroactively change what a round\n -- was priced at.\n points_per_dollar double precision not null check (points_per_dollar > 0),\n usd_per_eth double precision not null check (usd_per_eth > 0),\n -- The token spend is measured in: the coin's paired token, in whose base units every *_wei column\n -- of this round is denominated. Defaults describe ETH, which is what every round was before the\n -- columns existed, so a pre-existing row reads back exactly as it was priced. usd_per_eth holds\n -- the rate normalised to eighteen decimals (see economy.ts); usd_per_spend_token the token's own\n -- price, null on rows that predate it (for which usd_per_eth is that price).\n spend_token text not null default '0x0000000000000000000000000000000000000000' check (spend_token <> ''),\n spend_symbol text not null default 'ETH' check (spend_symbol <> ''),\n spend_decimals integer not null default 18 check (spend_decimals between 0 and 36),\n usd_per_spend_token double precision check (usd_per_spend_token > 0),\n max_points_per_player bigint not null check (max_points_per_player > 0),\n score_authority text not null check (score_authority in ('rules', 'external-server')),\n pool_id text not null,\n opens_at bigint not null,\n closes_at bigint not null,\n books_close_at bigint not null,\n coin_address text,\n coin_name text not null,\n coin_symbol text not null,\n coin_image_url text,\n -- The game's own state, opaque to the platform. Written after every command, so a restart\n -- resumes from here rather than replaying history.\n state jsonb not null,\n -- Commands applied so far. Doubles as the cursor a reconnecting client resumes from.\n seq integer not null default 0 check (seq >= 0),\n constraint rounds_window check (opens_at < closes_at),\n constraint rounds_books_tail check (books_close_at >= closes_at)\n);\n\n-- A developer may already have run the first published schema without creating any rounds. Add\n-- the new authority fields in place for that empty setup. Existing round data needs a deliberate\n-- reset because its original reward bound cannot be reconstructed safely.\nalter table rounds\n add column if not exists game_id text check (game_id <> '');\nalter table rounds\n add column if not exists max_points_per_player bigint check (max_points_per_player > 0);\nalter table rounds\n add column if not exists score_authority text check (score_authority in ('rules', 'external-server'));\nalter table rounds\n add column if not exists points_per_dollar double precision check (points_per_dollar > 0);\nalter table rounds\n add column if not exists usd_per_eth double precision check (usd_per_eth > 0);\ndo $$\nbegin\n if exists (\n select 1 from rounds\n where game_id is null or max_points_per_player is null or score_authority is null\n or points_per_dollar is null or usd_per_eth is null\n ) then\n raise exception 'pre-release round rows cannot be upgraded safely'\n using hint = 'Delete the pre-release round data, then run migrate(pool) again.';\n end if;\nend\n$$;\nalter table rounds alter column game_id set not null;\nalter table rounds alter column max_points_per_player set not null;\nalter table rounds alter column score_authority set not null;\nalter table rounds alter column points_per_dollar set not null;\nalter table rounds alter column usd_per_eth set not null;\n\n-- Paired-token rounds (0.5.0). Every earlier round was an ETH round, so the defaults ARE the\n-- backfill: Postgres fills a new not-null column with its default, and ETH/18 is exactly what those\n-- rows were priced in. No guard is needed \u2014 nothing here has to be guessed.\nalter table rounds\n add column if not exists spend_token text not null default '0x0000000000000000000000000000000000000000' check (spend_token <> '');\nalter table rounds\n add column if not exists spend_symbol text not null default 'ETH' check (spend_symbol <> '');\nalter table rounds\n add column if not exists spend_decimals integer not null default 18 check (spend_decimals between 0 and 36);\nalter table rounds\n add column if not exists usd_per_spend_token double precision check (usd_per_spend_token > 0);\n\ncreate table if not exists balances (\n round_id text not null references rounds(id) on delete cascade,\n player text not null,\n points bigint not null default 0 check (points >= 0),\n -- What the chain says this wallet has already spent on this pool. The contract keeps this and\n -- enforces it whatever we believe, so it is the only figure that is actually true.\n chain_spent_wei numeric(78,0) not null default 0 check (chain_spent_wei >= 0),\n primary key (round_id, player)\n);\n\n-- One row per authorisation ever issued. Nothing is deleted: a signature that exists in the world\n-- cannot be un-issued, so the ledger records its fate instead of forgetting it.\ncreate table if not exists claims (\n nonce bigint generated always as identity primary key,\n round_id text not null references rounds(id) on delete cascade,\n player text not null,\n request_id varchar(128) not null check (request_id <> ''),\n requested_wei numeric(78,0) not null check (requested_wei > 0),\n wei numeric(78,0) not null check (wei > 0 and wei <= requested_wei),\n -- Held or released, and nothing else. Whether a swap LANDED is the chain's to say, not ours.\n status text not null check (status in ('held', 'released')),\n deadline bigint not null,\n -- Persisted before a payload leaves the gate. Until signing completes, both values are null.\n signature text,\n signer text,\n created_at timestamptz not null default now(),\n constraint claims_by_request unique (round_id, player, request_id),\n constraint claims_signature_pair check ((signature is null) = (signer is null))\n);\n\ncreate index if not exists claims_by_player on claims (round_id, player) where status = 'held';\n\n-- Trusted creator servers use one stable event id for every score increment. The event and the\n-- balance update commit together, so a retry can recover the first result without paying twice.\ncreate table if not exists award_events (\n round_id text not null references rounds(id) on delete cascade,\n event_id varchar(128) not null check (event_id <> ''),\n player text not null,\n points bigint not null check (points > 0),\n total_points bigint not null check (total_points >= points),\n created_at timestamptz not null default now(),\n primary key (round_id, event_id)\n);\n\n-- Which game a coin plays. Addresses are chain-local, so the pin is one row per (chain, coin).\ncreate table if not exists registrations (\n chain_id bigint not null check (chain_id > 0),\n coin text not null check (coin <> ''),\n game_id text not null check (game_id <> ''),\n gate_origin text not null check (gate_origin <> ''),\n deploy_id text not null check (deploy_id <> ''),\n game_server_origins text[] not null default '{}'::text[],\n disabled_at timestamptz,\n updated_at timestamptz not null default now(),\n constraint registrations_game_server_origins_bounded check (cardinality(game_server_origins) <= 4),\n primary key (chain_id, coin)\n);\n\n-- Accepted play sessions: one row per wallet and round, keyed by a hash of both so a wallet is never\n-- stored in the clear. Independent of scoring and of websocket reconnects. play_tracking records\n-- when this gate began counting, so a dashboard can tell \"no plays\" from \"not yet tracked\".\ncreate table if not exists play_sessions (\n id text primary key,\n round_id text not null references rounds(id) on delete cascade,\n started_at bigint not null\n);\ncreate index if not exists play_sessions_started_id_idx on play_sessions(started_at, id);\ncreate table if not exists play_tracking (\n id boolean primary key default true check (id),\n started_at bigint not null\n);\ninsert into play_tracking(id, started_at) values (true, (extract(epoch from clock_timestamp()) * 1000)::bigint) on conflict do nothing;\n\n-- What actually happened, in order. Not used to rebuild state \u2014 the snapshot does that \u2014 but it is\n-- the record of why a state looks the way it does, which is what an incident needs.\ncreate table if not exists commands (\n round_id text not null references rounds(id) on delete cascade,\n seq integer not null check (seq > 0),\n command jsonb not null,\n primary key (round_id, seq)\n);\n";
10
10
  /**
11
11
  * Run `fn` inside a transaction, committing on success and rolling back on failure.
12
12
  *
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,IAAI,CAAC;AAE3C;;;;;;GAMG;AACH,eAAO,MAAM,MAAM,k8PAoJlB,CAAC;AAMF;;;;;GAKG;AACH,wBAAsB,aAAa,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,UAAU,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAiBjG;AAED,sGAAsG;AACtG,wBAAsB,OAAO,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAKvD;AAED;;;GAGG;AACH,eAAO,MAAM,QAAQ,GAAI,OAAO,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,KAAG,MAA4B,CAAC"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,IAAI,CAAC;AAE3C;;;;;;GAMG;AACH,eAAO,MAAM,MAAM,8vRAmKlB,CAAC;AAMF;;;;;GAKG;AACH,wBAAsB,aAAa,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,UAAU,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAiBjG;AAED,sGAAsG;AACtG,wBAAsB,OAAO,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAKvD;AAED;;;GAGG;AACH,eAAO,MAAM,QAAQ,GAAI,OAAO,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,KAAG,MAA4B,CAAC"}
package/dist/store.js CHANGED
@@ -145,6 +145,21 @@ create table if not exists registrations (
145
145
  primary key (chain_id, coin)
146
146
  );
147
147
 
148
+ -- Accepted play sessions: one row per wallet and round, keyed by a hash of both so a wallet is never
149
+ -- stored in the clear. Independent of scoring and of websocket reconnects. play_tracking records
150
+ -- when this gate began counting, so a dashboard can tell "no plays" from "not yet tracked".
151
+ create table if not exists play_sessions (
152
+ id text primary key,
153
+ round_id text not null references rounds(id) on delete cascade,
154
+ started_at bigint not null
155
+ );
156
+ create index if not exists play_sessions_started_id_idx on play_sessions(started_at, id);
157
+ create table if not exists play_tracking (
158
+ id boolean primary key default true check (id),
159
+ started_at bigint not null
160
+ );
161
+ insert into play_tracking(id, started_at) values (true, (extract(epoch from clock_timestamp()) * 1000)::bigint) on conflict do nothing;
162
+
148
163
  -- What actually happened, in order. Not used to rebuild state — the snapshot does that — but it is
149
164
  -- the record of why a state looks the way it does, which is what an incident needs.
150
165
  create table if not exists commands (
package/dist/store.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoJrB,CAAC;AAEF,MAAM,cAAc,GAAG;;EAErB,CAAC;AAEH;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAI,IAAU,EAAE,EAAkC;IACnF,MAAM,EAAE,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;IAChC,IAAI,CAAC;QACH,MAAM,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QACxB,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,EAAE,CAAC,CAAC;QAC5B,MAAM,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;QACzB,OAAO,MAAM,CAAC;IAChB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,CAAC;YACH,MAAM,EAAE,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;QAC7B,CAAC;QAAC,MAAM,CAAC;YACP,+BAA+B;QACjC,CAAC;QACD,MAAM,KAAK,CAAC;IACd,CAAC;YAAS,CAAC;QACT,EAAE,CAAC,OAAO,EAAE,CAAC;IACf,CAAC;AACH,CAAC;AAED,sGAAsG;AACtG,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,IAAU;IACtC,MAAM,aAAa,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE;QACrC,MAAM,EAAE,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC;QAC/B,MAAM,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IACzB,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAC,KAAyC,EAAU,EAAE,CAAC,MAAM,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmKrB,CAAC;AAEF,MAAM,cAAc,GAAG;;EAErB,CAAC;AAEH;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAI,IAAU,EAAE,EAAkC;IACnF,MAAM,EAAE,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;IAChC,IAAI,CAAC;QACH,MAAM,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QACxB,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,EAAE,CAAC,CAAC;QAC5B,MAAM,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;QACzB,OAAO,MAAM,CAAC;IAChB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,CAAC;YACH,MAAM,EAAE,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;QAC7B,CAAC;QAAC,MAAM,CAAC;YACP,+BAA+B;QACjC,CAAC;QACD,MAAM,KAAK,CAAC;IACd,CAAC;YAAS,CAAC;QACT,EAAE,CAAC,OAAO,EAAE,CAAC;IACf,CAAC;AACH,CAAC;AAED,sGAAsG;AACtG,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,IAAU;IACtC,MAAM,aAAa,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE;QACrC,MAAM,EAAE,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC;QAC/B,MAAM,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IACzB,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAC,KAAyC,EAAU,EAAE,CAAC,MAAM,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC"}
@@ -0,0 +1,5 @@
1
+ /** The published version of @flayerlabs/gamemode-gate, as a literal so it survives any bundler. */
2
+ export declare const GATE_VERSION: string;
3
+ /** `@flayerlabs/gamemode-gate@<version>`: the text a build of this package can be searched for. */
4
+ export declare const GATE_MARKER: string;
5
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AACA,mGAAmG;AACnG,eAAO,MAAM,YAAY,EAAE,MAAgB,CAAC;AAC5C,mGAAmG;AACnG,eAAO,MAAM,WAAW,EAAE,MAA0C,CAAC"}
@@ -0,0 +1,6 @@
1
+ // Generated by scripts/write-version.mjs from package.json at build time. Do not edit.
2
+ /** The published version of @flayerlabs/gamemode-gate, as a literal so it survives any bundler. */
3
+ export const GATE_VERSION = "0.5.5";
4
+ /** `@flayerlabs/gamemode-gate@<version>`: the text a build of this package can be searched for. */
5
+ export const GATE_MARKER = "@flayerlabs/gamemode-gate@0.5.5";
6
+ //# sourceMappingURL=version.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,uFAAuF;AACvF,mGAAmG;AACnG,MAAM,CAAC,MAAM,YAAY,GAAW,OAAO,CAAC;AAC5C,mGAAmG;AACnG,MAAM,CAAC,MAAM,WAAW,GAAW,iCAAiC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flayerlabs/gamemode-gate",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "Server-authoritative rooms, economy, admission and registry for Flaunch Game Modes",
5
5
  "license": "MIT",
6
6
  "author": "Flayer Labs",
@@ -35,7 +35,7 @@
35
35
  "node": ">=20"
36
36
  },
37
37
  "scripts": {
38
- "prebuild": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
38
+ "prebuild": "node ../../scripts/write-version.mjs && node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
39
39
  "build": "tsc -p tsconfig.build.json",
40
40
  "prepack": "pnpm build",
41
41
  "typecheck": "tsc --noEmit",
@@ -45,7 +45,7 @@
45
45
  "dependencies": {
46
46
  "@fastify/cors": "^11.3.0",
47
47
  "@fastify/websocket": "^11.3.0",
48
- "@flayerlabs/gamemode-spec": "^0.5.3",
48
+ "@flayerlabs/gamemode-spec": "^0.5.5",
49
49
  "@types/node": "^22.10.2",
50
50
  "@types/pg": "^8.21.0",
51
51
  "fastify": "^5.11.3",
package/src/index.ts CHANGED
@@ -66,7 +66,8 @@ export type { VerifiedPlayerJoin, VerifyPlayerJoinTicketOptions } from './player
66
66
  */
67
67
  export { startGate, resolveGateEnv } from './main.js';
68
68
  export type { ResolvedGateConfig, SpendTokenPricing, StartedGate, StartGateOverrides } from './main.js';
69
- export type { GateAnnouncement } from './server.js';
69
+ export type { GateAnnouncement, ServedGateAnnouncement } from './server.js';
70
+ export { GATE_MARKER, GATE_VERSION } from './version.js';
70
71
  export { NotSignedIn } from './sessions.js';
71
72
  export { DEPLOYMENTS, PAIRED_DEPLOYMENTS, deploymentFor } from './chain/chains.js';
72
73
  export type { FlaunchVariant, GateDeployment } from './chain/chains.js';
package/src/ledger.ts CHANGED
@@ -72,8 +72,8 @@ function availableFrom({ points, unitsPerPoint, capWei, held, chainSpentWei }: L
72
72
  // authorisations, and the surplus does not overspend, it reverts at the swap, which to a player
73
73
  // is indistinguishable from being robbed.
74
74
  //
75
- // Adding them double-counts a hold whose swap has already landed, until that hold expires. That
76
- // errs towards authorising too little, which is the direction that costs nobody anything.
75
+ // Settlement removes proven-consumed authorisations in the same transaction as recording spend.
76
+ // Unverified authorisations remain held; never guess overlap from equal amounts.
77
77
  const available = ceiling - (chainSpentWei + held);
78
78
  return available > 0n ? available : 0n;
79
79
  }
@@ -195,6 +195,39 @@ export class Ledger {
195
195
  return result.rowCount === 1;
196
196
  }
197
197
 
198
+ /** Signed claims whose consumption still needs chain proof. Bounded per reconciliation pass. */
199
+ async outstandingClaims(roundId: string, player: PlayerId): Promise<Claim[]> {
200
+ const result = await this.pool.query<ClaimRow>(
201
+ `select nonce::text, player, request_id, requested_wei::text, wei::text, deadline::text,
202
+ status, signature, signer from claims
203
+ where round_id = $1 and player = $2 and status = 'held' and signature is not null
204
+ order by nonce limit 100`, [roundId, player],
205
+ );
206
+ return result.rows.map(claimFrom);
207
+ }
208
+
209
+ /** Caller supplies consumed nonces and cumulative spend from one verified chain snapshot. */
210
+ async reconcileSpend(roundId: string, player: PlayerId, spent: bigint, consumed: bigint[], expiredBeforeSeconds?: bigint): Promise<boolean> {
211
+ return inTransaction(this.pool, async tx => {
212
+ const balance = await tx.query<{ chain_spent_wei: string }>(
213
+ `select chain_spent_wei from balances where round_id = $1 and player = $2 for update`,
214
+ [roundId, player],
215
+ );
216
+ if (!balance.rows[0] || spent < toBigInt(balance.rows[0].chain_spent_wei)) return false;
217
+ const updated = await tx.query(
218
+ `update balances set chain_spent_wei = $3 where round_id = $1 and player = $2 and chain_spent_wei < $3`,
219
+ [roundId, player, spent.toString()],
220
+ );
221
+ const released = await tx.query(
222
+ `update claims set status = 'released' where round_id = $1 and player = $2
223
+ and status = 'held' and ((signature is not null and nonce = any($3::bigint[]))
224
+ or deadline < $4::numeric)`,
225
+ [roundId, player, consumed.map(String), expiredBeforeSeconds?.toString() ?? null],
226
+ );
227
+ return (updated.rowCount ?? 0) > 0 || (released.rowCount ?? 0) > 0;
228
+ });
229
+ }
230
+
198
231
  /** Everyone with a balance in this round, for the reconciler to ask the chain about. */
199
232
  async playersIn(roundId: string): Promise<PlayerId[]> {
200
233
  const result = await this.pool.query<{ player: string }>(
@@ -233,7 +266,11 @@ export class Ledger {
233
266
 
234
267
  /** The authoritative balance snapshot, derived from the same state and ceiling as `claim`. */
235
268
  async economyBalance(roundId: string, player: PlayerId): Promise<EconomyBalance> {
236
- const state = await this.readState(this.pool, roundId, player);
269
+ // The balance and outstanding claims must be read from one database snapshot.
270
+ const state = await inTransaction(this.pool, async tx => {
271
+ await tx.query('set transaction isolation level repeatable read, read only');
272
+ return this.readState(tx, roundId, player);
273
+ });
237
274
  if (!state) {
238
275
  return { unitsPerPoint: 0n, earned: 0n, held: 0n, spent: 0n, available: 0n, holdExpiresAt: null };
239
276
  }
@@ -0,0 +1,25 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { Pool } from 'pg';
3
+
4
+ export function playSessionId(roundId: string, player: string): string {
5
+ return createHash('sha256').update(JSON.stringify([roundId, player.toLowerCase()])).digest('hex');
6
+ }
7
+ export async function recordPlaySession(pool: Pool, roundId: string, player: string, at = Date.now()): Promise<void> {
8
+ // One accepted start per wallet and launch. Reconnects and repeated joins retain the first time.
9
+ await pool.query('insert into play_sessions (id, round_id, started_at) values ($1, $2, $3) on conflict do nothing', [playSessionId(roundId, player), roundId, at]);
10
+ }
11
+ export function parsePlayCursor(value: unknown): { at: number; id: string } | null {
12
+ if (value === undefined) return { at: 0, id: '' };
13
+ if (typeof value !== 'string' || value.length > 100) return null;
14
+ const match = /^(\d{1,16}):([a-f0-9]{64})$/.exec(value);
15
+ if (!match || !Number.isSafeInteger(Number(match[1]))) return null;
16
+ return { at: Number(match[1]), id: match[2]! };
17
+ }
18
+ export async function readPlaySessions(pool: Pool, cursor: { at: number; id: string }) {
19
+ const [events, coverage] = await Promise.all([
20
+ pool.query<{ id: string; coin: string; started_at: string }>(`select p.id, r.coin_address as coin, p.started_at from play_sessions p join rounds r on r.id = p.round_id where r.coin_address is not null and (p.started_at, p.id) > ($1::bigint, $2::text) order by p.started_at, p.id limit 250`, [cursor.at, cursor.id]),
21
+ pool.query<{ started_at: string }>('select started_at from play_tracking where id = true'),
22
+ ]);
23
+ const last = events.rows.at(-1);
24
+ return { trackingSince: Number(coverage.rows[0]?.started_at ?? Date.now()), sessions: events.rows.map(r => ({ id: r.id, coin: r.coin, startedAt: Number(r.started_at) })), nextCursor: events.rows.length === 250 && last ? `${last.started_at}:${last.id}` : null };
25
+ }
package/src/server.ts CHANGED
@@ -20,6 +20,7 @@ import { Room, type ScoreAuthority } from './room.js';
20
20
  import { Standings } from './standings.js';
21
21
  import { CLAIM_AUTHORIZATION_TTL_MS, Claims, isClaimAmount, isClaimRequestId } from './claims.js';
22
22
  import { NotSignedIn, Sessions } from './sessions.js';
23
+ import { parsePlayCursor, readPlaySessions, recordPlaySession } from './play-sessions.js';
23
24
  import type { Discovery, Launch, PoolKey } from './chain/discover.js';
24
25
  import type { Settlement } from './settlement.js';
25
26
  import { resolveEconomy } from './economy.js';
@@ -28,6 +29,7 @@ import { hasBearerToken } from './bearer.js';
28
29
  import { GameServerAwards, type GameServerAwardRequest } from './game-server-awards.js';
29
30
  import { issuePlayerJoinTicket } from './player-join-tickets.js';
30
31
  import { gameServerOrigins } from './registry.js';
32
+ import { GATE_VERSION } from './version.js';
31
33
 
32
34
  /**
33
35
  * The gate's door.
@@ -179,6 +181,15 @@ export interface GateAnnouncement {
179
181
  privateLaunches: boolean;
180
182
  }
181
183
 
184
+ /**
185
+ * What `GET /config` actually serves: the operator's {@link GateAnnouncement} plus the version of
186
+ * `@flayerlabs/gamemode-gate` answering, so the platform can tell whether a gate runs a current SDK.
187
+ */
188
+ export interface ServedGateAnnouncement extends GateAnnouncement {
189
+ /** The SemVer of the `@flayerlabs/gamemode-gate` build serving this route. */
190
+ gateVersion: string;
191
+ }
192
+
182
193
  /** Where an admission check is being made. Enough to tell a sign-in from a spend. */
183
194
  export interface AdmissionRequest {
184
195
  /** `session` on sign-in, `action` on gameplay, `claim` when allowance is about to be committed. */
@@ -381,7 +392,7 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
381
392
  /** One Room per round in this process — the optimistic lock assumes a single writer. */
382
393
  async function roomFor(id: string) {
383
394
  const existing = rooms.get(id);
384
- if (existing) return existing;
395
+ if (existing) return Date.now() <= existing.launch.booksCloseAt ? existing : null;
385
396
  const loaded = await Room.load(pool, game, id);
386
397
  if (loaded && loaded.scoreAuthority !== scoreAuthority) {
387
398
  app.log.error(`${id}: round belongs to ${loaded.scoreAuthority}, not ${scoreAuthority}`);
@@ -627,9 +638,24 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
627
638
  total: row.total,
628
639
  })),
629
640
  lastRoundAt: totals.last_round_at === null ? null : Number(totals.last_round_at),
641
+ // The same version `/config` reports, so a dashboard already reading stats needs no second call.
642
+ gateVersion: GATE_VERSION,
630
643
  };
631
644
  });
632
645
 
646
+ /**
647
+ * Accepted play sessions, for the same dashboard: one row per wallet and round, so a per-game
648
+ * play count survives the room ending. Wallets are hashed into the row ID and never returned.
649
+ * Rounds without a coin (practice and mock rooms) are left out. Keyset-paginated at 250 rows on
650
+ * `(started_at, id)`; the cursor is `${startedAt}:${id}` and a malformed one is a 400.
651
+ */
652
+ app.get('/stats/plays', async (request, reply) => {
653
+ void reply.header('access-control-allow-origin', '*');
654
+ const cursor = parsePlayCursor((request.query as { cursor?: unknown }).cursor);
655
+ if (!cursor) return reply.code(400).send({ message: 'invalid play cursor' });
656
+ return readPlaySessions(pool, cursor);
657
+ });
658
+
633
659
  /**
634
660
  * How a coin's round stands: every wallet that scored, ranked, with what the chain says each
635
661
  * spent. This is what the coin page shows once the game is over — and during it, as the running
@@ -655,7 +681,10 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
655
681
  // allowlist names only the game's origins; the launch page is a different origin and every
656
682
  // fact here is public (a launch publishes them on chain), so `*` is honest rather than lax.
657
683
  void reply.header('access-control-allow-origin', '*');
658
- return announce();
684
+ // `gateVersion` is added here rather than asked of `announce()`: the library knows what it is
685
+ // and an operator's announcement should not be able to misreport it.
686
+ const served: ServedGateAnnouncement = { ...announce(), gateVersion: GATE_VERSION };
687
+ return served;
659
688
  });
660
689
  }
661
690
 
@@ -742,6 +771,7 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
742
771
  if (typeof audience !== 'string' || !awardEndpoint.ticketAudiences.has(audience)) {
743
772
  return reply.code(400).send({ message: 'that game server is not registered' });
744
773
  }
774
+ await recordPlaySession(pool, id, who);
745
775
  return issuePlayerJoinTicket({
746
776
  awardToken: awardEndpoint.token,
747
777
  player: who,
@@ -838,7 +868,9 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
838
868
 
839
869
  const id = (request.params as { id: string }).id;
840
870
  if (scoreAuthority === 'external-server') {
841
- player(request);
871
+ const who = player(request);
872
+ if (Date.now() >= room.window.closesAt) return reply.code(409).send({ message: 'that game has ended' });
873
+ await recordPlaySession(pool, id, who);
842
874
  return { joined: true };
843
875
  }
844
876
  const before = room.sequence();
@@ -850,7 +882,9 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
850
882
  at: Date.now(),
851
883
  });
852
884
  if (room.sequence() !== before) announce(id);
853
- return isRefusal(result) ? reply.code(409).send({ message: 'you cannot join right now' }) : { joined: true };
885
+ if (isRefusal(result)) return reply.code(409).send({ message: 'you cannot join right now' });
886
+ await recordPlaySession(pool, id, player(request));
887
+ return { joined: true };
854
888
  });
855
889
 
856
890
  app.post('/rounds/:id/actions', async (request, reply) => {
@@ -1202,7 +1236,9 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
1202
1236
  const now = Date.now();
1203
1237
  const sweeping = Boolean(settlement && now - reconciledAt >= RECONCILE_MS);
1204
1238
  // Authorisations nobody spent come back to the player who earned them.
1205
- const released = await claims.releaseExpired().catch((error) => {
1239
+ // A chain-backed gate expires holds atomically with confirmed spend, never ahead of a
1240
+ // lagging/failed RPC read. Demo and legacy adapters retain their existing expiry path.
1241
+ const released = settlement?.usesConfirmedSnapshots ? [] : await claims.releaseExpired().catch((error) => {
1206
1242
  app.log.error(error);
1207
1243
  return [];
1208
1244
  });
@@ -1213,6 +1249,9 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
1213
1249
  // the independent books tail. A process can wake for the first time after this tail (for
1214
1250
  // example after a stall or restart), so persist every due game wake before deleting it.
1215
1251
  if (now > room.launch.booksCloseAt) {
1252
+ // Confirmations may trail a last-second claim's deadline. Keep background accounting
1253
+ // alive at the normal cadence until holds settle; socket deadlines remain independent.
1254
+ if (settlement?.usesConfirmedSnapshots && !sweeping) continue;
1216
1255
  try {
1217
1256
  if (room.nextWakeAt() !== null) await room.tick(Math.min(now, room.window.closesAt));
1218
1257
  } catch (error) {
@@ -1224,6 +1263,8 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
1224
1263
  if (settlement) {
1225
1264
  const final = await settlement.reconcile(id).catch(() => ({ read: 0, failed: 1, changed: [] }));
1226
1265
  if (final.failed > 0) app.log.error(`${id}: ${final.failed} final spend reads failed`);
1266
+ if (settlement.usesConfirmedSnapshots &&
1267
+ (final.failed > 0 || await settlement.hasPendingClaims(id).catch(() => true))) continue;
1227
1268
  }
1228
1269
  for (const watcher of watchers.get(id) ?? []) {
1229
1270
  if (
package/src/settlement.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { parseAbi } from 'viem';
1
+ import { parseAbi, type PublicClient } from 'viem';
2
2
  import type { Pool } from 'pg';
3
3
  import type { PlayerId } from '@flayerlabs/gamemode-spec';
4
4
  import type { Ledger } from './ledger.js';
@@ -12,23 +12,34 @@ import type { ChainReader } from './chain/discover.js';
12
12
  * and the amount but not the nonce, and a wallet can submit through a relay, so correlating them is
13
13
  * guesswork.
14
14
  *
15
- * The contract already keeps the number that matters. `walletSpentWei(poolId, wallet)` is what it
16
- * enforces against, so reading it is reading the truth rather than inferring it. No matching, no
17
- * event log to replay, and nothing to get subtly wrong.
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.
18
17
  */
19
18
 
20
19
  const spendGatedAbi = parseAbi([
21
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)',
22
23
  ]);
23
24
 
24
25
  export class Settlement {
25
26
  constructor(
26
27
  private readonly pool: Pool,
27
28
  private readonly ledger: Ledger,
28
- private readonly client: ChainReader,
29
+ private readonly client: ChainReader & Partial<Pick<PublicClient, 'getBlock'>>,
29
30
  private readonly calculator: `0x${string}`,
30
31
  ) {}
31
32
 
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
+
32
43
  /**
33
44
  * Bring one round's view of spending up to date with the chain.
34
45
  *
@@ -50,14 +61,40 @@ export class Settlement {
50
61
 
51
62
  for (const player of players) {
52
63
  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.
67
+ 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! } : {};
53
72
  const spent = (await this.client.readContract({
54
73
  address: this.calculator,
55
74
  abi: spendGatedAbi,
56
75
  functionName: 'walletSpentWei',
57
76
  args: [poolId as `0x${string}`, player as `0x${string}`],
77
+ ...atBlock,
58
78
  })) as bigint;
59
-
60
- if (spent > 0n && await this.ledger.recordChainSpend(roundId, player, spent)) changed.push(player);
79
+ 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);
61
98
  read += 1;
62
99
  } catch {
63
100
  // One unreachable read is not a reason to stop the round, but it is counted. Swallowed
package/src/store.ts CHANGED
@@ -147,6 +147,21 @@ create table if not exists registrations (
147
147
  primary key (chain_id, coin)
148
148
  );
149
149
 
150
+ -- Accepted play sessions: one row per wallet and round, keyed by a hash of both so a wallet is never
151
+ -- stored in the clear. Independent of scoring and of websocket reconnects. play_tracking records
152
+ -- when this gate began counting, so a dashboard can tell "no plays" from "not yet tracked".
153
+ create table if not exists play_sessions (
154
+ id text primary key,
155
+ round_id text not null references rounds(id) on delete cascade,
156
+ started_at bigint not null
157
+ );
158
+ create index if not exists play_sessions_started_id_idx on play_sessions(started_at, id);
159
+ create table if not exists play_tracking (
160
+ id boolean primary key default true check (id),
161
+ started_at bigint not null
162
+ );
163
+ insert into play_tracking(id, started_at) values (true, (extract(epoch from clock_timestamp()) * 1000)::bigint) on conflict do nothing;
164
+
150
165
  -- What actually happened, in order. Not used to rebuild state — the snapshot does that — but it is
151
166
  -- the record of why a state looks the way it does, which is what an incident needs.
152
167
  create table if not exists commands (
package/src/version.ts ADDED
@@ -0,0 +1,5 @@
1
+ // Generated by scripts/write-version.mjs from package.json at build time. Do not edit.
2
+ /** The published version of @flayerlabs/gamemode-gate, as a literal so it survives any bundler. */
3
+ export const GATE_VERSION: string = "0.5.5";
4
+ /** `@flayerlabs/gamemode-gate@<version>`: the text a build of this package can be searched for. */
5
+ export const GATE_MARKER: string = "@flayerlabs/gamemode-gate@0.5.5";