genlayer 0.26.0 → 0.27.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/.github/workflows/validate-code.yml +3 -0
- package/CHANGELOG.md +6 -0
- package/README.md +19 -0
- package/dist/index.js +259 -179
- package/esbuild.config.dev.js +1 -1
- package/esbuild.config.prod.js +1 -1
- package/package.json +2 -1
- package/src/commands/keygen/index.ts +18 -0
- package/src/commands/keygen/lock.ts +31 -0
- package/src/commands/keygen/unlock.ts +41 -0
- package/src/lib/actions/BaseAction.ts +6 -7
- package/src/lib/config/ConfigFileManager.ts +0 -78
- package/src/lib/config/KeychainManager.ts +29 -0
- package/tests/actions/lock.test.ts +66 -0
- package/tests/actions/unlock.test.ts +121 -0
- package/tests/commands/keygen.test.ts +54 -8
- package/tests/libs/baseAction.test.ts +11 -7
- package/tests/libs/configFileManager.test.ts +0 -205
- package/tests/libs/keychainManager.test.ts +112 -0
package/esbuild.config.dev.js
CHANGED
|
@@ -11,7 +11,7 @@ export default {
|
|
|
11
11
|
banner: {
|
|
12
12
|
js: `const _importMetaUrl = new URL(import.meta.url).pathname;`,
|
|
13
13
|
},
|
|
14
|
-
external: ["commander", "dockerode", "dotenv", "ethers", "inquirer", "update-check", "ssh2", "fs-extra", "esbuild"]
|
|
14
|
+
external: ["commander", "dockerode", "dotenv", "ethers", "inquirer", "update-check", "ssh2", "fs-extra", "esbuild", "keytar"]
|
|
15
15
|
},
|
|
16
16
|
watch: true,
|
|
17
17
|
};
|
package/esbuild.config.prod.js
CHANGED
|
@@ -11,7 +11,7 @@ export default {
|
|
|
11
11
|
banner: {
|
|
12
12
|
js: `const _importMetaUrl = new URL(import.meta.url).pathname;`,
|
|
13
13
|
},
|
|
14
|
-
external: ["commander", "dockerode", "dotenv", "ethers", "inquirer", "update-check", "ssh2", "fs-extra", "esbuild"]
|
|
14
|
+
external: ["commander", "dockerode", "dotenv", "ethers", "inquirer", "update-check", "ssh2", "fs-extra", "esbuild", "keytar"]
|
|
15
15
|
},
|
|
16
16
|
watch: false,
|
|
17
17
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genlayer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "GenLayer Command Line Tool",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"fs-extra": "^11.3.0",
|
|
67
67
|
"genlayer-js": "^0.11.0",
|
|
68
68
|
"inquirer": "^12.0.0",
|
|
69
|
+
"keytar": "^7.9.0",
|
|
69
70
|
"node-fetch": "^3.0.0",
|
|
70
71
|
"open": "^10.1.0",
|
|
71
72
|
"ora": "^8.2.0",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { CreateKeypairOptions, KeypairCreator } from "./create";
|
|
3
|
+
import { UnlockAction } from "./unlock";
|
|
4
|
+
import { LockAction } from "./lock";
|
|
3
5
|
|
|
4
6
|
export function initializeKeygenCommands(program: Command) {
|
|
5
7
|
|
|
@@ -17,5 +19,21 @@ export function initializeKeygenCommands(program: Command) {
|
|
|
17
19
|
await keypairCreator.createKeypairAction(options);
|
|
18
20
|
});
|
|
19
21
|
|
|
22
|
+
keygenCommand
|
|
23
|
+
.command("unlock")
|
|
24
|
+
.description("Unlock your wallet by storing the decrypted private key in OS keychain")
|
|
25
|
+
.action(async () => {
|
|
26
|
+
const unlockAction = new UnlockAction();
|
|
27
|
+
await unlockAction.execute();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
keygenCommand
|
|
31
|
+
.command("lock")
|
|
32
|
+
.description("Lock your wallet by removing the decrypted private key from OS keychain")
|
|
33
|
+
.action(async () => {
|
|
34
|
+
const lockAction = new LockAction();
|
|
35
|
+
await lockAction.execute();
|
|
36
|
+
});
|
|
37
|
+
|
|
20
38
|
return program;
|
|
21
39
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { BaseAction } from "../../lib/actions/BaseAction";
|
|
2
|
+
|
|
3
|
+
export class LockAction extends BaseAction {
|
|
4
|
+
async execute(): Promise<void> {
|
|
5
|
+
this.startSpinner("Checking keychain availability...");
|
|
6
|
+
|
|
7
|
+
const keychainAvailable = await this.keychainManager.isKeychainAvailable();
|
|
8
|
+
if (!keychainAvailable) {
|
|
9
|
+
this.failSpinner("OS keychain is not available. This command requires a supported keychain (e.g. macOS Keychain, Windows Credential Manager, or GNOME Keyring).");
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
this.setSpinnerText("Checking for cached private key...");
|
|
14
|
+
|
|
15
|
+
const hasCachedKey = await this.keychainManager.getPrivateKey();
|
|
16
|
+
if (!hasCachedKey) {
|
|
17
|
+
this.succeedSpinner("Wallet is already locked (no cached key found in OS keychain).");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
this.setSpinnerText("Removing private key from OS keychain...");
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await this.keychainManager.removePrivateKey();
|
|
25
|
+
|
|
26
|
+
this.succeedSpinner("Wallet locked successfully! Your private key has been removed from the OS keychain.");
|
|
27
|
+
} catch (error) {
|
|
28
|
+
this.failSpinner("Failed to lock wallet.", error);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { BaseAction } from "../../lib/actions/BaseAction";
|
|
2
|
+
import { readFileSync, existsSync } from "fs";
|
|
3
|
+
import { ethers } from "ethers";
|
|
4
|
+
|
|
5
|
+
export class UnlockAction extends BaseAction {
|
|
6
|
+
async execute(): Promise<void> {
|
|
7
|
+
this.startSpinner("Checking keychain availability...");
|
|
8
|
+
|
|
9
|
+
const keychainAvailable = await this.keychainManager.isKeychainAvailable();
|
|
10
|
+
if (!keychainAvailable) {
|
|
11
|
+
this.failSpinner("OS keychain is not available. This command requires a supported keychain (e.g. macOS Keychain, Windows Credential Manager, or GNOME Keyring).");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
this.setSpinnerText("Checking for existing keystore...");
|
|
16
|
+
|
|
17
|
+
const keypairPath = this.getConfigByKey("keyPairPath");
|
|
18
|
+
if (!keypairPath || !existsSync(keypairPath)) {
|
|
19
|
+
this.failSpinner("No keystore file found. Please create a keypair first using 'genlayer keygen create'.");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const keystoreData = JSON.parse(readFileSync(keypairPath, "utf-8"));
|
|
24
|
+
if (!this.isValidKeystoreFormat(keystoreData)) {
|
|
25
|
+
this.failSpinner("Invalid keystore format. Expected encrypted keystore file.");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
this.stopSpinner();
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const password = await this.promptPassword("Enter password to decrypt keystore:");
|
|
33
|
+
const wallet = await ethers.Wallet.fromEncryptedJson(keystoreData.encrypted, password);
|
|
34
|
+
|
|
35
|
+
await this.keychainManager.storePrivateKey(wallet.privateKey);
|
|
36
|
+
this.succeedSpinner("Wallet unlocked successfully! Your private key is now stored securely in the OS keychain.");
|
|
37
|
+
} catch (error) {
|
|
38
|
+
this.failSpinner("Failed to unlock wallet.", error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {ConfigFileManager} from "../../lib/config/ConfigFileManager";
|
|
2
|
+
import {KeychainManager} from "../../lib/config/KeychainManager";
|
|
2
3
|
import ora, {Ora} from "ora";
|
|
3
4
|
import chalk from "chalk";
|
|
4
5
|
import inquirer from "inquirer";
|
|
@@ -14,15 +15,15 @@ export class BaseAction extends ConfigFileManager {
|
|
|
14
15
|
private static readonly DEFAULT_KEYSTORE_PATH = "./keypair.json";
|
|
15
16
|
private static readonly MAX_PASSWORD_ATTEMPTS = 3;
|
|
16
17
|
private static readonly MIN_PASSWORD_LENGTH = 8;
|
|
17
|
-
private static readonly TEMP_KEY_FILENAME = "decrypted_private_key";
|
|
18
18
|
|
|
19
19
|
private spinner: Ora;
|
|
20
20
|
private _genlayerClient: GenLayerClient<GenLayerChain> | null = null;
|
|
21
|
+
protected keychainManager: KeychainManager;
|
|
21
22
|
|
|
22
23
|
constructor() {
|
|
23
24
|
super();
|
|
24
25
|
this.spinner = ora({text: "", spinner: "dots"});
|
|
25
|
-
this.
|
|
26
|
+
this.keychainManager = new KeychainManager();
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
private async decryptKeystore(keystoreData: KeystoreData, attempt: number = 1): Promise<string> {
|
|
@@ -33,8 +34,6 @@ export class BaseAction extends ConfigFileManager {
|
|
|
33
34
|
const password = await this.promptPassword(message);
|
|
34
35
|
const wallet = await ethers.Wallet.fromEncryptedJson(keystoreData.encrypted, password);
|
|
35
36
|
|
|
36
|
-
this.storeTempFile(BaseAction.TEMP_KEY_FILENAME, wallet.privateKey);
|
|
37
|
-
|
|
38
37
|
return wallet.privateKey;
|
|
39
38
|
} catch (error) {
|
|
40
39
|
if (attempt >= BaseAction.MAX_PASSWORD_ATTEMPTS) {
|
|
@@ -45,7 +44,7 @@ export class BaseAction extends ConfigFileManager {
|
|
|
45
44
|
}
|
|
46
45
|
}
|
|
47
46
|
|
|
48
|
-
|
|
47
|
+
protected isValidKeystoreFormat(data: any): data is KeystoreData {
|
|
49
48
|
return Boolean(
|
|
50
49
|
data &&
|
|
51
50
|
data.version === 1 &&
|
|
@@ -101,7 +100,7 @@ export class BaseAction extends ConfigFileManager {
|
|
|
101
100
|
}
|
|
102
101
|
|
|
103
102
|
if (!decryptedPrivateKey) {
|
|
104
|
-
const cachedKey = this.
|
|
103
|
+
const cachedKey = await this.keychainManager.getPrivateKey();
|
|
105
104
|
decryptedPrivateKey = cachedKey ? cachedKey : await this.decryptKeystore(keystoreData);
|
|
106
105
|
}
|
|
107
106
|
return createAccount(decryptedPrivateKey as Hash);
|
|
@@ -146,7 +145,7 @@ export class BaseAction extends ConfigFileManager {
|
|
|
146
145
|
writeFileSync(finalOutputPath, JSON.stringify(keystoreData, null, 2));
|
|
147
146
|
this.writeConfig('keyPairPath', finalOutputPath);
|
|
148
147
|
|
|
149
|
-
this.
|
|
148
|
+
await this.keychainManager.removePrivateKey();
|
|
150
149
|
|
|
151
150
|
return wallet.privateKey;
|
|
152
151
|
}
|
|
@@ -2,24 +2,15 @@ import path from "path";
|
|
|
2
2
|
import os from "os";
|
|
3
3
|
import fs from "fs";
|
|
4
4
|
|
|
5
|
-
interface TempFileData {
|
|
6
|
-
content: string;
|
|
7
|
-
timestamp: number;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
5
|
export class ConfigFileManager {
|
|
11
6
|
private folderPath: string;
|
|
12
7
|
private configFilePath: string;
|
|
13
|
-
private tempFolderPath: string;
|
|
14
|
-
private static readonly TEMP_FILE_EXPIRATION_MS = 5 * 60 * 1000; // 5 minutes
|
|
15
8
|
|
|
16
9
|
constructor(baseFolder: string = ".genlayer/", configFileName: string = "genlayer-config.json") {
|
|
17
10
|
this.folderPath = path.resolve(os.homedir(), baseFolder);
|
|
18
11
|
this.configFilePath = path.resolve(this.folderPath, configFileName);
|
|
19
|
-
this.tempFolderPath = path.resolve(os.tmpdir(), "genlayer-temp");
|
|
20
12
|
this.ensureFolderExists();
|
|
21
13
|
this.ensureConfigFileExists();
|
|
22
|
-
this.ensureTempFolderExists();
|
|
23
14
|
}
|
|
24
15
|
|
|
25
16
|
private ensureFolderExists(): void {
|
|
@@ -34,12 +25,6 @@ export class ConfigFileManager {
|
|
|
34
25
|
}
|
|
35
26
|
}
|
|
36
27
|
|
|
37
|
-
private ensureTempFolderExists(): void {
|
|
38
|
-
if (!fs.existsSync(this.tempFolderPath)) {
|
|
39
|
-
fs.mkdirSync(this.tempFolderPath, { recursive: true, mode: 0o700 }); // Owner-only access
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
28
|
getFolderPath(): string {
|
|
44
29
|
return this.folderPath;
|
|
45
30
|
}
|
|
@@ -63,67 +48,4 @@ export class ConfigFileManager {
|
|
|
63
48
|
config[key] = value;
|
|
64
49
|
fs.writeFileSync(this.configFilePath, JSON.stringify(config, null, 2));
|
|
65
50
|
}
|
|
66
|
-
|
|
67
|
-
storeTempFile(fileName: string, content: string): void {
|
|
68
|
-
this.ensureTempFolderExists();
|
|
69
|
-
const filePath = path.resolve(this.tempFolderPath, fileName);
|
|
70
|
-
const tempData: TempFileData = {
|
|
71
|
-
content,
|
|
72
|
-
timestamp: Date.now()
|
|
73
|
-
};
|
|
74
|
-
fs.writeFileSync(filePath, JSON.stringify(tempData), { mode: 0o600 }); // Owner-only access
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
getTempFile(fileName: string): string | null {
|
|
78
|
-
const filePath = path.resolve(this.tempFolderPath, fileName);
|
|
79
|
-
|
|
80
|
-
if (!fs.existsSync(filePath)) {
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const fileContent = fs.readFileSync(filePath, "utf-8");
|
|
85
|
-
const tempData: TempFileData = JSON.parse(fileContent);
|
|
86
|
-
|
|
87
|
-
if (Date.now() - tempData.timestamp > ConfigFileManager.TEMP_FILE_EXPIRATION_MS) {
|
|
88
|
-
this.clearTempFile(fileName);
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return tempData.content;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
hasTempFile(fileName: string): boolean {
|
|
96
|
-
return this.getTempFile(fileName) !== null;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
clearTempFile(fileName: string): void {
|
|
100
|
-
const filePath = path.resolve(this.tempFolderPath, fileName);
|
|
101
|
-
if (fs.existsSync(filePath)) {
|
|
102
|
-
fs.unlinkSync(filePath);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
cleanupExpiredTempFiles(): void {
|
|
107
|
-
if (!fs.existsSync(this.tempFolderPath)) {
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const files = fs.readdirSync(this.tempFolderPath);
|
|
112
|
-
const now = Date.now();
|
|
113
|
-
|
|
114
|
-
for (const file of files) {
|
|
115
|
-
const filePath = path.resolve(this.tempFolderPath, file);
|
|
116
|
-
|
|
117
|
-
try {
|
|
118
|
-
const fileContent = fs.readFileSync(filePath, "utf-8");
|
|
119
|
-
const tempData: TempFileData = JSON.parse(fileContent);
|
|
120
|
-
|
|
121
|
-
if (now - tempData.timestamp > ConfigFileManager.TEMP_FILE_EXPIRATION_MS) {
|
|
122
|
-
fs.unlinkSync(filePath);
|
|
123
|
-
}
|
|
124
|
-
} catch (error) {
|
|
125
|
-
fs.unlinkSync(filePath);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
51
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {default as keytar} from 'keytar';
|
|
2
|
+
|
|
3
|
+
export class KeychainManager {
|
|
4
|
+
private static readonly SERVICE = 'genlayer-cli';
|
|
5
|
+
private static readonly ACCOUNT = 'default-user';
|
|
6
|
+
|
|
7
|
+
constructor() {}
|
|
8
|
+
|
|
9
|
+
async isKeychainAvailable(): Promise<boolean> {
|
|
10
|
+
try {
|
|
11
|
+
await keytar.findCredentials('test-service');
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async storePrivateKey(privateKey: string): Promise<void> {
|
|
19
|
+
return await keytar.setPassword(KeychainManager.SERVICE, KeychainManager.ACCOUNT, privateKey);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async getPrivateKey(): Promise<string | null> {
|
|
23
|
+
return await keytar.getPassword(KeychainManager.SERVICE, KeychainManager.ACCOUNT);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async removePrivateKey(): Promise<boolean> {
|
|
27
|
+
return await keytar.deletePassword(KeychainManager.SERVICE, KeychainManager.ACCOUNT);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {describe, test, vi, beforeEach, afterEach, expect} from "vitest";
|
|
2
|
+
import {LockAction} from "../../src/commands/keygen/lock";
|
|
3
|
+
|
|
4
|
+
describe("LockAction", () => {
|
|
5
|
+
let lockAction: LockAction;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.clearAllMocks();
|
|
9
|
+
lockAction = new LockAction();
|
|
10
|
+
|
|
11
|
+
// Mock the BaseAction methods
|
|
12
|
+
vi.spyOn(lockAction as any, "startSpinner").mockImplementation(() => {});
|
|
13
|
+
vi.spyOn(lockAction as any, "setSpinnerText").mockImplementation(() => {});
|
|
14
|
+
vi.spyOn(lockAction as any, "succeedSpinner").mockImplementation(() => {});
|
|
15
|
+
vi.spyOn(lockAction as any, "failSpinner").mockImplementation(() => {});
|
|
16
|
+
|
|
17
|
+
// Mock keychainManager
|
|
18
|
+
vi.spyOn(lockAction["keychainManager"], "isKeychainAvailable").mockResolvedValue(true);
|
|
19
|
+
vi.spyOn(lockAction["keychainManager"], "getPrivateKey").mockResolvedValue("test-private-key");
|
|
20
|
+
vi.spyOn(lockAction["keychainManager"], "removePrivateKey").mockResolvedValue(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.restoreAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("successfully locks wallet when keychain is available and key exists", async () => {
|
|
28
|
+
await lockAction.execute();
|
|
29
|
+
|
|
30
|
+
expect(lockAction["startSpinner"]).toHaveBeenCalledWith("Checking keychain availability...");
|
|
31
|
+
expect(lockAction["keychainManager"].isKeychainAvailable).toHaveBeenCalled();
|
|
32
|
+
expect(lockAction["setSpinnerText"]).toHaveBeenCalledWith("Checking for cached private key...");
|
|
33
|
+
expect(lockAction["keychainManager"].getPrivateKey).toHaveBeenCalled();
|
|
34
|
+
expect(lockAction["setSpinnerText"]).toHaveBeenCalledWith("Removing private key from OS keychain...");
|
|
35
|
+
expect(lockAction["keychainManager"].removePrivateKey).toHaveBeenCalled();
|
|
36
|
+
expect(lockAction["succeedSpinner"]).toHaveBeenCalledWith("Wallet locked successfully! Your private key has been removed from the OS keychain.");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("fails when keychain is not available", async () => {
|
|
40
|
+
vi.spyOn(lockAction["keychainManager"], "isKeychainAvailable").mockResolvedValue(false);
|
|
41
|
+
|
|
42
|
+
await lockAction.execute();
|
|
43
|
+
|
|
44
|
+
expect(lockAction["failSpinner"]).toHaveBeenCalledWith("OS keychain is not available. This command requires a supported keychain (e.g. macOS Keychain, Windows Credential Manager, or GNOME Keyring).");
|
|
45
|
+
expect(lockAction["keychainManager"].getPrivateKey).not.toHaveBeenCalled();
|
|
46
|
+
expect(lockAction["keychainManager"].removePrivateKey).not.toHaveBeenCalled();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("succeeds when wallet is already locked (no cached key)", async () => {
|
|
50
|
+
vi.spyOn(lockAction["keychainManager"], "getPrivateKey").mockResolvedValue(null);
|
|
51
|
+
|
|
52
|
+
await lockAction.execute();
|
|
53
|
+
|
|
54
|
+
expect(lockAction["succeedSpinner"]).toHaveBeenCalledWith("Wallet is already locked (no cached key found in OS keychain).");
|
|
55
|
+
expect(lockAction["keychainManager"].removePrivateKey).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("handles error during key removal", async () => {
|
|
59
|
+
const mockError = new Error("Keychain error");
|
|
60
|
+
vi.spyOn(lockAction["keychainManager"], "removePrivateKey").mockRejectedValue(mockError);
|
|
61
|
+
|
|
62
|
+
await lockAction.execute();
|
|
63
|
+
|
|
64
|
+
expect(lockAction["failSpinner"]).toHaveBeenCalledWith("Failed to lock wallet.", mockError);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {describe, test, vi, beforeEach, afterEach, expect} from "vitest";
|
|
2
|
+
import {UnlockAction} from "../../src/commands/keygen/unlock";
|
|
3
|
+
import {readFileSync, existsSync} from "fs";
|
|
4
|
+
import {ethers} from "ethers";
|
|
5
|
+
import inquirer from "inquirer";
|
|
6
|
+
|
|
7
|
+
vi.mock("fs");
|
|
8
|
+
vi.mock("ethers");
|
|
9
|
+
vi.mock("inquirer");
|
|
10
|
+
|
|
11
|
+
describe("UnlockAction", () => {
|
|
12
|
+
let unlockAction: UnlockAction;
|
|
13
|
+
const mockKeystoreData = {
|
|
14
|
+
version: 1,
|
|
15
|
+
encrypted: '{"address":"test","crypto":{"cipher":"aes-128-ctr"}}',
|
|
16
|
+
address: "0x1234567890123456789012345678901234567890"
|
|
17
|
+
};
|
|
18
|
+
const mockWallet = {
|
|
19
|
+
privateKey: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.clearAllMocks();
|
|
24
|
+
unlockAction = new UnlockAction();
|
|
25
|
+
|
|
26
|
+
// Mock the BaseAction methods
|
|
27
|
+
vi.spyOn(unlockAction as any, "startSpinner").mockImplementation(() => {});
|
|
28
|
+
vi.spyOn(unlockAction as any, "setSpinnerText").mockImplementation(() => {});
|
|
29
|
+
vi.spyOn(unlockAction as any, "stopSpinner").mockImplementation(() => {});
|
|
30
|
+
vi.spyOn(unlockAction as any, "succeedSpinner").mockImplementation(() => {});
|
|
31
|
+
vi.spyOn(unlockAction as any, "failSpinner").mockImplementation(() => {});
|
|
32
|
+
vi.spyOn(unlockAction as any, "promptPassword").mockResolvedValue("test-password");
|
|
33
|
+
vi.spyOn(unlockAction as any, "getConfigByKey").mockReturnValue("./test-keypair.json");
|
|
34
|
+
vi.spyOn(unlockAction as any, "isValidKeystoreFormat").mockReturnValue(true);
|
|
35
|
+
|
|
36
|
+
// Mock keychainManager
|
|
37
|
+
vi.spyOn(unlockAction["keychainManager"], "isKeychainAvailable").mockResolvedValue(true);
|
|
38
|
+
vi.spyOn(unlockAction["keychainManager"], "storePrivateKey").mockResolvedValue();
|
|
39
|
+
|
|
40
|
+
// Mock fs and ethers
|
|
41
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
|
42
|
+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockKeystoreData));
|
|
43
|
+
vi.mocked(ethers.Wallet.fromEncryptedJson).mockResolvedValue(mockWallet as any);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("successfully unlocks wallet when all conditions are met", async () => {
|
|
51
|
+
await unlockAction.execute();
|
|
52
|
+
|
|
53
|
+
expect(unlockAction["startSpinner"]).toHaveBeenCalledWith("Checking keychain availability...");
|
|
54
|
+
expect(unlockAction["keychainManager"].isKeychainAvailable).toHaveBeenCalled();
|
|
55
|
+
expect(unlockAction["setSpinnerText"]).toHaveBeenCalledWith("Checking for existing keystore...");
|
|
56
|
+
expect(unlockAction["getConfigByKey"]).toHaveBeenCalledWith("keyPairPath");
|
|
57
|
+
expect(existsSync).toHaveBeenCalledWith("./test-keypair.json");
|
|
58
|
+
expect(unlockAction["stopSpinner"]).toHaveBeenCalled();
|
|
59
|
+
expect(unlockAction["promptPassword"]).toHaveBeenCalledWith("Enter password to decrypt keystore:");
|
|
60
|
+
expect(readFileSync).toHaveBeenCalledWith("./test-keypair.json", "utf-8");
|
|
61
|
+
expect(ethers.Wallet.fromEncryptedJson).toHaveBeenCalledWith(mockKeystoreData.encrypted, "test-password");
|
|
62
|
+
expect(unlockAction["keychainManager"].storePrivateKey).toHaveBeenCalledWith(mockWallet.privateKey);
|
|
63
|
+
expect(unlockAction["succeedSpinner"]).toHaveBeenCalledWith("Wallet unlocked successfully! Your private key is now stored securely in the OS keychain.");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("fails when keychain is not available", async () => {
|
|
67
|
+
vi.spyOn(unlockAction["keychainManager"], "isKeychainAvailable").mockResolvedValue(false);
|
|
68
|
+
|
|
69
|
+
await unlockAction.execute();
|
|
70
|
+
|
|
71
|
+
expect(unlockAction["failSpinner"]).toHaveBeenCalledWith("OS keychain is not available. This command requires a supported keychain (e.g. macOS Keychain, Windows Credential Manager, or GNOME Keyring).");
|
|
72
|
+
expect(unlockAction["promptPassword"]).not.toHaveBeenCalled();
|
|
73
|
+
expect(unlockAction["keychainManager"].storePrivateKey).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("fails when no keystore file is found", async () => {
|
|
77
|
+
vi.spyOn(unlockAction as any, "getConfigByKey").mockReturnValue(null);
|
|
78
|
+
|
|
79
|
+
await unlockAction.execute();
|
|
80
|
+
|
|
81
|
+
expect(unlockAction["failSpinner"]).toHaveBeenCalledWith("No keystore file found. Please create a keypair first using 'genlayer keygen create'.");
|
|
82
|
+
expect(unlockAction["promptPassword"]).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("fails when keystore file does not exist", async () => {
|
|
86
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
87
|
+
|
|
88
|
+
await unlockAction.execute();
|
|
89
|
+
|
|
90
|
+
expect(unlockAction["failSpinner"]).toHaveBeenCalledWith("No keystore file found. Please create a keypair first using 'genlayer keygen create'.");
|
|
91
|
+
expect(unlockAction["promptPassword"]).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("fails when keystore format is invalid", async () => {
|
|
95
|
+
vi.spyOn(unlockAction as any, "isValidKeystoreFormat").mockReturnValue(false);
|
|
96
|
+
|
|
97
|
+
await unlockAction.execute();
|
|
98
|
+
|
|
99
|
+
expect(unlockAction["failSpinner"]).toHaveBeenCalledWith("Invalid keystore format. Expected encrypted keystore file.");
|
|
100
|
+
expect(unlockAction["promptPassword"]).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("handles error during wallet decryption", async () => {
|
|
104
|
+
const mockError = new Error("Decryption failed");
|
|
105
|
+
vi.mocked(ethers.Wallet.fromEncryptedJson).mockRejectedValue(mockError);
|
|
106
|
+
|
|
107
|
+
await unlockAction.execute();
|
|
108
|
+
|
|
109
|
+
expect(unlockAction["failSpinner"]).toHaveBeenCalledWith("Failed to unlock wallet.", mockError);
|
|
110
|
+
expect(unlockAction["keychainManager"].storePrivateKey).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("handles error during key storage", async () => {
|
|
114
|
+
const mockError = new Error("Storage failed");
|
|
115
|
+
vi.spyOn(unlockAction["keychainManager"], "storePrivateKey").mockRejectedValue(mockError);
|
|
116
|
+
|
|
117
|
+
await unlockAction.execute();
|
|
118
|
+
|
|
119
|
+
expect(unlockAction["failSpinner"]).toHaveBeenCalledWith("Failed to unlock wallet.", mockError);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -2,8 +2,12 @@ import { Command } from "commander";
|
|
|
2
2
|
import { vi, describe, beforeEach, afterEach, test, expect } from "vitest";
|
|
3
3
|
import { initializeKeygenCommands } from "../../src/commands/keygen";
|
|
4
4
|
import { KeypairCreator } from "../../src/commands/keygen/create";
|
|
5
|
+
import { UnlockAction } from "../../src/commands/keygen/unlock";
|
|
6
|
+
import { LockAction } from "../../src/commands/keygen/lock";
|
|
5
7
|
|
|
6
8
|
vi.mock("../../src/commands/keygen/create");
|
|
9
|
+
vi.mock("../../src/commands/keygen/unlock");
|
|
10
|
+
vi.mock("../../src/commands/keygen/lock");
|
|
7
11
|
|
|
8
12
|
describe("keygen create command", () => {
|
|
9
13
|
let program: Command;
|
|
@@ -59,15 +63,7 @@ describe("keygen create command", () => {
|
|
|
59
63
|
expect(KeypairCreator).toHaveBeenCalledTimes(1);
|
|
60
64
|
});
|
|
61
65
|
|
|
62
|
-
test("throws error for unrecognized options", async () => {
|
|
63
|
-
const keygenCommand = program.commands.find((cmd) => cmd.name() === "keygen");
|
|
64
|
-
const createCommand = keygenCommand?.commands.find((cmd) => cmd.name() === "create");
|
|
65
66
|
|
|
66
|
-
createCommand?.exitOverride();
|
|
67
|
-
expect(() => program.parse(["node", "test", "keygen", "create", "--unknown"])).toThrowError(
|
|
68
|
-
"error: unknown option '--unknown'"
|
|
69
|
-
);
|
|
70
|
-
});
|
|
71
67
|
|
|
72
68
|
test("keypairCreator.createKeypairAction is called without throwing errors for default options", async () => {
|
|
73
69
|
program.parse(["node", "test", "keygen", "create"]);
|
|
@@ -75,3 +71,53 @@ describe("keygen create command", () => {
|
|
|
75
71
|
expect(() => program.parse(["node", "test", "keygen", "create"])).not.toThrow();
|
|
76
72
|
});
|
|
77
73
|
});
|
|
74
|
+
|
|
75
|
+
describe("keygen unlock command", () => {
|
|
76
|
+
let program: Command;
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
program = new Command();
|
|
80
|
+
initializeKeygenCommands(program);
|
|
81
|
+
vi.clearAllMocks();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
vi.restoreAllMocks();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("UnlockAction is instantiated and execute is called", async () => {
|
|
89
|
+
program.parse(["node", "test", "keygen", "unlock"]);
|
|
90
|
+
expect(UnlockAction).toHaveBeenCalledTimes(1);
|
|
91
|
+
expect(UnlockAction.prototype.execute).toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("UnlockAction.execute is called without throwing errors", async () => {
|
|
95
|
+
vi.mocked(UnlockAction.prototype.execute).mockResolvedValue();
|
|
96
|
+
expect(() => program.parse(["node", "test", "keygen", "unlock"])).not.toThrow();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("keygen lock command", () => {
|
|
101
|
+
let program: Command;
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
program = new Command();
|
|
105
|
+
initializeKeygenCommands(program);
|
|
106
|
+
vi.clearAllMocks();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
vi.restoreAllMocks();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("LockAction is instantiated and execute is called", async () => {
|
|
114
|
+
program.parse(["node", "test", "keygen", "lock"]);
|
|
115
|
+
expect(LockAction).toHaveBeenCalledTimes(1);
|
|
116
|
+
expect(LockAction.prototype.execute).toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("LockAction.execute is called without throwing errors", async () => {
|
|
120
|
+
vi.mocked(LockAction.prototype.execute).mockResolvedValue();
|
|
121
|
+
expect(() => program.parse(["node", "test", "keygen", "lock"])).not.toThrow();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -83,11 +83,12 @@ describe("BaseAction", () => {
|
|
|
83
83
|
vi.spyOn(baseAction as any, "writeConfig").mockImplementation(() => {});
|
|
84
84
|
vi.spyOn(baseAction as any, "getConfig").mockReturnValue({});
|
|
85
85
|
|
|
86
|
-
// Mock
|
|
87
|
-
vi.spyOn(baseAction
|
|
88
|
-
vi.spyOn(baseAction
|
|
89
|
-
vi.spyOn(baseAction
|
|
90
|
-
vi.spyOn(baseAction
|
|
86
|
+
// Mock keychainManager methods
|
|
87
|
+
vi.spyOn(baseAction["keychainManager"], "isKeychainAvailable").mockResolvedValue(false);
|
|
88
|
+
vi.spyOn(baseAction["keychainManager"], "getPrivateKey").mockResolvedValue(null);
|
|
89
|
+
vi.spyOn(baseAction["keychainManager"], "storePrivateKey").mockResolvedValue();
|
|
90
|
+
vi.spyOn(baseAction["keychainManager"], "removePrivateKey").mockResolvedValue(true);
|
|
91
|
+
|
|
91
92
|
});
|
|
92
93
|
|
|
93
94
|
afterEach(() => {
|
|
@@ -291,12 +292,13 @@ describe("BaseAction", () => {
|
|
|
291
292
|
});
|
|
292
293
|
|
|
293
294
|
test("should use cached key when available", async () => {
|
|
294
|
-
vi.spyOn(baseAction
|
|
295
|
+
vi.spyOn(baseAction["keychainManager"], "isKeychainAvailable").mockResolvedValue(true);
|
|
296
|
+
vi.spyOn(baseAction["keychainManager"], "getPrivateKey").mockResolvedValue(mockWallet.privateKey);
|
|
295
297
|
|
|
296
298
|
const account = await baseAction["getAccount"](false);
|
|
297
299
|
|
|
298
300
|
expect((account as any).privateKey).toBe(mockWallet.privateKey);
|
|
299
|
-
expect(baseAction["
|
|
301
|
+
expect(baseAction["keychainManager"].getPrivateKey).toHaveBeenCalled();
|
|
300
302
|
expect(inquirer.prompt).not.toHaveBeenCalled();
|
|
301
303
|
});
|
|
302
304
|
|
|
@@ -368,6 +370,7 @@ describe("BaseAction", () => {
|
|
|
368
370
|
expect(ethers.Wallet.createRandom).toHaveBeenCalled();
|
|
369
371
|
expect(mockWallet.encrypt).toHaveBeenCalledWith("test-password");
|
|
370
372
|
expect(writeFileSync).toHaveBeenCalled();
|
|
373
|
+
expect(baseAction["keychainManager"].removePrivateKey).toHaveBeenCalled();
|
|
371
374
|
});
|
|
372
375
|
|
|
373
376
|
test("should fail when file exists and overwrite is false", async () => {
|
|
@@ -412,6 +415,7 @@ describe("BaseAction", () => {
|
|
|
412
415
|
|
|
413
416
|
expect(result).toBe(mockWallet.privateKey);
|
|
414
417
|
expect(writeFileSync).toHaveBeenCalled();
|
|
418
|
+
expect(baseAction["keychainManager"].removePrivateKey).toHaveBeenCalled();
|
|
415
419
|
});
|
|
416
420
|
|
|
417
421
|
test("should return true for valid keystore format", () => {
|