Skip to content
2 changes: 1 addition & 1 deletion src/commands/contracts/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class CallAction extends BaseAction {
args: any[];
rpc?: string;
}): Promise<void> {
const client = await this.getClient(rpc);
const client = await this.getClient(rpc, true);
await client.initializeConsensusSmartContract();
this.startSpinner(`Calling method ${method} on contract at ${contractAddress}...`);

Expand Down
57 changes: 43 additions & 14 deletions src/lib/actions/BaseAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,40 @@ import inquirer from "inquirer";
import { inspect } from "util";
import {createClient, createAccount} from "genlayer-js";
import {localnet} from "genlayer-js/chains";
import type {GenLayerClient, GenLayerChain} from "genlayer-js/types";
import type {GenLayerClient, GenLayerChain, Hash, Address, Account} from "genlayer-js/types";
import { ethers } from "ethers";
import { writeFileSync, existsSync, readFileSync } from "fs";
import { KeystoreData } from "../interfaces/KeystoreData";

export class BaseAction extends ConfigFileManager {
private static readonly DEFAULT_KEYSTORE_PATH = "./keypair.json";
private static readonly MAX_PASSWORD_ATTEMPTS = 3;
private static readonly MIN_PASSWORD_LENGTH = 8;
private static readonly TEMP_KEY_FILENAME = "decrypted_private_key";

private spinner: Ora;
private _genlayerClient: GenLayerClient<GenLayerChain> | null = null;

constructor() {
super();
this.spinner = ora({text: "", spinner: "dots"});
this.cleanupExpiredTempFiles();
}

private async decryptKeystore(keystoreData: KeystoreData, attempt: number = 1): Promise<string> {
try {
const message = attempt === 1
? "Enter password to decrypt keystore:"
: `Invalid password. Attempt ${attempt}/3 - Enter password to decrypt keystore:`;
: `Invalid password. Attempt ${attempt}/${BaseAction.MAX_PASSWORD_ATTEMPTS} - Enter password to decrypt keystore:`;
const password = await this.promptPassword(message);
const wallet = await ethers.Wallet.fromEncryptedJson(keystoreData.encrypted, password);

this.storeTempFile(BaseAction.TEMP_KEY_FILENAME, wallet.privateKey);

return wallet.privateKey;
} catch (error) {
if (attempt >= 3) {
this.failSpinner("Maximum password attempts exceeded (3/3).");
if (attempt >= BaseAction.MAX_PASSWORD_ATTEMPTS) {
this.failSpinner(`Maximum password attempts exceeded (${BaseAction.MAX_PASSWORD_ATTEMPTS}/${BaseAction.MAX_PASSWORD_ATTEMPTS}).`);
process.exit(1);
}
return await this.decryptKeystore(keystoreData, attempt + 1);
Expand All @@ -52,36 +61,54 @@ export class BaseAction extends ConfigFileManager {
return inspect(data, { depth: null, colors: false });
}

protected async getClient(rpcUrl?: string): Promise<GenLayerClient<GenLayerChain>> {
protected async getClient(rpcUrl?: string, readOnly: boolean = false): Promise<GenLayerClient<GenLayerChain>> {
if (!this._genlayerClient) {
const networkConfig = this.getConfig().network;
const network = networkConfig ? JSON.parse(networkConfig) : localnet;
const account = await this.getAccount(readOnly);
this._genlayerClient = createClient({
chain: network,
endpoint: rpcUrl,
account: createAccount((await this.getPrivateKey()) as any),
account: account,
});
}
return this._genlayerClient;
}

protected async getPrivateKey(): Promise<string> {
const keypairPath = this.getConfigByKey("keyPairPath");
private async getAccount(readOnly: boolean = false): Promise<Account | Address> {
let keypairPath = this.getConfigByKey("keyPairPath");
let decryptedPrivateKey;
let keystoreData;

if (!keypairPath || !existsSync(keypairPath)) {
await this.confirmPrompt("Keypair file not found. Would you like to create a new keypair?");
return await this.createKeypair("./keypair.json", false);
decryptedPrivateKey = await this.createKeypair(BaseAction.DEFAULT_KEYSTORE_PATH, false);
keypairPath = this.getConfigByKey("keyPairPath")!;
}

const keystoreData = JSON.parse(readFileSync(keypairPath, "utf-8"));
keystoreData = JSON.parse(readFileSync(keypairPath, "utf-8"));

if (!this.isValidKeystoreFormat(keystoreData)) {
this.failSpinner("Invalid keystore format. Expected encrypted keystore file.");
await this.confirmPrompt("Would you like to create a new keypair?");
return await this.createKeypair("./keypair.json", true);
decryptedPrivateKey = await this.createKeypair(BaseAction.DEFAULT_KEYSTORE_PATH, true);
keypairPath = this.getConfigByKey("keyPairPath")!;
keystoreData = JSON.parse(readFileSync(keypairPath, "utf-8"));
}

return await this.decryptKeystore(keystoreData);
if (readOnly) {
return this.getAddress(keystoreData);
}

if (!decryptedPrivateKey) {
const cachedKey = this.getTempFile(BaseAction.TEMP_KEY_FILENAME);
decryptedPrivateKey = cachedKey ? cachedKey : await this.decryptKeystore(keystoreData);
}
return createAccount(decryptedPrivateKey as Hash);
}

private getAddress(keystoreData: KeystoreData): Address {
return keystoreData.address as Address;
}

protected async createKeypair(outputPath: string, overwrite: boolean): Promise<string> {
Expand All @@ -103,8 +130,8 @@ export class BaseAction extends ConfigFileManager {
process.exit(1);
}

if (password.length < 8) {
this.failSpinner("Password must be at least 8 characters long");
if (password.length < BaseAction.MIN_PASSWORD_LENGTH) {
this.failSpinner(`Password must be at least ${BaseAction.MIN_PASSWORD_LENGTH} characters long`);
process.exit(1);
}

Expand All @@ -119,6 +146,8 @@ export class BaseAction extends ConfigFileManager {
writeFileSync(finalOutputPath, JSON.stringify(keystoreData, null, 2));
this.writeConfig('keyPairPath', finalOutputPath);

this.clearTempFile(BaseAction.TEMP_KEY_FILENAME);

return wallet.privateKey;
}

Expand Down
78 changes: 78 additions & 0 deletions src/lib/config/ConfigFileManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,24 @@ import path from "path";
import os from "os";
import fs from "fs";

interface TempFileData {
content: string;
timestamp: number;
}

export class ConfigFileManager {
private folderPath: string;
private configFilePath: string;
private tempFolderPath: string;
private static readonly TEMP_FILE_EXPIRATION_MS = 5 * 60 * 1000; // 5 minutes

constructor(baseFolder: string = ".genlayer/", configFileName: string = "genlayer-config.json") {
this.folderPath = path.resolve(os.homedir(), baseFolder);
this.configFilePath = path.resolve(this.folderPath, configFileName);
this.tempFolderPath = path.resolve(os.tmpdir(), "genlayer-temp");
this.ensureFolderExists();
this.ensureConfigFileExists();
this.ensureTempFolderExists();
}

private ensureFolderExists(): void {
Expand All @@ -25,6 +34,12 @@ export class ConfigFileManager {
}
}

private ensureTempFolderExists(): void {
if (!fs.existsSync(this.tempFolderPath)) {
fs.mkdirSync(this.tempFolderPath, { recursive: true, mode: 0o700 }); // Owner-only access
Comment thread
cristiam86 marked this conversation as resolved.
}
}

getFolderPath(): string {
return this.folderPath;
}
Expand All @@ -48,4 +63,67 @@ export class ConfigFileManager {
config[key] = value;
fs.writeFileSync(this.configFilePath, JSON.stringify(config, null, 2));
}

storeTempFile(fileName: string, content: string): void {
this.ensureTempFolderExists();
const filePath = path.resolve(this.tempFolderPath, fileName);
const tempData: TempFileData = {
content,
timestamp: Date.now()
};
fs.writeFileSync(filePath, JSON.stringify(tempData), { mode: 0o600 }); // Owner-only access
}

getTempFile(fileName: string): string | null {
const filePath = path.resolve(this.tempFolderPath, fileName);

if (!fs.existsSync(filePath)) {
return null;
}

const fileContent = fs.readFileSync(filePath, "utf-8");
const tempData: TempFileData = JSON.parse(fileContent);

if (Date.now() - tempData.timestamp > ConfigFileManager.TEMP_FILE_EXPIRATION_MS) {
this.clearTempFile(fileName);
return null;
}

return tempData.content;
}

hasTempFile(fileName: string): boolean {
return this.getTempFile(fileName) !== null;
}

clearTempFile(fileName: string): void {
const filePath = path.resolve(this.tempFolderPath, fileName);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}

cleanupExpiredTempFiles(): void {
if (!fs.existsSync(this.tempFolderPath)) {
return;
}

const files = fs.readdirSync(this.tempFolderPath);
const now = Date.now();

for (const file of files) {
const filePath = path.resolve(this.tempFolderPath, file);

try {
const fileContent = fs.readFileSync(filePath, "utf-8");
const tempData: TempFileData = JSON.parse(fileContent);

if (now - tempData.timestamp > ConfigFileManager.TEMP_FILE_EXPIRATION_MS) {
fs.unlinkSync(filePath);
}
} catch (error) {
fs.unlinkSync(filePath);
}
}
}
}
2 changes: 1 addition & 1 deletion tests/actions/appeal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe("AppealAction", () => {
vi.mocked(createClient).mockReturnValue(mockClient as any);
vi.mocked(createAccount).mockReturnValue({privateKey: mockPrivateKey} as any);
appealAction = new AppealAction();
vi.spyOn(appealAction as any, "getPrivateKey").mockResolvedValue(mockPrivateKey);
vi.spyOn(appealAction as any, "getAccount").mockResolvedValue({privateKey: mockPrivateKey});

vi.spyOn(appealAction as any, "startSpinner").mockImplementation(() => {});
vi.spyOn(appealAction as any, "succeedSpinner").mockImplementation(() => {});
Expand Down
2 changes: 1 addition & 1 deletion tests/actions/call.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe("CallAction", () => {
vi.mocked(createClient).mockReturnValue(mockClient as any);
vi.mocked(createAccount).mockReturnValue({privateKey: mockPrivateKey} as any);
callActions = new CallAction();
vi.spyOn(callActions as any, "getPrivateKey").mockResolvedValue(mockPrivateKey);
vi.spyOn(callActions as any, "getAccount").mockResolvedValue({privateKey: mockPrivateKey});

vi.spyOn(callActions as any, "startSpinner").mockImplementation(() => {});
vi.spyOn(callActions as any, "succeedSpinner").mockImplementation(() => {});
Expand Down
2 changes: 1 addition & 1 deletion tests/actions/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe("DeployAction", () => {
vi.mocked(createClient).mockReturnValue(mockClient as any);
vi.mocked(createAccount).mockReturnValue({privateKey: mockPrivateKey} as any);
deployer = new DeployAction();
vi.spyOn(deployer as any, "getPrivateKey").mockResolvedValue(mockPrivateKey);
vi.spyOn(deployer as any, "getAccount").mockResolvedValue({privateKey: mockPrivateKey});
vi.spyOn(deployer as any, "getConfig").mockReturnValue({});

vi.spyOn(deployer as any, "startSpinner").mockImplementation(() => {});
Expand Down
2 changes: 1 addition & 1 deletion tests/actions/receipt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe("ReceiptAction", () => {
vi.mocked(createClient).mockReturnValue(mockClient as any);
vi.mocked(createAccount).mockReturnValue({privateKey: mockPrivateKey} as any);
receiptAction = new ReceiptAction();
vi.spyOn(receiptAction as any, "getPrivateKey").mockResolvedValue(mockPrivateKey);
vi.spyOn(receiptAction as any, "getAccount").mockResolvedValue({privateKey: mockPrivateKey});

vi.spyOn(receiptAction as any, "startSpinner").mockImplementation(() => {});
vi.spyOn(receiptAction as any, "succeedSpinner").mockImplementation(() => {});
Expand Down
2 changes: 1 addition & 1 deletion tests/actions/write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe("WriteAction", () => {
vi.mocked(createClient).mockReturnValue(mockClient as any);
vi.mocked(createAccount).mockReturnValue({privateKey: mockPrivateKey} as any);
writeAction = new WriteAction();
vi.spyOn(writeAction as any, "getPrivateKey").mockResolvedValue(mockPrivateKey);
vi.spyOn(writeAction as any, "getAccount").mockResolvedValue({privateKey: mockPrivateKey});

vi.spyOn(writeAction as any, "startSpinner").mockImplementation(() => {});
vi.spyOn(writeAction as any, "succeedSpinner").mockImplementation(() => {});
Expand Down
Loading