@mingderwang/wallet 0.1.0 → 0.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.
- package/README.md +22 -3
- package/dist/cli.js +23966 -0
- package/dist/index.js +5 -0
- package/package.json +10 -4
- package/src/cli.ts +331 -0
- package/src/index.ts +110 -0
- package/src/keystore.ts +101 -0
package/dist/index.js
CHANGED
|
@@ -79,6 +79,10 @@ function randomBytesHex(len = 32) {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
// src/index.ts
|
|
82
|
+
function processEnvPassword() {
|
|
83
|
+
const v = process.env.WALLET_PASSWORD ?? process.env.XSIFT_WALLET_PASSWORD;
|
|
84
|
+
return v && v.length > 0 ? v : undefined;
|
|
85
|
+
}
|
|
82
86
|
var NETWORKS = {
|
|
83
87
|
ethereumMainnet: { networkId: "ethereum-mainnet", chainId: 1 },
|
|
84
88
|
ethereumSepolia: { networkId: "ethereum-sepolia", chainId: 11155111 },
|
|
@@ -162,6 +166,7 @@ export {
|
|
|
162
166
|
rpcUrl,
|
|
163
167
|
readKeystore,
|
|
164
168
|
randomBytesHex,
|
|
169
|
+
processEnvPassword,
|
|
165
170
|
loadWallet,
|
|
166
171
|
importWallet,
|
|
167
172
|
getBalances,
|
package/package.json
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mingderwang/wallet",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Ethereum wallet + encrypted keystore helpers (scrypt + AES-256-GCM) built on ethers v6. Ships a `wallet` CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
|
+
"bin": {
|
|
10
|
+
"wallet": "./dist/cli.js"
|
|
11
|
+
},
|
|
9
12
|
"exports": {
|
|
10
|
-
".":
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
}
|
|
11
17
|
},
|
|
12
|
-
"files": ["dist", "README.md"],
|
|
18
|
+
"files": ["dist", "src", "README.md"],
|
|
13
19
|
"engines": {
|
|
14
20
|
"node": ">=18.0.0"
|
|
15
21
|
},
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { Wallet } from 'ethers';
|
|
6
|
+
import {
|
|
7
|
+
createWallet as generateWallet,
|
|
8
|
+
importWallet,
|
|
9
|
+
loadWallet,
|
|
10
|
+
encryptPrivateKey,
|
|
11
|
+
saveKeystore,
|
|
12
|
+
readKeystore,
|
|
13
|
+
deleteFile,
|
|
14
|
+
getBalances,
|
|
15
|
+
formatAmount,
|
|
16
|
+
USDC,
|
|
17
|
+
processEnvPassword,
|
|
18
|
+
type EncryptedKeystore,
|
|
19
|
+
} from './index';
|
|
20
|
+
|
|
21
|
+
const VERSION = '0.1.2';
|
|
22
|
+
|
|
23
|
+
const C = {
|
|
24
|
+
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
|
25
|
+
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
|
26
|
+
yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
|
|
27
|
+
red: (s: string) => `\x1b[31m${s}\x1b[0m`,
|
|
28
|
+
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
|
|
29
|
+
gray: (s: string) => `\x1b[90m${s}\x1b[0m`,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const hasEnvPassword = () => typeof processEnvPassword() === 'string';
|
|
33
|
+
|
|
34
|
+
function prompt(question: string): Promise<string> {
|
|
35
|
+
process.stderr.write(question);
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const stdin = process.stdin;
|
|
38
|
+
stdin.resume();
|
|
39
|
+
stdin.once('data', (buf: Buffer) => {
|
|
40
|
+
stdin.pause();
|
|
41
|
+
const v = buf.toString().replace(/\r?\n$/, '');
|
|
42
|
+
process.stderr.write('');
|
|
43
|
+
resolve(v);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function promptHidden(question: string): Promise<string> {
|
|
49
|
+
if (hasEnvPassword()) {
|
|
50
|
+
process.stderr.write(`${question}[using WALLET_PASSWORD env]\n`);
|
|
51
|
+
return Promise.resolve(processEnvPassword() as string);
|
|
52
|
+
}
|
|
53
|
+
process.stderr.write(question);
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const stdin = process.stdin;
|
|
56
|
+
const wasRaw = !!stdin.isRaw;
|
|
57
|
+
stdin.setRawMode(true);
|
|
58
|
+
stdin.resume();
|
|
59
|
+
let input = '';
|
|
60
|
+
const onData = (buf: Buffer) => {
|
|
61
|
+
const code = buf[0];
|
|
62
|
+
if (code === 3) {
|
|
63
|
+
stdin.removeListener('data', onData);
|
|
64
|
+
stdin.setRawMode(false);
|
|
65
|
+
stdin.pause();
|
|
66
|
+
process.stderr.write('\n');
|
|
67
|
+
process.exit(130);
|
|
68
|
+
}
|
|
69
|
+
if (code === 13 || code === 10) {
|
|
70
|
+
stdin.removeListener('data', onData);
|
|
71
|
+
stdin.setRawMode(false);
|
|
72
|
+
stdin.pause();
|
|
73
|
+
process.stderr.write('\n');
|
|
74
|
+
resolve(input);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (code === 127 || code === 8) {
|
|
78
|
+
input = input.slice(0, -1);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
input += buf.toString('utf8');
|
|
82
|
+
};
|
|
83
|
+
stdin.on('data', onData);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function defaultPath(): string {
|
|
88
|
+
return process.env.WALLET_PATH ?? join(homedir(), '.wallet', 'wallet.json');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function walletPath(opts: Record<string, string>): string {
|
|
92
|
+
return opts.path ?? defaultPath();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function printCreated(wallet: Wallet, path: string): void {
|
|
96
|
+
console.log('');
|
|
97
|
+
console.log(` ${C.bold('Wallet created')}`);
|
|
98
|
+
console.log(' ' + '─'.repeat(46));
|
|
99
|
+
console.log(` ${C.green('Address')} ${wallet.address}`);
|
|
100
|
+
console.log(` ${C.gray('Keystore')} ${path}`);
|
|
101
|
+
console.log('');
|
|
102
|
+
console.log(` ${C.yellow('Keep the password safe — it signs payments.')}`);
|
|
103
|
+
console.log(` ${C.yellow('Back up the keystore file; losing both loses the funds.')}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function readPasswordTwice(action: string): Promise<string> {
|
|
107
|
+
for (;;) {
|
|
108
|
+
const p1 = await promptHidden(`New keystore password (${action}): `);
|
|
109
|
+
if (p1.length < 8) {
|
|
110
|
+
console.log(C.yellow('Password must be at least 8 characters.'));
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const p2 = await promptHidden('Confirm password: ');
|
|
114
|
+
if (p1 !== p2) {
|
|
115
|
+
console.log(C.red('Passwords do not match. Try again.'));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
return p1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function loadUnlocked(path: string, action: string): Promise<{ wallet: Wallet; keystore: EncryptedKeystore }> {
|
|
123
|
+
if (!existsSync(path)) {
|
|
124
|
+
console.log(C.red('No wallet found at ' + path) + ' — run ' + C.bold('wallet create') + ' first.');
|
|
125
|
+
process.exit(2);
|
|
126
|
+
}
|
|
127
|
+
const keystore = readKeystore(path);
|
|
128
|
+
for (let attempt = 1; ; attempt++) {
|
|
129
|
+
const password = await promptHidden(`Keystore password (${action}): `);
|
|
130
|
+
try {
|
|
131
|
+
const wallet = loadWallet(keystore, password);
|
|
132
|
+
return { wallet, keystore };
|
|
133
|
+
} catch {
|
|
134
|
+
if (attempt >= 3) {
|
|
135
|
+
console.log(C.red('Incorrect password. Exiting.'));
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
console.log(C.red('Incorrect password, try again.'));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function guardNoWallet(path: string): void {
|
|
144
|
+
if (existsSync(path)) {
|
|
145
|
+
console.log(C.yellow(`A wallet already exists at ${path}`) + ` — delete it with \`wallet delete --path ${path}\` first.`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------- commands ----------
|
|
151
|
+
|
|
152
|
+
async function cmdCreate(opts: Record<string, string>): Promise<void> {
|
|
153
|
+
const path = walletPath(opts);
|
|
154
|
+
guardNoWallet(path);
|
|
155
|
+
const password = await readPasswordTwice('create');
|
|
156
|
+
const { wallet } = generateWallet();
|
|
157
|
+
saveKeystore(encryptPrivateKey(wallet.privateKey, password, wallet.address), path);
|
|
158
|
+
if (opts.json) {
|
|
159
|
+
console.log(JSON.stringify({ address: wallet.address, path }, null, 2));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
printCreated(wallet, path);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function cmdImport(opts: Record<string, string>): Promise<void> {
|
|
166
|
+
const path = walletPath(opts);
|
|
167
|
+
guardNoWallet(path);
|
|
168
|
+
const secret = opts.secret ?? (await prompt('Enter mnemonic phrase (or 0x private key): ')).trim();
|
|
169
|
+
let wallet: Wallet;
|
|
170
|
+
try {
|
|
171
|
+
({ wallet } = importWallet(secret));
|
|
172
|
+
} catch {
|
|
173
|
+
console.log(C.red('Invalid mnemonic or private key.'));
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
const password = await readPasswordTwice('import');
|
|
177
|
+
saveKeystore(encryptPrivateKey(wallet.privateKey, password, wallet.address), path);
|
|
178
|
+
if (opts.json) {
|
|
179
|
+
console.log(JSON.stringify({ address: wallet.address, path }, null, 2));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
printCreated(wallet, path);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function cmdAddress(opts: Record<string, string>): Promise<void> {
|
|
186
|
+
const { wallet } = await loadUnlocked(walletPath(opts), 'unlock');
|
|
187
|
+
console.log(wallet.address);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function cmdBalance(opts: Record<string, string>): Promise<void> {
|
|
191
|
+
const { wallet } = await loadUnlocked(walletPath(opts), 'balance');
|
|
192
|
+
const network = opts.network ?? 'ethereum-sepolia';
|
|
193
|
+
try {
|
|
194
|
+
const { eth, usdc } = await getBalances(wallet, network);
|
|
195
|
+
const usdcInfo = USDC[network];
|
|
196
|
+
if (opts.json) {
|
|
197
|
+
console.log(
|
|
198
|
+
JSON.stringify(
|
|
199
|
+
{
|
|
200
|
+
network,
|
|
201
|
+
address: wallet.address,
|
|
202
|
+
eth,
|
|
203
|
+
usdc: usdcInfo
|
|
204
|
+
? { raw: usdc, formatted: formatAmount(usdc, usdcInfo.decimals), symbol: usdcInfo.symbol }
|
|
205
|
+
: null,
|
|
206
|
+
},
|
|
207
|
+
null,
|
|
208
|
+
2
|
|
209
|
+
)
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
console.log('');
|
|
214
|
+
console.log(` ${C.bold('Wallet')} ${wallet.address}`);
|
|
215
|
+
console.log(` ${C.bold('Network')} ${network}`);
|
|
216
|
+
console.log(' ' + '─'.repeat(46));
|
|
217
|
+
console.log(` ${C.cyan('ETH')} ${(Number(eth) / 1e18).toFixed(6)}`);
|
|
218
|
+
if (usdcInfo) console.log(` ${C.cyan(usdcInfo.symbol)} ${formatAmount(usdc, usdcInfo.decimals)}`);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
console.log(C.red(`Failed to query ${network}: ${e instanceof Error ? e.message : e}`));
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function cmdExport(opts: Record<string, string>): Promise<void> {
|
|
226
|
+
const { wallet } = await loadUnlocked(walletPath(opts), 'export');
|
|
227
|
+
console.log('\n' + C.yellow('Anyone with this private key controls the wallet.'));
|
|
228
|
+
console.log(` Private key: ${C.bold(wallet.privateKey)}`);
|
|
229
|
+
console.log(` Mnemonic : ${wallet.mnemonic?.phrase ?? C.gray('(imported as private key only)')}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function cmdDelete(opts: Record<string, string>): Promise<void> {
|
|
233
|
+
const path = walletPath(opts);
|
|
234
|
+
if (!existsSync(path)) {
|
|
235
|
+
console.log(C.red('No wallet found at ' + path));
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
const answer = (await prompt(`Delete ${path}? Type ${C.bold('yes')} to confirm: `)).trim();
|
|
239
|
+
if (answer !== 'yes') {
|
|
240
|
+
console.log(C.gray('Aborted.'));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
deleteFile(path);
|
|
244
|
+
console.log(C.green('Wallet deleted.'));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------- help ----------
|
|
248
|
+
|
|
249
|
+
const HELP = `wallet v${VERSION}
|
|
250
|
+
|
|
251
|
+
Manage an encrypted Ethereum keystore (scrypt + AES-256-GCM) at ~/.wallet/wallet.json.
|
|
252
|
+
|
|
253
|
+
Usage:
|
|
254
|
+
wallet create [--path <file>] [--json]
|
|
255
|
+
wallet import <mnemonic | private key> [--path <file>] [--json]
|
|
256
|
+
wallet address [--path <file>]
|
|
257
|
+
wallet balance [--path <file>] [--network <ethereum-sepolia|ethereum-mainnet|base-sepolia|base>]
|
|
258
|
+
wallet export [--path <file>]
|
|
259
|
+
wallet delete [--path <file>]
|
|
260
|
+
|
|
261
|
+
Options:
|
|
262
|
+
--path <file> Keystore file (default ~/.wallet/wallet.json)
|
|
263
|
+
--network <id> Network for balance lookups (default ethereum-sepolia)
|
|
264
|
+
--json Machine-readable output
|
|
265
|
+
|
|
266
|
+
Password:
|
|
267
|
+
Prompts interactively, or set WALLET_PASSWORD in the environment for scripting.`;
|
|
268
|
+
|
|
269
|
+
function printHelp(): void {
|
|
270
|
+
console.log(HELP);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------- entry ----------
|
|
274
|
+
|
|
275
|
+
interface Parsed {
|
|
276
|
+
cmd: string;
|
|
277
|
+
positional: string[];
|
|
278
|
+
opts: Record<string, string>;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function parseArgs(args: string[]): Parsed {
|
|
282
|
+
const cmd = args.find((a) => !a.startsWith('--')) ?? 'help';
|
|
283
|
+
const positional: string[] = [];
|
|
284
|
+
const opts: Record<string, string> = {};
|
|
285
|
+
for (const a of args) {
|
|
286
|
+
if (a === cmd) continue;
|
|
287
|
+
if (a === '--json') {
|
|
288
|
+
opts.json = '1';
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (a === '--network' || a === '--path') {
|
|
292
|
+
const next = args[args.indexOf(a) + 1];
|
|
293
|
+
if (!next || next.startsWith('--')) {
|
|
294
|
+
console.log(C.red(`Missing value for ${a}`));
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
opts[a.slice(2)] = next;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (!a.startsWith('--')) positional.push(a);
|
|
301
|
+
}
|
|
302
|
+
return { cmd, positional, opts };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function main(): Promise<void> {
|
|
306
|
+
const { cmd, positional, opts } = parseArgs(process.argv.slice(2));
|
|
307
|
+
switch (cmd) {
|
|
308
|
+
case 'create': return cmdCreate(opts);
|
|
309
|
+
case 'import': return cmdImport({ ...opts, secret: positional[0] ?? opts.secret });
|
|
310
|
+
case 'address': return cmdAddress(opts);
|
|
311
|
+
case 'balance': return cmdBalance(opts);
|
|
312
|
+
case 'export': return cmdExport(opts);
|
|
313
|
+
case 'delete': return cmdDelete(opts);
|
|
314
|
+
case 'help':
|
|
315
|
+
case '-h':
|
|
316
|
+
case '--help':
|
|
317
|
+
return printHelp();
|
|
318
|
+
case '-v':
|
|
319
|
+
case '--version':
|
|
320
|
+
return console.log(VERSION);
|
|
321
|
+
default:
|
|
322
|
+
console.log(C.red(`Unknown command: ${cmd}`) + '\n');
|
|
323
|
+
printHelp();
|
|
324
|
+
process.exit(1);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
main().catch((e) => {
|
|
329
|
+
console.error(e instanceof Error ? e.message : e);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { Wallet, HDNodeWallet } from 'ethers';
|
|
4
|
+
import {
|
|
5
|
+
encryptPrivateKey,
|
|
6
|
+
decryptPrivateKey,
|
|
7
|
+
verifyPassword,
|
|
8
|
+
randomBytesHex,
|
|
9
|
+
type EncryptedKeystore,
|
|
10
|
+
} from './keystore';
|
|
11
|
+
|
|
12
|
+
export { encryptPrivateKey, decryptPrivateKey, verifyPassword, randomBytesHex };
|
|
13
|
+
export type { EncryptedKeystore };
|
|
14
|
+
|
|
15
|
+
export function processEnvPassword(): string | undefined {
|
|
16
|
+
const v = process.env.WALLET_PASSWORD ?? process.env.XSIFT_WALLET_PASSWORD;
|
|
17
|
+
return v && v.length > 0 ? v : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const NETWORKS = {
|
|
21
|
+
ethereumMainnet: { networkId: 'ethereum-mainnet', chainId: 1 },
|
|
22
|
+
ethereumSepolia: { networkId: 'ethereum-sepolia', chainId: 11155111 },
|
|
23
|
+
base: { networkId: 'base', chainId: 8453 },
|
|
24
|
+
baseSepolia: { networkId: 'base-sepolia', chainId: 84532 },
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
export const USDC: Record<string, { address: string; decimals: number; symbol: string }> = {
|
|
28
|
+
'ethereum-sepolia': { address: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', decimals: 6, symbol: 'USDC' },
|
|
29
|
+
'ethereum-mainnet': { address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6, symbol: 'USDC' },
|
|
30
|
+
'base-sepolia': { address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', decimals: 6, symbol: 'USDC' },
|
|
31
|
+
base: { address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', decimals: 6, symbol: 'USDC' },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function rpcUrl(networkId: string): string {
|
|
35
|
+
const fromEnv = process.env.X402_RPC_URL;
|
|
36
|
+
if (fromEnv) return fromEnv;
|
|
37
|
+
switch (networkId) {
|
|
38
|
+
case 'ethereum-sepolia':
|
|
39
|
+
return 'https://ethereum-sepolia-rpc.publicnode.com';
|
|
40
|
+
case 'ethereum-mainnet':
|
|
41
|
+
return 'https://eth.llamarpc.com';
|
|
42
|
+
case 'base-sepolia':
|
|
43
|
+
return 'https://base-sepolia-rpc.publicnode.com';
|
|
44
|
+
case 'base':
|
|
45
|
+
return 'https://mainnet.base.org';
|
|
46
|
+
default:
|
|
47
|
+
throw new Error(`No default RPC for ${networkId}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type SigningWallet = Wallet | HDNodeWallet;
|
|
52
|
+
|
|
53
|
+
export function createWallet(): { wallet: HDNodeWallet; privateKey: string; address: string } {
|
|
54
|
+
const wallet = Wallet.createRandom();
|
|
55
|
+
return { wallet, privateKey: wallet.privateKey, address: wallet.address };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function importWallet(secret: string): { wallet: SigningWallet; privateKey: string; address: string } {
|
|
59
|
+
const wallet: SigningWallet = /^[a-f0-9]{64}$/i.test(secret.replace(/^0x/, ''))
|
|
60
|
+
? new Wallet(secret)
|
|
61
|
+
: Wallet.fromPhrase(secret);
|
|
62
|
+
return { wallet, privateKey: wallet.privateKey, address: wallet.address };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function loadWallet(keystore: EncryptedKeystore, password: string): Wallet {
|
|
66
|
+
const privateKey = decryptPrivateKey(keystore, password);
|
|
67
|
+
const wallet = new Wallet(privateKey);
|
|
68
|
+
if (wallet.address.toLowerCase() !== keystore.address.toLowerCase()) {
|
|
69
|
+
throw new Error('Keystore address does not match its private key.');
|
|
70
|
+
}
|
|
71
|
+
return wallet;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function saveKeystore(keystore: EncryptedKeystore, path: string): void {
|
|
75
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
76
|
+
writeFileSync(path, JSON.stringify(keystore, null, 2), { mode: 0o600 });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function readKeystore(path: string): EncryptedKeystore {
|
|
80
|
+
return JSON.parse(readFileSync(path, 'utf8')) as EncryptedKeystore;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function deleteFile(path: string): void {
|
|
84
|
+
rmSync(path, { force: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function getBalances(walletOrAddress: string | Wallet, networkId: string): Promise<{ eth: string; usdc: string }> {
|
|
88
|
+
const { JsonRpcProvider, Contract } = await import('ethers');
|
|
89
|
+
const provider = new JsonRpcProvider(rpcUrl(networkId));
|
|
90
|
+
const address = typeof walletOrAddress === 'string' ? walletOrAddress : walletOrAddress.address;
|
|
91
|
+
const usdc = USDC[networkId];
|
|
92
|
+
const eth = await provider.getBalance(address);
|
|
93
|
+
let usdcBalance = 0n;
|
|
94
|
+
if (usdc) {
|
|
95
|
+
const contract = new Contract(usdc.address, ERC20_ABI, provider);
|
|
96
|
+
const balanceOf = (contract as unknown as { balanceOf: (a: string) => Promise<bigint> }).balanceOf;
|
|
97
|
+
usdcBalance = await balanceOf(address);
|
|
98
|
+
}
|
|
99
|
+
return { eth: eth.toString(), usdc: usdcBalance.toString() };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function formatAmount(amountWei: bigint | string | number, decimals: number): string {
|
|
103
|
+
return (Number(amountWei) / 10 ** decimals).toFixed(decimals);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const ERC20_ABI = [
|
|
107
|
+
'function balanceOf(address) view returns (uint256)',
|
|
108
|
+
'function transferWithAuthorization(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce)',
|
|
109
|
+
'function nonces(address) view returns (uint256)',
|
|
110
|
+
] as const;
|
package/src/keystore.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, scryptSync, createCipheriv, createDecipheriv } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export interface EncryptedKeystore {
|
|
4
|
+
version: 1;
|
|
5
|
+
id: string;
|
|
6
|
+
type: 'scrypt-aes-256-gcm';
|
|
7
|
+
params: {
|
|
8
|
+
n: number;
|
|
9
|
+
r: number;
|
|
10
|
+
p: number;
|
|
11
|
+
dkLen: number;
|
|
12
|
+
salt: string; // hex
|
|
13
|
+
iv: string; // hex
|
|
14
|
+
tag: string; // hex
|
|
15
|
+
};
|
|
16
|
+
ciphertext: string; // hex
|
|
17
|
+
address: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PARAMS = { n: 2 ** 15, r: 8, p: 1, dkLen: 32 };
|
|
21
|
+
|
|
22
|
+
// Coerce the (pre-generics) Buffer type into the stricter Uint8Array<ArrayBufferLike>
|
|
23
|
+
// the current node crypto typings demand. Runtime-identical to the input buffer.
|
|
24
|
+
function bytes(b: Buffer): Uint8Array<ArrayBufferLike> {
|
|
25
|
+
return b as unknown as Uint8Array<ArrayBufferLike>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function deriveKey(password: string, salt: Buffer): Buffer {
|
|
29
|
+
return scryptSync(password, bytes(salt), PARAMS.dkLen, {
|
|
30
|
+
N: PARAMS.n,
|
|
31
|
+
r: PARAMS.r,
|
|
32
|
+
p: PARAMS.p,
|
|
33
|
+
maxmem: 128 * PARAMS.n * PARAMS.r * 2,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hexString(buf: Buffer): string {
|
|
38
|
+
return buf.toString('hex');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function encryptPrivateKey(privateKey: string, password: string, address: string): EncryptedKeystore {
|
|
42
|
+
const salt = randomBytes(16);
|
|
43
|
+
const iv = randomBytes(12);
|
|
44
|
+
const key = deriveKey(password, salt);
|
|
45
|
+
const cipher = createCipheriv('aes-256-gcm', bytes(key), bytes(iv));
|
|
46
|
+
const data = Buffer.from(privateKey.replace(/^0x/, ''), 'hex');
|
|
47
|
+
const ciphertext = Buffer.concat([cipher.update(bytes(data)), cipher.final()] as unknown as Uint8Array<ArrayBufferLike>[]);
|
|
48
|
+
const tag = cipher.getAuthTag();
|
|
49
|
+
return {
|
|
50
|
+
version: 1,
|
|
51
|
+
id: randomUUID(),
|
|
52
|
+
type: 'scrypt-aes-256-gcm',
|
|
53
|
+
params: {
|
|
54
|
+
n: PARAMS.n,
|
|
55
|
+
r: PARAMS.r,
|
|
56
|
+
p: PARAMS.p,
|
|
57
|
+
dkLen: PARAMS.dkLen,
|
|
58
|
+
salt: hexString(salt),
|
|
59
|
+
iv: hexString(iv),
|
|
60
|
+
tag: hexString(tag),
|
|
61
|
+
},
|
|
62
|
+
ciphertext: hexString(ciphertext),
|
|
63
|
+
address,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function decryptPrivateKey(keystore: EncryptedKeystore, password: string): string {
|
|
68
|
+
if (keystore.version !== 1 || keystore.type !== 'scrypt-aes-256-gcm') {
|
|
69
|
+
throw new Error('Unsupported keystore format');
|
|
70
|
+
}
|
|
71
|
+
const salt = Buffer.from(keystore.params.salt, 'hex');
|
|
72
|
+
const iv = Buffer.from(keystore.params.iv, 'hex');
|
|
73
|
+
const tag = Buffer.from(keystore.params.tag, 'hex');
|
|
74
|
+
const key = deriveKey(password, salt);
|
|
75
|
+
const decipher = createDecipheriv('aes-256-gcm', bytes(key), bytes(iv));
|
|
76
|
+
decipher.setAuthTag(bytes(tag));
|
|
77
|
+
try {
|
|
78
|
+
const data = Buffer.from(keystore.ciphertext, 'hex');
|
|
79
|
+
const plain = Buffer.concat([decipher.update(bytes(data)), decipher.final()] as unknown as Uint8Array<ArrayBufferLike>[]);
|
|
80
|
+
return '0x' + plain.toString('hex');
|
|
81
|
+
} catch {
|
|
82
|
+
throw new Error('Incorrect password or corrupted keystore.');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function verifyPassword(keystore: EncryptedKeystore, password: string): boolean {
|
|
87
|
+
try {
|
|
88
|
+
decryptPrivateKey(keystore, password);
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function bytesToHex(b: Uint8Array): string {
|
|
96
|
+
return '0x' + Buffer.from(b).toString('hex');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function randomBytesHex(len = 32): string {
|
|
100
|
+
return '0x' + randomBytes(len).toString('hex');
|
|
101
|
+
}
|