@flayerlabs/gamemode-gate 0.4.3 → 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/chain/discover.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { encodeAbiParameters, keccak256, parseAbi, type PublicClient } from 'viem';
|
|
2
|
+
import { ETH_SPEND_TOKEN, type SpendToken } from '@flayerlabs/gamemode-spec/live';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* All this needs of a client: the ability to read a contract.
|
|
@@ -20,6 +21,12 @@ export type ChainReader = Pick<PublicClient, 'readContract'>;
|
|
|
20
21
|
* launch's own gate parameters, and that is public state the creator paid to write and nobody else
|
|
21
22
|
* can forge. So adoption needs no API key and no allowlist — anyone may ask, and the chain decides.
|
|
22
23
|
* That single property is what makes third-party games permissionless.
|
|
24
|
+
*
|
|
25
|
+
* A gate serves exactly one (PositionManager, calculator) pair and adopts only coins launched on
|
|
26
|
+
* THAT manager: `poolKey(coin)` is asked of the configured manager alone, so a coin on a superseded
|
|
27
|
+
* generation of hooks (which keeps trading on its own gate) reads back as not a launch here. That
|
|
28
|
+
* is deliberate — the EIP-712 domain binds every authorisation to the calculator, so a coin this
|
|
29
|
+
* gate cannot sign for must not be given a room by it.
|
|
23
30
|
*/
|
|
24
31
|
|
|
25
32
|
const poolKeyComponents = [
|
|
@@ -39,17 +46,28 @@ export const positionManagerAbi = [
|
|
|
39
46
|
outputs: [{ name: '', type: 'tuple', components: poolKeyComponents }],
|
|
40
47
|
},
|
|
41
48
|
// The scheduled open of the buy window; 0 once the pool is tradeable.
|
|
42
|
-
...parseAbi([
|
|
49
|
+
...parseAbi([
|
|
50
|
+
'function flaunchesAt(bytes32 _poolId) view returns (uint256)',
|
|
51
|
+
// V1_3 only: the token the pool is paired with. A legacy manager has no such function; a revert
|
|
52
|
+
// there is read as "flETH", which is the only pairing that generation can produce.
|
|
53
|
+
'function pairedToken(bytes32 _poolId) view returns (address)',
|
|
54
|
+
]),
|
|
43
55
|
] as const;
|
|
44
56
|
|
|
45
57
|
export const spendGatedAbi = parseAbi([
|
|
46
58
|
// Four fields. Decoding a four-word return against a shorter ABI is how a check quietly reads the
|
|
47
59
|
// wrong value, so this must match the DEPLOYED calculator rather than the newest source.
|
|
48
|
-
'function spendGateSettings(bytes32 _poolId) view returns (bool enabled, uint256
|
|
60
|
+
'function spendGateSettings(bytes32 _poolId) view returns (bool enabled, uint256 walletCap, address settler, uint256 endsAt)',
|
|
49
61
|
'function trustedPoolKeySigner(bytes32 _poolId) view returns (address signer, bool enabled)',
|
|
50
62
|
]);
|
|
51
63
|
|
|
52
|
-
const erc20 = parseAbi([
|
|
64
|
+
const erc20 = parseAbi([
|
|
65
|
+
'function name() view returns (string)',
|
|
66
|
+
'function symbol() view returns (string)',
|
|
67
|
+
'function decimals() view returns (uint8)',
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
|
|
53
71
|
|
|
54
72
|
export interface PoolKey {
|
|
55
73
|
currency0: `0x${string}`;
|
|
@@ -67,11 +85,17 @@ export interface Launch {
|
|
|
67
85
|
opensAt: number;
|
|
68
86
|
/** When the gate stops enforcing on chain, in epoch ms. */
|
|
69
87
|
closesAt: number;
|
|
70
|
-
|
|
88
|
+
/** In base units of `spendToken`. */
|
|
89
|
+
walletCap: bigint;
|
|
71
90
|
signer: `0x${string}`;
|
|
72
91
|
name: string;
|
|
73
92
|
symbol: string;
|
|
74
93
|
imageUrl?: string | null;
|
|
94
|
+
/**
|
|
95
|
+
* The token spend is measured in: the coin's paired token, read from the manager. Absent only
|
|
96
|
+
* from hand-built launches (demos, tests), where it means ETH.
|
|
97
|
+
*/
|
|
98
|
+
spendToken?: SpendToken;
|
|
75
99
|
}
|
|
76
100
|
|
|
77
101
|
/**
|
|
@@ -166,7 +190,7 @@ export async function verifyLaunch(
|
|
|
166
190
|
return refuse('launch.unreadable', true);
|
|
167
191
|
}
|
|
168
192
|
|
|
169
|
-
const [enabled,
|
|
193
|
+
const [enabled, walletCap, , endsAt] = settings;
|
|
170
194
|
const [signer, signerEnabled] = trusted;
|
|
171
195
|
|
|
172
196
|
if (!enabled) return refuse('launch.not_a_game', false);
|
|
@@ -174,12 +198,15 @@ export async function verifyLaunch(
|
|
|
174
198
|
if (!signerEnabled || signer.toLowerCase() !== deployment.signer.toLowerCase()) {
|
|
175
199
|
return refuse('launch.another_signer', false);
|
|
176
200
|
}
|
|
177
|
-
if (
|
|
201
|
+
if (walletCap <= 0n) return refuse('launch.no_spending_cap', false);
|
|
178
202
|
|
|
179
203
|
const opensAt = Number(flaunchesAt) * 1000;
|
|
180
204
|
const closesAt = Number(endsAt) * 1000;
|
|
181
205
|
if (closesAt <= Date.now()) return refuse('launch.already_over', false);
|
|
182
206
|
|
|
207
|
+
const spendToken = await resolveSpendToken(client, deployment, memecoin, poolKey, poolId);
|
|
208
|
+
if (!spendToken) return refuse('launch.paired_unreadable', true);
|
|
209
|
+
|
|
183
210
|
let name = '';
|
|
184
211
|
let symbol = '';
|
|
185
212
|
try {
|
|
@@ -192,7 +219,60 @@ export async function verifyLaunch(
|
|
|
192
219
|
// display string would be refusing a round for no reason anyone could act on.
|
|
193
220
|
}
|
|
194
221
|
|
|
195
|
-
return {
|
|
222
|
+
return {
|
|
223
|
+
ok: true,
|
|
224
|
+
coinAddress: memecoin,
|
|
225
|
+
poolId,
|
|
226
|
+
poolKey,
|
|
227
|
+
opensAt,
|
|
228
|
+
closesAt,
|
|
229
|
+
walletCap,
|
|
230
|
+
signer,
|
|
231
|
+
name,
|
|
232
|
+
symbol,
|
|
233
|
+
spendToken,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Which token this pool's spend is measured in.
|
|
239
|
+
*
|
|
240
|
+
* The calculator measures spend on the pool's PAIRED side, so the gate's cap and allocations must
|
|
241
|
+
* be in that token's base units — a six-decimal stablecoin priced as if it were wei authorises a
|
|
242
|
+
* trillion times too much. Asked of the manager first (`pairedToken`, V1_3), and derived from the
|
|
243
|
+
* pool key when the manager predates the function: the key names two currencies and one of them
|
|
244
|
+
* is the coin. Decimals are load-bearing, so unlike the coin's cosmetic name an unreadable token
|
|
245
|
+
* is a refusal — retryable, because an RPC blip is not a statement about the token.
|
|
246
|
+
*/
|
|
247
|
+
async function resolveSpendToken(
|
|
248
|
+
client: ChainReader,
|
|
249
|
+
deployment: Deployment,
|
|
250
|
+
memecoin: `0x${string}`,
|
|
251
|
+
poolKey: PoolKey,
|
|
252
|
+
poolId: `0x${string}`,
|
|
253
|
+
): Promise<SpendToken | null> {
|
|
254
|
+
let paired: `0x${string}`;
|
|
255
|
+
try {
|
|
256
|
+
paired = (await client.readContract({
|
|
257
|
+
address: deployment.positionManager,
|
|
258
|
+
abi: positionManagerAbi,
|
|
259
|
+
functionName: 'pairedToken',
|
|
260
|
+
args: [poolId],
|
|
261
|
+
})) as `0x${string}`;
|
|
262
|
+
} catch {
|
|
263
|
+
paired = poolKey.currency0.toLowerCase() === memecoin.toLowerCase() ? poolKey.currency1 : poolKey.currency0;
|
|
264
|
+
}
|
|
265
|
+
if (paired.toLowerCase() === ZERO_ADDRESS) return ETH_SPEND_TOKEN;
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const [symbol, decimals] = (await Promise.all([
|
|
269
|
+
client.readContract({ address: paired, abi: erc20, functionName: 'symbol' }),
|
|
270
|
+
client.readContract({ address: paired, abi: erc20, functionName: 'decimals' }),
|
|
271
|
+
])) as [string, number];
|
|
272
|
+
return { address: paired, symbol, decimals: Number(decimals), isNative: false };
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
196
276
|
}
|
|
197
277
|
|
|
198
278
|
/**
|
package/src/chain/secure.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { keccak256, parseAbi, toHex, type PublicClient } from 'viem';
|
|
2
|
+
import type { FlaunchVariant } from './chains.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The boot-time refusals {@link PayloadSigner}'s doc comment promises.
|
|
@@ -16,11 +17,28 @@ const ZERO_KEY = `0x${'00'.repeat(32)}`;
|
|
|
16
17
|
/** The demo's session-secret fallback, which every copy of this repo shares. */
|
|
17
18
|
export const DEMO_SESSION_SECRET = 'dev-secret-that-is-long-enough-to-pass';
|
|
18
19
|
|
|
20
|
+
/** `ProtocolRoles.POSITION_MANAGER` in flaunch-contracts: the role a calculator demands of its caller. */
|
|
21
|
+
export const POSITION_MANAGER_ROLE = keccak256(toHex('PositionManager'));
|
|
22
|
+
|
|
23
|
+
const wiringAbi = parseAbi([
|
|
24
|
+
'function feeCalculator() view returns (address)',
|
|
25
|
+
'function registeredCalculators(address) view returns (bool)',
|
|
26
|
+
'function hasRole(bytes32 role, address account) view returns (bool)',
|
|
27
|
+
'function approvedRouters(address) view returns (bool)',
|
|
28
|
+
]);
|
|
29
|
+
|
|
19
30
|
export interface SecureConfig {
|
|
20
31
|
chainId: number;
|
|
21
32
|
signerPrivateKey: string;
|
|
22
33
|
spendGatedCalculator: `0x${string}`;
|
|
23
34
|
sessionSecret: string;
|
|
35
|
+
/**
|
|
36
|
+
* When given, the wiring between the manager and the calculator is read back and asserted — see
|
|
37
|
+
* {@link assertWiring}. Optional so a caller that only wants the key and chain checks keeps them.
|
|
38
|
+
*/
|
|
39
|
+
positionManager?: `0x${string}`;
|
|
40
|
+
flaunchVariant?: FlaunchVariant;
|
|
41
|
+
poolSwap?: `0x${string}`;
|
|
24
42
|
}
|
|
25
43
|
|
|
26
44
|
export async function assertSecureConfig(client: PublicClient, config: SecureConfig): Promise<void> {
|
|
@@ -46,4 +64,71 @@ export async function assertSecureConfig(client: PublicClient, config: SecureCon
|
|
|
46
64
|
if (!code || code === '0x') {
|
|
47
65
|
throw new Error(`SPEND_GATED_CALCULATOR ${config.spendGatedCalculator} holds no code on chain ${chainId}`);
|
|
48
66
|
}
|
|
67
|
+
|
|
68
|
+
if (config.positionManager) await assertWiring(client, config);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The manager must route fees through a dispatcher that knows this calculator, and the calculator
|
|
73
|
+
* must accept calls from that dispatcher. Both are one-time owner actions on chain, and a stack
|
|
74
|
+
* missing either ADOPTS launches whose swaps never reach the gate: the pool trades ungated while
|
|
75
|
+
* the gate believes it is enforcing a cap. That is the whole failure this function refuses.
|
|
76
|
+
*
|
|
77
|
+
* A paired stack additionally needs its PoolSwap approved as a router, because a paired buy arrives
|
|
78
|
+
* through it and the calculator binds a smart wallet's buyer identity to an approved router's
|
|
79
|
+
* `msgSender()` — unapproved, every such buy reverts at the swap.
|
|
80
|
+
*/
|
|
81
|
+
async function assertWiring(client: PublicClient, config: SecureConfig): Promise<void> {
|
|
82
|
+
const manager = config.positionManager!;
|
|
83
|
+
const calculator = config.spendGatedCalculator;
|
|
84
|
+
|
|
85
|
+
let dispatcher: `0x${string}`;
|
|
86
|
+
try {
|
|
87
|
+
dispatcher = await client.readContract({ address: manager, abi: wiringAbi, functionName: 'feeCalculator' });
|
|
88
|
+
} catch {
|
|
89
|
+
throw new Error(`POSITION_MANAGER ${manager} does not answer feeCalculator() — is it a Flaunch PositionManager?`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const [registered, hasRole] = await Promise.all([
|
|
93
|
+
client.readContract({
|
|
94
|
+
address: dispatcher,
|
|
95
|
+
abi: wiringAbi,
|
|
96
|
+
functionName: 'registeredCalculators',
|
|
97
|
+
args: [calculator],
|
|
98
|
+
}).catch(() => false),
|
|
99
|
+
client.readContract({
|
|
100
|
+
address: calculator,
|
|
101
|
+
abi: wiringAbi,
|
|
102
|
+
functionName: 'hasRole',
|
|
103
|
+
args: [POSITION_MANAGER_ROLE, dispatcher],
|
|
104
|
+
}).catch(() => false),
|
|
105
|
+
]);
|
|
106
|
+
if (!registered) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`the fee dispatcher ${dispatcher} of POSITION_MANAGER ${manager} does not have SPEND_GATED_CALCULATOR ` +
|
|
109
|
+
`${calculator} registered — its owner must call setCalculatorRegistered(${calculator}, true) before ` +
|
|
110
|
+
'this gate can serve launches on that manager',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (!hasRole) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`SPEND_GATED_CALCULATOR ${calculator} has not granted the PositionManager role to the dispatcher ${dispatcher} ` +
|
|
116
|
+
`— its admin must call grantRole(${POSITION_MANAGER_ROLE}, ${dispatcher})`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (config.flaunchVariant === 'v1_3') {
|
|
121
|
+
if (!config.poolSwap) {
|
|
122
|
+
throw new Error('a v1_3 stack needs POOL_SWAP — the router a paired-token buy goes through');
|
|
123
|
+
}
|
|
124
|
+
const approved = await client
|
|
125
|
+
.readContract({ address: calculator, abi: wiringAbi, functionName: 'approvedRouters', args: [config.poolSwap] })
|
|
126
|
+
.catch(() => false);
|
|
127
|
+
if (!approved) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`SPEND_GATED_CALCULATOR ${calculator} has not approved POOL_SWAP ${config.poolSwap} as a router — ` +
|
|
130
|
+
`its owner must call setApprovedRouter(${config.poolSwap}, true)`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
49
134
|
}
|
package/src/claims.ts
CHANGED
|
@@ -116,8 +116,8 @@ export class Claims {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/** What this player could spend right now. */
|
|
119
|
-
|
|
120
|
-
return this.ledger.
|
|
119
|
+
available(roundId: string, player: PlayerId): Promise<bigint> {
|
|
120
|
+
return this.ledger.available(roundId, player);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
/** Full authoritative balance for live snapshots. */
|
package/src/demo.ts
CHANGED
|
@@ -41,7 +41,7 @@ export interface DemoOptions<Config, State, Event, Action, PublicView, PlayerVie
|
|
|
41
41
|
lobbyMs?: number;
|
|
42
42
|
roundMs?: number;
|
|
43
43
|
/** The cap a wallet may spend, as a real launch would set on chain. Default 0.025 ETH. */
|
|
44
|
-
|
|
44
|
+
walletCap?: bigint;
|
|
45
45
|
pointsPerDollar?: number;
|
|
46
46
|
usdPerEth?: number;
|
|
47
47
|
roundId?: string;
|
|
@@ -83,7 +83,7 @@ export async function createDemoGate<Config, State, Event, Action, PublicView, P
|
|
|
83
83
|
databaseUrl = process.env.DATABASE_URL ?? 'postgres://postgres:gamemode@127.0.0.1:55439/gamemode',
|
|
84
84
|
lobbyMs = 8_000,
|
|
85
85
|
roundMs = 10 * 60_000,
|
|
86
|
-
|
|
86
|
+
walletCap = ETH / 40n,
|
|
87
87
|
pointsPerDollar = 100,
|
|
88
88
|
usdPerEth = 3_000,
|
|
89
89
|
roundId = 'dev',
|
|
@@ -96,9 +96,9 @@ export async function createDemoGate<Config, State, Event, Action, PublicView, P
|
|
|
96
96
|
const maxPointsPerPlayer = game.rewardBounds(config).maxPointsPerPlayer;
|
|
97
97
|
const economy = resolveEconomy({
|
|
98
98
|
maxPointsPerPlayer,
|
|
99
|
-
|
|
99
|
+
walletCap,
|
|
100
100
|
pointsPerDollar,
|
|
101
|
-
usdPerEth,
|
|
101
|
+
usdPerSpendToken: usdPerEth,
|
|
102
102
|
});
|
|
103
103
|
if (!economy.ok) {
|
|
104
104
|
throw new Error(
|
|
@@ -117,10 +117,10 @@ export async function createDemoGate<Config, State, Event, Action, PublicView, P
|
|
|
117
117
|
config,
|
|
118
118
|
seed: Math.floor(Math.random() * 1_000_000),
|
|
119
119
|
window: { opensAt, closesAt: opensAt + roundMs },
|
|
120
|
-
|
|
120
|
+
unitsPerPoint: economy.economy.unitsPerPoint,
|
|
121
121
|
pointsPerDollar: economy.economy.pointsPerDollar,
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
usdPerSpendToken: economy.economy.usdPerSpendToken,
|
|
123
|
+
walletCap,
|
|
124
124
|
maxPointsPerPlayer,
|
|
125
125
|
scoreAuthority: 'rules',
|
|
126
126
|
poolId: DEMO_POOL_ID,
|
|
@@ -147,11 +147,11 @@ export async function createDemoGate<Config, State, Event, Action, PublicView, P
|
|
|
147
147
|
contracts: { positionManager: DEMO_CALCULATOR, spendGatedCalculator: DEMO_CALCULATOR },
|
|
148
148
|
signer: signer.address,
|
|
149
149
|
settler: signer.address,
|
|
150
|
-
|
|
150
|
+
walletCap: walletCap.toString(),
|
|
151
151
|
roundDurationMs: roundMs,
|
|
152
152
|
minLobbyLeadMs: lobbyMs,
|
|
153
153
|
gateEndsAtGraceS: 10,
|
|
154
|
-
flaunchVariant: '
|
|
154
|
+
flaunchVariant: 'v1_2',
|
|
155
155
|
requiresEoa: false,
|
|
156
156
|
accepting: true,
|
|
157
157
|
publicLaunchesOpen: true,
|
|
@@ -171,7 +171,7 @@ export async function createDemoGate<Config, State, Event, Action, PublicView, P
|
|
|
171
171
|
app.post('/dev/wallet/buy', (request) => {
|
|
172
172
|
const { maxSpendWei } = request.body as { maxSpendWei: string };
|
|
173
173
|
purchases.push({ maxSpendWei, at: Date.now() });
|
|
174
|
-
return {
|
|
174
|
+
return { spent: maxSpendWei };
|
|
175
175
|
});
|
|
176
176
|
|
|
177
177
|
return { app, roundId, opensAt, walletAddress: wallet.address, purchases, pool };
|
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,7 @@ 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 { resolveEconomy } from './economy.js';
|
|
33
|
+
export { ETH_DECIMALS, resolveEconomy, unitsPerToken } from './economy.js';
|
|
32
34
|
export type { Economy, EconomyInput, EconomyResult } from './economy.js';
|
|
33
35
|
export { migrate } from './store.js';
|
|
34
36
|
export { createDemoGate } from './demo.js';
|
|
@@ -62,9 +64,9 @@ export type { VerifiedPlayerJoin, VerifyPlayerJoinTicketOptions } from './player
|
|
|
62
64
|
* gate (see DEPLOY.md), and the pieces below it stay exported for anyone wiring their own.
|
|
63
65
|
*/
|
|
64
66
|
export { startGate, resolveGateEnv } from './main.js';
|
|
65
|
-
export type { ResolvedGateConfig, StartedGate, StartGateOverrides } from './main.js';
|
|
67
|
+
export type { ResolvedGateConfig, SpendTokenPricing, StartedGate, StartGateOverrides } from './main.js';
|
|
66
68
|
export type { GateAnnouncement } from './server.js';
|
|
67
69
|
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';
|
|
70
|
+
export { DEPLOYMENTS, PAIRED_DEPLOYMENTS, deploymentFor } from './chain/chains.js';
|
|
71
|
+
export type { FlaunchVariant, GateDeployment } from './chain/chains.js';
|
|
72
|
+
export { POSITION_MANAGER_ROLE, assertSecureConfig } from './chain/secure.js';
|