@flayerlabs/gamemode-gate 0.6.0 → 0.7.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.
- 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/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 +40 -34
- 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 +30 -19
- 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/room.ts +15 -0
- package/src/server.ts +39 -34
- package/src/settlement.ts +12 -43
- package/src/standings.ts +9 -1
- package/src/store.ts +30 -19
- package/src/version.ts +2 -2
package/src/ledger.ts
CHANGED
|
@@ -1,39 +1,47 @@
|
|
|
1
1
|
import type { Pool } from 'pg';
|
|
2
2
|
import type { PlayerId } from '@flayerlabs/gamemode-spec';
|
|
3
3
|
import type { EconomyBalance } from '@flayerlabs/gamemode-spec/live';
|
|
4
|
-
import {
|
|
4
|
+
import { migrate, toBigInt } from './store.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* What a player has earned, what
|
|
7
|
+
* What a player has earned, what the chain says they have spent, and what is still available.
|
|
8
8
|
*
|
|
9
9
|
* Two ceilings bind, and they are different in kind. The first is what the game says they earned.
|
|
10
10
|
* The second is the pool's on-chain per-wallet cap, which the contract enforces whatever this file
|
|
11
11
|
* believes — so issuing past it does not overspend, it produces an authorisation that reverts at
|
|
12
|
-
* the swap and looks to the player like theft.
|
|
12
|
+
* the swap and looks to the player like theft. The smaller of the two is the CEILING, and the
|
|
13
|
+
* ceiling is what the gate signs: not the amount of one purchase, but the most this wallet may ever
|
|
14
|
+
* have spent on this pool. The calculator keeps its own `walletSpentWei` and refuses any swap that
|
|
15
|
+
* would carry the total past the ceiling it was handed.
|
|
13
16
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
* Nothing is held. Two authorisations with the same ceiling cannot add up, so an unused one costs
|
|
18
|
+
* nothing, a declined wallet dialog changes nothing, and `available` moves only when the chain
|
|
19
|
+
* shows a purchase (down) or the game awards points (up). The per-request lock this file used to
|
|
20
|
+
* take is gone with the reservation it protected; idempotency is the primary key on
|
|
21
|
+
* `authorisations` instead.
|
|
22
|
+
*
|
|
23
|
+
* One round is one pool. The pool-level `walletSpentWei` is therefore the round's spent figure,
|
|
24
|
+
* recorded per (round, player) in `balances.chain_spent_wei`.
|
|
17
25
|
*/
|
|
18
26
|
|
|
19
|
-
|
|
20
|
-
|
|
27
|
+
/** A signed ceiling the gate handed out, kept so a retry can get the same bytes back. */
|
|
28
|
+
export interface Authorisation {
|
|
21
29
|
player: PlayerId;
|
|
22
30
|
requestId: string;
|
|
31
|
+
/** What the caller asked to spend, before clamping. */
|
|
23
32
|
requestedWei: bigint;
|
|
24
|
-
|
|
33
|
+
/** The signed cumulative ceiling: min(earned, wallet cap) at issue. */
|
|
34
|
+
spendCeilingWei: bigint;
|
|
35
|
+
/** The exact-in amount for this buy, clamped to what was available. Not signed. */
|
|
36
|
+
spendWei: bigint;
|
|
25
37
|
deadline: bigint;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
signer: `0x${string}` | null;
|
|
38
|
+
signature: `0x${string}`;
|
|
39
|
+
signer: `0x${string}`;
|
|
29
40
|
}
|
|
30
41
|
|
|
31
|
-
export type
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
roundId: string;
|
|
35
|
-
player: PlayerId;
|
|
36
|
-
}
|
|
42
|
+
export type Quote =
|
|
43
|
+
| { ok: true; ceiling: bigint; available: bigint; deadlineSeconds: bigint }
|
|
44
|
+
| { ok: false; refuse: string };
|
|
37
45
|
|
|
38
46
|
interface LedgerState {
|
|
39
47
|
points: bigint;
|
|
@@ -41,44 +49,23 @@ interface LedgerState {
|
|
|
41
49
|
capWei: bigint;
|
|
42
50
|
opensAt: number;
|
|
43
51
|
closesAt: number;
|
|
44
|
-
|
|
45
|
-
held: bigint;
|
|
52
|
+
booksCloseAt: number;
|
|
46
53
|
/** What the chain says has actually been spent. */
|
|
47
54
|
chainSpentWei: bigint;
|
|
48
|
-
/**
|
|
49
|
-
* Deadline of the earliest outstanding hold, in epoch SECONDS, or null when nothing is held.
|
|
50
|
-
*
|
|
51
|
-
* The earliest rather than the latest, because it is the first moment any allowance comes back.
|
|
52
|
-
*/
|
|
53
|
-
holdExpiresAtSeconds: number | null;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
*
|
|
59
|
-
* Two limits bind and the smaller wins: what the game says was earned, and what the pool lets one
|
|
60
|
-
* wallet spend. Issuing past the second does not overspend — the chain refuses it — it mints a
|
|
61
|
-
* signature that reverts at the swap, which to a player is indistinguishable from being robbed.
|
|
62
|
-
*/
|
|
63
|
-
function availableFrom({ points, unitsPerPoint, capWei, held, chainSpentWei }: LedgerState): bigint {
|
|
57
|
+
/** The one place the ceiling rule lives: the smaller of what was earned and what the pool allows. */
|
|
58
|
+
function ceilingFrom({ points, unitsPerPoint, capWei }: LedgerState): bigint {
|
|
64
59
|
const earned = points * unitsPerPoint;
|
|
65
|
-
|
|
60
|
+
return earned < capWei ? earned : capWei;
|
|
61
|
+
}
|
|
66
62
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
// 60: the chain has 40 gone and 60 is outstanding, so nothing remains — but `max(60, 40)` says
|
|
71
|
-
// 40 is available and a further 40 gets authorised. Against a cap of 100 that is 140 of live
|
|
72
|
-
// authorisations, and the surplus does not overspend, it reverts at the swap, which to a player
|
|
73
|
-
// is indistinguishable from being robbed.
|
|
74
|
-
//
|
|
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
|
-
const available = ceiling - (chainSpentWei + held);
|
|
63
|
+
/** What a signed ceiling would let this wallet add right now. */
|
|
64
|
+
function availableFrom(state: LedgerState): bigint {
|
|
65
|
+
const available = ceilingFrom(state) - state.chainSpentWei;
|
|
78
66
|
return available > 0n ? available : 0n;
|
|
79
67
|
}
|
|
80
68
|
|
|
81
|
-
|
|
82
69
|
export class Ledger {
|
|
83
70
|
constructor(private readonly pool: Pool) {}
|
|
84
71
|
|
|
@@ -88,97 +75,69 @@ export class Ledger {
|
|
|
88
75
|
}
|
|
89
76
|
|
|
90
77
|
/**
|
|
91
|
-
*
|
|
78
|
+
* What the gate would sign for this player right now, or why it would not.
|
|
92
79
|
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
80
|
+
* The deadline is the round's books close: nothing new is issued after `closesAt`, ceilings are
|
|
81
|
+
* monotone so a long-lived signature is never over-generous, and a cached authorisation must stay
|
|
82
|
+
* returnable for as long as a retry can arrive.
|
|
96
83
|
*/
|
|
97
|
-
async
|
|
98
|
-
roundId
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
at: number = Date.now(),
|
|
104
|
-
): Promise<ClaimResult> {
|
|
105
|
-
return inTransaction(this.pool, async (tx) => {
|
|
106
|
-
// The lock is the point. Held for the transaction, it serialises every concurrent claim by
|
|
107
|
-
// this player, so two requests cannot both read the same balance and both decide they fit.
|
|
108
|
-
//
|
|
109
|
-
// It only works because a claim needs a balance row to exist: `for update` on a row that is
|
|
110
|
-
// not there locks NOTHING, and would serialise nothing. The `points <= 0` refusal below is
|
|
111
|
-
// therefore load-bearing rather than a convenience — it is what guarantees that by the time
|
|
112
|
-
// any claim proceeds past this point, there is a row holding the lock. Do not reorder it, and
|
|
113
|
-
// do not make the ledger issue against a player it has never credited.
|
|
114
|
-
const state = await this.readState(tx, roundId, player, true);
|
|
115
|
-
if (!state) return { ok: false, refuse: 'claim.no_such_round' };
|
|
116
|
-
|
|
117
|
-
// Inside the same player lock as the balance calculation. Two replicas handling one retried
|
|
118
|
-
// HTTP request therefore converge on one reservation instead of racing an uncommitted row.
|
|
119
|
-
const existing = await this.findClaimWith(tx, roundId, player, requestId);
|
|
120
|
-
if (existing) {
|
|
121
|
-
if (existing.requestedWei !== upToWei) return { ok: false, refuse: 'claim.request_mismatch' };
|
|
122
|
-
return { ok: true, claim: existing };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Existing requests deliberately win over the clock: they were issued under the earlier
|
|
126
|
-
// call's window. Only a genuinely new reservation is subject to the current instant.
|
|
127
|
-
if (at < state.opensAt) return { ok: false, refuse: 'claim.not_open_yet' };
|
|
128
|
-
if (at >= state.closesAt) return { ok: false, refuse: 'claim.window_closed' };
|
|
129
|
-
if (state.points <= 0n) return { ok: false, refuse: 'claim.nothing_earned' };
|
|
84
|
+
async quote(roundId: string, player: PlayerId, at: number = Date.now()): Promise<Quote> {
|
|
85
|
+
const state = await this.readState(this.pool, roundId, player);
|
|
86
|
+
if (!state) return { ok: false, refuse: 'claim.no_such_round' };
|
|
87
|
+
if (at < state.opensAt) return { ok: false, refuse: 'claim.not_open_yet' };
|
|
88
|
+
if (at >= state.closesAt) return { ok: false, refuse: 'claim.window_closed' };
|
|
89
|
+
if (state.points <= 0n) return { ok: false, refuse: 'claim.nothing_earned' };
|
|
130
90
|
|
|
131
|
-
|
|
132
|
-
|
|
91
|
+
const available = availableFrom(state);
|
|
92
|
+
if (available <= 0n) return { ok: false, refuse: 'claim.nothing_available' };
|
|
133
93
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const inserted = await tx.query<ClaimRow>(
|
|
141
|
-
`insert into claims (round_id, player, request_id, requested_wei, wei, status, deadline)
|
|
142
|
-
values ($1, $2, $3, $4, $5, 'held', $6)
|
|
143
|
-
returning nonce::text, player, request_id, requested_wei::text, wei::text, deadline::text,
|
|
144
|
-
status, signature, signer`,
|
|
145
|
-
[roundId, player, requestId, upToWei.toString(), wei.toString(), deadline.toString()],
|
|
146
|
-
);
|
|
147
|
-
const created = inserted.rows[0];
|
|
148
|
-
if (!created) throw new Error(`${roundId}: claim reservation disappeared before commit`);
|
|
149
|
-
return { ok: true, claim: claimFrom(created) };
|
|
150
|
-
});
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
ceiling: ceilingFrom(state),
|
|
97
|
+
available,
|
|
98
|
+
deadlineSeconds: BigInt(Math.ceil(state.booksCloseAt / 1000)),
|
|
99
|
+
};
|
|
151
100
|
}
|
|
152
101
|
|
|
153
|
-
/**
|
|
154
|
-
async
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
signature,
|
|
163
|
-
signer,
|
|
164
|
-
]);
|
|
165
|
-
}
|
|
166
|
-
return this.findClaimByNonce(tx, nonce);
|
|
167
|
-
});
|
|
102
|
+
/** The authorisation already issued for this request, if any. */
|
|
103
|
+
async findAuthorisation(roundId: string, player: PlayerId, requestId: string): Promise<Authorisation | null> {
|
|
104
|
+
const result = await this.pool.query<AuthorisationRow>(
|
|
105
|
+
`select player, request_id, requested_wei::text, spend_ceiling_wei::text, spend_wei::text,
|
|
106
|
+
deadline::text, signature, signer
|
|
107
|
+
from authorisations where round_id = $1 and player = $2 and request_id = $3`,
|
|
108
|
+
[roundId, player, requestId],
|
|
109
|
+
);
|
|
110
|
+
return result.rows[0] ? authorisationFrom(result.rows[0]) : null;
|
|
168
111
|
}
|
|
169
112
|
|
|
170
113
|
/**
|
|
171
|
-
*
|
|
114
|
+
* Store a signed authorisation and return the one every caller sees.
|
|
172
115
|
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
116
|
+
* Two replicas answering one retried request both insert; the primary key lets exactly one win,
|
|
117
|
+
* and both return the winner. Never hand out bytes that were not durably recorded, or a retry
|
|
118
|
+
* could receive a different signature for the same request id.
|
|
175
119
|
*/
|
|
176
|
-
async
|
|
120
|
+
async storeAuthorisation(roundId: string, authorisation: Authorisation): Promise<Authorisation> {
|
|
177
121
|
await this.pool.query(
|
|
178
|
-
`
|
|
179
|
-
|
|
180
|
-
|
|
122
|
+
`insert into authorisations
|
|
123
|
+
(round_id, player, request_id, requested_wei, spend_ceiling_wei, spend_wei, deadline, signature, signer)
|
|
124
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
125
|
+
on conflict (round_id, player, request_id) do nothing`,
|
|
126
|
+
[
|
|
127
|
+
roundId,
|
|
128
|
+
authorisation.player,
|
|
129
|
+
authorisation.requestId,
|
|
130
|
+
authorisation.requestedWei.toString(),
|
|
131
|
+
authorisation.spendCeilingWei.toString(),
|
|
132
|
+
authorisation.spendWei.toString(),
|
|
133
|
+
authorisation.deadline.toString(),
|
|
134
|
+
authorisation.signature,
|
|
135
|
+
authorisation.signer,
|
|
136
|
+
],
|
|
181
137
|
);
|
|
138
|
+
const stored = await this.findAuthorisation(roundId, authorisation.player, authorisation.requestId);
|
|
139
|
+
if (!stored) throw new Error(`${roundId}: authorisation disappeared before it could be read back`);
|
|
140
|
+
return stored;
|
|
182
141
|
}
|
|
183
142
|
|
|
184
143
|
/**
|
|
@@ -195,39 +154,6 @@ export class Ledger {
|
|
|
195
154
|
return result.rowCount === 1;
|
|
196
155
|
}
|
|
197
156
|
|
|
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
|
-
|
|
231
157
|
/** Everyone with a balance in this round, for the reconciler to ask the chain about. */
|
|
232
158
|
async playersIn(roundId: string): Promise<PlayerId[]> {
|
|
233
159
|
const result = await this.pool.query<{ player: string }>(
|
|
@@ -237,87 +163,41 @@ export class Ledger {
|
|
|
237
163
|
return result.rows.map((row) => row.player);
|
|
238
164
|
}
|
|
239
165
|
|
|
240
|
-
/**
|
|
241
|
-
* Give back every hold whose deadline has passed.
|
|
242
|
-
*
|
|
243
|
-
* An expired authorisation cannot be spent — the contract checks the same deadline — so a hold
|
|
244
|
-
* still sitting against it is allowance the player earned, cannot use, and would never get back.
|
|
245
|
-
* A player who opens the buy dialog and wanders off would otherwise lose it for the round.
|
|
246
|
-
*
|
|
247
|
-
* Returns how many were returned, so a caller can log something worth reading.
|
|
248
|
-
*/
|
|
249
|
-
async releaseExpired(nowSeconds: number): Promise<BalanceKey[]> {
|
|
250
|
-
const result = await this.pool.query<{ round_id: string; player: PlayerId }>(
|
|
251
|
-
`update claims set status = 'released'
|
|
252
|
-
where status = 'held' and deadline < $1
|
|
253
|
-
returning round_id, player`,
|
|
254
|
-
[nowSeconds],
|
|
255
|
-
);
|
|
256
|
-
const unique = new Map(result.rows.map((row) => [
|
|
257
|
-
`${row.round_id}\u0000${row.player}`,
|
|
258
|
-
{ roundId: row.round_id, player: row.player },
|
|
259
|
-
]));
|
|
260
|
-
return [...unique.values()];
|
|
261
|
-
}
|
|
262
|
-
|
|
263
166
|
async available(roundId: string, player: PlayerId): Promise<bigint> {
|
|
264
167
|
return (await this.economyBalance(roundId, player)).available;
|
|
265
168
|
}
|
|
266
169
|
|
|
267
|
-
/** The authoritative balance snapshot, derived from the same state and ceiling as `
|
|
170
|
+
/** The authoritative balance snapshot, derived from the same state and ceiling as `quote`. */
|
|
268
171
|
async economyBalance(roundId: string, player: PlayerId): Promise<EconomyBalance> {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
await tx.query('set transaction isolation level repeatable read, read only');
|
|
272
|
-
return this.readState(tx, roundId, player);
|
|
273
|
-
});
|
|
274
|
-
if (!state) {
|
|
275
|
-
return { unitsPerPoint: 0n, earned: 0n, held: 0n, spent: 0n, available: 0n, holdExpiresAt: null };
|
|
276
|
-
}
|
|
172
|
+
const state = await this.readState(this.pool, roundId, player);
|
|
173
|
+
if (!state) return { unitsPerPoint: 0n, earned: 0n, spent: 0n, available: 0n };
|
|
277
174
|
|
|
278
175
|
return {
|
|
279
176
|
unitsPerPoint: state.unitsPerPoint,
|
|
280
177
|
earned: state.points * state.unitsPerPoint,
|
|
281
|
-
held: state.held,
|
|
282
178
|
spent: state.chainSpentWei,
|
|
283
179
|
available: availableFrom(state),
|
|
284
|
-
// Seconds on the way in, because that is what the contract checks against; milliseconds on
|
|
285
|
-
// the way out, because that is what a browser counts down with.
|
|
286
|
-
holdExpiresAt: state.holdExpiresAtSeconds === null ? null : state.holdExpiresAtSeconds * 1000,
|
|
287
180
|
};
|
|
288
181
|
}
|
|
289
182
|
|
|
290
|
-
private async readState(
|
|
291
|
-
q: Pick<Pool, 'query'>,
|
|
292
|
-
roundId: string,
|
|
293
|
-
player: PlayerId,
|
|
294
|
-
lock = false,
|
|
295
|
-
): Promise<LedgerState | null> {
|
|
183
|
+
private async readState(q: Pick<Pool, 'query'>, roundId: string, player: PlayerId): Promise<LedgerState | null> {
|
|
296
184
|
const round = await q.query<{
|
|
297
185
|
wei_per_point: string;
|
|
298
186
|
wallet_cap_wei: string;
|
|
299
187
|
opens_at: string;
|
|
300
188
|
closes_at: string;
|
|
189
|
+
books_close_at: string;
|
|
301
190
|
}>(
|
|
302
|
-
`select wei_per_point, wallet_cap_wei, opens_at, closes_at from rounds where id = $1`,
|
|
191
|
+
`select wei_per_point, wallet_cap_wei, opens_at, closes_at, books_close_at from rounds where id = $1`,
|
|
303
192
|
[roundId],
|
|
304
193
|
);
|
|
305
194
|
const row = round.rows[0];
|
|
306
195
|
if (!row) return null;
|
|
307
196
|
|
|
308
197
|
const balance = await q.query<{ points: string; chain_spent_wei: string }>(
|
|
309
|
-
`select points, chain_spent_wei from balances where round_id = $1 and player = $2
|
|
198
|
+
`select points, chain_spent_wei from balances where round_id = $1 and player = $2`,
|
|
310
199
|
[roundId, player],
|
|
311
200
|
);
|
|
312
|
-
// Sum and earliest deadline together: they come from the same rows, and asking twice would let
|
|
313
|
-
// a hold expire between the two reads and report a total that no deadline explains.
|
|
314
|
-
const held = await q.query<{ total: string; earliest: string | null }>(
|
|
315
|
-
`select coalesce(sum(wei), 0)::text as total, min(deadline)::text as earliest from claims
|
|
316
|
-
where round_id = $1 and player = $2 and status = 'held'`,
|
|
317
|
-
[roundId, player],
|
|
318
|
-
);
|
|
319
|
-
|
|
320
|
-
const earliest = held.rows[0]?.earliest;
|
|
321
201
|
|
|
322
202
|
return {
|
|
323
203
|
points: toBigInt(balance.rows[0]?.points),
|
|
@@ -326,57 +206,29 @@ export class Ledger {
|
|
|
326
206
|
capWei: toBigInt(row.wallet_cap_wei),
|
|
327
207
|
opensAt: Number(row.opens_at),
|
|
328
208
|
closesAt: Number(row.closes_at),
|
|
329
|
-
|
|
330
|
-
holdExpiresAtSeconds: earliest == null ? null : Number(earliest),
|
|
209
|
+
booksCloseAt: Number(row.books_close_at),
|
|
331
210
|
};
|
|
332
211
|
}
|
|
333
|
-
|
|
334
|
-
private async findClaimWith(
|
|
335
|
-
q: Pick<Pool, 'query'>,
|
|
336
|
-
roundId: string,
|
|
337
|
-
player: PlayerId,
|
|
338
|
-
requestId: string,
|
|
339
|
-
): Promise<Claim | null> {
|
|
340
|
-
const result = await q.query<ClaimRow>(
|
|
341
|
-
`select nonce::text, player, request_id, requested_wei::text, wei::text, deadline::text,
|
|
342
|
-
status, signature, signer
|
|
343
|
-
from claims where round_id = $1 and player = $2 and request_id = $3`,
|
|
344
|
-
[roundId, player, requestId],
|
|
345
|
-
);
|
|
346
|
-
return result.rows[0] ? claimFrom(result.rows[0]) : null;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
private async findClaimByNonce(q: Pick<Pool, 'query'>, nonce: bigint, lock = false): Promise<Claim | null> {
|
|
350
|
-
const result = await q.query<ClaimRow>(
|
|
351
|
-
`select nonce::text, player, request_id, requested_wei::text, wei::text, deadline::text,
|
|
352
|
-
status, signature, signer
|
|
353
|
-
from claims where nonce = $1${lock ? ' for update' : ''}`,
|
|
354
|
-
[nonce.toString()],
|
|
355
|
-
);
|
|
356
|
-
return result.rows[0] ? claimFrom(result.rows[0]) : null;
|
|
357
|
-
}
|
|
358
212
|
}
|
|
359
213
|
|
|
360
|
-
interface
|
|
361
|
-
nonce: string;
|
|
214
|
+
interface AuthorisationRow {
|
|
362
215
|
player: string;
|
|
363
216
|
request_id: string;
|
|
364
217
|
requested_wei: string;
|
|
365
|
-
|
|
218
|
+
spend_ceiling_wei: string;
|
|
219
|
+
spend_wei: string;
|
|
366
220
|
deadline: string;
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
signer: `0x${string}` | null;
|
|
221
|
+
signature: `0x${string}`;
|
|
222
|
+
signer: `0x${string}`;
|
|
370
223
|
}
|
|
371
224
|
|
|
372
|
-
const
|
|
373
|
-
nonce: BigInt(row.nonce),
|
|
225
|
+
const authorisationFrom = (row: AuthorisationRow): Authorisation => ({
|
|
374
226
|
player: row.player,
|
|
375
227
|
requestId: row.request_id,
|
|
376
228
|
requestedWei: BigInt(row.requested_wei),
|
|
377
|
-
|
|
229
|
+
spendCeilingWei: BigInt(row.spend_ceiling_wei),
|
|
230
|
+
spendWei: BigInt(row.spend_wei),
|
|
378
231
|
deadline: BigInt(row.deadline),
|
|
379
|
-
status: row.status,
|
|
380
232
|
signature: row.signature,
|
|
381
233
|
signer: row.signer,
|
|
382
234
|
});
|
package/src/room.ts
CHANGED
|
@@ -372,6 +372,21 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
372
372
|
return this.seq;
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Record that the round is final: every landed buy is in `spent` and nothing will change again.
|
|
377
|
+
*
|
|
378
|
+
* The server calls this once, after its last clean settlement pass past `booksCloseAt`, before it
|
|
379
|
+
* closes the room's sockets. Idempotent — the first stamp wins, so a retried teardown after a
|
|
380
|
+
* failed socket close cannot move the moment. It is what `GET /coins/:coin/rankings` reads back
|
|
381
|
+
* as `settledAt`/`final`.
|
|
382
|
+
*/
|
|
383
|
+
async markSettled(at: number): Promise<void> {
|
|
384
|
+
await this.pool.query(`update rounds set settled_at = $1 where id = $2 and settled_at is null`, [
|
|
385
|
+
at,
|
|
386
|
+
this.id,
|
|
387
|
+
]);
|
|
388
|
+
}
|
|
389
|
+
|
|
375
390
|
}
|
|
376
391
|
|
|
377
392
|
/**
|