@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/claims.ts CHANGED
@@ -2,26 +2,23 @@ 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
4
  import { maxUint256 } from 'viem';
5
- import { Ledger, type BalanceKey, type Claim } from './ledger.js';
5
+ import { Ledger, type Authorisation as StoredAuthorisation } from './ledger.js';
6
6
  import type { PayloadSigner, SignedPayload } from './chain/signer.js';
7
7
 
8
8
  /**
9
9
  * Turning points into something a player can actually spend.
10
10
  *
11
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.
12
+ * knows what this player earned and what the chain says they have spent. The signer knows whether
13
+ * it is willing to vouch for this pool at all. Only when both say yes does an authorisation exist.
15
14
  *
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.
15
+ * What gets signed is a ceiling, not a purchase: the most this wallet may ever have spent on the
16
+ * pool. The calculator compares it against its own running total, so the gate reserves nothing
17
+ * and a signature that is never used costs nobody anything. The one thing this file must get
18
+ * right is idempotency — a retried request must receive the same bytes — and that is a primary
19
+ * key, not a lock.
19
20
  */
20
21
 
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
22
  const REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
26
23
 
27
24
  /** Request ids cross an unauthenticated parser before they reach Postgres, so bound them here too. */
@@ -35,7 +32,7 @@ export function isClaimAmount(value: unknown): value is bigint {
35
32
  }
36
33
 
37
34
  export type Authorisation =
38
- | { ok: true; payload: SignedPayload }
35
+ | { ok: true; payload: SignedPayload; /** Exact-in amount for this buy, clamped to what was available. */ spend: bigint }
39
36
  | { ok: false; refuse: string };
40
37
 
41
38
  export class Claims {
@@ -43,76 +40,68 @@ export class Claims {
43
40
  private readonly pool: Pool,
44
41
  private readonly ledger: Ledger,
45
42
  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
43
  /** An internal seam for exact window-boundary tests. Production uses the process clock. */
49
44
  private readonly now: () => number = Date.now,
50
45
  ) {}
51
46
 
52
47
  /**
53
- * Authorise a player to spend up to `upToWei`, or explain why not.
48
+ * Authorise a player to spend up to `spendWei`, or explain why not.
54
49
  *
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.
50
+ * Returns the largest amount available rather than refusing an over-ask: a player pressing buy
51
+ * wants to spend what they have, and telling them "you asked for too much" when they asked for
52
+ * everything is a worse product than giving them everything. The ceiling in the signature is the
53
+ * same either way — it is what they have earned, not what they asked for.
54
+ *
55
+ * The ledger's view of spend is a conservative mirror of the chain's. Being behind it means
56
+ * signing a ceiling the calculator will refuse at the swap, which to a player is
57
+ * indistinguishable from being robbed; settlement keeps the gap to one reconciliation pass.
59
58
  */
60
- async authorise(roundId: string, player: PlayerId, upToWei: bigint, requestId: string): Promise<Authorisation> {
59
+ async authorise(roundId: string, player: PlayerId, spendWei: bigint, requestId: string): Promise<Authorisation> {
61
60
  if (!isClaimRequestId(requestId)) return { ok: false, refuse: 'claim.request_invalid' };
62
- if (!isClaimAmount(upToWei)) return { ok: false, refuse: 'claim.amount_invalid' };
61
+ if (!isClaimAmount(spendWei)) return { ok: false, refuse: 'claim.amount_invalid' };
63
62
 
64
63
  const round = await this.pool.query<{ pool_id: string }>(`select pool_id from rounds where id = $1`, [roundId]);
65
64
  const row = round.rows[0];
66
65
  if (!row) return { ok: false, refuse: 'claim.no_such_round' };
66
+ const poolId = row.pool_id as `0x${string}`;
67
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 };
68
+ // An existing request deliberately wins over the clock and the current balance: it was issued
69
+ // under the earlier call's window, and a retry must get the same answer.
70
+ const existing = await this.ledger.findAuthorisation(roundId, player, requestId);
71
+ if (existing) return this.answer(poolId, existing, spendWei);
76
72
 
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
- }
73
+ const quote = await this.ledger.quote(roundId, player, this.now());
74
+ if (!quote.ok) return { ok: false, refuse: quote.refuse };
84
75
 
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
- }
76
+ const spend = spendWei < quote.available ? spendWei : quote.available;
77
+ const signed = await this.signer.sign(player as `0x${string}`, poolId, quote.deadlineSeconds, quote.ceiling);
78
+ const stored = await this.ledger.storeAuthorisation(roundId, {
79
+ player,
80
+ requestId,
81
+ requestedWei: spendWei,
82
+ spendCeilingWei: signed.spendCeilingWei,
83
+ spendWei: spend,
84
+ deadline: signed.deadline,
85
+ signature: signed.signature,
86
+ signer: signed.signer,
87
+ });
88
+ return this.answer(poolId, stored, spendWei);
111
89
  }
112
90
 
