@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.
- package/README.md +26 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +4 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +28 -3
- package/dist/ledger.js.map +1 -1
- package/dist/play-sessions.d.ts +20 -0
- package/dist/play-sessions.d.ts.map +1 -0
- package/dist/play-sessions.js +27 -0
- package/dist/play-sessions.js.map +1 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +41 -5
- package/dist/server.js.map +1 -1
- package/dist/settlement.d.ts +5 -1
- package/dist/settlement.d.ts.map +1 -1
- package/dist/settlement.js +45 -4
- package/dist/settlement.js.map +1 -1
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +15 -0
- package/dist/store.js.map +1 -1
- package/dist/version.d.ts +5 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +6 -0
- package/dist/version.js.map +1 -0
- package/package.json +3 -3
- package/src/index.ts +2 -1
- package/src/ledger.ts +40 -3
- package/src/play-sessions.ts +25 -0
- package/src/server.ts +46 -5
- package/src/settlement.ts +44 -7
- package/src/store.ts +15 -0
- package/src/version.ts +5 -0
package/dist/settlement.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/settlement.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"settlement.d.ts","sourceRoot":"","sources":["../src/settlement.ts"],"names":[],"mappings":"
|
|
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"}
|
package/dist/settlement.js
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
}
|
package/dist/settlement.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"settlement.js","sourceRoot":"","sources":["../src/settlement.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,
|
|
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
|
*
|
package/dist/store.d.ts.map
CHANGED
|
@@ -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,
|
|
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
|
|
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"}
|
package/dist/version.js
ADDED
|
@@ -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
|
+
"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.
|
|
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
|
-
//
|
|
76
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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";
|