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.
@@ -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
  };
@@ -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.26.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.cleanupExpiredTempFiles();
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
- private isValidKeystoreFormat(data: any): data is KeystoreData {
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.getTempFile(BaseAction.TEMP_KEY_FILENAME);
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.clearTempFile(BaseAction.TEMP_KEY_FILENAME);
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 temp file methods
87
- vi.spyOn(baseAction as any, "storeTempFile").mockImplementation(() => {});
88
- vi.spyOn(baseAction as any, "getTempFile").mockReturnValue(null);
89
- vi.spyOn(baseAction as any, "clearTempFile").mockImplementation(() => {});
90
- vi.spyOn(baseAction as any, "cleanupExpiredTempFiles").mockImplementation(() => {});
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 as any, "getTempFile").mockReturnValue(mockWallet.privateKey);
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["getTempFile"]).toHaveBeenCalledWith("decrypted_private_key");
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", () => {