@flayerlabs/gamemode-gate 0.5.6 → 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.
Files changed (74) hide show
  1. package/DEPLOY.md +31 -3
  2. package/README.md +10 -5
  3. package/dist/chain/chains.d.ts +14 -6
  4. package/dist/chain/chains.d.ts.map +1 -1
  5. package/dist/chain/chains.js +11 -5
  6. package/dist/chain/chains.js.map +1 -1
  7. package/dist/chain/discover.d.ts +61 -3
  8. package/dist/chain/discover.d.ts.map +1 -1
  9. package/dist/chain/discover.js +181 -21
  10. package/dist/chain/discover.js.map +1 -1
  11. package/dist/chain/secure.d.ts +28 -0
  12. package/dist/chain/secure.d.ts.map +1 -1
  13. package/dist/chain/secure.js +55 -20
  14. package/dist/chain/secure.js.map +1 -1
  15. package/dist/chain/signer.d.ts +19 -13
  16. package/dist/chain/signer.d.ts.map +1 -1
  17. package/dist/chain/signer.js +11 -10
  18. package/dist/chain/signer.js.map +1 -1
  19. package/dist/claims.d.ts +14 -30
  20. package/dist/claims.d.ts.map +1 -1
  21. package/dist/claims.js +55 -76
  22. package/dist/claims.js.map +1 -1
  23. package/dist/demo.d.ts +1 -1
  24. package/dist/demo.d.ts.map +1 -1
  25. package/dist/demo.js +3 -3
  26. package/dist/demo.js.map +1 -1
  27. package/dist/index.d.ts +6 -6
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -3
  30. package/dist/index.js.map +1 -1
  31. package/dist/ledger.d.ts +39 -45
  32. package/dist/ledger.d.ts.map +1 -1
  33. package/dist/ledger.js +71 -172
  34. package/dist/ledger.js.map +1 -1
  35. package/dist/main.d.ts +4 -2
  36. package/dist/main.d.ts.map +1 -1
  37. package/dist/main.js +11 -6
  38. package/dist/main.js.map +1 -1
  39. package/dist/room.d.ts +16 -0
  40. package/dist/room.d.ts.map +1 -1
  41. package/dist/room.js +29 -6
  42. package/dist/room.js.map +1 -1
  43. package/dist/server.d.ts +20 -0
  44. package/dist/server.d.ts.map +1 -1
  45. package/dist/server.js +43 -34
  46. package/dist/server.js.map +1 -1
  47. package/dist/settlement.d.ts +0 -3
  48. package/dist/settlement.d.ts.map +1 -1
  49. package/dist/settlement.js +12 -44
  50. package/dist/settlement.js.map +1 -1
  51. package/dist/standings.d.ts.map +1 -1
  52. package/dist/standings.js +7 -1
  53. package/dist/standings.js.map +1 -1
  54. package/dist/store.d.ts +1 -1
  55. package/dist/store.d.ts.map +1 -1
  56. package/dist/store.js +36 -19
  57. package/dist/store.js.map +1 -1
  58. package/dist/version.js +2 -2
  59. package/package.json +2 -2
  60. package/src/chain/chains.ts +17 -7
  61. package/src/chain/discover.ts +212 -21
  62. package/src/chain/secure.ts +75 -31
  63. package/src/chain/signer.ts +24 -30
  64. package/src/claims.ts +54 -78
  65. package/src/demo.ts +5 -5
  66. package/src/index.ts +6 -6
  67. package/src/ledger.ts +100 -248
  68. package/src/main.ts +11 -6
  69. package/src/room.ts +29 -3
  70. package/src/server.ts +58 -34
  71. package/src/settlement.ts +12 -43
  72. package/src/standings.ts +9 -1
  73. package/src/store.ts +36 -19
  74. 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 { inTransaction, migrate, toBigInt } from './store.js';
4
+ import { migrate, toBigInt } from './store.js';
5
5
 
6
6
  /**
7
- * What a player has earned, what they have been authorised to spend, and what is still available.
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. Both are checked here.
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
- * Every claim takes a row lock on the player's balance for the length of the transaction. Two
15
- * claims arriving at once therefore serialise, rather than each reading the same balance and each
16
- * deciding it fits. That is the whole reason this is in the database and not in a Map.
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
- export interface Claim {
20
- nonce: bigint;
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
- wei: bigint;
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
- status: 'held' | 'released';
27
- signature: `0x${string}` | null;
28
- signer: `0x${string}` | null;
38
+ signature: `0x${string}`;
39
+ signer: `0x${string}`;
29
40
  }
30
41
 
31
- export type ClaimResult = { ok: true; claim: Claim } | { ok: false; refuse: string };
32
-
33
- export interface BalanceKey {
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
- /** Authorisations still outstanding: issued, not yet expired, fate unknown. */
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
- * The one place the ceiling rule lives.
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
- const ceiling = earned < capWei ? earned : capWei;
60
+ return earned < capWei ? earned : capWei;
61
+ }
66
62
 
