@flayerlabs/gamemode-gate 0.5.5 → 0.6.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 (54) hide show
  1. package/DEPLOY.md +37 -9
  2. package/README.md +7 -1
  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 +8 -4
  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/registry-prices.d.ts +69 -0
  12. package/dist/chain/registry-prices.d.ts.map +1 -0
  13. package/dist/chain/registry-prices.js +157 -0
  14. package/dist/chain/registry-prices.js.map +1 -0
  15. package/dist/chain/secure.d.ts +28 -0
  16. package/dist/chain/secure.d.ts.map +1 -1
  17. package/dist/chain/secure.js +55 -20
  18. package/dist/chain/secure.js.map +1 -1
  19. package/dist/economy.d.ts.map +1 -1
  20. package/dist/economy.js +12 -3
  21. package/dist/economy.js.map +1 -1
  22. package/dist/index.d.ts +7 -4
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/main.d.ts +14 -3
  27. package/dist/main.d.ts.map +1 -1
  28. package/dist/main.js +44 -11
  29. package/dist/main.js.map +1 -1
  30. package/dist/room.d.ts +7 -0
  31. package/dist/room.d.ts.map +1 -1
  32. package/dist/room.js +15 -6
  33. package/dist/room.js.map +1 -1
  34. package/dist/server.d.ts +26 -0
  35. package/dist/server.d.ts.map +1 -1
  36. package/dist/server.js +21 -0
  37. package/dist/server.js.map +1 -1
  38. package/dist/store.d.ts +1 -1
  39. package/dist/store.d.ts.map +1 -1
  40. package/dist/store.js +6 -0
  41. package/dist/store.js.map +1 -1
  42. package/dist/version.js +2 -2
  43. package/package.json +2 -2
  44. package/src/chain/chains.ts +14 -6
  45. package/src/chain/discover.ts +212 -21
  46. package/src/chain/registry-prices.ts +194 -0
  47. package/src/chain/secure.ts +75 -31
  48. package/src/economy.ts +13 -3
  49. package/src/index.ts +7 -4
  50. package/src/main.ts +50 -11
  51. package/src/room.ts +14 -3
  52. package/src/server.ts +49 -0
  53. package/src/store.ts +6 -0
  54. package/src/version.ts +2 -2
@@ -69,57 +69,101 @@ export async function assertSecureConfig(client: PublicClient, config: SecureCon
69
69
  }
70
70
 
71
71
  /**
72
- * The manager must route fees through a dispatcher that knows this calculator, and the calculator
72
+ * The verdict of {@link checkPositionManagerWiring}: wired, with the dispatcher the manager routes
73
+ * through, or not, with a reason written for the operator who has to fix it.
74
+ */
75
+ export type PositionManagerWiring =
76
+ | { ok: true; dispatcher: `0x${string}` }
77
+ | { ok: false; reason: string };
78
+
79
+ /**
80
+ * Is this manager's fee wiring routed to this calculator?
81
+ *
82
+ * The manager must route fees through a dispatcher that knows the calculator, and the calculator
73
83
  * must accept calls from that dispatcher. Both are one-time owner actions on chain, and a stack
74
84
  * missing either ADOPTS launches whose swaps never reach the gate: the pool trades ungated while
75
85
  * the gate believes it is enforcing a cap. That is the whole failure this function refuses.
76
86
  *
77
- * A paired stack additionally needs its PoolSwap approved as a router, because a paired buy arrives
78
- * through it and the calculator binds a smart wallet's buyer identity to an approved router's
79
- * `msgSender()` — unapproved, every such buy reverts at the swap.
87
+ * It is also how a manager is APPROVED for a gate. The gate never holds a list of managers: a coin
88
+ * offered for adoption names its own manager, and the manager is accepted when these three reads
89
+ * say its fees reach the gate's calculator. Wiring a new manager to the dispatcher on chain is the
90
+ * approval, and needs no gate release. Boot asks the same question of the primary manager.
80
91
  */
