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