@flayerlabs/gamemode-gate 0.5.4 → 0.5.6
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 +12 -6
- package/README.md +24 -0
- package/dist/chain/registry-prices.d.ts +69 -0
- package/dist/chain/registry-prices.d.ts.map +1 -0
- package/dist/chain/registry-prices.js +157 -0
- package/dist/chain/registry-prices.js.map +1 -0
- package/dist/economy.d.ts.map +1 -1
- package/dist/economy.js +12 -3
- package/dist/economy.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/main.d.ts +10 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +33 -5
- package/dist/main.js.map +1 -1
- package/dist/play-sessions.d.ts +20 -0
- package/dist/play-sessions.d.ts.map +1 -0
- package/dist/play-sessions.js +27 -0
- package/dist/play-sessions.js.map +1 -0
- package/dist/server.d.ts +21 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +48 -3
- 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 +15 -0
- package/dist/store.js.map +1 -1
- package/dist/version.d.ts +5 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +6 -0
- package/dist/version.js.map +1 -0
- package/package.json +3 -3
- package/src/chain/registry-prices.ts +194 -0
- package/src/economy.ts +13 -3
- package/src/index.ts +4 -1
- package/src/main.ts +39 -5
- package/src/play-sessions.ts +25 -0
- package/src/server.ts +67 -3
- package/src/store.ts +15 -0
- package/src/version.ts +5 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { formatUnits, parseAbi, type PublicClient } from 'viem';
|
|
2
|
+
import type { SpendToken } from '@flayerlabs/gamemode-spec/live';
|
|
3
|
+
import type { AnnouncedSpendToken, PriceFor } from '../server.js';
|
|
4
|
+
import { resolveEconomy, unitsPerToken } from '../economy.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Prices for any paired token the chain itself has approved, so a gate runs rounds on every
|
|
8
|
+
* pairing the protocol allows without anyone listing it first.
|
|
9
|
+
*
|
|
10
|
+
* A v1.3 PositionManager names its `PairedTokenRegistry`, and the registry names the price
|
|
11
|
+
* calculator each approved token was registered with — the same quote a launch uses to size a
|
|
12
|
+
* coin's starting price. That calculator answers "how many tokens is one ETH", and the operator's
|
|
13
|
+
* `USD_PER_ETH` turns that into dollars. Nothing here is typed by a person: an unapproved token has
|
|
14
|
+
* no calculator and is refused, exactly as the launch itself would be.
|
|
15
|
+
*
|
|
16
|
+
* Quotes are cached briefly. Adoption happens once per launch, so the cache protects the RPC from a
|
|
17
|
+
* burst of `/config/spend-tokens` reads by launch tooling, not from adoption itself. A quote that
|
|
18
|
+
* cannot be read throws, and the server answers adoption with 503 rather than a made-up price.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const managerAbi = parseAbi(['function pairedTokenRegistry() view returns (address)']);
|
|
22
|
+
const registryAbi = parseAbi([
|
|
23
|
+
'function isApproved(address token) view returns (bool)',
|
|
24
|
+
'function tokenConfig(address token) view returns ((bool approved, uint8 tokenType, uint8 decimals, address underlying, address feeEscrow, address priceCalculator, uint256 minDistribute, uint256 bidWallThreshold))',
|
|
25
|
+
]);
|
|
26
|
+
const calculatorAbi = parseAbi(['function ethToPairedToken(uint256 ethAmount) view returns (uint256)']);
|
|
27
|
+
const erc20Abi = parseAbi(['function symbol() view returns (string)', 'function decimals() view returns (uint8)']);
|
|
28
|
+
|
|
29
|
+
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
|
|
30
|
+
|
|
31
|
+
/** The subset of a viem public client this module needs, so tests can script it. */
|
|
32
|
+
export type RegistryReader = Pick<PublicClient, 'readContract'>;
|
|
33
|
+
|
|
34
|
+
export interface RegistryPricesOptions {
|
|
35
|
+
/** The manager whose registry decides which pairings exist. */
|
|
36
|
+
positionManager: `0x${string}`;
|
|
37
|
+
/** The chain's flETH wrapper, priced as ETH and never asked of the registry. */
|
|
38
|
+
fleth?: `0x${string}`;
|
|
39
|
+
/** Dollars per ETH; every registry quote is in ETH. */
|
|
40
|
+
usdPerEth: number;
|
|
41
|
+
/** How long a quote is reused, in milliseconds. */
|
|
42
|
+
ttlMs?: number;
|
|
43
|
+
/** Injectable for tests. */
|
|
44
|
+
now?: () => number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RegistryQuote {
|
|
48
|
+
/** Dollars per whole token, to eight significant digits. */
|
|
49
|
+
usdPerToken: number;
|
|
50
|
+
/** Whole tokens per ETH as the registry's calculator quoted them. */
|
|
51
|
+
tokensPerEth: number;
|
|
52
|
+
priceCalculator: `0x${string}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What `/config/spend-tokens/:address` needs to size a launch's cap. */
|
|
56
|
+
export interface AnnounceOptions {
|
|
57
|
+
maxPointsPerPlayer: number;
|
|
58
|
+
pointsPerDollar: number;
|
|
59
|
+
/** Percent added over the smallest cap a flawless round fits under. */
|
|
60
|
+
headroomPercent: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class RegistryPrices {
|
|
64
|
+
private registry: Promise<`0x${string}` | null> | undefined;
|
|
65
|
+
private readonly quotes = new Map<string, { quote: RegistryQuote | null; at: number }>();
|
|
66
|
+
private readonly ttlMs: number;
|
|
67
|
+
private readonly now: () => number;
|
|
68
|
+
|
|
69
|
+
constructor(
|
|
70
|
+
private readonly client: RegistryReader,
|
|
71
|
+
private readonly options: RegistryPricesOptions,
|
|
72
|
+
) {
|
|
73
|
+
this.ttlMs = options.ttlMs ?? 60_000;
|
|
74
|
+
this.now = options.now ?? Date.now;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The registry the manager names, or null for a manager that predates paired tokens. Read once:
|
|
79
|
+
* the address is immutable on chain, and a gate serves exactly one manager.
|
|
80
|
+
*/
|
|
81
|
+
registryAddress(): Promise<`0x${string}` | null> {
|
|
82
|
+
this.registry ??= this.client
|
|
83
|
+
.readContract({ address: this.options.positionManager, abi: managerAbi, functionName: 'pairedTokenRegistry' })
|
|
84
|
+
.then((address) => (address.toLowerCase() === ZERO_ADDRESS ? null : address))
|
|
85
|
+
.catch(() => null);
|
|
86
|
+
return this.registry;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** ETH by any name is the operator's `USD_PER_ETH`; it is never asked of the registry. */
|
|
90
|
+
private isEthEquivalent(address: string): boolean {
|
|
91
|
+
const lower = address.toLowerCase();
|
|
92
|
+
return lower === ZERO_ADDRESS || (this.options.fleth !== undefined && lower === this.options.fleth.toLowerCase());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The registry's quote for a token, or null when it is not approved or has no calculator.
|
|
97
|
+
* Throws when the chain cannot be read, so a caller refuses for now rather than for good.
|
|
98
|
+
*/
|
|
99
|
+
async quote(address: string): Promise<RegistryQuote | null> {
|
|
100
|
+
const key = address.toLowerCase();
|
|
101
|
+
const cached = this.quotes.get(key);
|
|
102
|
+
if (cached && this.now() - cached.at < this.ttlMs) return cached.quote;
|
|
103
|
+
const quote = await this.read(address as `0x${string}`);
|
|
104
|
+
this.quotes.set(key, { quote, at: this.now() });
|
|
105
|
+
return quote;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async read(token: `0x${string}`): Promise<RegistryQuote | null> {
|
|
109
|
+
const registry = await this.registryAddress();
|
|
110
|
+
if (!registry) return null;
|
|
111
|
+
const approved = await this.client.readContract({ address: registry, abi: registryAbi, functionName: 'isApproved', args: [token] });
|
|
112
|
+
if (!approved) return null;
|
|
113
|
+
const config = await this.client.readContract({ address: registry, abi: registryAbi, functionName: 'tokenConfig', args: [token] });
|
|
114
|
+
const priceCalculator = config.priceCalculator;
|
|
115
|
+
if (!priceCalculator || priceCalculator.toLowerCase() === ZERO_ADDRESS) return null;
|
|
116
|
+
const decimals = Number(config.decimals);
|
|
117
|
+
const perEth = await this.client.readContract({
|
|
118
|
+
address: priceCalculator,
|
|
119
|
+
abi: calculatorAbi,
|
|
120
|
+
functionName: 'ethToPairedToken',
|
|
121
|
+
args: [unitsPerToken(18)],
|
|
122
|
+
});
|
|
123
|
+
if (perEth <= 0n) return null;
|
|
124
|
+
// String-based division: no binary float noise in a whole-token quote.
|
|
125
|
+
const tokensPerEth = Number(formatUnits(perEth, decimals));
|
|
126
|
+
if (!Number.isFinite(tokensPerEth) || tokensPerEth <= 0) return null;
|
|
127
|
+
const usdPerToken = Number((this.options.usdPerEth / tokensPerEth).toPrecision(8));
|
|
128
|
+
return { usdPerToken, tokensPerEth, priceCalculator };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A {@link PriceFor}: dollars per whole token for any approved pairing, null for the rest. */
|
|
132
|
+
priceFor: PriceFor = async (token: SpendToken) => {
|
|
133
|
+
if (this.isEthEquivalent(token.address)) return null;
|
|
134
|
+
const quote = await this.quote(token.address);
|
|
135
|
+
return quote?.usdPerToken ?? null;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The entry launch tooling needs before a coin is paid for: the token as the chain describes
|
|
140
|
+
* it, the price the gate will fix a round at, and the smallest per-wallet cap a flawless round
|
|
141
|
+
* fits under plus the operator's headroom. Null for a token the registry does not approve.
|
|
142
|
+
*/
|
|
143
|
+
async announce(address: `0x${string}`, options: AnnounceOptions): Promise<AnnouncedSpendToken | null> {
|
|
144
|
+
if (this.isEthEquivalent(address)) return null;
|
|
145
|
+
const quote = await this.quote(address);
|
|
146
|
+
if (!quote) return null;
|
|
147
|
+
const [symbol, decimals] = await Promise.all([
|
|
148
|
+
this.client.readContract({ address, abi: erc20Abi, functionName: 'symbol' }),
|
|
149
|
+
this.client.readContract({ address, abi: erc20Abi, functionName: 'decimals' }).then(Number),
|
|
150
|
+
]);
|
|
151
|
+
const walletCap = minimumWalletCap(quote.usdPerToken, decimals, options);
|
|
152
|
+
if (walletCap === null) return null;
|
|
153
|
+
return {
|
|
154
|
+
address,
|
|
155
|
+
symbol,
|
|
156
|
+
decimals,
|
|
157
|
+
usdPerToken: quote.usdPerToken,
|
|
158
|
+
walletCap: withHeadroom(walletCap, options.headroomPercent).toString(),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The smallest per-wallet cap under which a flawless round fits, in the token's base units, or
|
|
165
|
+
* null when no cap makes the economy work at this price. Checked against {@link resolveEconomy}
|
|
166
|
+
* rather than trusted, because that function is the one place the rule is written down.
|
|
167
|
+
*/
|
|
168
|
+
export function minimumWalletCap(
|
|
169
|
+
usdPerToken: number,
|
|
170
|
+
decimals: number,
|
|
171
|
+
options: Pick<AnnounceOptions, 'maxPointsPerPlayer' | 'pointsPerDollar'>,
|
|
172
|
+
): bigint | null {
|
|
173
|
+
const pointsPerToken = Math.round(usdPerToken * options.pointsPerDollar);
|
|
174
|
+
if (pointsPerToken <= 0) return null;
|
|
175
|
+
const unitsPerPoint = unitsPerToken(decimals) / BigInt(pointsPerToken);
|
|
176
|
+
if (unitsPerPoint <= 0n) return null;
|
|
177
|
+
const minimum = BigInt(Math.floor(options.maxPointsPerPlayer)) * unitsPerPoint;
|
|
178
|
+
const fit = resolveEconomy({
|
|
179
|
+
maxPointsPerPlayer: options.maxPointsPerPlayer,
|
|
180
|
+
walletCap: minimum,
|
|
181
|
+
pointsPerDollar: options.pointsPerDollar,
|
|
182
|
+
usdPerSpendToken: usdPerToken,
|
|
183
|
+
decimals,
|
|
184
|
+
});
|
|
185
|
+
return fit.ok ? minimum : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A cap with the operator's percentage headroom, rounded down to base units. */
|
|
189
|
+
export function withHeadroom(cap: bigint, headroomPercent: number): bigint {
|
|
190
|
+
if (!Number.isSafeInteger(headroomPercent) || headroomPercent < 0) {
|
|
191
|
+
throw new Error('headroom percent must be a non-negative integer');
|
|
192
|
+
}
|
|
193
|
+
return (cap * BigInt(100 + headroomPercent)) / 100n;
|
|
194
|
+
}
|
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 =
|
|
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
|
@@ -66,11 +66,14 @@ 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 } from './server.js';
|
|
69
|
+
export type { GateAnnouncement, ServedGateAnnouncement } from './server.js';
|
|
70
|
+
export { GATE_MARKER, GATE_VERSION } from './version.js';
|
|
70
71
|
export { NotSignedIn } from './sessions.js';
|
|
71
72
|
export { DEPLOYMENTS, PAIRED_DEPLOYMENTS, deploymentFor } from './chain/chains.js';
|
|
72
73
|
export type { FlaunchVariant, GateDeployment } from './chain/chains.js';
|
|
73
74
|
export { POSITION_MANAGER_ROLE, assertSecureConfig } from './chain/secure.js';
|
|
75
|
+
export { RegistryPrices, minimumWalletCap, withHeadroom } from './chain/registry-prices.js';
|
|
76
|
+
export type { AnnounceOptions, RegistryPricesOptions, RegistryQuote, RegistryReader } from './chain/registry-prices.js';
|
|
74
77
|
|
|
75
78
|
export { createGateReadiness } from './readiness.js';
|
|
76
79
|
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
|
}
|
|
@@ -167,7 +174,10 @@ function address(env: Env, name: string): `0x${string}` | null {
|
|
|
167
174
|
* POINTS_PER_DOLLAR default 100
|
|
168
175
|
* USD_PER_ETH default 3000; fractional allowed
|
|
169
176
|
* SPEND_TOKENS optional JSON array of { address, usdPerToken, walletCap } — the non-ETH
|
|
170
|
-
* paired tokens this gate runs rounds on.
|
|
177
|
+
* paired tokens this gate runs rounds on. Pins a price the registry
|
|
178
|
+
* would otherwise quote; tokens neither here nor approved on the
|
|
179
|
+
* manager's PairedTokenRegistry are refused.
|
|
180
|
+
* PAIRED_TOKEN_CAP_HEADROOM_PERCENT default 25; headroom on caps sized for registry-priced pairings
|
|
171
181
|
*/
|
|
172
182
|
export function resolveGateEnv(env: Env): ResolvedGateConfig {
|
|
173
183
|
const chainId = Number(required(env, 'CHAIN_ID'));
|
|
@@ -247,6 +257,7 @@ export function resolveGateEnv(env: Env): ResolvedGateConfig {
|
|
|
247
257
|
pointsPerDollar: integer(env, 'POINTS_PER_DOLLAR', 100),
|
|
248
258
|
usdPerEth: positiveNumber(env, 'USD_PER_ETH', 3_000),
|
|
249
259
|
spendTokens: spendTokens(env),
|
|
260
|
+
pairedTokenCapHeadroomPercent: integer(env, 'PAIRED_TOKEN_CAP_HEADROOM_PERCENT', 25),
|
|
250
261
|
settler: address(env, 'SETTLER'),
|
|
251
262
|
};
|
|
252
263
|
}
|
|
@@ -262,9 +273,11 @@ function isEthEquivalent(token: SpendToken, fleth: `0x${string}` | undefined): b
|
|
|
262
273
|
* The price list a running gate answers adoption with.
|
|
263
274
|
*
|
|
264
275
|
* 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`;
|
|
276
|
+
* operator's `USD_PER_ETH`; then the manager's own PairedTokenRegistry, which prices every token the
|
|
277
|
+
* protocol has approved through the calculator it was registered with. Only a token none of them
|
|
278
|
+
* know is unpriced, and that launch is refused — it could not have been launched either.
|
|
266
279
|
*/
|
|
267
|
-
function priceList(config: ResolvedGateConfig, override: PriceFor | undefined): PriceFor {
|
|
280
|
+
function priceList(config: ResolvedGateConfig, override: PriceFor | undefined, registry: RegistryPrices): PriceFor {
|
|
268
281
|
return async (token) => {
|
|
269
282
|
if (override) {
|
|
270
283
|
const quoted = await override(token);
|
|
@@ -273,7 +286,7 @@ function priceList(config: ResolvedGateConfig, override: PriceFor | undefined):
|
|
|
273
286
|
const listed = config.spendTokens.get(token.address.toLowerCase());
|
|
274
287
|
if (listed) return listed.usdPerToken;
|
|
275
288
|
if (isEthEquivalent(token, config.deployment.fleth)) return config.usdPerEth;
|
|
276
|
-
return
|
|
289
|
+
return registry.priceFor(token);
|
|
277
290
|
};
|
|
278
291
|
}
|
|
279
292
|
|
|
@@ -365,6 +378,16 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
|
|
|
365
378
|
});
|
|
366
379
|
}
|
|
367
380
|
|
|
381
|
+
// Every pairing the protocol approves, priced by the calculator it was registered with. The
|
|
382
|
+
// registry is read from the manager, so nothing is configured and nothing goes stale when a new
|
|
383
|
+
// token is approved: the next launch on it is adopted like any other.
|
|
384
|
+
const registryPrices = new RegistryPrices(client, {
|
|
385
|
+
positionManager: config.deployment.positionManager,
|
|
386
|
+
...(config.deployment.fleth ? { fleth: config.deployment.fleth } : {}),
|
|
387
|
+
usdPerEth: config.usdPerEth,
|
|
388
|
+
});
|
|
389
|
+
const pairedTokenRegistry = await registryPrices.registryAddress();
|
|
390
|
+
|
|
368
391
|
const pool = new pg.Pool({ connectionString: config.databaseUrl });
|
|
369
392
|
await migrate(pool);
|
|
370
393
|
|
|
@@ -387,6 +410,7 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
|
|
|
387
410
|
gateEndsAtGraceS: config.gateEndsAtGraceS,
|
|
388
411
|
flaunchVariant: config.deployment.flaunchVariant,
|
|
389
412
|
...(announcedSpendTokens.length > 0 ? { spendTokens: announcedSpendTokens } : {}),
|
|
413
|
+
...(pairedTokenRegistry ? { pairedTokenRegistry } : {}),
|
|
390
414
|
requiresEoa: false,
|
|
391
415
|
accepting: true,
|
|
392
416
|
publicLaunchesOpen: true,
|
|
@@ -399,7 +423,17 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
|
|
|
399
423
|
config: gameConfig,
|
|
400
424
|
pointsPerDollar: config.pointsPerDollar,
|
|
401
425
|
usdPerEth: config.usdPerEth,
|
|
402
|
-
priceFor: priceList(config, overrides.priceProvider),
|
|
426
|
+
priceFor: priceList(config, overrides.priceProvider, registryPrices),
|
|
427
|
+
announceSpendToken: async (address) => {
|
|
428
|
+
// An operator-pinned token is announced exactly as pinned; anything else is the registry's word.
|
|
429
|
+
const pinned = announcedSpendTokens.find((entry) => entry.address.toLowerCase() === address.toLowerCase());
|
|
430
|
+
if (pinned) return pinned;
|
|
431
|
+
return registryPrices.announce(address, {
|
|
432
|
+
maxPointsPerPlayer,
|
|
433
|
+
pointsPerDollar: config.pointsPerDollar,
|
|
434
|
+
headroomPercent: config.pairedTokenCapHeadroomPercent,
|
|
435
|
+
});
|
|
436
|
+
},
|
|
403
437
|
sessions: new Sessions(config.sessionSecret, config.signInDomain, config.gateOrigin),
|
|
404
438
|
claims: new Claims(pool, ledger, signer),
|
|
405
439
|
discovery: new Discovery(client, {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { Pool } from 'pg';
|
|
3
|
+
|
|
4
|
+
export function playSessionId(roundId: string, player: string): string {
|
|
5
|
+
return createHash('sha256').update(JSON.stringify([roundId, player.toLowerCase()])).digest('hex');
|
|
6
|
+
}
|
|
7
|
+
export async function recordPlaySession(pool: Pool, roundId: string, player: string, at = Date.now()): Promise<void> {
|
|
8
|
+
// One accepted start per wallet and launch. Reconnects and repeated joins retain the first time.
|
|
9
|
+
await pool.query('insert into play_sessions (id, round_id, started_at) values ($1, $2, $3) on conflict do nothing', [playSessionId(roundId, player), roundId, at]);
|
|
10
|
+
}
|
|
11
|
+
export function parsePlayCursor(value: unknown): { at: number; id: string } | null {
|
|
12
|
+
if (value === undefined) return { at: 0, id: '' };
|
|
13
|
+
if (typeof value !== 'string' || value.length > 100) return null;
|
|
14
|
+
const match = /^(\d{1,16}):([a-f0-9]{64})$/.exec(value);
|
|
15
|
+
if (!match || !Number.isSafeInteger(Number(match[1]))) return null;
|
|
16
|
+
return { at: Number(match[1]), id: match[2]! };
|
|
17
|
+
}
|
|
18
|
+
export async function readPlaySessions(pool: Pool, cursor: { at: number; id: string }) {
|
|
19
|
+
const [events, coverage] = await Promise.all([
|
|
20
|
+
pool.query<{ id: string; coin: string; started_at: string }>(`select p.id, r.coin_address as coin, p.started_at from play_sessions p join rounds r on r.id = p.round_id where r.coin_address is not null and (p.started_at, p.id) > ($1::bigint, $2::text) order by p.started_at, p.id limit 250`, [cursor.at, cursor.id]),
|
|
21
|
+
pool.query<{ started_at: string }>('select started_at from play_tracking where id = true'),
|
|
22
|
+
]);
|
|
23
|
+
const last = events.rows.at(-1);
|
|
24
|
+
return { trackingSince: Number(coverage.rows[0]?.started_at ?? Date.now()), sessions: events.rows.map(r => ({ id: r.id, coin: r.coin, startedAt: Number(r.started_at) })), nextCursor: events.rows.length === 250 && last ? `${last.started_at}:${last.id}` : null };
|
|
25
|
+
}
|
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';
|
|
@@ -20,6 +21,7 @@ import { Room, type ScoreAuthority } from './room.js';
|
|
|
20
21
|
import { Standings } from './standings.js';
|
|
21
22
|
import { CLAIM_AUTHORIZATION_TTL_MS, Claims, isClaimAmount, isClaimRequestId } from './claims.js';
|
|
22
23
|
import { NotSignedIn, Sessions } from './sessions.js';
|
|
24
|
+
import { parsePlayCursor, readPlaySessions, recordPlaySession } from './play-sessions.js';
|
|
23
25
|
import type { Discovery, Launch, PoolKey } from './chain/discover.js';
|
|
24
26
|
import type { Settlement } from './settlement.js';
|
|
25
27
|
import { resolveEconomy } from './economy.js';
|
|
@@ -28,6 +30,7 @@ import { hasBearerToken } from './bearer.js';
|
|
|
28
30
|
import { GameServerAwards, type GameServerAwardRequest } from './game-server-awards.js';
|
|
29
31
|
import { issuePlayerJoinTicket } from './player-join-tickets.js';
|
|
30
32
|
import { gameServerOrigins } from './registry.js';
|
|
33
|
+
import { GATE_VERSION } from './version.js';
|
|
31
34
|
|
|
32
35
|
/**
|
|
33
36
|
* The gate's door.
|
|
@@ -56,6 +59,13 @@ interface GateBaseOptions {
|
|
|
56
59
|
* refused: a gate that guessed a price would be authorising real money on a made-up number.
|
|
57
60
|
*/
|
|
58
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>;
|
|
59
69
|
/** Verifies from the chain that a coin really is a game launch. */
|
|
60
70
|
discovery?: Discovery;
|
|
61
71
|
/** Keeps the ledger's view of spending level with the chain's. */
|
|
@@ -166,6 +176,12 @@ export interface GateAnnouncement {
|
|
|
166
176
|
* listed here, which is the fail-closed answer for a gate that has not been told the token's price.
|
|
167
177
|
*/
|
|
168
178
|
spendTokens?: AnnouncedSpendToken[];
|
|
179
|
+
/**
|
|
180
|
+
* The manager's PairedTokenRegistry, when it has one. Its presence means this gate prices every
|
|
181
|
+
* token the registry approves, not only `spendTokens`: ask `/config/spend-tokens/:address` for the
|
|
182
|
+
* entry of any pairing before building a launch on it.
|
|
183
|
+
*/
|
|
184
|
+
pairedTokenRegistry?: `0x${string}`;
|
|
169
185
|
requiresEoa: boolean;
|
|
170
186
|
accepting: boolean;
|
|
171
187
|
/**
|
|
@@ -179,6 +195,15 @@ export interface GateAnnouncement {
|
|
|
179
195
|
privateLaunches: boolean;
|
|
180
196
|
}
|
|
181
197
|
|
|
198
|
+
/**
|
|
199
|
+
* What `GET /config` actually serves: the operator's {@link GateAnnouncement} plus the version of
|
|
200
|
+
* `@flayerlabs/gamemode-gate` answering, so the platform can tell whether a gate runs a current SDK.
|
|
201
|
+
*/
|
|
202
|
+
export interface ServedGateAnnouncement extends GateAnnouncement {
|
|
203
|
+
/** The SemVer of the `@flayerlabs/gamemode-gate` build serving this route. */
|
|
204
|
+
gateVersion: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
182
207
|
/** Where an admission check is being made. Enough to tell a sign-in from a spend. */
|
|
183
208
|
export interface AdmissionRequest {
|
|
184
209
|
/** `session` on sign-in, `action` on gameplay, `claim` when allowance is about to be committed. */
|
|
@@ -627,9 +652,24 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
627
652
|
total: row.total,
|
|
628
653
|
})),
|
|
629
654
|
lastRoundAt: totals.last_round_at === null ? null : Number(totals.last_round_at),
|
|
655
|
+
// The same version `/config` reports, so a dashboard already reading stats needs no second call.
|
|
656
|
+
gateVersion: GATE_VERSION,
|
|
630
657
|
};
|
|
631
658
|
});
|
|
632
659
|
|
|
660
|
+
/**
|
|
661
|
+
* Accepted play sessions, for the same dashboard: one row per wallet and round, so a per-game
|
|
662
|
+
* play count survives the room ending. Wallets are hashed into the row ID and never returned.
|
|
663
|
+
* Rounds without a coin (practice and mock rooms) are left out. Keyset-paginated at 250 rows on
|
|
664
|
+
* `(started_at, id)`; the cursor is `${startedAt}:${id}` and a malformed one is a 400.
|
|
665
|
+
*/
|
|
666
|
+
app.get('/stats/plays', async (request, reply) => {
|
|
667
|
+
void reply.header('access-control-allow-origin', '*');
|
|
668
|
+
const cursor = parsePlayCursor((request.query as { cursor?: unknown }).cursor);
|
|
669
|
+
if (!cursor) return reply.code(400).send({ message: 'invalid play cursor' });
|
|
670
|
+
return readPlaySessions(pool, cursor);
|
|
671
|
+
});
|
|
672
|
+
|
|
633
673
|
/**
|
|
634
674
|
* How a coin's round stands: every wallet that scored, ranked, with what the chain says each
|
|
635
675
|
* spent. This is what the coin page shows once the game is over — and during it, as the running
|
|
@@ -655,8 +695,27 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
655
695
|
// allowlist names only the game's origins; the launch page is a different origin and every
|
|
656
696
|
// fact here is public (a launch publishes them on chain), so `*` is honest rather than lax.
|
|
657
697
|
void reply.header('access-control-allow-origin', '*');
|
|
658
|
-
|
|
698
|
+
// `gateVersion` is added here rather than asked of `announce()`: the library knows what it is
|
|
699
|
+
// and an operator's announcement should not be able to misreport it.
|
|
700
|
+
const served: ServedGateAnnouncement = { ...announce(), gateVersion: GATE_VERSION };
|
|
701
|
+
return served;
|
|
659
702
|
});
|
|
703
|
+
if (options.announceSpendToken) {
|
|
704
|
+
const announceSpendToken = options.announceSpendToken;
|
|
705
|
+
app.get<{ Params: { address: string } }>('/config/spend-tokens/:address', async (request, reply) => {
|
|
706
|
+
void reply.header('access-control-allow-origin', '*');
|
|
707
|
+
// A quote moves with the market and an approval can be revoked; nothing here may be cached
|
|
708
|
+
// past the response.
|
|
709
|
+
void reply.header('cache-control', 'no-store');
|
|
710
|
+
const { address } = request.params;
|
|
711
|
+
if (!isAddress(address, { strict: false })) {
|
|
712
|
+
return reply.code(400).send({ message: 'not a token address' });
|
|
713
|
+
}
|
|
714
|
+
const entry = await announceSpendToken(address as `0x${string}`);
|
|
715
|
+
if (!entry) return reply.code(404).send({ message: 'this gate does not run rounds on that token' });
|
|
716
|
+
return entry;
|
|
717
|
+
});
|
|
718
|
+
}
|
|
660
719
|
}
|
|
661
720
|
|
|
662
721
|
app.post('/session/challenge', async (request) => {
|
|
@@ -742,6 +801,7 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
742
801
|
if (typeof audience !== 'string' || !awardEndpoint.ticketAudiences.has(audience)) {
|
|
743
802
|
return reply.code(400).send({ message: 'that game server is not registered' });
|
|
744
803
|
}
|
|
804
|
+
await recordPlaySession(pool, id, who);
|
|
745
805
|
return issuePlayerJoinTicket({
|
|
746
806
|
awardToken: awardEndpoint.token,
|
|
747
807
|
player: who,
|
|
@@ -838,7 +898,9 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
838
898
|
|
|
839
899
|
const id = (request.params as { id: string }).id;
|
|
840
900
|
if (scoreAuthority === 'external-server') {
|
|
841
|
-
player(request);
|
|
901
|
+
const who = player(request);
|
|
902
|
+
if (Date.now() >= room.window.closesAt) return reply.code(409).send({ message: 'that game has ended' });
|
|
903
|
+
await recordPlaySession(pool, id, who);
|
|
842
904
|
return { joined: true };
|
|
843
905
|
}
|
|
844
906
|
const before = room.sequence();
|
|
@@ -850,7 +912,9 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
850
912
|
at: Date.now(),
|
|
851
913
|
});
|
|
852
914
|
if (room.sequence() !== before) announce(id);
|
|
853
|
-
|
|
915
|
+
if (isRefusal(result)) return reply.code(409).send({ message: 'you cannot join right now' });
|
|
916
|
+
await recordPlaySession(pool, id, player(request));
|
|
917
|
+
return { joined: true };
|
|
854
918
|
});
|
|
855
919
|
|
|
856
920
|
app.post('/rounds/:id/actions', async (request, reply) => {
|
package/src/store.ts
CHANGED
|
@@ -147,6 +147,21 @@ create table if not exists registrations (
|
|
|
147
147
|
primary key (chain_id, coin)
|
|
148
148
|
);
|
|
149
149
|
|
|
150
|
+
-- Accepted play sessions: one row per wallet and round, keyed by a hash of both so a wallet is never
|
|
151
|
+
-- stored in the clear. Independent of scoring and of websocket reconnects. play_tracking records
|
|
152
|
+
-- when this gate began counting, so a dashboard can tell "no plays" from "not yet tracked".
|
|
153
|
+
create table if not exists play_sessions (
|
|
154
|
+
id text primary key,
|
|
155
|
+
round_id text not null references rounds(id) on delete cascade,
|
|
156
|
+
started_at bigint not null
|
|
157
|
+
);
|
|
158
|
+
create index if not exists play_sessions_started_id_idx on play_sessions(started_at, id);
|
|
159
|
+
create table if not exists play_tracking (
|
|
160
|
+
id boolean primary key default true check (id),
|
|
161
|
+
started_at bigint not null
|
|
162
|
+
);
|
|
163
|
+
insert into play_tracking(id, started_at) values (true, (extract(epoch from clock_timestamp()) * 1000)::bigint) on conflict do nothing;
|
|
164
|
+
|
|
150
165
|
-- What actually happened, in order. Not used to rebuild state — the snapshot does that — but it is
|
|
151
166
|
-- the record of why a state looks the way it does, which is what an incident needs.
|
|
152
167
|
create table if not exists commands (
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Generated by scripts/write-version.mjs from package.json at build time. Do not edit.
|
|
2
|
+
/** The published version of @flayerlabs/gamemode-gate, as a literal so it survives any bundler. */
|
|
3
|
+
export const GATE_VERSION: string = "0.5.6";
|
|
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.6";
|