81
- async function assertWiring(client: PublicClient, config: SecureConfig): Promise<void> {
82
- const manager = config.positionManager!;
83
- const calculator = config.spendGatedCalculator;
92
+ export async function checkPositionManagerWiring(
93
+ client: Pick<PublicClient, 'readContract'>,
94
+ wiring: { positionManager: `0x${string}`; spendGatedCalculator: `0x${string}` },
95
+ ): Promise<PositionManagerWiring> {
96
+ const manager = wiring.positionManager;
97
+ const calculator = wiring.spendGatedCalculator;
84
98
 
85
99
  let dispatcher: `0x${string}`;
86
100
  try {
87
- dispatcher = await client.readContract({ address: manager, abi: wiringAbi, functionName: 'feeCalculator' });
101
+ dispatcher = (await client.readContract({
102
+ address: manager,
103
+ abi: wiringAbi,
104
+ functionName: 'feeCalculator',
105
+ })) as `0x${string}`;
88
106
  } catch {
89
- throw new Error(`POSITION_MANAGER ${manager} does not answer feeCalculator() — is it a Flaunch PositionManager?`);
107
+ return { ok: false, reason: `${manager} does not answer feeCalculator() — is it a Flaunch PositionManager?` };
90
108
  }
91
109
 
92
110
  const [registered, hasRole] = await Promise.all([
93
- client.readContract({
94
- address: dispatcher,
95
- abi: wiringAbi,
96
- functionName: 'registeredCalculators',
97
- args: [calculator],
98
- }).catch(() => false),
99
- client.readContract({
100
- address: calculator,
101
- abi: wiringAbi,
102
- functionName: 'hasRole',
103
- args: [POSITION_MANAGER_ROLE, dispatcher],
104
- }).catch(() => false),
111
+ client
112
+ .readContract({
113
+ address: dispatcher,
114
+ abi: wiringAbi,
115
+ functionName: 'registeredCalculators',
116
+ args: [calculator],
117
+ })
118
+ .catch(() => false),
119
+ client
120
+ .readContract({
121
+ address: calculator,
122
+ abi: wiringAbi,
123
+ functionName: 'hasRole',
124
+ args: [POSITION_MANAGER_ROLE, dispatcher],
125
+ })
126
+ .catch(() => false),
105
127
  ]);
106
128
  if (!registered) {
107
- throw new Error(
108
- `the fee dispatcher ${dispatcher} of POSITION_MANAGER ${manager} does not have SPEND_GATED_CALCULATOR ` +
129
+ return {
130
+ ok: false,
131
+ reason:
132
+ `the fee dispatcher ${dispatcher} of PositionManager ${manager} does not have the spend-gated calculator ` +
109
133
  `${calculator} registered — its owner must call setCalculatorRegistered(${calculator}, true) before ` +
110
- 'this gate can serve launches on that manager',
111
- );
134
+ 'a gate on that calculator can serve launches on that manager',
135
+ };
112
136
  }
113
137
  if (!hasRole) {
114
- throw new Error(
115
- `SPEND_GATED_CALCULATOR ${calculator} has not granted the PositionManager role to the dispatcher ${dispatcher} ` +
116
- `— its admin must call grantRole(${POSITION_MANAGER_ROLE}, ${dispatcher})`,
117
- );
138
+ return {
139
+ ok: false,
140
+ reason:
141
+ `the spend-gated calculator ${calculator} has not granted the PositionManager role to the dispatcher ` +
142
+ `${dispatcher} — its admin must call grantRole(${POSITION_MANAGER_ROLE}, ${dispatcher})`,
143
+ };
118
144
  }
145
+ return { ok: true, dispatcher };
146
+ }
147
+
148
+ /**
149
+ * Boot's assertion about the PRIMARY manager: {@link checkPositionManagerWiring}, thrown rather
150
+ * than returned, because a gate whose own address book is unwired must not come up at all.
151
+ *
152
+ * A paired stack additionally needs its PoolSwap approved as a router, because a paired buy arrives
153
+ * through it and the calculator binds a smart wallet's buyer identity to an approved router's
154
+ * `msgSender()` — unapproved, every such buy reverts at the swap.
155
+ */
156
+ async function assertWiring(client: PublicClient, config: SecureConfig): Promise<void> {
157
+ const calculator = config.spendGatedCalculator;
158
+ const wiring = await checkPositionManagerWiring(client, {
159
+ positionManager: config.positionManager!,
160
+ spendGatedCalculator: calculator,
161
+ });
162
+ if (!wiring.ok) throw new Error(`POSITION_MANAGER: ${wiring.reason}`);
119
163
 
120
- if (config.flaunchVariant === 'v1_3') {
164
+ if (config.flaunchVariant === 'v1_3' || config.flaunchVariant === 'any_v1_3') {
121
165
  if (!config.poolSwap) {
122
- throw new Error('a v1_3 stack needs POOL_SWAP — the router a paired-token buy goes through');
166
+ throw new Error(`a ${config.flaunchVariant} stack needs POOL_SWAP — the router a paired-token buy goes through`);
123
167
  }
124
168
  const approved = await client
125
169
  .readContract({ address: calculator, abi: wiringAbi, functionName: 'approvedRouters', args: [config.poolSwap] })
package/src/economy.ts CHANGED
@@ -81,6 +81,16 @@ function resolveInputs(input: EconomyInput): { usdPerSpendToken: number; decimal
81
81
  * goes and reads this file.
82
82
  */
83
83
  export function resolveEconomy(input: EconomyInput): EconomyResult {
84
+ return evaluate(input, true);
85
+ }
86
+
87
+ /**
88
+ * The check itself. `suggest` is false while {@link minimumPointsPerDollar} is probing candidates:
89
+ * a refusal there must not go looking for a suggestion of its own, or a cap one unit short of
90
+ * fitting recurses until the stack gives out — which is how this function once took a gate down at
91
+ * boot instead of printing the number that fixes it.
92
+ */
93
+ function evaluate(input: EconomyInput, suggest: boolean): EconomyResult {
84
94
  const { maxPointsPerPlayer, walletCap, pointsPerDollar } = input;
85
95
  const { usdPerSpendToken, decimals } = resolveInputs(input);
86
96
 
@@ -125,7 +135,7 @@ export function resolveEconomy(input: EconomyInput): EconomyResult {
125
135
  return {
126
136
  ok: false,
127
137
  problem: 'one point would be worth more than a whole spend token, which cannot be what was meant',
128
- suggestedPointsPerDollar: minimumPointsPerDollar(input),
138
+ suggestedPointsPerDollar: suggest ? minimumPointsPerDollar(input) : pointsPerDollar,
129
139
  };
130
140
  }
131
141
 
@@ -144,7 +154,7 @@ export function resolveEconomy(input: EconomyInput): EconomyResult {
144
154
  return {
145
155
  ok: false,
146
156
  problem: 'a perfect round would earn more than this launch lets one wallet spend',
147
- suggestedPointsPerDollar: minimumPointsPerDollar(input),
157
+ suggestedPointsPerDollar: suggest ? minimumPointsPerDollar(input) : pointsPerDollar,
148
158
  };
149
159
  }
150
160
 
@@ -179,7 +189,7 @@ function minimumPointsPerDollar(input: EconomyInput): number {
179
189
  Number.isSafeInteger(decimals) && decimals >= 0 && decimals <= 36 ? unitsPerToken(decimals) : WEI_PER_ETH;
180
190
  let candidate = Math.ceil(Number(units / maxUnitsPerPoint) / usdPerSpendToken);
181
191
  for (let i = 0; i < 3; i++) {
182
- const result = resolveEconomy({ ...input, pointsPerDollar: candidate });
192
+ const result = evaluate({ ...input, pointsPerDollar: candidate }, false);
183
193
  if (result.ok) return candidate;
184
194
  candidate += 1;
185
195
  }
package/src/index.ts CHANGED
@@ -27,8 +27,8 @@ export type { BalanceKey, Claim, ClaimResult } from './ledger.js';
27
27
  export { CLAIM_AUTHORIZATION_TTL_MS } from './claims.js';
28
28
  export { PayloadSigner, encodeHookData } from './chain/signer.js';
29
29
  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';
30
+ export { Discovery, WiringCache, poolIdOf, resolvePositionManager, verifyLaunch } from './chain/discover.js';
31
+ export type { ChainReader, Deployment, Launch, LaunchCheck, PoolKey, Refused, WiringCheck } from './chain/discover.js';
32
32
  export { Settlement } from './settlement.js';
33
33
  export { Standings } from './standings.js';
34
34
  export { ETH_DECIMALS, resolveEconomy, unitsPerToken } from './economy.js';
@@ -66,12 +66,15 @@ export type { VerifiedPlayerJoin, VerifyPlayerJoinTicketOptions } from './player
66
66
  */
67
67
  export { startGate, resolveGateEnv } from './main.js';
68
68
  export type { ResolvedGateConfig, SpendTokenPricing, StartedGate, StartGateOverrides } from './main.js';
69
- export type { GateAnnouncement, ServedGateAnnouncement } from './server.js';
69
+ export type { GateAnnouncement, PositionManagerPolicy, ServedGateAnnouncement } from './server.js';
70
70
  export { GATE_MARKER, GATE_VERSION } from './version.js';
71
71
  export { NotSignedIn } from './sessions.js';
72
72
  export { DEPLOYMENTS, PAIRED_DEPLOYMENTS, deploymentFor } from './chain/chains.js';
73
73
  export type { FlaunchVariant, GateDeployment } from './chain/chains.js';
74
- export { POSITION_MANAGER_ROLE, assertSecureConfig } from './chain/secure.js';
74
+ export { POSITION_MANAGER_ROLE, assertSecureConfig, checkPositionManagerWiring } from './chain/secure.js';
75
+ export type { PositionManagerWiring } from './chain/secure.js';
76
+ export { RegistryPrices, minimumWalletCap, withHeadroom } from './chain/registry-prices.js';
77
+ export type { AnnounceOptions, RegistryPricesOptions, RegistryQuote, RegistryReader } from './chain/registry-prices.js';
75
78
 
76
79
  export { createGateReadiness } from './readiness.js';
77
80
  export type { GateReadinessOptions } from './readiness.js';
package/src/main.ts CHANGED
@@ -15,6 +15,7 @@ import { migrate } from './store.js';
15
15
  import { resolveEconomy } from './economy.js';
16
16
  import { deploymentFor, type GateDeployment } from './chain/chains.js';
17
17
  import { assertSecureConfig } from './chain/secure.js';
18
+ import { RegistryPrices } from './chain/registry-prices.js';
18
19
 
19
20
  /**
20
21
  * A production gate from an environment, assembled and checked.
@@ -59,6 +60,12 @@ export interface ResolvedGateConfig {
59
60
  * refused at adoption — the gate does not guess a token's price.
60
61
  */
61
62
  spendTokens: ReadonlyMap<string, SpendTokenPricing>;
63
+ /**
64
+ * Percent added over the smallest cap a flawless round fits under when the gate sizes a cap for a
65
+ * registry-priced pairing (`/config/spend-tokens/:address`). Prices move between the quote and the
66
+ * launch; this is the room left for that.
67
+ */
68
+ pairedTokenCapHeadroomPercent: number;
62
69
  /** May lift the gate early or rotate the signer on-chain. Defaults to the signing key's address. */
63
70
  settler: `0x${string}` | null;
64
71
  }
@@ -156,9 +163,11 @@ function address(env: Env, name: string): `0x${string}` | null {
156
163
  * POSITION_MANAGER optional; selects a recorded stack on the chain (the legacy one when
157
164
  * unset), or overrides the address for a chain with no address book
158
165
  * SPEND_GATED_CALCULATOR optional address-book override
159
- * 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
160
167
  * FLETH optional address-book override; the chain's flETH wrapper
161
- * 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)
162
171
  * SETTLER optional; defaults to the signing key's own address
163
172
  * WALLET_CAP_WEI default 0.025 ETH; the cap for ETH-paired launches
164
173
  * ROUND_MS default 90000
@@ -167,7 +176,10 @@ function address(env: Env, name: string): `0x${string}` | null {
167
176
  * POINTS_PER_DOLLAR default 100
168
177
  * USD_PER_ETH default 3000; fractional allowed
169
178
  * SPEND_TOKENS optional JSON array of { address, usdPerToken, walletCap } — the non-ETH
170
- * paired tokens this gate runs rounds on. Unlisted tokens are refused.
179
+ * paired tokens this gate runs rounds on. Pins a price the registry
180
+ * would otherwise quote; tokens neither here nor approved on the
181
+ * manager's PairedTokenRegistry are refused.
182
+ * PAIRED_TOKEN_CAP_HEADROOM_PERCENT default 25; headroom on caps sized for registry-priced pairings
171
183
  */
172
184
  export function resolveGateEnv(env: Env): ResolvedGateConfig {
173
185
  const chainId = Number(required(env, 'CHAIN_ID'));
@@ -190,14 +202,16 @@ export function resolveGateEnv(env: Env): ResolvedGateConfig {
190
202
  variantOverride !== undefined &&
191
203
  variantOverride !== 'v1_2' &&
192
204
  variantOverride !== 'multichain' &&
193
- variantOverride !== 'v1_3'
205
+ variantOverride !== 'v1_3' &&
206
+ variantOverride !== 'any_v1_3'
194
207
  ) {
195
- 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}`);
196
209
  }
197
210
  const flaunchVariant = variantOverride ?? recorded?.flaunchVariant ?? 'v1_2';
198
211
  const poolSwap = address(env, 'POOL_SWAP') ?? recorded?.poolSwap ?? null;
199
- if (flaunchVariant === 'v1_3' && !poolSwap) {
200
- 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`);
201
215
  }
202
216
  const fleth = address(env, 'FLETH') ?? recorded?.fleth ?? null;
203
217
 
@@ -247,6 +261,7 @@ export function resolveGateEnv(env: Env): ResolvedGateConfig {
247
261
  pointsPerDollar: integer(env, 'POINTS_PER_DOLLAR', 100),
248
262
  usdPerEth: positiveNumber(env, 'USD_PER_ETH', 3_000),
249
263
  spendTokens: spendTokens(env),
264
+ pairedTokenCapHeadroomPercent: integer(env, 'PAIRED_TOKEN_CAP_HEADROOM_PERCENT', 25),
250
265
  settler: address(env, 'SETTLER'),
251
266
  };
252
267
  }
@@ -262,9 +277,11 @@ function isEthEquivalent(token: SpendToken, fleth: `0x${string}` | undefined): b
262
277
  * The price list a running gate answers adoption with.
263
278
  *
264
279
  * An override (an oracle, a test) is asked first; the env map second; ETH and its wrapper are the
265
- * operator's `USD_PER_ETH`; anything else is unpriced and the launch is refused.
280
+ * operator's `USD_PER_ETH`; then the manager's own PairedTokenRegistry, which prices every token the
281
+ * protocol has approved through the calculator it was registered with. Only a token none of them
282
+ * know is unpriced, and that launch is refused — it could not have been launched either.
266
283
  */
267
- function priceList(config: ResolvedGateConfig, override: PriceFor | undefined): PriceFor {
284
+ function priceList(config: ResolvedGateConfig, override: PriceFor | undefined, registry: RegistryPrices): PriceFor {
268
285
  return async (token) => {
269
286
  if (override) {
270
287
  const quoted = await override(token);
@@ -273,7 +290,7 @@ function priceList(config: ResolvedGateConfig, override: PriceFor | undefined):
273
290
  const listed = config.spendTokens.get(token.address.toLowerCase());
274
291
  if (listed) return listed.usdPerToken;
275
292
  if (isEthEquivalent(token, config.deployment.fleth)) return config.usdPerEth;
276
- return null;
293
+ return registry.priceFor(token);
277
294
  };
278
295
  }
279
296
 
@@ -365,6 +382,16 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
365
382
  });
366
383
  }
367
384
 
385
+ // Every pairing the protocol approves, priced by the calculator it was registered with. The
386
+ // registry is read from the manager, so nothing is configured and nothing goes stale when a new
387
+ // token is approved: the next launch on it is adopted like any other.
388
+ const registryPrices = new RegistryPrices(client, {
389
+ positionManager: config.deployment.positionManager,
390
+ ...(config.deployment.fleth ? { fleth: config.deployment.fleth } : {}),
391
+ usdPerEth: config.usdPerEth,
392
+ });
393
+ const pairedTokenRegistry = await registryPrices.registryAddress();
394
+
368
395
  const pool = new pg.Pool({ connectionString: config.databaseUrl });
369
396
  await migrate(pool);
370
397
 
@@ -386,7 +413,9 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
386
413
  minLobbyLeadMs: config.minLobbyLeadMs,
387
414
  gateEndsAtGraceS: config.gateEndsAtGraceS,
388
415
  flaunchVariant: config.deployment.flaunchVariant,
416
+ positionManagerPolicy: 'wired-to-calculator',
389
417
  ...(announcedSpendTokens.length > 0 ? { spendTokens: announcedSpendTokens } : {}),
418
+ ...(pairedTokenRegistry ? { pairedTokenRegistry } : {}),
390
419
  requiresEoa: false,
391
420
  accepting: true,
392
421
  publicLaunchesOpen: true,
@@ -399,7 +428,17 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
399
428
  config: gameConfig,
400
429
  pointsPerDollar: config.pointsPerDollar,
401
430
  usdPerEth: config.usdPerEth,
402
- priceFor: priceList(config, overrides.priceProvider),
431
+ priceFor: priceList(config, overrides.priceProvider, registryPrices),
432
+ announceSpendToken: async (address) => {
433
+ // An operator-pinned token is announced exactly as pinned; anything else is the registry's word.
434
+ const pinned = announcedSpendTokens.find((entry) => entry.address.toLowerCase() === address.toLowerCase());
435
+ if (pinned) return pinned;
436
+ return registryPrices.announce(address, {
437
+ maxPointsPerPlayer,
438
+ pointsPerDollar: config.pointsPerDollar,
439
+ headroomPercent: config.pairedTokenCapHeadroomPercent,
440
+ });
441
+ },
403
442
  sessions: new Sessions(config.sessionSecret, config.signInDomain, config.gateOrigin),
404
443
  claims: new Claims(pool, ledger, signer),
405
444
  discovery: new Discovery(client, {
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
 
package/src/server.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';
2
2
  import websocket from '@fastify/websocket';
3
3
  import cors from '@fastify/cors';
4
+ import { isAddress } from 'viem';
4
5
  import type { Pool } from 'pg';
5
6
  import type { Command, GameModule, PlayerId } from '@flayerlabs/gamemode-spec';
6
7
  import { isRefusal } from '@flayerlabs/gamemode-spec';
@@ -58,6 +59,13 @@ interface GateBaseOptions {
58
59
  * refused: a gate that guessed a price would be authorising real money on a made-up number.
59
60
  */
60
61
  priceFor?: PriceFor;
62
+ /**
63
+ * Serves `GET /config/spend-tokens/:address`: the announced entry for one paired token — price,
64
+ * symbol, decimals and the per-wallet cap a launch on it should write — or null to answer 404 for
65
+ * a token this gate will not run rounds on. Lets launch tooling check any pairing before a coin
66
+ * is paid for, without the gate having listed it in `/config` up front.
67
+ */
68
+ announceSpendToken?: (address: `0x${string}`) => Promise<AnnouncedSpendToken | null>;
61
69
  /** Verifies from the chain that a coin really is a game launch. */
62
70
  discovery?: Discovery;
63
71
  /** Keeps the ledger's view of spending level with the chain's. */
@@ -140,6 +148,12 @@ export interface GameServerGateOptions extends GateBaseOptions {
140
148
  gameServerOrigins?: readonly string[];
141
149
  }
142
150
 
151
+ /**
152
+ * How a gate decides which PositionManagers it adopts coins from. One value today; the type exists
153
+ * so a client can compare rather than test for presence, and so a later policy has a name.
154
+ */
155
+ export type PositionManagerPolicy = 'wired-to-calculator';
156
+
143
157
  /**
144
158
  * The gate's public identity, in the exact shape the flaunch frontend's `GateServerConfig` already
145
159
  * parses — served this way so the page that builds launches needs no second config type.
@@ -161,13 +175,27 @@ export interface GateAnnouncement {
161
175
  roundDurationMs: number;
162
176
  minLobbyLeadMs: number;
163
177
  gateEndsAtGraceS: number;
178
+ /** The generation of the PRIMARY manager in `contracts.positionManager`; not a limit on adoption. */
164
179
  flaunchVariant: FlaunchVariant;
180
+ /**
181
+ * How this gate decides which PositionManagers it adopts coins from. `'wired-to-calculator'`
182
+ * means any manager whose fee wiring routes to `contracts.spendGatedCalculator`, derived from the
183
+ * coin itself at adoption; `contracts.positionManager` is then the primary manager, not the only
184
+ * one. A gate that omits the field (below 0.6.0) adopts coins on `contracts.positionManager` only.
185
+ */
186
+ positionManagerPolicy?: PositionManagerPolicy;
165
187
  /**
166
188
  * The non-ETH paired tokens this gate will run rounds on, each with the cap a launch on it should
167
189
  * write. Absent or empty means ETH pairings only. Launch tooling refuses a pairing that is not
168
190
  * listed here, which is the fail-closed answer for a gate that has not been told the token's price.
169
191
  */
170
192
  spendTokens?: AnnouncedSpendToken[];
193
+ /**
194
+ * The manager's PairedTokenRegistry, when it has one. Its presence means this gate prices every
195
+ * token the registry approves, not only `spendTokens`: ask `/config/spend-tokens/:address` for the
196
+ * entry of any pairing before building a launch on it.
197
+ */
198
+ pairedTokenRegistry?: `0x${string}`;
171
199
  requiresEoa: boolean;
172
200
  accepting: boolean;
173
201
  /**
@@ -498,12 +526,17 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
498
526
  name: launch.name,
499
527
  symbol: launch.symbol,
500
528
  imageUrl: launch.imageUrl ?? null,
529
+ positionManager: launch.positionManager ?? null,
501
530
  },
502
531
  });
503
532
  } catch (error) {
504
533
  app.log.error(error);
505
534
  return { ok: false, status: 503, message: 'this game is still being prepared' };
506
535
  }
536
+ app.log.info(
537
+ `${launch.coinAddress}: adopted ${launch.symbol || 'coin'} on position manager ` +
538
+ `${launch.positionManager ?? 'primary'} (pool ${launch.poolId}, ${spendToken.symbol})`,
539
+ );
507
540
  rooms.set(launch.poolId, room);
508
541
  return { ok: true, room, created: true };
509
542
  }
@@ -686,6 +719,22 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
686
719
  const served: ServedGateAnnouncement = { ...announce(), gateVersion: GATE_VERSION };
687
720
  return served;
688
721
  });
722
+ if (options.announceSpendToken) {
723
+ const announceSpendToken = options.announceSpendToken;
724
+ app.get<{ Params: { address: string } }>('/config/spend-tokens/:address', async (request, reply) => {
725
+ void reply.header('access-control-allow-origin', '*');
726
+ // A quote moves with the market and an approval can be revoked; nothing here may be cached
727
+ // past the response.
728
+ void reply.header('cache-control', 'no-store');
729
+ const { address } = request.params;
730
+ if (!isAddress(address, { strict: false })) {
731
+ return reply.code(400).send({ message: 'not a token address' });
732
+ }
733
+ const entry = await announceSpendToken(address as `0x${string}`);
734
+ if (!entry) return reply.code(404).send({ message: 'this gate does not run rounds on that token' });
735
+ return entry;
736
+ });
737
+ }
689
738
  }
690
739
 
691
740
  app.post('/session/challenge', async (request) => {
package/src/store.ts CHANGED
@@ -89,6 +89,12 @@ alter table rounds
89
89
  alter table rounds
90
90
  add column if not exists usd_per_spend_token double precision check (usd_per_spend_token > 0);
91
91
 
92
+ -- Any wired PositionManager (0.6.0). The manager a round's coin was launched on, as the gate
93
+ -- resolved it from the coin at adoption. Null on rounds adopted before the gate recorded it, and on
94
+ -- demo and mock rounds; nothing reads it back for gameplay, it is provenance for the operator.
95
+ alter table rounds
96
+ add column if not exists position_manager text check (position_manager <> '');
97
+
92
98
  create table if not exists balances (
93
99
  round_id text not null references rounds(id) on delete cascade,
94
100
  player text not null,
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.5.5";
3
+ export const GATE_VERSION: string = "0.6.0";
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.5";
5
+ export const GATE_MARKER: string = "@flayerlabs/gamemode-gate@0.6.0";