113
- /** Give back every authorisation that expired unspent. */
114
- releaseExpired(): Promise<BalanceKey[]> {
115
- return this.ledger.releaseExpired(Math.floor(this.now() / 1000));
91
+ private answer(poolId: `0x${string}`, stored: StoredAuthorisation, requestedWei: bigint): Authorisation {
92
+ if (stored.requestedWei !== requestedWei) return { ok: false, refuse: 'claim.request_mismatch' };
93
+ return {
94
+ ok: true,
95
+ payload: {
96
+ buyer: stored.player as `0x${string}`,
97
+ poolId,
98
+ deadline: stored.deadline,
99
+ spendCeilingWei: stored.spendCeilingWei,
100
+ signature: stored.signature,
101
+ signer: stored.signer,
102
+ },
103
+ spend: stored.spendWei,
104
+ };
116
105
  }
117
106
 
118
107
  /** What this player could spend right now. */
@@ -125,16 +114,3 @@ export class Claims {
125
114
  return this.ledger.economyBalance(roundId, player);
126
115
  }
127
116
  }
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
- }
package/src/demo.ts CHANGED
@@ -62,7 +62,7 @@ export interface Demo {
62
62
  /** The throwaway wallet standing in for the player's real one. */
63
63
  walletAddress: `0x${string}`;
64
64
  /** Everything the fake wallet was asked to buy, in order. Empty until somebody buys. */
65
- purchases: { maxSpendWei: string; at: number }[];
65
+ purchases: { spend: string; at: number }[];
66
66
  pool: pg.Pool;
67
67
  }
68
68
 
@@ -160,7 +160,7 @@ export async function createDemoGate<Config, State, Event, Action, PublicView, P
160
160
  });
161
161
 
162
162
  const wallet = privateKeyToAccount(DEMO_WALLET_KEY);
163
- const purchases: { maxSpendWei: string; at: number }[] = [];
163
+ const purchases: { spend: string; at: number }[] = [];
164
164
 
165
165
  // Standing in for the page a game is embedded in. In production this is flaunch.gg holding the
166
166
  // player's real wallet; here the signature is real and the purchase is only written down.
@@ -169,9 +169,9 @@ export async function createDemoGate<Config, State, Event, Action, PublicView, P
169
169
  signature: await wallet.signMessage({ message: (request.body as { message: string }).message }),
170
170
  }));
171
171
  app.post('/dev/wallet/buy', (request) => {
172
- const { maxSpendWei } = request.body as { maxSpendWei: string };
173
- purchases.push({ maxSpendWei, at: Date.now() });
174
- return { spent: maxSpendWei };
172
+ const { spend } = request.body as { spend: string };
173
+ purchases.push({ spend, at: Date.now() });
174
+ return { spent: spend };
175
175
  });
176
176
 
177
177
  return { app, roundId, opensAt, walletAddress: wallet.address, purchases, pool };
package/src/index.ts CHANGED
@@ -23,12 +23,11 @@ export { createTurnstileAdmit } from './turnstile.js';
23
23
  export type { TurnstileAdmitOptions } from './turnstile.js';
24
24
 
25
25
  export { Ledger } from './ledger.js';
26
- export type { BalanceKey, Claim, ClaimResult } from './ledger.js';
27
- export { CLAIM_AUTHORIZATION_TTL_MS } from './claims.js';
26
+ export type { Authorisation, Quote } from './ledger.js';
28
27
  export { PayloadSigner, encodeHookData } from './chain/signer.js';
29
28
  export type { SignedPayload } from './chain/signer.js';
30
- export { Discovery, poolIdOf, verifyLaunch } from './chain/discover.js';
31
- export type { ChainReader, Deployment, Launch, LaunchCheck, PoolKey, Refused } from './chain/discover.js';
29
+ export { Discovery, WiringCache, poolIdOf, resolvePositionManager, verifyLaunch } from './chain/discover.js';
30
+ export type { ChainReader, Deployment, Launch, LaunchCheck, PoolKey, Refused, WiringCheck } from './chain/discover.js';
32
31
  export { Settlement } from './settlement.js';
33
32
  export { Standings } from './standings.js';
34
33
  export { ETH_DECIMALS, resolveEconomy, unitsPerToken } from './economy.js';
@@ -66,12 +65,13 @@ export type { VerifiedPlayerJoin, VerifyPlayerJoinTicketOptions } from './player
66
65
  */
67
66
  export { startGate, resolveGateEnv } from './main.js';
68
67
  export type { ResolvedGateConfig, SpendTokenPricing, StartedGate, StartGateOverrides } from './main.js';
69
- export type { GateAnnouncement, ServedGateAnnouncement } from './server.js';
68
+ export type { GateAnnouncement, PositionManagerPolicy, ServedGateAnnouncement } from './server.js';
70
69
  export { GATE_MARKER, GATE_VERSION } from './version.js';
71
70
  export { NotSignedIn } from './sessions.js';
72
71
  export { DEPLOYMENTS, PAIRED_DEPLOYMENTS, deploymentFor } from './chain/chains.js';
73
72
  export type { FlaunchVariant, GateDeployment } from './chain/chains.js';
74
- export { POSITION_MANAGER_ROLE, assertSecureConfig } from './chain/secure.js';
73
+ export { POSITION_MANAGER_ROLE, assertSecureConfig, checkPositionManagerWiring } from './chain/secure.js';
74
+ export type { PositionManagerWiring } from './chain/secure.js';
75
75
  export { RegistryPrices, minimumWalletCap, withHeadroom } from './chain/registry-prices.js';
76
76
  export type { AnnounceOptions, RegistryPricesOptions, RegistryQuote, RegistryReader } from './chain/registry-prices.js';
77
77