@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
@@ -1,5 +1,6 @@
1
1
  import { encodeAbiParameters, keccak256, parseAbi, type PublicClient } from 'viem';
2
2
  import { ETH_SPEND_TOKEN, type SpendToken } from '@flayerlabs/gamemode-spec/live';
3
+ import { checkPositionManagerWiring, type PositionManagerWiring } from './secure.js';
3
4
 
4
5
  /**
5
6
  * All this needs of a client: the ability to read a contract.
@@ -22,11 +23,15 @@ export type ChainReader = Pick<PublicClient, 'readContract'>;
22
23
  * can forge. So adoption needs no API key and no allowlist — anyone may ask, and the chain decides.
23
24
  * That single property is what makes third-party games permissionless.
24
25
  *
25
- * A gate serves exactly one (PositionManager, calculator) pair and adopts only coins launched on
26
- * THAT manager: `poolKey(coin)` is asked of the configured manager alone, so a coin on a superseded
27
- * generation of hooks (which keeps trading on its own gate) reads back as not a launch here. That
28
- * is deliberate — the EIP-712 domain binds every authorisation to the calculator, so a coin this
29
- * gate cannot sign for must not be given a room by it.
26
+ * A gate serves one calculator and adopts a coin on ANY PositionManager wired to it. The coin
27
+ * names its own manager (`Memecoin.flaunch()` is the launch ERC721, whose `positionManager()` is
28
+ * the hook), and that manager is accepted when its fee wiring routes to the gate's calculator —
29
+ * the same three reads boot makes of the primary manager, made here per manager and cached. There
30
+ * is no list: wiring a new manager to the dispatcher on chain is what approves it. A coin on a
31
+ * manager wired to some OTHER calculator is refused for good — the EIP-712 domain binds every
32
+ * authorisation to the calculator, so a coin this gate cannot sign for must not be given a room
33
+ * by it — and every later read (`poolKey`, `flaunchesAt`, `pairedToken`) is asked of the coin's
34
+ * own manager, never the configured one.
30
35
  */
31
36
 