67
- // Spent-on-chain PLUS still-outstanding, never the larger of the two.
68
- //
69
- // Taking the larger looked safe and was not. Hold 40, spend it, let the hold expire, then hold
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
- * Authorise up to `upToWei`, or refuse.
78
+ * What the gate would sign for this player right now, or why it would not.
92
79
  *
93
- * Returns the largest amount available rather than refusing an over-ask: a player pressing buy
94
- * wants to spend what they have, and telling them "you asked for too much" when they asked for
95
- * everything is a worse product than giving them everything.
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 claim(
98
- roundId: string,
99
- player: PlayerId,
100
- upToWei: bigint,
101
- requestId: string,
102
- deadline: bigint,
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
- const available = availableFrom(state);
132
- if (available <= 0n) return { ok: false, refuse: 'claim.nothing_available' };
91
+ const available = availableFrom(state);
92
+ if (available <= 0n) return { ok: false, refuse: 'claim.nothing_available' };
133
93
 
134
- const wei = upToWei < available ? upToWei : available;
135
- if (wei <= 0n) return { ok: false, refuse: 'claim.nothing_available' };
136
-
137
- // Record the hold BEFORE anything signs against it. A signature that exists without a hold is
138
- // allowance nobody is tracking; a hold without a signature is merely a refund waiting to
139
- // happen. Only one of those two failures costs money.
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
- /** Store exactly one signature and return the stored winner to every concurrent signer. */
154
- async completeClaim(nonce: bigint, signature: `0x${string}`, signer: `0x${string}`): Promise<Claim | null> {
155
- return inTransaction(this.pool, async (tx) => {
156
- const current = await this.findClaimByNonce(tx, nonce, true);
157
- if (!current || current.status !== 'held') return current?.signature ? current : null;
158
-
159
- if (!current.signature) {
160
- await tx.query(`update claims set signature = $2, signer = $3 where nonce = $1`, [
161
- nonce.toString(),
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
- * Release only a reservation that provably never became a bearer authorisation.
114
+ * Store a signed authorisation and return the one every caller sees.
172
115
  *
173
- * A signed payload cannot be revoked. Releasing one because a wallet dialog was declined would
174
- * let a new nonce spend the same earned allowance while the first payload remained usable.
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 releaseUnsigned(nonce: bigint): Promise<void> {
120
+ async storeAuthorisation(roundId: string, authorisation: Authorisation): Promise<Authorisation> {
177
121
  await this.pool.query(
178
- `update claims set status = 'released'
179
- where nonce = $1 and status = 'held' and signature is null`,
180
- [nonce.toString()],
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 `claim`. */
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
- // 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
- });
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${lock ? ' for update' : ''}`,
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
- held: toBigInt(held.rows[0]?.total),
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 ClaimRow {
361
- nonce: string;
214
+ interface AuthorisationRow {
362
215
  player: string;
363
216
  request_id: string;
364
217
  requested_wei: string;
365
- wei: string;
218
+ spend_ceiling_wei: string;
219
+ spend_wei: string;
366
220
  deadline: string;
367
- status: 'held' | 'released';
368
- signature: `0x${string}` | null;
369
- signer: `0x${string}` | null;
221
+ signature: `0x${string}`;
222
+ signer: `0x${string}`;
370
223
  }
371
224
 
372
- const claimFrom = (row: ClaimRow): Claim => ({
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
- wei: BigInt(row.wei),
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/main.ts CHANGED
@@ -163,9 +163,11 @@ function address(env: Env, name: string): `0x${string}` | null {
163
163
  * POSITION_MANAGER optional; selects a recorded stack on the chain (the legacy one when
164
164
  * unset), or overrides the address for a chain with no address book
165
165
  * SPEND_GATED_CALCULATOR optional address-book override
166
- * POOL_SWAP optional address-book override; required for a v1_3 stack
166
+ * POOL_SWAP optional address-book override; required for a v1_3 or any_v1_3 stack
167
167
  * FLETH optional address-book override; the chain's flETH wrapper
168
- * FLAUNCH_VARIANT optional; v1_2, multichain or v1_3
168
+ * FLAUNCH_VARIANT optional; v1_2, multichain, v1_3 or any_v1_3 — the PRIMARY manager's
169
+ * generation. Adoption is not limited to it: any manager wired to the
170
+ * calculator is served (see chain/discover.ts)
169
171
  * SETTLER optional; defaults to the signing key's own address
170
172
  * WALLET_CAP_WEI default 0.025 ETH; the cap for ETH-paired launches
171
173
  * ROUND_MS default 90000
@@ -200,14 +202,16 @@ export function resolveGateEnv(env: Env): ResolvedGateConfig {
200
202
  variantOverride !== undefined &&
201
203
  variantOverride !== 'v1_2' &&
202
204
  variantOverride !== 'multichain' &&
203
- variantOverride !== 'v1_3'
205
+ variantOverride !== 'v1_3' &&
206
+ variantOverride !== 'any_v1_3'
204
207
  ) {
205
- throw new Error(`FLAUNCH_VARIANT must be v1_2, multichain or v1_3, not ${variantOverride}`);
208
+ throw new Error(`FLAUNCH_VARIANT must be v1_2, multichain, v1_3 or any_v1_3, not ${variantOverride}`);
206
209
  }
207
210
  const flaunchVariant = variantOverride ?? recorded?.flaunchVariant ?? 'v1_2';
208
211
  const poolSwap = address(env, 'POOL_SWAP') ?? recorded?.poolSwap ?? null;
209
- if (flaunchVariant === 'v1_3' && !poolSwap) {
210
- throw new Error('a v1_3 stack needs POOL_SWAP — the approved router a paired-token buy goes through');
212
+ // Both V1_3 generations take paired buys through the approved router.
213
+ if ((flaunchVariant === 'v1_3' || flaunchVariant === 'any_v1_3') && !poolSwap) {
214
+ throw new Error(`a ${flaunchVariant} stack needs POOL_SWAP — the approved router a paired-token buy goes through`);
211
215
  }
212
216
  const fleth = address(env, 'FLETH') ?? recorded?.fleth ?? null;
213
217
 
@@ -409,6 +413,7 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
409
413
  minLobbyLeadMs: config.minLobbyLeadMs,
410
414
  gateEndsAtGraceS: config.gateEndsAtGraceS,
411
415
  flaunchVariant: config.deployment.flaunchVariant,
416
+ positionManagerPolicy: 'wired-to-calculator',
412
417
  ...(announcedSpendTokens.length > 0 ? { spendTokens: announcedSpendTokens } : {}),
413
418
  ...(pairedTokenRegistry ? { pairedTokenRegistry } : {}),
414
419
  requiresEoa: false,
package/src/room.ts CHANGED
@@ -42,6 +42,12 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
42
42
  readonly terms: RoundTerms,
43
43
  readonly scoreAuthority: ScoreAuthority,
44
44
  private seq: number,
45
+ /**
46
+ * The PositionManager the coin was launched on, as the gate resolved it at adoption. Null for
47
+ * rounds adopted before the gate recorded it (below 0.6.0), and for demo and mock rooms. Kept
48
+ * off `LaunchContext`, which is the wire type games receive.
49
+ */
50
+ readonly positionManager: string | null = null,
45
51
  ) {}
46
52
 
47
53
  static async create<Config, State, Event, Action, PublicView, PlayerView>(
@@ -68,6 +74,7 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
68
74
  name?: string;
69
75
  symbol?: string;
70
76
  imageUrl?: string | null;
77
+ positionManager?: string | null;
71
78
  };
72
79
  },
73
80
  ): Promise<Room<Config, State, Event, Action, PublicView, PlayerView>> {
@@ -109,8 +116,8 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
109
116
  spend_token, spend_symbol, spend_decimals, usd_per_spend_token,
110
117
  max_points_per_player, score_authority,
111
118
  pool_id, opens_at, closes_at, books_close_at,
112
- coin_address, coin_name, coin_symbol, coin_image_url, state, seq
113
- ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, 0)`,
119
+ coin_address, coin_name, coin_symbol, coin_image_url, position_manager, state, seq
120
+ ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, 0)`,
114
121
  [
115
122
  round.id,
116
123
  game.id,
@@ -132,6 +139,7 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
132
139
  launch.name,
133
140
  launch.symbol,
134
141
  launch.imageUrl,
142
+ round.launch?.positionManager?.toLowerCase() ?? null,
135
143
  serialise(game.id, started.snapshot()),
136
144
  ],
137
145
  );
@@ -145,6 +153,7 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
145
153
  terms,
146
154
  round.scoreAuthority,
147
155
  0,
156
+ round.launch?.positionManager?.toLowerCase() ?? null,
148
157
  );
149
158
  }
150
159
 
@@ -165,6 +174,7 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
165
174
  coin_name: string;
166
175
  coin_symbol: string;
167
176
  coin_image_url: string | null;
177
+ position_manager: string | null;
168
178
  wei_per_point: string;
169
179
  wallet_cap_wei: string;
170
180
  points_per_dollar: number;
@@ -181,7 +191,7 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
181
191
  wallet_cap_wei, points_per_dollar, usd_per_eth,
182
192
  spend_token, spend_symbol, spend_decimals, usd_per_spend_token,
183
193
  max_points_per_player, score_authority, game_id,
184
- coin_address, coin_name, coin_symbol, coin_image_url
194
+ coin_address, coin_name, coin_symbol, coin_image_url, position_manager
185
195
  from rounds where id = $1`,
186
196
  [id],
187
197
  );
@@ -225,6 +235,7 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
225
235
  terms,
226
236
  row.score_authority,
227
237
  row.seq,
238
+ row.position_manager,
228
239
  );
229
240
  }
230
241
 
@@ -361,6 +372,21 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
361
372
  return this.seq;
362
373
  }
363
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
+
364
390
  }
365
391
 
366
392
  /**