@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.
Files changed (63) hide show
  1. package/DEPLOY.md +27 -3
  2. package/README.md +3 -3
  3. package/dist/chain/chains.d.ts +45 -6
  4. package/dist/chain/chains.d.ts.map +1 -1
  5. package/dist/chain/chains.js +56 -5
  6. package/dist/chain/chains.js.map +1 -1
  7. package/dist/chain/discover.d.ts +20 -2
  8. package/dist/chain/discover.d.ts.map +1 -1
  9. package/dist/chain/discover.js +74 -6
  10. package/dist/chain/discover.js.map +1 -1
  11. package/dist/chain/secure.d.ts +11 -1
  12. package/dist/chain/secure.d.ts.map +1 -1
  13. package/dist/chain/secure.js +67 -0
  14. package/dist/chain/secure.js.map +1 -1
  15. package/dist/claims.d.ts +1 -1
  16. package/dist/claims.d.ts.map +1 -1
  17. package/dist/claims.js +2 -2
  18. package/dist/claims.js.map +1 -1
  19. package/dist/demo.d.ts +1 -1
  20. package/dist/demo.d.ts.map +1 -1
  21. package/dist/demo.js +9 -9
  22. package/dist/demo.js.map +1 -1
  23. package/dist/economy.d.ts +25 -9
  24. package/dist/economy.d.ts.map +1 -1
  25. package/dist/economy.js +63 -22
  26. package/dist/economy.js.map +1 -1
  27. package/dist/index.d.ts +6 -6
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +3 -3
  30. package/dist/index.js.map +1 -1
  31. package/dist/ledger.d.ts +2 -2
  32. package/dist/ledger.d.ts.map +1 -1
  33. package/dist/ledger.js +16 -16
  34. package/dist/ledger.js.map +1 -1
  35. package/dist/main.d.ts +30 -5
  36. package/dist/main.d.ts.map +1 -1
  37. package/dist/main.js +158 -17
  38. package/dist/main.js.map +1 -1
  39. package/dist/room.d.ts +7 -4
  40. package/dist/room.d.ts.map +1 -1
  41. package/dist/room.js +31 -10
  42. package/dist/room.js.map +1 -1
  43. package/dist/server.d.ts +33 -3
  44. package/dist/server.d.ts.map +1 -1
  45. package/dist/server.js +63 -25
  46. package/dist/server.js.map +1 -1
  47. package/dist/store.d.ts +1 -1
  48. package/dist/store.d.ts.map +1 -1
  49. package/dist/store.js +21 -0
  50. package/dist/store.js.map +1 -1
  51. package/package.json +2 -2
  52. package/src/chain/chains.ts +82 -9
  53. package/src/chain/discover.ts +87 -7
  54. package/src/chain/secure.ts +86 -1
  55. package/src/claims.ts +2 -2
  56. package/src/demo.ts +10 -10
  57. package/src/economy.ts +83 -31
  58. package/src/index.ts +7 -5
  59. package/src/ledger.ts +18 -18
  60. package/src/main.ts +181 -19
  61. package/src/room.ts +41 -14
  62. package/src/server.ts +107 -35
  63. package/src/store.ts +21 -0
@@ -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(['function flaunchesAt(bytes32 _poolId) view returns (uint256)']),
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 walletCapWei, address settler, uint256 endsAt)',
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(['function name() view returns (string)', 'function symbol() view returns (string)']);
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
- walletCapWei: bigint;
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, walletCapWei, , endsAt] = settings;
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 (walletCapWei <= 0n) return refuse('launch.no_spending_cap', false);
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 { ok: true, coinAddress: memecoin, poolId, poolKey, opensAt, closesAt, walletCapWei, signer, name, symbol };
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
  /**
@@ -1,4 +1,5 @@
1
- import type { PublicClient } from 'viem';
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
- availableWei(roundId: string, player: PlayerId): Promise<bigint> {
120
- return this.ledger.availableWei(roundId, player);
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
- walletCapWei?: bigint;
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
- walletCapWei = ETH / 40n,
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
- walletCapWei,
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
- weiPerPoint: economy.economy.weiPerPoint,
120
+ unitsPerPoint: economy.economy.unitsPerPoint,
121
121
  pointsPerDollar: economy.economy.pointsPerDollar,
122
- usdPerEth: economy.economy.usdPerEth,
123
- walletCapWei,
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
- walletCapWei: walletCapWei.toString(),
150
+ walletCap: walletCap.toString(),
151
151
  roundDurationMs: roundMs,
152
152
  minLobbyLeadMs: lobbyMs,
153
153
  gateEndsAtGraceS: 10,
154
- flaunchVariant: 'legacy11',
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 { spentWei: maxSpendWei };
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 wei.
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
- /** 1 ETH in wei. */
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. The chain enforces this whatever we do. */
20
- walletCapWei: bigint;
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
- usdPerEth: number;
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
- weiPerPoint: bigint;
28
- /** What a flawless round pays. Always at or under `walletCapWei`, or this would not exist. */
29
- maxPayoutWei: bigint;
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 two inputs, returned beside the rate they produced.
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
- usdPerEth: number;
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, walletCapWei, pointsPerDollar, usdPerEth } = input;
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.isFinite(usdPerEth) || usdPerEth <= 0) {
63
- return { ok: false, problem: 'the ETH price must be a positive number', suggestedPointsPerDollar: pointsPerDollar };
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 (walletCapWei <= 0n) {
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 ETH buys. Checked before it becomes a divisor: `pointsPerDollar` is
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 pointsPerEth = Math.round(usdPerEth * pointsPerDollar);
85
- if (pointsPerEth <= 0) {
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 ETH, which cannot be what was meant',
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 weiPerPoint = WEI_PER_ETH / BigInt(pointsPerEth);
94
- if (weiPerPoint <= 0n) {
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 ETH',
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 maxPayoutWei = BigInt(Math.floor(maxPointsPerPlayer)) * weiPerPoint;
103
- if (maxPayoutWei > walletCapWei) {
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 { ok: true, economy: { weiPerPoint, maxPayoutWei, pointsPerDollar, usdPerEth } };
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 wei but fails the check is worse than useless in an error
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, walletCapWei, usdPerEth } = input;
172
+ const { maxPointsPerPlayer, walletCap } = input;
173
+ const { usdPerSpendToken, decimals } = resolveInputs(input);
124
174
  const points = BigInt(Math.floor(maxPointsPerPlayer));
125
- const maxWeiPerPoint = walletCapWei / points;
126
- if (maxWeiPerPoint <= 0n) return Math.ceil(maxPointsPerPlayer);
175
+ const maxUnitsPerPoint = walletCap / points;
176
+ if (maxUnitsPerPoint <= 0n) return Math.ceil(maxPointsPerPlayer);
127
177
 
128
- let candidate = Math.ceil(Number(WEI_PER_ETH / maxWeiPerPoint) / usdPerEth);
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';