32
37
  const poolKeyComponents = [
@@ -37,6 +42,12 @@ const poolKeyComponents = [
37
42
  { name: 'hooks', type: 'address' },
38
43
  ] as const;
39
44
 
45
+ /** What a coin knows about its own launch: `flaunch()` is the ERC721 that minted it. */
46
+ const memecoinAbi = parseAbi(['function flaunch() view returns (address)']);
47
+
48
+ /** The launch ERC721 (`Flaunch`, `AnyFlaunch`) names the PositionManager that hooks its pools. */
49
+ const flaunchAbi = parseAbi(['function positionManager() view returns (address)']);
50
+
40
51
  export const positionManagerAbi = [
41
52
  {
42
53
  type: 'function',
@@ -91,6 +102,11 @@ export interface Launch {
91
102
  name: string;
92
103
  symbol: string;
93
104
  imageUrl?: string | null;
105
+ /**
106
+ * The PositionManager the coin was launched on — the pool's hook — resolved from the coin
107
+ * itself. Absent only from hand-built launches (demos, tests), where it means the primary one.
108
+ */
109
+ positionManager?: `0x${string}`;
94
110
  /**
95
111
  * The token spend is measured in: the coin's paired token, read from the manager. Absent only
96
112
  * from hand-built launches (demos, tests), where it means ETH.
@@ -115,12 +131,122 @@ export interface Refused {
115
131
  export type LaunchCheck = ({ ok: true } & Launch) | Refused;
116
132
 
117
133
  export interface Deployment {
134
+ /**
135
+ * The primary manager: what boot asserted and `/config` announces. Adoption does not require a
136
+ * coin to be on it — see {@link verifyLaunch} — but it is the one manager known wired before any
137
+ * coin arrives, so its wiring is never asked of the chain again.
138
+ */
118
139
  positionManager: `0x${string}`;
119
140
  spendGatedCalculator: `0x${string}`;
120
141
  /** The signer this gate holds. A launch naming anyone else is not ours to run. */
121
142
  signer: `0x${string}`;
122
143
  }
123
144
 
145
+ /**
146
+ * Whether a manager's fee wiring routes to a calculator. {@link checkPositionManagerWiring} is the
147
+ * one implementation; this is its shape, so a test can stand in for the chain.
148
+ */
149
+ export type WiringCheck = (
150
+ client: ChainReader,
151
+ wiring: { positionManager: `0x${string}`; spendGatedCalculator: `0x${string}` },
152
+ ) => Promise<PositionManagerWiring>;
153
+
154
+ /**
155
+ * Remembers which managers are wired to this gate's calculator, so the three wiring reads happen
156
+ * once per manager rather than once per coin.
157
+ *
158
+ * A yes is kept for the life of the process: wiring is a one-time owner action, and un-wiring a
159
+ * manager with live pools would also strand every coin already adopted on it, so nothing is lost
160
+ * by not re-asking. A no is kept a minute — long enough that a coin page polling an unwired
161
+ * manager is not a lever on the RPC, short enough that the owner wiring it up sees the gate follow.
162
+ */
163
+ export class WiringCache {
164
+ private readonly verdicts = new Map<string, { verdict: PositionManagerWiring; until: number }>();
165
+ private readonly inFlight = new Map<string, Promise<PositionManagerWiring>>();
166
+
167
+ constructor(
168
+ private readonly check: WiringCheck = checkPositionManagerWiring,
169
+ private readonly now: () => number = Date.now,
170
+ private readonly negativeHoldMs = 60_000,
171
+ ) {}
172
+
173
+ async isWired(
174
+ client: ChainReader,
175
+ positionManager: `0x${string}`,
176
+ spendGatedCalculator: `0x${string}`,
177
+ ): Promise<PositionManagerWiring> {
178
+ const key = positionManager.toLowerCase();
179
+ const cached = this.verdicts.get(key);
180
+ if (cached && cached.until > this.now()) return cached.verdict;
181
+
182
+ const existing = this.inFlight.get(key);
183
+ if (existing) return existing;
184
+
185
+ const flight = this.check(client, { positionManager, spendGatedCalculator })
186
+ .then((verdict) => {
187
+ const until = verdict.ok ? Number.POSITIVE_INFINITY : this.now() + this.negativeHoldMs;
188
+ this.verdicts.set(key, { verdict, until });
189
+ return verdict;
190
+ })
191
+ .finally(() => this.inFlight.delete(key));
192
+ this.inFlight.set(key, flight);
193
+ return flight;
194
+ }
195
+
196
+ /** A fresh cache that already trusts the primary manager, which boot asserted before any coin arrived. */
197
+ static trusting(positionManager: `0x${string}`): WiringCache {
198
+ const cache = new WiringCache();
199
+ cache.trust(positionManager);
200
+ return cache;
201
+ }
202
+
203
+ /** Record a manager known wired without asking — the primary one, which boot already asserted. */
204
+ trust(positionManager: `0x${string}`, dispatcher: `0x${string}` = ZERO_ADDRESS): void {
205
+ this.verdicts.set(positionManager.toLowerCase(), {
206
+ verdict: { ok: true, dispatcher },
207
+ until: Number.POSITIVE_INFINITY,
208
+ });
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Which PositionManager a coin was launched on, from the coin itself.
214
+ *
215
+ * Two reads: `Memecoin.flaunch()` names the launch ERC721 that minted the coin, and that contract's
216
+ * `positionManager()` names the hook. Both are public state written at launch, so the answer is the
217
+ * coin's and cannot be steered by whoever offers it for adoption. Null when the CONTRACT says no —
218
+ * an address with no code, or one that is not a Flaunch coin, answers `flaunch()` with no data.
219
+ * An RPC that did not answer is not a statement about the coin, so that is thrown to the caller
220
+ * rather than folded into null.
221
+ */
222
+ export async function resolvePositionManager(
223
+ client: ChainReader,
224
+ memecoin: `0x${string}`,
225
+ ): Promise<`0x${string}` | null> {
226
+ let flaunch: `0x${string}`;
227
+ try {
228
+ flaunch = (await client.readContract({ address: memecoin, abi: memecoinAbi, functionName: 'flaunch' })) as `0x${string}`;
229
+ } catch (error) {
230
+ if (isMissingFunction(error)) return null;
231
+ throw error;
232
+ }
233
+ if (!flaunch || flaunch.toLowerCase() === ZERO_ADDRESS) return null;
234
+
235
+ let manager: `0x${string}`;
236
+ try {
237
+ manager = (await client.readContract({
238
+ address: flaunch,
239
+ abi: flaunchAbi,
240
+ functionName: 'positionManager',
241
+ })) as `0x${string}`;
242
+ } catch (error) {
243
+ if (isMissingFunction(error)) return null;
244
+ throw error;
245
+ }
246
+ if (!manager || manager.toLowerCase() === ZERO_ADDRESS) return null;
247
+ return manager;
248
+ }
249
+
124
250
  export function poolIdOf(key: PoolKey): `0x${string}` {
125
251
  return keccak256(
126
252
  encodeAbiParameters(
@@ -142,11 +268,28 @@ export async function verifyLaunch(
142
268
  client: ChainReader,
143
269
  deployment: Deployment,
144
270
  memecoin: `0x${string}`,
271
+ wiring: WiringCache = WiringCache.trusting(deployment.positionManager),
145
272
  ): Promise<LaunchCheck> {
273
+ // The coin's own manager first. A coin the protocol has never heard of has no `flaunch()`, which
274
+ // is still "not yet": the coin page loads the instant a launch is submitted, before the chain has
275
+ // caught up, and that must not be remembered as "no".
276
+ let positionManager: `0x${string}` | null;
277
+ try {
278
+ positionManager = await resolvePositionManager(client, memecoin);
279
+ } catch {
280
+ return refuse('launch.unreadable', true);
281
+ }
282
+ if (!positionManager) return refuse('launch.not_flaunched', true);
283
+
284
+ // Approval is a fact about the chain: is this manager's fee wiring routed to OUR calculator? A
285
+ // manager wired elsewhere is another gate's, permanently — no amount of waiting rewires it.
286
+ const wired = await wiring.isWired(client, positionManager, deployment.spendGatedCalculator);
287
+ if (!wired.ok) return refuse('launch.unapproved_position_manager', false);
288
+
146
289
  let poolKey: PoolKey;
147
290
  try {
148
291
  poolKey = (await client.readContract({
149
- address: deployment.positionManager,
292
+ address: positionManager,
150
293
  abi: positionManagerAbi,
151
294
  functionName: 'poolKey',
152
295
  args: [memecoin],
@@ -164,9 +307,8 @@ export async function verifyLaunch(
164
307
 
165
308
  let settings: readonly [boolean, bigint, `0x${string}`, bigint];
166
309
  let trusted: readonly [`0x${string}`, boolean];
167
- let flaunchesAt: bigint;
168
310
  try {
169
- [settings, trusted, flaunchesAt] = (await Promise.all([
311
+ [settings, trusted] = (await Promise.all([
170
312
  client.readContract({
171
313
  address: deployment.spendGatedCalculator,
172
314
  abi: spendGatedAbi,
@@ -179,17 +321,27 @@ export async function verifyLaunch(
179
321
  functionName: 'trustedPoolKeySigner',
180
322
  args: [poolId],
181
323
  }),
182
- client.readContract({
183
- address: deployment.positionManager,
184
- abi: positionManagerAbi,
185
- functionName: 'flaunchesAt',
186
- args: [poolId],
187
- }),
188
- ])) as [typeof settings, typeof trusted, bigint];
324
+ ])) as [typeof settings, typeof trusted];
189
325
  } catch {
190
326
  return refuse('launch.unreadable', true);
191
327
  }
192
328
 
329
+ // The window's open is the manager's to report. A manager that is wired but has no
330
+ // `flaunchesAt` is a generation this gate has no round model for — a permanent fact about the
331
+ // contract, not an outage, so it is refused as unsupported rather than as unreadable and retried
332
+ // forever.
333
+ let flaunchesAt: bigint;
334
+ try {
335
+ flaunchesAt = (await client.readContract({
336
+ address: positionManager,
337
+ abi: positionManagerAbi,
338
+ functionName: 'flaunchesAt',
339
+ args: [poolId],
340
+ })) as bigint;
341
+ } catch (error) {
342
+ return refuse(isMissingFunction(error) ? 'launch.unsupported_position_manager' : 'launch.unreadable', !isMissingFunction(error));
343
+ }
344
+
193
345
  const [enabled, walletCap, , endsAt] = settings;
194
346
  const [signer, signerEnabled] = trusted;
195
347
 
@@ -204,7 +356,7 @@ export async function verifyLaunch(
204
356
  const closesAt = Number(endsAt) * 1000;
205
357
  if (closesAt <= Date.now()) return refuse('launch.already_over', false);
206
358
 
207
- const spendToken = await resolveSpendToken(client, deployment, memecoin, poolKey, poolId);
359
+ const spendToken = await resolveSpendToken(client, positionManager, memecoin, poolKey, poolId);
208
360
  if (!spendToken) return refuse('launch.paired_unreadable', true);
209
361
 
210
362
  let name = '';
@@ -231,9 +383,38 @@ export async function verifyLaunch(
231
383
  name,
232
384
  symbol,
233
385
  spendToken,
386
+ positionManager,
234
387
  };
235
388
  }
236
389
 
390
+ /**
391
+ * Did a read fail because the contract has no such function, rather than because the chain did
392
+ * not answer? viem reports the former as a call that returned no data or reverted at the
393
+ * contract (`ContractFunctionZeroDataError`, `ContractFunctionRevertedError`); a transport
394
+ * failure looks nothing like it, and a `view` that exists does not revert on a pool id.
395
+ */
396
+ function isMissingFunction(error: unknown): boolean {
397
+ const seen = new Set<unknown>();
398
+ let current: unknown = error;
399
+ while (current && typeof current === 'object' && !seen.has(current)) {
400
+ seen.add(current);
401
+ const name = (current as { name?: unknown }).name;
402
+ if (
403
+ name === 'ContractFunctionZeroDataError' ||
404
+ name === 'AbiDecodingZeroDataError' ||
405
+ name === 'ContractFunctionRevertedError'
406
+ ) {
407
+ return true;
408
+ }
409
+ const message = (current as { message?: unknown }).message;
410
+ if (typeof message === 'string' && /returned no data|zero data|function (selector )?was not (recognized|found)/i.test(message)) {
411
+ return true;
412
+ }
413
+ current = (current as { cause?: unknown }).cause;
414
+ }
415
+ return false;
416
+ }
417
+
237
418
  /**
238
419
  * Which token this pool's spend is measured in.
239
420
  *
@@ -246,7 +427,7 @@ export async function verifyLaunch(
246
427
  */
247
428
  async function resolveSpendToken(
248
429
  client: ChainReader,
249
- deployment: Deployment,
430
+ positionManager: `0x${string}`,
250
431
  memecoin: `0x${string}`,
251
432
  poolKey: PoolKey,
252
433
  poolId: `0x${string}`,
@@ -254,7 +435,7 @@ async function resolveSpendToken(
254
435
  let paired: `0x${string}`;
255
436
  try {
256
437
  paired = (await client.readContract({
257
- address: deployment.positionManager,
438
+ address: positionManager,
258
439
  abi: positionManagerAbi,
259
440
  functionName: 'pairedToken',
260
441
  args: [poolId],
@@ -278,7 +459,7 @@ async function resolveSpendToken(
278
459
  /**
279
460
  * Verification with a memory, because every coin-page view asks.
280
461
  *
281
- * Unmemoised, each call costs five chain reads against one endpoint from one address — which is a
462
+ * Unmemoised, each call costs seven chain reads against one endpoint from one address — which is a
282
463
  * free denial-of-service lever, and public endpoints rate-limit long before a launch does.
283
464
  *
284
465
  * A positive verdict is cached until the window closes, because a launch that qualifies cannot
@@ -288,12 +469,22 @@ async function resolveSpendToken(
288
469
  export class Discovery {
289
470
  private readonly verdicts = new Map<string, { verdict: LaunchCheck; until: number }>();
290
471
  private readonly inFlight = new Map<string, Promise<LaunchCheck>>();
472
+ /**
473
+ * Which managers are wired to this gate's calculator, remembered per manager. The primary one is
474
+ * trusted from the start: boot asserted it, and asking again would be the only three reads that
475
+ * could never answer differently.
476
+ */
477
+ readonly wiring: WiringCache;
291
478
 
292
479
  constructor(
293
480
  private readonly client: ChainReader,
294
481
  private readonly deployment: Deployment,
295
482
  private readonly now: () => number = Date.now,
296
- ) {}
483
+ wiring: WiringCache = new WiringCache(checkPositionManagerWiring, now),
484
+ ) {
485
+ this.wiring = wiring;
486
+ this.wiring.trust(deployment.positionManager);
487
+ }
297
488
 
298
489
  async verify(memecoin: `0x${string}`): Promise<LaunchCheck> {
299
490
  const key = memecoin.toLowerCase();
@@ -305,7 +496,7 @@ export class Discovery {
305
496
  const existing = this.inFlight.get(key);
306
497
  if (existing) return existing;
307
498
 
308
- const flight = verifyLaunch(this.client, this.deployment, memecoin)
499
+ const flight = verifyLaunch(this.client, this.deployment, memecoin, this.wiring)
309
500
  .then((verdict) => {
310
501
  this.verdicts.set(key, { verdict, until: this.now() + this.holdFor(verdict) });
311
502
  return verdict;
@@ -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] })
@@ -3,15 +3,19 @@ import { privateKeyToAccount, type PrivateKeyAccount } from 'viem/accounts';
3
3
  import { gateReadinessTypedData, READINESS_TTL_SECONDS, type GateReadiness, spendAuthorisationTypedData } from '@flayerlabs/gamemode-spec/embed';
4
4
 
5
5
  /**
6
- * Issues the signed authorizations SpendGatedSignerFeeCalculator.trackSwap verifies.
6
+ * Issues the signed authorisations SpendGatedSignerFeeCalculator.trackSwap verifies.
7
7
  *
8
- * EIP-712, over the `SpendAuthorization` struct the calculator declares. It used to be an
9
- * EIP-191 personal-sign over `keccak256(abi.encodePacked(...))`, which committed to the five
10
- * fields and nothing else — no chain id, no verifying contract. An audit flagged that as the
11
- * same defect already raised against the parent calculator (M-6, remediated there in the
12
- * EIP-712 PR but never ported to this fork): the protocol deploys deterministically across
13
- * chains, so the same poolId genuinely recurs, and one signing key shared between two
14
- * environments made an authorization issued for one valid on the other. The domain closes it.
8
+ * EIP-712, over the `SpendAuthorization` struct the calculator declares, under the
9
+ * `FlaunchSpendGate` version `2` domain. The struct carries a cumulative ceiling: the most this
10
+ * buyer may EVER have spent on this pool, which the calculator compares against its own
11
+ * `walletSpentWei` accumulator. There is no nonce and no per-purchase amount, because there is
12
+ * nothing to consume — two authorisations with the same ceiling cannot add up, so reusing one is
13
+ * harmless by construction and the gate holds nothing against it.
14
+ *
15
+ * The domain matters: the protocol deploys deterministically across chains, so the same poolId
16
+ * genuinely recurs, and one signing key shared between two environments would otherwise make an
17
+ * authorisation issued for one valid on the other. Version `2` also keeps 0.5.x signatures (a
18
+ * five-field struct with a nonce) from verifying on this calculator and vice versa.
15
19
  *
16
20
  * The digest MUST match `hashSpendAuthorization` on the calculator exactly. That function is
17
21
  * public for this reason — a domain drift is otherwise a silent `InvalidSigner` at swap time
@@ -22,8 +26,7 @@ export interface SignedPayload {
22
26
  buyer: `0x${string}`;
23
27
  poolId: `0x${string}`;
24
28
  deadline: bigint;
25
- maxSpendWei: bigint;
26
- nonce: bigint;
29
+ spendCeilingWei: bigint;
27
30
  signature: `0x${string}`;
28
31
  signer: `0x${string}`;
29
32
  }
@@ -62,15 +65,10 @@ export class PayloadSigner {
62
65
  buyer: `0x${string}`,
63
66
  poolId: `0x${string}`,
64
67
  deadline: bigint,
65
- maxSpendWei: bigint,
66
- nonce: bigint,
68
+ spendCeilingWei: bigint,
67
69
  ): `0x${string}` {
68
70
  return hashTypedData(
69
- spendAuthorisationTypedData(
70
- { buyer, poolId, deadline, maxSpendWei, nonce },
71
- this.chainId,
72
- this.verifyingContract,
73
- ),
71
+ spendAuthorisationTypedData({ buyer, poolId, deadline, spendCeilingWei }, this.chainId, this.verifyingContract),
74
72
  );
75
73
  }
76
74
 
@@ -78,23 +76,21 @@ export class PayloadSigner {
78
76
  buyer: `0x${string}`,
79
77
  poolId: `0x${string}`,
80
78
  deadline: bigint,
81
- maxSpendWei: bigint,
82
- nonce: bigint,
79
+ spendCeilingWei: bigint,
83
80
  ): Promise<SignedPayload> {
84
81
  const signature = await this.account.signTypedData(
85
- spendAuthorisationTypedData(
86
- { buyer, poolId, deadline, maxSpendWei, nonce },
87
- this.chainId,
88
- this.verifyingContract,
89
- ),
82
+ spendAuthorisationTypedData({ buyer, poolId, deadline, spendCeilingWei }, this.chainId, this.verifyingContract),
90
83
  );
91
- return { buyer, poolId, deadline, maxSpendWei, nonce, signature, signer: this.account.address };
84
+ return { buyer, poolId, deadline, spendCeilingWei, signature, signer: this.account.address };
92
85
  }
93
86
  }
94
87
 
95
88
  /**
96
89
  * Encodes a signed payload as the hookData the swap must carry:
97
- * `abi.encode(address referrer, SignedMessage{buyer, poolId, deadline, maxSpendWei, nonce, signature})`
90
+ * `abi.encode(address referrer, SignedMessage{buyer, poolId, deadline, spendCeilingWei, signature})`
91
+ *
92
+ * The referrer word is always zero. The calculator ignores it, but the trusted page checks it, so
93
+ * a creator iframe cannot redirect attribution while keeping a valid signature.
98
94
  */
99
95
  export function encodeHookData(payload: SignedPayload): `0x${string}` {
100
96
  return encodeAbiParameters(
@@ -106,8 +102,7 @@ export function encodeHookData(payload: SignedPayload): `0x${string}` {
106
102
  { name: 'buyer', type: 'address' },
107
103
  { name: 'poolId', type: 'bytes32' },
108
104
  { name: 'deadline', type: 'uint256' },
109
- { name: 'maxSpendWei', type: 'uint256' },
110
- { name: 'nonce', type: 'uint256' },
105
+ { name: 'spendCeilingWei', type: 'uint256' },
111
106
  { name: 'signature', type: 'bytes' },
112
107
  ],
113
108
  },
@@ -118,8 +113,7 @@ export function encodeHookData(payload: SignedPayload): `0x${string}` {
118
113
  buyer: payload.buyer,
119
114
  poolId: payload.poolId,
120
115
  deadline: payload.deadline,
121
- maxSpendWei: payload.maxSpendWei,
122
- nonce: payload.nonce,
116
+ spendCeilingWei: payload.spendCeilingWei,
123
117
  signature: payload.signature,
124
118
  },
125
119
  ],