@flayerlabs/gamemode-gate 0.4.3 → 0.5.1
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 +8 -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 +7 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -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 +93 -25
- package/dist/server.js.map +1 -1
- package/dist/standings.d.ts +34 -0
- package/dist/standings.d.ts.map +1 -0
- package/dist/standings.js +103 -0
- package/dist/standings.js.map +1 -0
- 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 +8 -5
- package/src/ledger.ts +18 -18
- package/src/main.ts +181 -19
- package/src/room.ts +41 -14
- package/src/server.ts +142 -35
- package/src/standings.ts +138 -0
- package/src/store.ts +21 -0
package/src/economy.ts
CHANGED
|
@@ -7,41 +7,68 @@
|
|
|
7
7
|
* environment variable was wrong, and no code was in a position to notice.
|
|
8
8
|
*
|
|
9
9
|
* So the arithmetic lives here, runs at round creation with the actual values, and refuses. A
|
|
10
|
-
* creator sets one human number — how many points a dollar is worth — and never sees
|
|
10
|
+
* creator sets one human number — how many points a dollar is worth — and never sees base units.
|
|
11
|
+
*
|
|
12
|
+
* Denomination. A pool's spend is measured on chain in its PAIRED token's own base units: wei for
|
|
13
|
+
* an ETH-paired coin, 10^-6 for a six-decimal stablecoin. Every amount in this package is
|
|
14
|
+
* therefore base units of the round's spend token, and the arithmetic here is parameterised by
|
|
15
|
+
* that token's decimals rather than assuming eighteen.
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
|
-
/**
|
|
18
|
+
/** The decimals of ETH, which is what every field was denominated in before paired-token rounds. */
|
|
19
|
+
export const ETH_DECIMALS = 18;
|
|
20
|
+
|
|
21
|
+
/** 1 ETH in wei. Kept as the default `unitsPerToken` so an ETH round is byte-identical to before. */
|
|
14
22
|
const WEI_PER_ETH = 1_000_000_000_000_000_000n;
|
|
15
23
|
|
|
24
|
+
/** 10^decimals: one whole spend token in its base units. */
|
|
25
|
+
export function unitsPerToken(decimals: number): bigint {
|
|
26
|
+
return 10n ** BigInt(decimals);
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
export interface EconomyInput {
|
|
17
30
|
/** The most one player can earn, declared by the game itself via `rewardBounds`. */
|
|
18
31
|
maxPointsPerPlayer: number;
|
|
19
|
-
/** The pool's on-chain per-wallet ceiling
|
|
20
|
-
|
|
32
|
+
/** The pool's on-chain per-wallet ceiling, in base units of the spend token. */
|
|
33
|
+
walletCap: bigint;
|
|
21
34
|
/** The creator's one knob. 100 means 100 points is worth a dollar. */
|
|
22
35
|
pointsPerDollar: number;
|
|
23
|
-
|
|
36
|
+
/** The market price of one whole spend token, in dollars. For an ETH round this IS the ETH price. */
|
|
37
|
+
usdPerSpendToken: number;
|
|
38
|
+
/** Decimals of the spend token. Defaults to 18 — ETH — which is what every older caller meant. */
|
|
39
|
+
decimals?: number;
|
|
24
40
|
}
|
|
25
41
|
|
|
26
42
|
export interface Economy {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
43
|
+
/** Base units of the spend token per point. */
|
|
44
|
+
unitsPerPoint: bigint;
|
|
45
|
+
/** What a flawless round pays. Always at or under `walletCap`, or this would not exist. */
|
|
46
|
+
maxPayout: bigint;
|
|
30
47
|
/**
|
|
31
|
-
* The
|
|
48
|
+
* The inputs, returned beside the rate they produced.
|
|
32
49
|
*
|
|
33
50
|
* They travel together so a caller cannot persist a rate from one calculation and a dollar
|
|
34
51
|
* figure from another. Both are process-wide settings, so the only place they are true of a
|
|
35
52
|
* particular round is the moment that round's rate was fixed.
|
|
36
53
|
*/
|
|
37
54
|
pointsPerDollar: number;
|
|
38
|
-
|
|
55
|
+
/** The market price of one whole spend token — what a display should call the token's price. */
|
|
56
|
+
usdPerSpendToken: number;
|
|
57
|
+
/** The spend token's decimals. */
|
|
58
|
+
decimals: number;
|
|
39
59
|
}
|
|
40
60
|
|
|
41
61
|
export type EconomyResult =
|
|
42
62
|
| { ok: true; economy: Economy }
|
|
43
63
|
| { ok: false; problem: string; suggestedPointsPerDollar: number };
|
|
44
64
|
|
|
65
|
+
function resolveInputs(input: EconomyInput): { usdPerSpendToken: number; decimals: number } {
|
|
66
|
+
return {
|
|
67
|
+
usdPerSpendToken: input.usdPerSpendToken ?? Number.NaN,
|
|
68
|
+
decimals: input.decimals ?? ETH_DECIMALS,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
45
72
|
/**
|
|
46
73
|
* Resolve the rate for one round, or refuse with the number that fixes it.
|
|
47
74
|
*
|
|
@@ -54,13 +81,25 @@ export type EconomyResult =
|
|
|
54
81
|
* goes and reads this file.
|
|
55
82
|
*/
|
|
56
83
|
export function resolveEconomy(input: EconomyInput): EconomyResult {
|
|
57
|
-
const { maxPointsPerPlayer,
|
|
84
|
+
const { maxPointsPerPlayer, walletCap, pointsPerDollar } = input;
|
|
85
|
+
const { usdPerSpendToken, decimals } = resolveInputs(input);
|
|
58
86
|
|
|
59
87
|
if (!Number.isFinite(pointsPerDollar) || pointsPerDollar <= 0) {
|
|
60
88
|
return { ok: false, problem: 'points per dollar must be a positive number', suggestedPointsPerDollar: 100 };
|
|
61
89
|
}
|
|
62
|
-
if (!Number.
|
|
63
|
-
return {
|
|
90
|
+
if (!Number.isSafeInteger(decimals) || decimals < 0 || decimals > 36) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
problem: 'the spend token must have between 0 and 36 decimals',
|
|
94
|
+
suggestedPointsPerDollar: pointsPerDollar,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (!Number.isFinite(usdPerSpendToken) || usdPerSpendToken <= 0) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
problem: 'the spend token price must be a positive number',
|
|
101
|
+
suggestedPointsPerDollar: pointsPerDollar,
|
|
102
|
+
};
|
|
64
103
|
}
|
|
65
104
|
if (!Number.isSafeInteger(maxPointsPerPlayer) || maxPointsPerPlayer <= 0) {
|
|
66
105
|
return {
|
|
@@ -69,7 +108,7 @@ export function resolveEconomy(input: EconomyInput): EconomyResult {
|
|
|
69
108
|
suggestedPointsPerDollar: pointsPerDollar,
|
|
70
109
|
};
|
|
71
110
|
}
|
|
72
|
-
if (
|
|
111
|
+
if (walletCap <= 0n) {
|
|
73
112
|
return {
|
|
74
113
|
ok: false,
|
|
75
114
|
problem: 'this launch has no per-wallet spending cap, so nothing can be authorised',
|
|
@@ -77,30 +116,31 @@ export function resolveEconomy(input: EconomyInput): EconomyResult {
|
|
|
77
116
|
};
|
|
78
117
|
}
|
|
79
118
|
|
|
80
|
-
// How many points one
|
|
81
|
-
// guarded as positive above, but a small enough positive value still rounds this product to
|
|
119
|
+
// How many points one whole token buys. Checked before it becomes a divisor: `pointsPerDollar`
|
|
120
|
+
// is guarded as positive above, but a small enough positive value still rounds this product to
|
|
82
121
|
// zero — and dividing by it threw `Division by zero` out of the one function whose entire job is
|
|
83
122
|
// to refuse a bad economy with a sentence an operator can act on.
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
123
|
+
const pointsPerToken = Math.round(usdPerSpendToken * pointsPerDollar);
|
|
124
|
+
if (pointsPerToken <= 0) {
|
|
86
125
|
return {
|
|
87
126
|
ok: false,
|
|
88
|
-
problem: 'one point would be worth more than a whole
|
|
127
|
+
problem: 'one point would be worth more than a whole spend token, which cannot be what was meant',
|
|
89
128
|
suggestedPointsPerDollar: minimumPointsPerDollar(input),
|
|
90
129
|
};
|
|
91
130
|
}
|
|
92
131
|
|
|
93
|
-
const
|
|
94
|
-
|
|
132
|
+
const units = unitsPerToken(decimals);
|
|
133
|
+
const unitsPerPoint = units / BigInt(pointsPerToken);
|
|
134
|
+
if (unitsPerPoint <= 0n) {
|
|
95
135
|
return {
|
|
96
136
|
ok: false,
|
|
97
|
-
problem: 'each point would be worth less than the smallest unit of
|
|
137
|
+
problem: 'each point would be worth less than the smallest unit of the spend token',
|
|
98
138
|
suggestedPointsPerDollar: Math.max(1, Math.floor(pointsPerDollar / 1000)),
|
|
99
|
-
|
|
139
|
+
};
|
|
100
140
|
}
|
|
101
141
|
|
|
102
|
-
const
|
|
103
|
-
if (
|
|
142
|
+
const maxPayout = BigInt(Math.floor(maxPointsPerPlayer)) * unitsPerPoint;
|
|
143
|
+
if (maxPayout > walletCap) {
|
|
104
144
|
return {
|
|
105
145
|
ok: false,
|
|
106
146
|
problem: 'a perfect round would earn more than this launch lets one wallet spend',
|
|
@@ -108,24 +148,36 @@ export function resolveEconomy(input: EconomyInput): EconomyResult {
|
|
|
108
148
|
};
|
|
109
149
|
}
|
|
110
150
|
|
|
111
|
-
return {
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
economy: {
|
|
154
|
+
unitsPerPoint,
|
|
155
|
+
maxPayout,
|
|
156
|
+
pointsPerDollar,
|
|
157
|
+
usdPerSpendToken,
|
|
158
|
+
decimals,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
112
161
|
}
|
|
113
162
|
|
|
114
163
|
/**
|
|
115
164
|
* The smallest `pointsPerDollar` that keeps a flawless round inside the cap.
|
|
116
165
|
*
|
|
117
166
|
* Rounded up, then nudged until it actually passes: the division that produces it floors, and a
|
|
118
|
-
* value that is correct to the last
|
|
167
|
+
* value that is correct to the last unit but fails the check is worse than useless in an error
|
|
119
168
|
* message. Two steps is enough for any input — the loop is a guard against an off-by-one in the
|
|
120
169
|
* rounding, not an iterative search.
|
|
121
170
|
*/
|
|
122
171
|
function minimumPointsPerDollar(input: EconomyInput): number {
|
|
123
|
-
const { maxPointsPerPlayer,
|
|
172
|
+
const { maxPointsPerPlayer, walletCap } = input;
|
|
173
|
+
const { usdPerSpendToken, decimals } = resolveInputs(input);
|
|
124
174
|
const points = BigInt(Math.floor(maxPointsPerPlayer));
|
|
125
|
-
const
|
|
126
|
-
if (
|
|
175
|
+
const maxUnitsPerPoint = walletCap / points;
|
|
176
|
+
if (maxUnitsPerPoint <= 0n) return Math.ceil(maxPointsPerPlayer);
|
|
127
177
|
|
|
128
|
-
|
|
178
|
+
const units =
|
|
179
|
+
Number.isSafeInteger(decimals) && decimals >= 0 && decimals <= 36 ? unitsPerToken(decimals) : WEI_PER_ETH;
|
|
180
|
+
let candidate = Math.ceil(Number(units / maxUnitsPerPoint) / usdPerSpendToken);
|
|
129
181
|
for (let i = 0; i < 3; i++) {
|
|
130
182
|
const result = resolveEconomy({ ...input, pointsPerDollar: candidate });
|
|
131
183
|
if (result.ok) return candidate;
|
package/src/index.ts
CHANGED
|
@@ -11,10 +11,12 @@ export type {
|
|
|
11
11
|
Admission,
|
|
12
12
|
AdmissionRequest,
|
|
13
13
|
Admit,
|
|
14
|
+
AnnouncedSpendToken,
|
|
14
15
|
ConfigForLaunch,
|
|
15
16
|
GameServerGateOptions,
|
|
16
17
|
GateOptions,
|
|
17
18
|
MaxPointsForLaunch,
|
|
19
|
+
PriceFor,
|
|
18
20
|
VerifiedLaunch,
|
|
19
21
|
} from './server.js';
|
|
20
22
|
export { createTurnstileAdmit } from './turnstile.js';
|
|
@@ -28,7 +30,8 @@ export type { SignedPayload } from './chain/signer.js';
|
|
|
28
30
|
export { Discovery, poolIdOf, verifyLaunch } from './chain/discover.js';
|
|
29
31
|
export type { ChainReader, Deployment, Launch, LaunchCheck, PoolKey, Refused } from './chain/discover.js';
|
|
30
32
|
export { Settlement } from './settlement.js';
|
|
31
|
-
export {
|
|
33
|
+
export { Standings } from './standings.js';
|
|
34
|
+
export { ETH_DECIMALS, resolveEconomy, unitsPerToken } from './economy.js';
|
|
32
35
|
export type { Economy, EconomyInput, EconomyResult } from './economy.js';
|
|
33
36
|
export { migrate } from './store.js';
|
|
34
37
|
export { createDemoGate } from './demo.js';
|
|
@@ -62,9 +65,9 @@ export type { VerifiedPlayerJoin, VerifyPlayerJoinTicketOptions } from './player
|
|
|
62
65
|
* gate (see DEPLOY.md), and the pieces below it stay exported for anyone wiring their own.
|
|
63
66
|
*/
|
|
64
67
|
export { startGate, resolveGateEnv } from './main.js';
|
|
65
|
-
export type { ResolvedGateConfig, StartedGate, StartGateOverrides } from './main.js';
|
|
68
|
+
export type { ResolvedGateConfig, SpendTokenPricing, StartedGate, StartGateOverrides } from './main.js';
|
|
66
69
|
export type { GateAnnouncement } from './server.js';
|
|
67
70
|
export { NotSignedIn } from './sessions.js';
|
|
68
|
-
export { DEPLOYMENTS, deploymentFor } from './chain/chains.js';
|
|
69
|
-
export type { GateDeployment } from './chain/chains.js';
|
|
70
|
-
export { assertSecureConfig } from './chain/secure.js';
|
|
71
|
+
export { DEPLOYMENTS, PAIRED_DEPLOYMENTS, deploymentFor } from './chain/chains.js';
|
|
72
|
+
export type { FlaunchVariant, GateDeployment } from './chain/chains.js';
|
|
73
|
+
export { POSITION_MANAGER_ROLE, assertSecureConfig } from './chain/secure.js';
|
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, {
|