kill-switch-mcp 1.0.1 → 1.1.2

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.
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Kill Switch Game Wallet
3
+ *
4
+ * Manages a local access key for interacting with tournament contracts.
5
+ * The access key is a scoped sub-key of the player's Tempo passkey account.
6
+ *
7
+ * Flow:
8
+ * 1. Player creates Tempo account via `tempo wallet login`
9
+ * 2. setup_game_wallet generates a local keypair and guides the player
10
+ * through authorizing it as an access key on their Tempo account
11
+ * 3. The access key is stored locally in ~/.killswitch/
12
+ * 4. join_tournament uses the access key to sign approve + deposit txs
13
+ */
14
+
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
16
+ import { join } from 'path';
17
+ import { homedir } from 'os';
18
+ import {
19
+ createPublicClient,
20
+ createWalletClient,
21
+ http,
22
+ parseUnits,
23
+ formatUnits,
24
+ defineChain,
25
+ encodeFunctionData,
26
+ stringToHex,
27
+ pad,
28
+ type Hex,
29
+ } from 'viem';
30
+ import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts';
31
+
32
+ // ── Config ──
33
+
34
+ const WALLET_DIR = join(homedir(), '.killswitch');
35
+ const WALLET_FILE = join(WALLET_DIR, 'access-key.json');
36
+
37
+ // Tempo chain config — defaults to testnet, override via env
38
+ const TEMPO_RPC = process.env.TEMPO_RPC_URL || 'https://rpc.moderato.tempo.xyz';
39
+ const TEMPO_CHAIN_ID = parseInt(process.env.TEMPO_CHAIN_ID || '42431');
40
+
41
+ const tempoChain = defineChain({
42
+ id: TEMPO_CHAIN_ID,
43
+ name: TEMPO_CHAIN_ID === 4217 ? 'Tempo' : 'Tempo Testnet',
44
+ nativeCurrency: { name: 'USD', symbol: 'USD', decimals: 18 },
45
+ rpcUrls: {
46
+ default: { http: [TEMPO_RPC] },
47
+ },
48
+ });
49
+
50
+ // USDC on Tempo testnet (pathUSD)
51
+ const USDC_ADDRESS = (process.env.USDC_ADDRESS || '0x20c0000000000000000000000000000000000000') as Hex;
52
+
53
+ // Factory address — set after deployment
54
+ const FACTORY_ADDRESS = process.env.FACTORY_ADDRESS as Hex | undefined;
55
+
56
+ // Game server URL for API calls
57
+ const SERVER_URL = (() => {
58
+ const idx = process.argv.indexOf('--server');
59
+ if (idx !== -1 && process.argv[idx + 1]) return process.argv[idx + 1];
60
+ return process.env.KILL_SWITCH_SERVER || 'localhost';
61
+ })();
62
+
63
+ function getWebBase(): string {
64
+ const isLocal = SERVER_URL === 'localhost' || SERVER_URL === '127.0.0.1';
65
+ return isLocal ? 'http://localhost:8888' : `https://${SERVER_URL}`;
66
+ }
67
+
68
+ // ── ABIs (minimal, just what we need) ──
69
+
70
+ const ERC20_ABI = [
71
+ {
72
+ name: 'approve',
73
+ type: 'function',
74
+ inputs: [
75
+ { name: 'spender', type: 'address' },
76
+ { name: 'amount', type: 'uint256' },
77
+ ],
78
+ outputs: [{ name: '', type: 'bool' }],
79
+ stateMutability: 'nonpayable',
80
+ },
81
+ {
82
+ name: 'balanceOf',
83
+ type: 'function',
84
+ inputs: [{ name: 'account', type: 'address' }],
85
+ outputs: [{ name: '', type: 'uint256' }],
86
+ stateMutability: 'view',
87
+ },
88
+ {
89
+ name: 'decimals',
90
+ type: 'function',
91
+ inputs: [],
92
+ outputs: [{ name: '', type: 'uint8' }],
93
+ stateMutability: 'view',
94
+ },
95
+ ] as const;
96
+
97
+ const TOURNAMENT_ABI = [
98
+ {
99
+ name: 'deposit',
100
+ type: 'function',
101
+ inputs: [{ name: 'username', type: 'bytes32' }],
102
+ outputs: [],
103
+ stateMutability: 'nonpayable',
104
+ },
105
+ {
106
+ name: 'buyIn',
107
+ type: 'function',
108
+ inputs: [],
109
+ outputs: [{ name: '', type: 'uint256' }],
110
+ stateMutability: 'view',
111
+ },
112
+ {
113
+ name: 'state',
114
+ type: 'function',
115
+ inputs: [],
116
+ outputs: [{ name: '', type: 'uint8' }],
117
+ stateMutability: 'view',
118
+ },
119
+ {
120
+ name: 'getDepositorCount',
121
+ type: 'function',
122
+ inputs: [],
123
+ outputs: [{ name: '', type: 'uint256' }],
124
+ stateMutability: 'view',
125
+ },
126
+ {
127
+ name: 'maxPlayers',
128
+ type: 'function',
129
+ inputs: [],
130
+ outputs: [{ name: '', type: 'uint8' }],
131
+ stateMutability: 'view',
132
+ },
133
+ {
134
+ name: 'startTime',
135
+ type: 'function',
136
+ inputs: [],
137
+ outputs: [{ name: '', type: 'uint256' }],
138
+ stateMutability: 'view',
139
+ },
140
+ {
141
+ name: 'hasDeposited',
142
+ type: 'function',
143
+ inputs: [{ name: '', type: 'address' }],
144
+ outputs: [{ name: '', type: 'bool' }],
145
+ stateMutability: 'view',
146
+ },
147
+ ] as const;
148
+
149
+ // ── Wallet Storage ──
150
+
151
+ interface StoredWallet {
152
+ accessKeyPrivate: string; // hex private key
153
+ accountAddress: string; // the player's Tempo account address
154
+ createdAt: string;
155
+ }
156
+
157
+ function loadWallet(): StoredWallet | null {
158
+ try {
159
+ if (existsSync(WALLET_FILE)) {
160
+ return JSON.parse(readFileSync(WALLET_FILE, 'utf-8'));
161
+ }
162
+ } catch (e) {
163
+ console.error('[Wallet] Failed to load wallet:', e);
164
+ }
165
+ return null;
166
+ }
167
+
168
+ function saveWallet(wallet: StoredWallet): void {
169
+ mkdirSync(WALLET_DIR, { recursive: true });
170
+ writeFileSync(WALLET_FILE, JSON.stringify(wallet, null, 2), { mode: 0o600 });
171
+ }
172
+
173
+ // ── Client Factories ──
174
+
175
+ function getPublicClient() {
176
+ return createPublicClient({
177
+ chain: tempoChain,
178
+ transport: http(),
179
+ });
180
+ }
181
+
182
+ function getWalletClient(privateKey: Hex) {
183
+ const account = privateKeyToAccount(privateKey);
184
+ return createWalletClient({
185
+ account,
186
+ chain: tempoChain,
187
+ transport: http(),
188
+ });
189
+ }
190
+
191
+ // ── Public API (used by MCP tools) ──
192
+
193
+ /**
194
+ * Check if a game wallet exists locally.
195
+ */
196
+ export function hasGameWallet(): boolean {
197
+ return existsSync(WALLET_FILE);
198
+ }
199
+
200
+ /**
201
+ * Get wallet info — address and USDC balance.
202
+ */
203
+ export async function getWalletInfo(): Promise<{
204
+ address: string;
205
+ balance: string;
206
+ balanceRaw: bigint;
207
+ }> {
208
+ const wallet = loadWallet();
209
+ if (!wallet) {
210
+ throw new Error('No game wallet found. Run setup_game_wallet first.');
211
+ }
212
+
213
+ const pub = getPublicClient();
214
+
215
+ const balanceRaw = await pub.readContract({
216
+ address: USDC_ADDRESS,
217
+ abi: ERC20_ABI,
218
+ functionName: 'balanceOf',
219
+ args: [wallet.accountAddress as Hex],
220
+ });
221
+
222
+ const balance = formatUnits(balanceRaw, 6);
223
+
224
+ return {
225
+ address: wallet.accountAddress,
226
+ balance,
227
+ balanceRaw,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Generate a new access key and return setup instructions.
233
+ * Does NOT authorize it — the player must do that with their passkey.
234
+ */
235
+ export function generateAccessKey(): {
236
+ accessKeyAddress: string;
237
+ privateKey: string;
238
+ instructions: string;
239
+ } {
240
+ const privateKey = generatePrivateKey();
241
+ const account = privateKeyToAccount(privateKey);
242
+
243
+ const instructions = [
244
+ 'A new game access key has been generated.',
245
+ '',
246
+ `Access Key Address: ${account.address}`,
247
+ '',
248
+ 'To authorize this key on your Tempo account, you need to call the',
249
+ 'Account Keychain precompile. This requires your passkey (biometric).',
250
+ '',
251
+ 'You can do this via the Tempo wallet web interface or by running:',
252
+ '',
253
+ ` cast send 0xAAAAAAAA00000000000000000000000000000000 \\`,
254
+ ` "authorizeKey(address,uint8,uint64,bool,(address,uint256)[])" \\`,
255
+ ` ${account.address} 0 ${Math.floor(Date.now() / 1000) + 90 * 86400} true \\`,
256
+ ` "[(${USDC_ADDRESS},1000000000)]" \\`,
257
+ ` --rpc-url ${TEMPO_RPC}`,
258
+ '',
259
+ 'This authorizes the key to spend up to 1000 USDC on game transactions,',
260
+ 'expiring in 90 days. You can revoke it anytime from your Tempo account.',
261
+ '',
262
+ 'Learn more about Tempo access keys:',
263
+ 'https://docs.tempo.xyz/protocol/tips/tip-1011',
264
+ ].join('\n');
265
+
266
+ return { accessKeyAddress: account.address, privateKey, instructions };
267
+ }
268
+
269
+ /**
270
+ * Save the access key after the player has authorized it.
271
+ */
272
+ export function saveAccessKey(privateKey: string, accountAddress: string): void {
273
+ saveWallet({
274
+ accessKeyPrivate: privateKey,
275
+ accountAddress,
276
+ createdAt: new Date().toISOString(),
277
+ });
278
+ }
279
+
280
+ /**
281
+ * Get upcoming paid tournament schedule from the game server.
282
+ */
283
+ export async function getTournamentSchedule(): Promise<any[]> {
284
+ const res = await fetch(`${getWebBase()}/api/paid-schedule`);
285
+ const data = await res.json() as any;
286
+
287
+ if (data.error) {
288
+ throw new Error(data.error);
289
+ }
290
+
291
+ return data.slots || [];
292
+ }
293
+
294
+ /**
295
+ * Join a paid tournament by depositing the buy-in.
296
+ * Signs approve() + deposit() using the local access key.
297
+ */
298
+ export async function joinTournament(
299
+ tournamentAddress: string,
300
+ username: string,
301
+ ): Promise<{ txHash: string; amount: string }> {
302
+ const wallet = loadWallet();
303
+ if (!wallet) {
304
+ throw new Error('No game wallet found. Run setup_game_wallet first.');
305
+ }
306
+
307
+ const pub = getPublicClient();
308
+ const walletClient = getWalletClient(wallet.accessKeyPrivate as Hex);
309
+
310
+ // Read buy-in amount from the contract
311
+ const buyIn = await pub.readContract({
312
+ address: tournamentAddress as Hex,
313
+ abi: TOURNAMENT_ABI,
314
+ functionName: 'buyIn',
315
+ });
316
+
317
+ // Check balance
318
+ const balance = await pub.readContract({
319
+ address: USDC_ADDRESS,
320
+ abi: ERC20_ABI,
321
+ functionName: 'balanceOf',
322
+ args: [wallet.accountAddress as Hex],
323
+ });
324
+
325
+ if (balance < buyIn) {
326
+ const needed = formatUnits(buyIn, 6);
327
+ const have = formatUnits(balance, 6);
328
+ throw new Error(`Insufficient balance. Need $${needed} USDC, have $${have}.`);
329
+ }
330
+
331
+ // Check if already deposited
332
+ const alreadyDeposited = await pub.readContract({
333
+ address: tournamentAddress as Hex,
334
+ abi: TOURNAMENT_ABI,
335
+ functionName: 'hasDeposited',
336
+ args: [wallet.accountAddress as Hex],
337
+ });
338
+
339
+ if (alreadyDeposited) {
340
+ throw new Error('You have already deposited in this tournament.');
341
+ }
342
+
343
+ // Step 1: Approve USDC spend
344
+ const approveHash = await walletClient.writeContract({
345
+ address: USDC_ADDRESS,
346
+ abi: ERC20_ABI,
347
+ functionName: 'approve',
348
+ args: [tournamentAddress as Hex, buyIn],
349
+ });
350
+
351
+ await pub.waitForTransactionReceipt({ hash: approveHash });
352
+
353
+ // Step 2: Deposit with username
354
+ const usernameBytes = pad(stringToHex(username), { size: 32 });
355
+
356
+ const depositHash = await walletClient.writeContract({
357
+ address: tournamentAddress as Hex,
358
+ abi: TOURNAMENT_ABI,
359
+ functionName: 'deposit',
360
+ args: [usernameBytes],
361
+ });
362
+
363
+ await pub.waitForTransactionReceipt({ hash: depositHash });
364
+
365
+ return {
366
+ txHash: depositHash,
367
+ amount: formatUnits(buyIn, 6),
368
+ };
369
+ }