@flayerlabs/gamemode-gate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +79 -0
  3. package/dist/chain/discover.d.ts +158 -0
  4. package/dist/chain/discover.d.ts.map +1 -0
  5. package/dist/chain/discover.js +169 -0
  6. package/dist/chain/discover.js.map +1 -0
  7. package/dist/chain/signer.d.ts +46 -0
  8. package/dist/chain/signer.d.ts.map +1 -0
  9. package/dist/chain/signer.js +60 -0
  10. package/dist/chain/signer.js.map +1 -0
  11. package/dist/claims.d.ts +62 -0
  12. package/dist/claims.d.ts.map +1 -0
  13. package/dist/claims.js +128 -0
  14. package/dist/claims.js.map +1 -0
  15. package/dist/demo.d.ts +40 -0
  16. package/dist/demo.d.ts.map +1 -0
  17. package/dist/demo.js +90 -0
  18. package/dist/demo.js.map +1 -0
  19. package/dist/economy.d.ts +46 -0
  20. package/dist/economy.d.ts.map +1 -0
  21. package/dist/economy.js +100 -0
  22. package/dist/economy.js.map +1 -0
  23. package/dist/game-registry.d.ts +17 -0
  24. package/dist/game-registry.d.ts.map +1 -0
  25. package/dist/game-registry.js +90 -0
  26. package/dist/game-registry.js.map +1 -0
  27. package/dist/index.d.ts +23 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +14 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/ledger.d.ts +87 -0
  32. package/dist/ledger.d.ts.map +1 -0
  33. package/dist/ledger.js +222 -0
  34. package/dist/ledger.js.map +1 -0
  35. package/dist/registry.d.ts +55 -0
  36. package/dist/registry.d.ts.map +1 -0
  37. package/dist/registry.js +164 -0
  38. package/dist/registry.js.map +1 -0
  39. package/dist/room.d.ts +98 -0
  40. package/dist/room.d.ts.map +1 -0
  41. package/dist/room.js +215 -0
  42. package/dist/room.js.map +1 -0
  43. package/dist/server.d.ts +92 -0
  44. package/dist/server.d.ts.map +1 -0
  45. package/dist/server.js +695 -0
  46. package/dist/server.js.map +1 -0
  47. package/dist/sessions.d.ts +40 -0
  48. package/dist/sessions.d.ts.map +1 -0
  49. package/dist/sessions.js +144 -0
  50. package/dist/sessions.js.map +1 -0
  51. package/dist/settlement.d.ts +24 -0
  52. package/dist/settlement.d.ts.map +1 -0
  53. package/dist/settlement.js +69 -0
  54. package/dist/settlement.js.map +1 -0
  55. package/dist/store.d.ts +24 -0
  56. package/dist/store.d.ts.map +1 -0
  57. package/dist/store.js +125 -0
  58. package/dist/store.js.map +1 -0
  59. package/dist/turnstile.d.ts +19 -0
  60. package/dist/turnstile.d.ts.map +1 -0
  61. package/dist/turnstile.js +61 -0
  62. package/dist/turnstile.js.map +1 -0
  63. package/package.json +54 -0
  64. package/src/chain/discover.ts +243 -0
  65. package/src/chain/signer.ts +118 -0
  66. package/src/claims.ts +140 -0
  67. package/src/demo.ts +149 -0
  68. package/src/economy.ts +126 -0
  69. package/src/game-registry.ts +107 -0
  70. package/src/index.ts +45 -0
  71. package/src/ledger.ts +355 -0
  72. package/src/registry.ts +212 -0
  73. package/src/room.ts +297 -0
  74. package/src/server.ts +833 -0
  75. package/src/sessions.ts +155 -0
  76. package/src/settlement.ts +72 -0
  77. package/src/store.ts +127 -0
  78. package/src/turnstile.ts +74 -0
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@flayerlabs/gamemode-gate",
3
+ "version": "0.1.0",
4
+ "description": "Server-authoritative rooms, economy, admission and registry for Flaunch Game Modes",
5
+ "license": "MIT",
6
+ "author": "Flayer Labs",
7
+ "homepage": "https://github.com/flayerlabs/gamemode-sdk#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/flayerlabs/gamemode-sdk.git",
11
+ "directory": "packages/gate"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/flayerlabs/gamemode-sdk/issues"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "type": "module",
20
+ "main": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "src"
31
+ ],
32
+ "sideEffects": false,
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "scripts": {
37
+ "prebuild": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
38
+ "build": "tsc -p tsconfig.build.json",
39
+ "prepack": "pnpm build",
40
+ "typecheck": "tsc --noEmit",
41
+ "test": "vitest run",
42
+ "db": "docker run -d --rm --name gamemode-pg -e POSTGRES_PASSWORD=gamemode -e POSTGRES_DB=gamemode -p 55439:5432 postgres:16-alpine"
43
+ },
44
+ "dependencies": {
45
+ "@flayerlabs/gamemode-spec": "^0.1.0",
46
+ "@fastify/cors": "^11.3.0",
47
+ "@fastify/websocket": "^11.3.0",
48
+ "@types/node": "^22.10.2",
49
+ "@types/pg": "^8.21.0",
50
+ "fastify": "^5.11.3",
51
+ "pg": "^8.23.0",
52
+ "viem": "^2.21.54"
53
+ }
54
+ }
@@ -0,0 +1,243 @@
1
+ import { encodeAbiParameters, keccak256, parseAbi, type PublicClient } from 'viem';
2
+
3
+ /**
4
+ * All this needs of a client: the ability to read a contract.
5
+ *
6
+ * Naming the one method rather than taking a whole `PublicClient` also sidesteps viem's chain
7
+ * generics, which make a client built for one chain structurally incompatible with the general
8
+ * type over unrelated methods like `getBlock`.
9
+ */
10
+ export type ChainReader = Pick<PublicClient, 'readContract'>;
11
+
12
+ /**
13
+ * Asking the chain whether a coin is really a game launch, and on what terms.
14
+ *
15
+ * This is what makes rooms exist because a LAUNCH exists rather than because somebody asked for
16
+ * one. A room used to be minted on demand, which on a public URL means anyone can invent as many
17
+ * as they like.
18
+ *
19
+ * The authorisation is already on chain: a launch that wants a game names its signer in the
20
+ * launch's own gate parameters, and that is public state the creator paid to write and nobody else
21
+ * can forge. So adoption needs no API key and no allowlist — anyone may ask, and the chain decides.
22
+ * That single property is what makes third-party games permissionless.
23
+ */
24
+
25
+ const poolKeyComponents = [
26
+ { name: 'currency0', type: 'address' },
27
+ { name: 'currency1', type: 'address' },
28
+ { name: 'fee', type: 'uint24' },
29
+ { name: 'tickSpacing', type: 'int24' },
30
+ { name: 'hooks', type: 'address' },
31
+ ] as const;
32
+
33
+ export const positionManagerAbi = [
34
+ {
35
+ type: 'function',
36
+ name: 'poolKey',
37
+ stateMutability: 'view',
38
+ inputs: [{ name: '_token', type: 'address' }],
39
+ outputs: [{ name: '', type: 'tuple', components: poolKeyComponents }],
40
+ },
41
+ // The scheduled open of the buy window; 0 once the pool is tradeable.
42
+ ...parseAbi(['function flaunchesAt(bytes32 _poolId) view returns (uint256)']),
43
+ ] as const;
44
+
45
+ export const spendGatedAbi = parseAbi([
46
+ // Four fields. Decoding a four-word return against a shorter ABI is how a check quietly reads the
47
+ // wrong value, so this must match the DEPLOYED calculator rather than the newest source.
48
+ 'function spendGateSettings(bytes32 _poolId) view returns (bool enabled, uint256 walletCapWei, address settler, uint256 endsAt)',
49
+ 'function trustedPoolKeySigner(bytes32 _poolId) view returns (address signer, bool enabled)',
50
+ ]);
51
+
52
+ const erc20 = parseAbi(['function name() view returns (string)', 'function symbol() view returns (string)']);
53
+
54
+ export interface PoolKey {
55
+ currency0: `0x${string}`;
56
+ currency1: `0x${string}`;
57
+ fee: number;
58
+ tickSpacing: number;
59
+ hooks: `0x${string}`;
60
+ }
61
+
62
+ export interface Launch {
63
+ coinAddress: `0x${string}`;
64
+ poolId: `0x${string}`;
65
+ poolKey: PoolKey;
66
+ /** When buying opens, in epoch ms. */
67
+ opensAt: number;
68
+ /** When the gate stops enforcing on chain, in epoch ms. */
69
+ closesAt: number;
70
+ walletCapWei: bigint;
71
+ signer: `0x${string}`;
72
+ name: string;
73
+ symbol: string;
74
+ imageUrl?: string | null;
75
+ }
76
+
77
+ /**
78
+ * A refusal, with the one distinction that matters.
79
+ *
80
+ * `retryable` separates "the chain has not caught up yet" from "this coin is not a game launch".
81
+ * Collapsing the two once cost a real launch its whole window: a coin page loaded the instant a
82
+ * launch was submitted, asked before the pool was readable, was told "not a game launch", and both
83
+ * ends cached that answer for thirty seconds each — against a ninety-second window.
84
+ */
85
+ export interface Refused {
86
+ ok: false;
87
+ code: string;
88
+ retryable: boolean;
89
+ }
90
+
91
+ export type LaunchCheck = ({ ok: true } & Launch) | Refused;
92
+
93
+ export interface Deployment {
94
+ positionManager: `0x${string}`;
95
+ spendGatedCalculator: `0x${string}`;
96
+ /** The signer this gate holds. A launch naming anyone else is not ours to run. */
97
+ signer: `0x${string}`;
98
+ }
99
+
100
+ export function poolIdOf(key: PoolKey): `0x${string}` {
101
+ return keccak256(
102
+ encodeAbiParameters(
103
+ [{ type: 'address' }, { type: 'address' }, { type: 'uint24' }, { type: 'int24' }, { type: 'address' }],
104
+ [key.currency0, key.currency1, key.fee, key.tickSpacing, key.hooks],
105
+ ),
106
+ );
107
+ }
108
+
109
+ const refuse = (code: string, retryable: boolean): Refused => ({ ok: false, code, retryable });
110
+
111
+ /**
112
+ * Verify a coin, from the chain.
113
+ *
114
+ * Every read is wrapped, because an RPC failure is not a statement about the token. Treating one as
115
+ * "not a game launch" is exactly the mistake described on {@link Refused}.
116
+ */
117
+ export async function verifyLaunch(
118
+ client: ChainReader,
119
+ deployment: Deployment,
120
+ memecoin: `0x${string}`,
121
+ ): Promise<LaunchCheck> {
122
+ let poolKey: PoolKey;
123
+ try {
124
+ poolKey = (await client.readContract({
125
+ address: deployment.positionManager,
126
+ abi: positionManagerAbi,
127
+ functionName: 'poolKey',
128
+ args: [memecoin],
129
+ })) as PoolKey;
130
+ } catch {
131
+ return refuse('launch.unreadable', true);
132
+ }
133
+
134
+ // A coin the protocol has never heard of returns an empty key rather than throwing.
135
+ if (!poolKey || poolKey.hooks === '0x0000000000000000000000000000000000000000') {
136
+ return refuse('launch.not_flaunched', true);
137
+ }
138
+
139
+ const poolId = poolIdOf(poolKey);
140
+
141
+ let settings: readonly [boolean, bigint, `0x${string}`, bigint];
142
+ let trusted: readonly [`0x${string}`, boolean];
143
+ let flaunchesAt: bigint;
144
+ try {
145
+ [settings, trusted, flaunchesAt] = (await Promise.all([
146
+ client.readContract({
147
+ address: deployment.spendGatedCalculator,
148
+ abi: spendGatedAbi,
149
+ functionName: 'spendGateSettings',
150
+ args: [poolId],
151
+ }),
152
+ client.readContract({
153
+ address: deployment.spendGatedCalculator,
154
+ abi: spendGatedAbi,
155
+ functionName: 'trustedPoolKeySigner',
156
+ args: [poolId],
157
+ }),
158
+ client.readContract({
159
+ address: deployment.positionManager,
160
+ abi: positionManagerAbi,
161
+ functionName: 'flaunchesAt',
162
+ args: [poolId],
163
+ }),
164
+ ])) as [typeof settings, typeof trusted, bigint];
165
+ } catch {
166
+ return refuse('launch.unreadable', true);
167
+ }
168
+
169
+ const [enabled, walletCapWei, , endsAt] = settings;
170
+ const [signer, signerEnabled] = trusted;
171
+
172
+ if (!enabled) return refuse('launch.not_a_game', false);
173
+ // Not ours to run. Permanent: another gate holds that key, and no amount of waiting changes it.
174
+ if (!signerEnabled || signer.toLowerCase() !== deployment.signer.toLowerCase()) {
175
+ return refuse('launch.another_signer', false);
176
+ }
177
+ if (walletCapWei <= 0n) return refuse('launch.no_spending_cap', false);
178
+
179
+ const opensAt = Number(flaunchesAt) * 1000;
180
+ const closesAt = Number(endsAt) * 1000;
181
+ if (closesAt <= Date.now()) return refuse('launch.already_over', false);
182
+
183
+ let name = '';
184
+ let symbol = '';
185
+ try {
186
+ [name, symbol] = (await Promise.all([
187
+ client.readContract({ address: memecoin, abi: erc20, functionName: 'name' }),
188
+ client.readContract({ address: memecoin, abi: erc20, functionName: 'symbol' }),
189
+ ])) as [string, string];
190
+ } catch {
191
+ // Cosmetic. A coin with an unreadable name is still a valid launch, and refusing one over a
192
+ // display string would be refusing a round for no reason anyone could act on.
193
+ }
194
+
195
+ return { ok: true, coinAddress: memecoin, poolId, poolKey, opensAt, closesAt, walletCapWei, signer, name, symbol };
196
+ }
197
+
198
+ /**
199
+ * Verification with a memory, because every coin-page view asks.
200
+ *
201
+ * Unmemoised, each call costs five chain reads against one endpoint from one address — which is a
202
+ * free denial-of-service lever, and public endpoints rate-limit long before a launch does.
203
+ *
204
+ * A positive verdict is cached until the window closes, because a launch that qualifies cannot
205
+ * un-qualify while it is open. A retryable refusal is cached only briefly, and a permanent one for
206
+ * longer — the whole point of the distinction is that "not yet" must not be remembered like "no".
207
+ */
208
+ export class Discovery {
209
+ private readonly verdicts = new Map<string, { verdict: LaunchCheck; until: number }>();
210
+ private readonly inFlight = new Map<string, Promise<LaunchCheck>>();
211
+
212
+ constructor(
213
+ private readonly client: ChainReader,
214
+ private readonly deployment: Deployment,
215
+ private readonly now: () => number = Date.now,
216
+ ) {}
217
+
218
+ async verify(memecoin: `0x${string}`): Promise<LaunchCheck> {
219
+ const key = memecoin.toLowerCase();
220
+ const cached = this.verdicts.get(key);
221
+ if (cached && cached.until > this.now()) return cached.verdict;
222
+
223
+ // One flight per coin: a burst of coin-page views is one caller as far as the chain is
224
+ // concerned, and without this the first launch of the day is its own thundering herd.
225
+ const existing = this.inFlight.get(key);
226
+ if (existing) return existing;
227
+
228
+ const flight = verifyLaunch(this.client, this.deployment, memecoin)
229
+ .then((verdict) => {
230
+ this.verdicts.set(key, { verdict, until: this.now() + this.holdFor(verdict) });
231
+ return verdict;
232
+ })
233
+ .finally(() => this.inFlight.delete(key));
234
+
235
+ this.inFlight.set(key, flight);
236
+ return flight;
237
+ }
238
+
239
+ private holdFor(verdict: LaunchCheck): number {
240
+ if (verdict.ok) return Math.max(0, verdict.closesAt - this.now());
241
+ return verdict.retryable ? 3_000 : 60_000;
242
+ }
243
+ }
@@ -0,0 +1,118 @@
1
+ import { encodeAbiParameters, hashTypedData, zeroAddress } from 'viem';
2
+ import { privateKeyToAccount, type PrivateKeyAccount } from 'viem/accounts';
3
+ import { spendAuthorisationTypedData } from '@flayerlabs/gamemode-spec/embed';
4
+
5
+ /**
6
+ * Issues the signed authorizations SpendGatedSignerFeeCalculator.trackSwap verifies.
7
+ *
8
+ * EIP-712, over the `SpendAuthorization` struct the calculator declares. It used to be an
9
+ * EIP-191 personal-sign over `keccak256(abi.encodePacked(...))`, which committed to the five
10
+ * fields and nothing else — no chain id, no verifying contract. An audit flagged that as the
11
+ * same defect already raised against the parent calculator (M-6, remediated there in the
12
+ * EIP-712 PR but never ported to this fork): the protocol deploys deterministically across
13
+ * chains, so the same poolId genuinely recurs, and one signing key shared between two
14
+ * environments made an authorization issued for one valid on the other. The domain closes it.
15
+ *
16
+ * The digest MUST match `hashSpendAuthorization` on the calculator exactly. That function is
17
+ * public for this reason — a domain drift is otherwise a silent `InvalidSigner` at swap time
18
+ * rather than a loud failure at signing time. Parity is pinned by test/signer-parity.test.ts
19
+ * against vectors generated with Foundry.
20
+ */
21
+ export interface SignedPayload {
22
+ buyer: `0x${string}`;
23
+ poolId: `0x${string}`;
24
+ deadline: bigint;
25
+ maxSpendWei: bigint;
26
+ nonce: bigint;
27
+ signature: `0x${string}`;
28
+ signer: `0x${string}`;
29
+ }
30
+
31
+ export class PayloadSigner {
32
+ private readonly account: PrivateKeyAccount;
33
+
34
+ /**
35
+ * @param verifyingContract The calculator that will validate these signatures. Binding it is
36
+ * half the point of the domain. Unit tests may use a placeholder because they have no deployment.
37
+ * A production operator must pass the calculator that chain discovery also verifies.
38
+ */
39
+ constructor(
40
+ privateKey: `0x${string}`,
41
+ private readonly chainId: number,
42
+ private readonly verifyingContract: `0x${string}`,
43
+ ) {
44
+ this.account = privateKeyToAccount(privateKey);
45
+ }
46
+
47
+ get address(): `0x${string}` {
48
+ return this.account.address;
49
+ }
50
+
51
+ /** The digest the calculator reconstructs; mirrors its `hashSpendAuthorization`. */
52
+ messageHash(
53
+ buyer: `0x${string}`,
54
+ poolId: `0x${string}`,
55
+ deadline: bigint,
56
+ maxSpendWei: bigint,
57
+ nonce: bigint,
58
+ ): `0x${string}` {
59
+ return hashTypedData(
60
+ spendAuthorisationTypedData(
61
+ { buyer, poolId, deadline, maxSpendWei, nonce },
62
+ this.chainId,
63
+ this.verifyingContract,
64
+ ),
65
+ );
66
+ }
67
+
68
+ async sign(
69
+ buyer: `0x${string}`,
70
+ poolId: `0x${string}`,
71
+ deadline: bigint,
72
+ maxSpendWei: bigint,
73
+ nonce: bigint,
74
+ ): Promise<SignedPayload> {
75
+ const signature = await this.account.signTypedData(
76
+ spendAuthorisationTypedData(
77
+ { buyer, poolId, deadline, maxSpendWei, nonce },
78
+ this.chainId,
79
+ this.verifyingContract,
80
+ ),
81
+ );
82
+ return { buyer, poolId, deadline, maxSpendWei, nonce, signature, signer: this.account.address };
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Encodes a signed payload as the hookData the swap must carry:
88
+ * `abi.encode(address referrer, SignedMessage{buyer, poolId, deadline, maxSpendWei, nonce, signature})`
89
+ */
90
+ export function encodeHookData(payload: SignedPayload): `0x${string}` {
91
+ return encodeAbiParameters(
92
+ [
93
+ { type: 'address' },
94
+ {
95
+ type: 'tuple',
96
+ components: [
97
+ { name: 'buyer', type: 'address' },
98
+ { name: 'poolId', type: 'bytes32' },
99
+ { name: 'deadline', type: 'uint256' },
100
+ { name: 'maxSpendWei', type: 'uint256' },
101
+ { name: 'nonce', type: 'uint256' },
102
+ { name: 'signature', type: 'bytes' },
103
+ ],
104
+ },
105
+ ],
106
+ [
107
+ zeroAddress,
108
+ {
109
+ buyer: payload.buyer,
110
+ poolId: payload.poolId,
111
+ deadline: payload.deadline,
112
+ maxSpendWei: payload.maxSpendWei,
113
+ nonce: payload.nonce,
114
+ signature: payload.signature,
115
+ },
116
+ ],
117
+ );
118
+ }
package/src/claims.ts ADDED
@@ -0,0 +1,140 @@
1
+ import type { Pool } from 'pg';
2
+ import type { PlayerId } from '@flayerlabs/gamemode-spec';
3
+ import type { EconomyBalance } from '@flayerlabs/gamemode-spec/live';
4
+ import { maxUint256 } from 'viem';
5
+ import { Ledger, type BalanceKey, type Claim } from './ledger.js';
6
+ import type { PayloadSigner, SignedPayload } from './chain/signer.js';
7
+
8
+ /**
9
+ * Turning points into something a player can actually spend.
10
+ *
11
+ * Two parties have to agree before a buy can happen, and they check different things. The ledger
12
+ * knows what this player earned and what they have already been authorised for. The signer knows
13
+ * whether it is willing to vouch for this pool at all. Only when both say yes does an
14
+ * authorisation exist.
15
+ *
16
+ * The order matters and is not interchangeable: the hold is recorded first, then signed. A
17
+ * signature that exists without a hold is spending nobody is tracking; a hold without a signature
18
+ * is a refund waiting to happen. Only one of those costs money.
19
+ */
20
+
21
+ /** How long an authorisation stays valid. Long enough to approve in a wallet, short enough that an
22
+ * abandoned one does not sit around spendable. */
23
+ export const CLAIM_AUTHORIZATION_TTL_MS = 5 * 60_000;
24
+ const DEADLINE_SECONDS = CLAIM_AUTHORIZATION_TTL_MS / 1_000;
25
+ const REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
26
+
27
+ /** Request ids cross an unauthenticated parser before they reach Postgres, so bound them here too. */
28
+ export function isClaimRequestId(value: unknown): value is string {
29
+ return typeof value === 'string' && REQUEST_ID.test(value);
30
+ }
31
+
32
+ /** The signed message carries uint256, while JavaScript's bigint has no upper bound. */
33
+ export function isClaimAmount(value: unknown): value is bigint {
34
+ return typeof value === 'bigint' && value > 0n && value <= maxUint256;
35
+ }
36
+
37
+ export type Authorisation =
38
+ | { ok: true; payload: SignedPayload }
39
+ | { ok: false; refuse: string };
40
+
41
+ export class Claims {
42
+ constructor(
43
+ private readonly pool: Pool,
44
+ private readonly ledger: Ledger,
45
+ private readonly signer: PayloadSigner,
46
+ /** Called when a hold could neither be signed nor released, and needs a human. */
47
+ private readonly onStrandedHold?: (nonce: bigint, error: unknown) => void,
48
+ /** An internal seam for exact window-boundary tests. Production uses the process clock. */
49
+ private readonly now: () => number = Date.now,
50
+ ) {}
51
+
52
+ /**
53
+ * Authorise a player to spend up to `upToWei`, or explain why not.
54
+ *
55
+ * The ledger's ceiling is a conservative mirror of the chain's: the contract tracks
56
+ * `walletSpentWei` per pool per wallet and enforces it whatever we believe. Being ahead of the
57
+ * chain is safe — we simply authorise less than we could. Being behind it would mean issuing a
58
+ * signature that reverts at the swap, which to a player is indistinguishable from being robbed.
59
+ */
60
+ async authorise(roundId: string, player: PlayerId, upToWei: bigint, requestId: string): Promise<Authorisation> {
61
+ if (!isClaimRequestId(requestId)) return { ok: false, refuse: 'claim.request_invalid' };
62
+ if (!isClaimAmount(upToWei)) return { ok: false, refuse: 'claim.amount_invalid' };
63
+
64
+ const round = await this.pool.query<{ pool_id: string }>(`select pool_id from rounds where id = $1`, [roundId]);
65
+ const row = round.rows[0];
66
+ if (!row) return { ok: false, refuse: 'claim.no_such_round' };
67
+
68
+ // The deadline is decided before the hold so both agree on when it stops being spendable.
69
+ const now = this.now();
70
+ const deadline = BigInt(Math.floor(now / 1000) + DEADLINE_SECONDS);
71
+ // Idempotency and the clock are decided under the same player lock. If an accepted request is
72
+ // still committing at the buzzer, a retry waits for it and recovers it rather than observing
73
+ // no row outside the transaction and being refused as a new, late request.
74
+ const held = await this.ledger.claim(roundId, player, upToWei, requestId, deadline, now);
75
+ if (!held.ok) return { ok: false, refuse: held.refuse };
76
+
77
+ // Another replica may have completed the request while this one waited for the balance lock.
78
+ if (held.claim.signature && held.claim.signer) {
79
+ return { ok: true, payload: payload(row.pool_id, held.claim) };
80
+ }
81
+ if (held.claim.status === 'released') return { ok: false, refuse: 'claim.request_released' };
82
+ return this.finish(row.pool_id, held.claim);
83
+ }
84
+
85
+ private async finish(poolId: string, claim: Claim): Promise<Authorisation> {
86
+ try {
87
+ const candidate = await this.signer.sign(
88
+ claim.player as `0x${string}`,
89
+ poolId as `0x${string}`,
90
+ claim.deadline,
91
+ claim.wei,
92
+ claim.nonce,
93
+ );
94
+ // Never return bytes that were not durably recorded. Concurrent signers both return the one
95
+ // stored winner, so retries stay byte-identical even if an implementation signs randomly.
96
+ const completed = await this.ledger.completeClaim(claim.nonce, candidate.signature, candidate.signer);
97
+ if (!completed?.signature || !completed.signer) throw new Error('claim could not be completed');
98
+ return { ok: true, payload: payload(poolId, completed) };
99
+ } catch (error) {
100
+ // The hold was taken and nothing will ever be spent against it, so it goes back. Without
101
+ // this, a signer outage quietly eats a player's allowance for the rest of the round.
102
+ //
103
+ // If the release ALSO fails, the original error still wins. Replacing it with a database
104
+ // error would hide the reason and leave the nonce nowhere in the logs — so it is named here,
105
+ // because that number is what a person needs to give the allowance back by hand.
106
+ await this.ledger.releaseUnsigned(claim.nonce).catch((releaseError: unknown) => {
107
+ this.onStrandedHold?.(claim.nonce, releaseError);
108
+ });
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ /** Give back every authorisation that expired unspent. */
114
+ releaseExpired(): Promise<BalanceKey[]> {
115
+ return this.ledger.releaseExpired(Math.floor(this.now() / 1000));
116
+ }
117
+
118
+ /** What this player could spend right now. */
119
+ availableWei(roundId: string, player: PlayerId): Promise<bigint> {
120
+ return this.ledger.availableWei(roundId, player);
121
+ }
122
+
123
+ /** Full authoritative balance for live snapshots. */
124
+ economyBalance(roundId: string, player: PlayerId): Promise<EconomyBalance> {
125
+ return this.ledger.economyBalance(roundId, player);
126
+ }
127
+ }
128
+
129
+ function payload(poolId: string, claim: Claim): SignedPayload {
130
+ if (!claim.signature || !claim.signer) throw new Error('claim is not signed');
131
+ return {
132
+ buyer: claim.player as `0x${string}`,
133
+ poolId: poolId as `0x${string}`,
134
+ deadline: claim.deadline,
135
+ maxSpendWei: claim.wei,
136
+ nonce: claim.nonce,
137
+ signature: claim.signature,
138
+ signer: claim.signer,
139
+ };
140
+ }