@flayerlabs/gamemode-gate 0.4.2 → 0.5.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 +27 -3
- package/README.md +3 -3
- package/dist/chain/chains.d.ts +45 -6
- package/dist/chain/chains.d.ts.map +1 -1
- package/dist/chain/chains.js +56 -5
- package/dist/chain/chains.js.map +1 -1
- package/dist/chain/discover.d.ts +20 -2
- package/dist/chain/discover.d.ts.map +1 -1
- package/dist/chain/discover.js +74 -6
- package/dist/chain/discover.js.map +1 -1
- package/dist/chain/secure.d.ts +11 -1
- package/dist/chain/secure.d.ts.map +1 -1
- package/dist/chain/secure.js +67 -0
- package/dist/chain/secure.js.map +1 -1
- package/dist/claims.d.ts +1 -1
- package/dist/claims.d.ts.map +1 -1
- package/dist/claims.js +2 -2
- package/dist/claims.js.map +1 -1
- package/dist/demo.d.ts +1 -1
- package/dist/demo.d.ts.map +1 -1
- package/dist/demo.js +9 -9
- package/dist/demo.js.map +1 -1
- package/dist/economy.d.ts +25 -9
- package/dist/economy.d.ts.map +1 -1
- package/dist/economy.js +63 -22
- package/dist/economy.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +2 -2
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +16 -16
- package/dist/ledger.js.map +1 -1
- package/dist/main.d.ts +30 -5
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +158 -17
- package/dist/main.js.map +1 -1
- package/dist/room.d.ts +7 -4
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +31 -10
- package/dist/room.js.map +1 -1
- package/dist/server.d.ts +33 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +63 -25
- 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 +21 -0
- package/dist/store.js.map +1 -1
- package/package.json +2 -2
- package/src/chain/chains.ts +82 -9
- package/src/chain/discover.ts +87 -7
- package/src/chain/secure.ts +86 -1
- package/src/claims.ts +2 -2
- package/src/demo.ts +10 -10
- package/src/economy.ts +83 -31
- package/src/index.ts +7 -5
- package/src/ledger.ts +18 -18
- package/src/main.ts +181 -19
- package/src/room.ts +41 -14
- package/src/server.ts +107 -35
- package/src/store.ts +21 -0
package/src/ledger.ts
CHANGED
|
@@ -37,12 +37,12 @@ export interface BalanceKey {
|
|
|
37
37
|
|
|
38
38
|
interface LedgerState {
|
|
39
39
|
points: bigint;
|
|
40
|
-
|
|
40
|
+
unitsPerPoint: bigint;
|
|
41
41
|
capWei: bigint;
|
|
42
42
|
opensAt: number;
|
|
43
43
|
closesAt: number;
|
|
44
44
|
/** Authorisations still outstanding: issued, not yet expired, fate unknown. */
|
|
45
|
-
|
|
45
|
+
held: bigint;
|
|
46
46
|
/** What the chain says has actually been spent. */
|
|
47
47
|
chainSpentWei: bigint;
|
|
48
48
|
/**
|
|
@@ -60,9 +60,9 @@ interface LedgerState {
|
|
|
60
60
|
* wallet spend. Issuing past the second does not overspend — the chain refuses it — it mints a
|
|
61
61
|
* signature that reverts at the swap, which to a player is indistinguishable from being robbed.
|
|
62
62
|
*/
|
|
63
|
-
function availableFrom({ points,
|
|
64
|
-
const
|
|
65
|
-
const ceiling =
|
|
63
|
+
function availableFrom({ points, unitsPerPoint, capWei, held, chainSpentWei }: LedgerState): bigint {
|
|
64
|
+
const earned = points * unitsPerPoint;
|
|
65
|
+
const ceiling = earned < capWei ? earned : capWei;
|
|
66
66
|
|
|
67
67
|
// Spent-on-chain PLUS still-outstanding, never the larger of the two.
|
|
68
68
|
//
|
|
@@ -74,7 +74,7 @@ function availableFrom({ points, weiPerPoint, capWei, heldWei, chainSpentWei }:
|
|
|
74
74
|
//
|
|
75
75
|
// Adding them double-counts a hold whose swap has already landed, until that hold expires. That
|
|
76
76
|
// errs towards authorising too little, which is the direction that costs nobody anything.
|
|
77
|
-
const available = ceiling - (chainSpentWei +
|
|
77
|
+
const available = ceiling - (chainSpentWei + held);
|
|
78
78
|
return available > 0n ? available : 0n;
|
|
79
79
|
}
|
|
80
80
|
|
|
@@ -186,11 +186,11 @@ export class Ledger {
|
|
|
186
186
|
*
|
|
187
187
|
* Never allowed to go backwards: a read from a lagging node must not undo what a later one saw.
|
|
188
188
|
*/
|
|
189
|
-
async recordChainSpend(roundId: string, player: PlayerId,
|
|
189
|
+
async recordChainSpend(roundId: string, player: PlayerId, spent: bigint): Promise<boolean> {
|
|
190
190
|
const result = await this.pool.query(
|
|
191
191
|
`update balances set chain_spent_wei = greatest(chain_spent_wei, $3)
|
|
192
192
|
where round_id = $1 and player = $2 and chain_spent_wei < $3`,
|
|
193
|
-
[roundId, player,
|
|
193
|
+
[roundId, player, spent.toString()],
|
|
194
194
|
);
|
|
195
195
|
return result.rowCount === 1;
|
|
196
196
|
}
|
|
@@ -227,23 +227,23 @@ export class Ledger {
|
|
|
227
227
|
return [...unique.values()];
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
async
|
|
231
|
-
return (await this.economyBalance(roundId, player)).
|
|
230
|
+
async available(roundId: string, player: PlayerId): Promise<bigint> {
|
|
231
|
+
return (await this.economyBalance(roundId, player)).available;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
234
|
/** The authoritative balance snapshot, derived from the same state and ceiling as `claim`. */
|
|
235
235
|
async economyBalance(roundId: string, player: PlayerId): Promise<EconomyBalance> {
|
|
236
236
|
const state = await this.readState(this.pool, roundId, player);
|
|
237
237
|
if (!state) {
|
|
238
|
-
return {
|
|
238
|
+
return { unitsPerPoint: 0n, earned: 0n, held: 0n, spent: 0n, available: 0n, holdExpiresAt: null };
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
return {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
242
|
+
unitsPerPoint: state.unitsPerPoint,
|
|
243
|
+
earned: state.points * state.unitsPerPoint,
|
|
244
|
+
held: state.held,
|
|
245
|
+
spent: state.chainSpentWei,
|
|
246
|
+
available: availableFrom(state),
|
|
247
247
|
// Seconds on the way in, because that is what the contract checks against; milliseconds on
|
|
248
248
|
// the way out, because that is what a browser counts down with.
|
|
249
249
|
holdExpiresAt: state.holdExpiresAtSeconds === null ? null : state.holdExpiresAtSeconds * 1000,
|
|
@@ -285,11 +285,11 @@ export class Ledger {
|
|
|
285
285
|
return {
|
|
286
286
|
points: toBigInt(balance.rows[0]?.points),
|
|
287
287
|
chainSpentWei: toBigInt(balance.rows[0]?.chain_spent_wei),
|
|
288
|
-
|
|
288
|
+
unitsPerPoint: toBigInt(row.wei_per_point),
|
|
289
289
|
capWei: toBigInt(row.wallet_cap_wei),
|
|
290
290
|
opensAt: Number(row.opens_at),
|
|
291
291
|
closesAt: Number(row.closes_at),
|
|
292
|
-
|
|
292
|
+
held: toBigInt(held.rows[0]?.total),
|
|
293
293
|
holdExpiresAtSeconds: earliest == null ? null : Number(earliest),
|
|
294
294
|
};
|
|
295
295
|
}
|
package/src/main.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import pg from 'pg';
|
|
2
|
-
import { createPublicClient, http, isAddress, type PublicClient } from 'viem';
|
|
2
|
+
import { createPublicClient, http, isAddress, parseAbi, type PublicClient } from 'viem';
|
|
3
3
|
import type { FastifyInstance } from 'fastify';
|
|
4
4
|
import type { GameModule } from '@flayerlabs/gamemode-spec';
|
|
5
|
-
import {
|
|
5
|
+
import type { SpendToken } from '@flayerlabs/gamemode-spec/live';
|
|
6
|
+
import { createGate, type AnnouncedSpendToken, type GateAnnouncement, type PriceFor } from './server.js';
|
|
6
7
|
import { Ledger } from './ledger.js';
|
|
7
8
|
import { Claims } from './claims.js';
|
|
8
9
|
import { Sessions } from './sessions.js';
|
|
@@ -44,16 +45,31 @@ export interface ResolvedGateConfig {
|
|
|
44
45
|
port: number;
|
|
45
46
|
deployment: GateDeployment;
|
|
46
47
|
/** Announced to launch tooling; the creator's launch writes it into the pool. */
|
|
47
|
-
|
|
48
|
+
walletCap: bigint;
|
|
48
49
|
roundDurationMs: number;
|
|
49
50
|
minLobbyLeadMs: number;
|
|
50
51
|
gateEndsAtGraceS: number;
|
|
51
52
|
pointsPerDollar: number;
|
|
53
|
+
/** The price of ETH; what ETH-paired rounds are priced at. Fractional values are fine. */
|
|
52
54
|
usdPerEth: number;
|
|
55
|
+
/**
|
|
56
|
+
* The non-ETH paired tokens this gate will run rounds on, with the price it fixes them at and the
|
|
57
|
+
* per-wallet cap a launch on them should write. Keyed by lowercase address. Anything not here is
|
|
58
|
+
* refused at adoption — the gate does not guess a token's price.
|
|
59
|
+
*/
|
|
60
|
+
spendTokens: ReadonlyMap<string, SpendTokenPricing>;
|
|
53
61
|
/** May lift the gate early or rotate the signer on-chain. Defaults to the signing key's address. */
|
|
54
62
|
settler: `0x${string}` | null;
|
|
55
63
|
}
|
|
56
64
|
|
|
65
|
+
export interface SpendTokenPricing {
|
|
66
|
+
address: `0x${string}`;
|
|
67
|
+
/** Dollars per whole token. */
|
|
68
|
+
usdPerToken: number;
|
|
69
|
+
/** Per-wallet cap for a launch on this token, in its base units. */
|
|
70
|
+
walletCap: bigint;
|
|
71
|
+
}
|
|
72
|
+
|
|
57
73
|
function required(env: Env, name: string): string {
|
|
58
74
|
const value = env[name];
|
|
59
75
|
if (!value) throw new Error(`${name} is not set — the gate refuses to guess it`);
|
|
@@ -68,6 +84,54 @@ function integer(env: Env, name: string, fallback: number): number {
|
|
|
68
84
|
return value;
|
|
69
85
|
}
|
|
70
86
|
|
|
87
|
+
/** A price or a rate: positive, finite, and allowed to be fractional — a dollar stablecoin is 1.0. */
|
|
88
|
+
function positiveNumber(env: Env, name: string, fallback: number): number {
|
|
89
|
+
const raw = env[name];
|
|
90
|
+
if (raw === undefined || raw === '') return fallback;
|
|
91
|
+
const value = Number(raw);
|
|
92
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`);
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `SPEND_TOKENS`: a JSON array of `{ address, usdPerToken, walletCap }`.
|
|
98
|
+
*
|
|
99
|
+
* Only what the chain cannot tell us: the price, and the cap the operator wants launches on this
|
|
100
|
+
* token to carry. Symbol and decimals are read from the token itself at boot — an operator-typed
|
|
101
|
+
* decimals is the exact mistake this whole feature exists to make impossible.
|
|
102
|
+
*/
|
|
103
|
+
function spendTokens(env: Env): ReadonlyMap<string, SpendTokenPricing> {
|
|
104
|
+
const raw = env['SPEND_TOKENS'];
|
|
105
|
+
const out = new Map<string, SpendTokenPricing>();
|
|
106
|
+
if (raw === undefined || raw.trim() === '') return out;
|
|
107
|
+
let parsed: unknown;
|
|
108
|
+
try {
|
|
109
|
+
parsed = JSON.parse(raw);
|
|
110
|
+
} catch {
|
|
111
|
+
throw new Error('SPEND_TOKENS must be a JSON array of { address, usdPerToken, walletCap }');
|
|
112
|
+
}
|
|
113
|
+
if (!Array.isArray(parsed)) throw new Error('SPEND_TOKENS must be a JSON array');
|
|
114
|
+
for (const entry of parsed as unknown[]) {
|
|
115
|
+
const value = (entry ?? {}) as Record<string, unknown>;
|
|
116
|
+
// Casing is not checked: operators paste addresses from explorers and configs in every case,
|
|
117
|
+
// and a rejected checksum here would read as "wrong address" for a right one.
|
|
118
|
+
if (typeof value.address !== 'string' || !isAddress(value.address, { strict: false })) {
|
|
119
|
+
throw new Error('SPEND_TOKENS: every entry needs an address');
|
|
120
|
+
}
|
|
121
|
+
const usdPerToken = Number(value.usdPerToken);
|
|
122
|
+
if (!Number.isFinite(usdPerToken) || usdPerToken <= 0) {
|
|
123
|
+
throw new Error(`SPEND_TOKENS: ${value.address} needs a positive usdPerToken`);
|
|
124
|
+
}
|
|
125
|
+
if (typeof value.walletCap !== 'string' || !/^[1-9][0-9]*$/.test(value.walletCap)) {
|
|
126
|
+
throw new Error(`SPEND_TOKENS: ${value.address} needs walletCap as a decimal string of base units`);
|
|
127
|
+
}
|
|
128
|
+
const key = value.address.toLowerCase();
|
|
129
|
+
if (out.has(key)) throw new Error(`SPEND_TOKENS lists ${value.address} twice`);
|
|
130
|
+
out.set(key, { address: value.address as `0x${string}`, usdPerToken, walletCap: BigInt(value.walletCap) });
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
71
135
|
function address(env: Env, name: string): `0x${string}` | null {
|
|
72
136
|
const raw = env[name];
|
|
73
137
|
if (raw === undefined || raw === '') return null;
|
|
@@ -88,23 +152,31 @@ function address(env: Env, name: string): `0x${string}` | null {
|
|
|
88
152
|
* GATE_ORIGIN required; this gate's own public https origin (the SIWE audience)
|
|
89
153
|
* ALLOWED_ORIGINS required; comma-separated origins that may call this gate
|
|
90
154
|
* PORT default 8790
|
|
91
|
-
* POSITION_MANAGER optional
|
|
155
|
+
* POSITION_MANAGER optional; selects a recorded stack on the chain (the legacy one when
|
|
156
|
+
* unset), or overrides the address for a chain with no address book
|
|
92
157
|
* SPEND_GATED_CALCULATOR optional address-book override
|
|
93
|
-
*
|
|
158
|
+
* POOL_SWAP optional address-book override; required for a v1_3 stack
|
|
159
|
+
* FLETH optional address-book override; the chain's flETH wrapper
|
|
160
|
+
* FLAUNCH_VARIANT optional; v1_2, multichain or v1_3
|
|
94
161
|
* SETTLER optional; defaults to the signing key's own address
|
|
95
|
-
* WALLET_CAP_WEI default 0.025 ETH
|
|
162
|
+
* WALLET_CAP_WEI default 0.025 ETH; the cap for ETH-paired launches
|
|
96
163
|
* ROUND_MS default 90000
|
|
97
164
|
* MIN_LOBBY_LEAD_MS default 60000
|
|
98
165
|
* GATE_ENDS_AT_GRACE_S default 10
|
|
99
166
|
* POINTS_PER_DOLLAR default 100
|
|
100
|
-
* USD_PER_ETH default 3000
|
|
167
|
+
* USD_PER_ETH default 3000; fractional allowed
|
|
168
|
+
* SPEND_TOKENS optional JSON array of { address, usdPerToken, walletCap } — the non-ETH
|
|
169
|
+
* paired tokens this gate runs rounds on. Unlisted tokens are refused.
|
|
101
170
|
*/
|
|
102
171
|
export function resolveGateEnv(env: Env): ResolvedGateConfig {
|
|
103
172
|
const chainId = Number(required(env, 'CHAIN_ID'));
|
|
104
173
|
if (!Number.isSafeInteger(chainId) || chainId <= 0) throw new Error('CHAIN_ID must be a positive integer');
|
|
105
174
|
|
|
106
|
-
const
|
|
107
|
-
|
|
175
|
+
const namedManager = address(env, 'POSITION_MANAGER');
|
|
176
|
+
// Naming a manager picks the recorded stack it belongs to — calculator and router with it — so a
|
|
177
|
+
// paired manager is never paired with the legacy calculator by a half-edited env file.
|
|
178
|
+
const recorded = deploymentFor(chainId, namedManager);
|
|
179
|
+
const positionManager = namedManager ?? recorded?.positionManager ?? null;
|
|
108
180
|
const spendGatedCalculator = address(env, 'SPEND_GATED_CALCULATOR') ?? recorded?.spendGatedCalculator ?? null;
|
|
109
181
|
if (!positionManager || !spendGatedCalculator) {
|
|
110
182
|
throw new Error(
|
|
@@ -113,10 +185,20 @@ export function resolveGateEnv(env: Env): ResolvedGateConfig {
|
|
|
113
185
|
);
|
|
114
186
|
}
|
|
115
187
|
const variantOverride = env['FLAUNCH_VARIANT'];
|
|
116
|
-
if (
|
|
117
|
-
|
|
188
|
+
if (
|
|
189
|
+
variantOverride !== undefined &&
|
|
190
|
+
variantOverride !== 'v1_2' &&
|
|
191
|
+
variantOverride !== 'multichain' &&
|
|
192
|
+
variantOverride !== 'v1_3'
|
|
193
|
+
) {
|
|
194
|
+
throw new Error(`FLAUNCH_VARIANT must be v1_2, multichain or v1_3, not ${variantOverride}`);
|
|
118
195
|
}
|
|
119
|
-
const flaunchVariant = variantOverride ?? recorded?.flaunchVariant ?? '
|
|
196
|
+
const flaunchVariant = variantOverride ?? recorded?.flaunchVariant ?? 'v1_2';
|
|
197
|
+
const poolSwap = address(env, 'POOL_SWAP') ?? recorded?.poolSwap ?? null;
|
|
198
|
+
if (flaunchVariant === 'v1_3' && !poolSwap) {
|
|
199
|
+
throw new Error('a v1_3 stack needs POOL_SWAP — the approved router a paired-token buy goes through');
|
|
200
|
+
}
|
|
201
|
+
const fleth = address(env, 'FLETH') ?? recorded?.fleth ?? null;
|
|
120
202
|
|
|
121
203
|
const signerPrivateKey = required(env, 'SIGNER_PRIVATE_KEY');
|
|
122
204
|
if (!/^0x[0-9a-fA-F]{64}$/.test(signerPrivateKey)) {
|
|
@@ -150,17 +232,50 @@ export function resolveGateEnv(env: Env): ResolvedGateConfig {
|
|
|
150
232
|
gateOrigin: required(env, 'GATE_ORIGIN'),
|
|
151
233
|
allowedOrigins,
|
|
152
234
|
port: integer(env, 'PORT', 8790),
|
|
153
|
-
deployment: {
|
|
154
|
-
|
|
235
|
+
deployment: {
|
|
236
|
+
positionManager,
|
|
237
|
+
spendGatedCalculator,
|
|
238
|
+
flaunchVariant,
|
|
239
|
+
...(poolSwap ? { poolSwap } : {}),
|
|
240
|
+
...(fleth ? { fleth } : {}),
|
|
241
|
+
},
|
|
242
|
+
walletCap: BigInt(env['WALLET_CAP_WEI'] ?? 25_000_000_000_000_000n.toString()),
|
|
155
243
|
roundDurationMs: integer(env, 'ROUND_MS', 90_000),
|
|
156
244
|
minLobbyLeadMs: integer(env, 'MIN_LOBBY_LEAD_MS', 60_000),
|
|
157
245
|
gateEndsAtGraceS: integer(env, 'GATE_ENDS_AT_GRACE_S', 10),
|
|
158
246
|
pointsPerDollar: integer(env, 'POINTS_PER_DOLLAR', 100),
|
|
159
|
-
usdPerEth:
|
|
247
|
+
usdPerEth: positiveNumber(env, 'USD_PER_ETH', 3_000),
|
|
248
|
+
spendTokens: spendTokens(env),
|
|
160
249
|
settler: address(env, 'SETTLER'),
|
|
161
250
|
};
|
|
162
251
|
}
|
|
163
252
|
|
|
253
|
+
const erc20 = parseAbi(['function symbol() view returns (string)', 'function decimals() view returns (uint8)']);
|
|
254
|
+
|
|
255
|
+
/** Whether a paired token is ETH by another name: native, or the chain's flETH wrapper. */
|
|
256
|
+
function isEthEquivalent(token: SpendToken, fleth: `0x${string}` | undefined): boolean {
|
|
257
|
+
return token.isNative || (fleth !== undefined && token.address.toLowerCase() === fleth.toLowerCase());
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The price list a running gate answers adoption with.
|
|
262
|
+
*
|
|
263
|
+
* An override (an oracle, a test) is asked first; the env map second; ETH and its wrapper are the
|
|
264
|
+
* operator's `USD_PER_ETH`; anything else is unpriced and the launch is refused.
|
|
265
|
+
*/
|
|
266
|
+
function priceList(config: ResolvedGateConfig, override: PriceFor | undefined): PriceFor {
|
|
267
|
+
return async (token) => {
|
|
268
|
+
if (override) {
|
|
269
|
+
const quoted = await override(token);
|
|
270
|
+
if (quoted !== null) return quoted;
|
|
271
|
+
}
|
|
272
|
+
const listed = config.spendTokens.get(token.address.toLowerCase());
|
|
273
|
+
if (listed) return listed.usdPerToken;
|
|
274
|
+
if (isEthEquivalent(token, config.deployment.fleth)) return config.usdPerEth;
|
|
275
|
+
return null;
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
164
279
|
export interface StartedGate {
|
|
165
280
|
app: FastifyInstance;
|
|
166
281
|
port: number;
|
|
@@ -175,6 +290,11 @@ export interface StartGateOverrides {
|
|
|
175
290
|
client?: PublicClient;
|
|
176
291
|
/** False builds and checks everything but never binds a port. */
|
|
177
292
|
listen?: boolean;
|
|
293
|
+
/**
|
|
294
|
+
* A price source consulted before `SPEND_TOKENS` — an oracle, or a test's constant. Answering
|
|
295
|
+
* null defers to the env list; a token neither knows is refused at adoption.
|
|
296
|
+
*/
|
|
297
|
+
priceProvider?: PriceFor;
|
|
178
298
|
}
|
|
179
299
|
|
|
180
300
|
export async function startGate<Config, State, Event, Action, PublicView, PlayerView>(
|
|
@@ -183,13 +303,14 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
|
|
|
183
303
|
overrides: StartGateOverrides = {},
|
|
184
304
|
): Promise<StartedGate> {
|
|
185
305
|
const config = resolveGateEnv(overrides.env ?? process.env);
|
|
306
|
+
const maxPointsPerPlayer = game.rewardBounds(gameConfig).maxPointsPerPlayer;
|
|
186
307
|
|
|
187
308
|
// The same refusal the demo makes: an economy that cannot pay out is caught before anyone plays.
|
|
188
309
|
const economy = resolveEconomy({
|
|
189
|
-
maxPointsPerPlayer
|
|
190
|
-
|
|
310
|
+
maxPointsPerPlayer,
|
|
311
|
+
walletCap: config.walletCap,
|
|
191
312
|
pointsPerDollar: config.pointsPerDollar,
|
|
192
|
-
|
|
313
|
+
usdPerSpendToken: config.usdPerEth,
|
|
193
314
|
});
|
|
194
315
|
if (!economy.ok) {
|
|
195
316
|
throw new Error(
|
|
@@ -203,8 +324,46 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
|
|
|
203
324
|
signerPrivateKey: config.signerPrivateKey,
|
|
204
325
|
spendGatedCalculator: config.deployment.spendGatedCalculator,
|
|
205
326
|
sessionSecret: config.sessionSecret,
|
|
327
|
+
positionManager: config.deployment.positionManager,
|
|
328
|
+
flaunchVariant: config.deployment.flaunchVariant,
|
|
329
|
+
...(config.deployment.poolSwap ? { poolSwap: config.deployment.poolSwap } : {}),
|
|
206
330
|
});
|
|
207
331
|
|
|
332
|
+
// Each listed spend token: read what the chain says it is, and refuse an economy that does not
|
|
333
|
+
// fit its cap now, at boot, rather than at the first launch somebody has already paid for.
|
|
334
|
+
const announcedSpendTokens: AnnouncedSpendToken[] = [];
|
|
335
|
+
for (const pricing of config.spendTokens.values()) {
|
|
336
|
+
let symbol: string;
|
|
337
|
+
let decimals: number;
|
|
338
|
+
try {
|
|
339
|
+
[symbol, decimals] = await Promise.all([
|
|
340
|
+
client.readContract({ address: pricing.address, abi: erc20, functionName: 'symbol' }),
|
|
341
|
+
client.readContract({ address: pricing.address, abi: erc20, functionName: 'decimals' }).then(Number),
|
|
342
|
+
]);
|
|
343
|
+
} catch {
|
|
344
|
+
throw new Error(`SPEND_TOKENS: ${pricing.address} does not answer symbol()/decimals() on chain ${config.chainId}`);
|
|
345
|
+
}
|
|
346
|
+
const fit = resolveEconomy({
|
|
347
|
+
maxPointsPerPlayer,
|
|
348
|
+
walletCap: pricing.walletCap,
|
|
349
|
+
pointsPerDollar: config.pointsPerDollar,
|
|
350
|
+
usdPerSpendToken: pricing.usdPerToken,
|
|
351
|
+
decimals,
|
|
352
|
+
});
|
|
353
|
+
if (!fit.ok) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`That economy does not work for ${symbol}: ${fit.problem}. Try ${fit.suggestedPointsPerDollar} points per dollar.`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
announcedSpendTokens.push({
|
|
359
|
+
address: pricing.address,
|
|
360
|
+
symbol,
|
|
361
|
+
decimals,
|
|
362
|
+
usdPerToken: pricing.usdPerToken,
|
|
363
|
+
walletCap: pricing.walletCap.toString(),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
208
367
|
const pool = new pg.Pool({ connectionString: config.databaseUrl });
|
|
209
368
|
await migrate(pool);
|
|
210
369
|
|
|
@@ -217,14 +376,16 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
|
|
|
217
376
|
contracts: {
|
|
218
377
|
positionManager: config.deployment.positionManager,
|
|
219
378
|
spendGatedCalculator: config.deployment.spendGatedCalculator,
|
|
379
|
+
...(config.deployment.poolSwap ? { poolSwap: config.deployment.poolSwap } : {}),
|
|
220
380
|
},
|
|
221
381
|
signer: signer.address,
|
|
222
382
|
settler,
|
|
223
|
-
|
|
383
|
+
walletCap: config.walletCap.toString(),
|
|
224
384
|
roundDurationMs: config.roundDurationMs,
|
|
225
385
|
minLobbyLeadMs: config.minLobbyLeadMs,
|
|
226
386
|
gateEndsAtGraceS: config.gateEndsAtGraceS,
|
|
227
387
|
flaunchVariant: config.deployment.flaunchVariant,
|
|
388
|
+
...(announcedSpendTokens.length > 0 ? { spendTokens: announcedSpendTokens } : {}),
|
|
228
389
|
requiresEoa: false,
|
|
229
390
|
accepting: true,
|
|
230
391
|
publicLaunchesOpen: true,
|
|
@@ -237,6 +398,7 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
|
|
|
237
398
|
config: gameConfig,
|
|
238
399
|
pointsPerDollar: config.pointsPerDollar,
|
|
239
400
|
usdPerEth: config.usdPerEth,
|
|
401
|
+
priceFor: priceList(config, overrides.priceProvider),
|
|
240
402
|
sessions: new Sessions(config.sessionSecret, config.signInDomain, config.gateOrigin),
|
|
241
403
|
claims: new Claims(pool, ledger, signer),
|
|
242
404
|
discovery: new Discovery(client, {
|
package/src/room.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Pool } from 'pg';
|
|
2
2
|
import { Round } from '@flayerlabs/gamemode-spec/round';
|
|
3
3
|
import type { Award, Command, Decision, GameModule, PlayerId, Refusal, RoundWindow } from '@flayerlabs/gamemode-spec';
|
|
4
|
-
import type
|
|
4
|
+
import { ETH_SPEND_TOKEN, type LaunchContext, type RoundTerms, type SpendToken } from '@flayerlabs/gamemode-spec/live';
|
|
5
5
|
import { isRefusal } from '@flayerlabs/gamemode-spec';
|
|
6
6
|
import { inTransaction, toBigInt } from './store.js';
|
|
7
7
|
|
|
@@ -52,10 +52,13 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
52
52
|
config: Config;
|
|
53
53
|
seed: number;
|
|
54
54
|
window: RoundWindow;
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
unitsPerPoint: bigint;
|
|
56
|
+
walletCap: bigint;
|
|
57
57
|
pointsPerDollar: number;
|
|
58
|
-
|
|
58
|
+
/** The token spend is measured in. Defaults to ETH — a flETH- or ETH-paired round. */
|
|
59
|
+
spendToken?: SpendToken;
|
|
60
|
+
/** The market price of one whole spend token, in dollars, at adoption. */
|
|
61
|
+
usdPerSpendToken: number;
|
|
59
62
|
maxPointsPerPlayer: number;
|
|
60
63
|
scoreAuthority: ScoreAuthority;
|
|
61
64
|
poolId: string;
|
|
@@ -86,28 +89,39 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
86
89
|
closesAt: round.window.closesAt,
|
|
87
90
|
booksCloseAt,
|
|
88
91
|
});
|
|
92
|
+
const spendToken: SpendToken = Object.freeze({ ...(round.spendToken ?? ETH_SPEND_TOKEN) });
|
|
89
93
|
const terms: RoundTerms = Object.freeze({
|
|
90
94
|
maxPointsPerPlayer: round.maxPointsPerPlayer,
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
walletCap: round.walletCap,
|
|
96
|
+
unitsPerPoint: round.unitsPerPoint,
|
|
93
97
|
pointsPerDollar: round.pointsPerDollar,
|
|
94
|
-
|
|
98
|
+
usdPerSpendToken: round.usdPerSpendToken,
|
|
99
|
+
spendToken,
|
|
95
100
|
});
|
|
96
101
|
const started = Round.start(game, round.config, round.seed, round.window);
|
|
102
|
+
// The columns keep their original wei-flavoured names while the API does not. Renaming them
|
|
103
|
+
// would be a migration with no reader outside this file, so the divergence is deliberate:
|
|
104
|
+
// wei_per_point is unitsPerPoint, wallet_cap_wei is walletCap, and usd_per_spend_token is
|
|
105
|
+
// usdPerSpendToken. usd_per_eth is a legacy column, written for provenance only.
|
|
97
106
|
await pool.query(
|
|
98
107
|
`insert into rounds (
|
|
99
108
|
id, game_id, wei_per_point, wallet_cap_wei, points_per_dollar, usd_per_eth,
|
|
109
|
+
spend_token, spend_symbol, spend_decimals, usd_per_spend_token,
|
|
100
110
|
max_points_per_player, score_authority,
|
|
101
111
|
pool_id, opens_at, closes_at, books_close_at,
|
|
102
112
|
coin_address, coin_name, coin_symbol, coin_image_url, state, seq
|
|
103
|
-
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 0)`,
|
|
113
|
+
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, 0)`,
|
|
104
114
|
[
|
|
105
115
|
round.id,
|
|
106
116
|
game.id,
|
|
107
|
-
round.
|
|
108
|
-
round.
|
|
117
|
+
round.unitsPerPoint.toString(),
|
|
118
|
+
round.walletCap.toString(),
|
|
109
119
|
round.pointsPerDollar,
|
|
110
|
-
round.
|
|
120
|
+
round.usdPerSpendToken,
|
|
121
|
+
spendToken.address.toLowerCase(),
|
|
122
|
+
spendToken.symbol,
|
|
123
|
+
spendToken.decimals,
|
|
124
|
+
round.usdPerSpendToken,
|
|
111
125
|
round.maxPointsPerPlayer,
|
|
112
126
|
round.scoreAuthority,
|
|
113
127
|
round.poolId,
|
|
@@ -155,12 +169,17 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
155
169
|
wallet_cap_wei: string;
|
|
156
170
|
points_per_dollar: number;
|
|
157
171
|
usd_per_eth: number;
|
|
172
|
+
spend_token: string;
|
|
173
|
+
spend_symbol: string;
|
|
174
|
+
spend_decimals: number;
|
|
175
|
+
usd_per_spend_token: number | null;
|
|
158
176
|
max_points_per_player: string;
|
|
159
177
|
score_authority: ScoreAuthority;
|
|
160
178
|
game_id: string;
|
|
161
179
|
}>(
|
|
162
180
|
`select state, seq, pool_id, opens_at, closes_at, books_close_at, wei_per_point,
|
|
163
181
|
wallet_cap_wei, points_per_dollar, usd_per_eth,
|
|
182
|
+
spend_token, spend_symbol, spend_decimals, usd_per_spend_token,
|
|
164
183
|
max_points_per_player, score_authority, game_id,
|
|
165
184
|
coin_address, coin_name, coin_symbol, coin_image_url
|
|
166
185
|
from rounds where id = $1`,
|
|
@@ -183,10 +202,18 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
183
202
|
});
|
|
184
203
|
const terms: RoundTerms = Object.freeze({
|
|
185
204
|
maxPointsPerPlayer: Number(row.max_points_per_player),
|
|
186
|
-
|
|
187
|
-
|
|
205
|
+
walletCap: toBigInt(row.wallet_cap_wei),
|
|
206
|
+
unitsPerPoint: toBigInt(row.wei_per_point),
|
|
188
207
|
pointsPerDollar: Number(row.points_per_dollar),
|
|
189
|
-
|
|
208
|
+
spendToken: Object.freeze({
|
|
209
|
+
address: row.spend_token,
|
|
210
|
+
symbol: row.spend_symbol,
|
|
211
|
+
decimals: Number(row.spend_decimals),
|
|
212
|
+
isNative: row.spend_token.toLowerCase() === ETH_SPEND_TOKEN.address,
|
|
213
|
+
}),
|
|
214
|
+
// usd_per_eth is the legacy column: on a row written before the spend-token columns existed
|
|
215
|
+
// it holds that round's ETH price, which is exactly its spend token's price.
|
|
216
|
+
usdPerSpendToken: row.usd_per_spend_token === null ? Number(row.usd_per_eth) : Number(row.usd_per_spend_token),
|
|
190
217
|
});
|
|
191
218
|
return new Room(
|
|
192
219
|
pool,
|