@flayerlabs/gamemode-gate 0.5.6 → 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.
- package/DEPLOY.md +25 -3
- package/README.md +4 -1
- package/dist/chain/chains.d.ts +14 -6
- package/dist/chain/chains.d.ts.map +1 -1
- package/dist/chain/chains.js +8 -4
- package/dist/chain/chains.js.map +1 -1
- package/dist/chain/discover.d.ts +61 -3
- package/dist/chain/discover.d.ts.map +1 -1
- package/dist/chain/discover.js +181 -21
- package/dist/chain/discover.js.map +1 -1
- package/dist/chain/secure.d.ts +28 -0
- package/dist/chain/secure.d.ts.map +1 -1
- package/dist/chain/secure.js +55 -20
- package/dist/chain/secure.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/main.d.ts +4 -2
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +11 -6
- package/dist/main.js.map +1 -1
- package/dist/room.d.ts +7 -0
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +15 -6
- package/dist/room.js.map +1 -1
- package/dist/server.d.ts +13 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -0
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +6 -0
- package/dist/store.js.map +1 -1
- package/dist/version.js +2 -2
- package/package.json +2 -2
- package/src/chain/chains.ts +14 -6
- package/src/chain/discover.ts +212 -21
- package/src/chain/secure.ts +75 -31
- package/src/index.ts +5 -4
- package/src/main.ts +11 -6
- package/src/room.ts +14 -3
- package/src/server.ts +19 -0
- package/src/store.ts +6 -0
- package/src/version.ts +2 -2
package/src/chain/discover.ts
CHANGED
|
@@ -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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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:
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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
|
|
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;
|
package/src/chain/secure.ts
CHANGED
|
@@ -69,57 +69,101 @@ export async function assertSecureConfig(client: PublicClient, config: SecureCon
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
* The
|
|
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
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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({
|
|
101
|
+
dispatcher = (await client.readContract({
|
|
102
|
+
address: manager,
|
|
103
|
+
abi: wiringAbi,
|
|
104
|
+
functionName: 'feeCalculator',
|
|
105
|
+
})) as `0x${string}`;
|
|
88
106
|
} catch {
|
|
89
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
'
|
|
111
|
-
|
|
134
|
+
'a gate on that calculator can serve launches on that manager',
|
|
135
|
+
};
|
|
112
136
|
}
|
|
113
137
|
if (!hasRole) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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(
|
|
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/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,13 @@ 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';
|
|
75
76
|
export { RegistryPrices, minimumWalletCap, withHeadroom } from './chain/registry-prices.js';
|
|
76
77
|
export type { AnnounceOptions, RegistryPricesOptions, RegistryQuote, RegistryReader } from './chain/registry-prices.js';
|
|
77
78
|
|
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
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
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
|
|