diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 00000000..4963f9c2 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,18 @@ +name: Account event tests +on: + pull_request: + branches: [master] + push: + branches: [master] +jobs: + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - run: npm run compile + - run: npm run test:unit diff --git a/package.json b/package.json index 6c0643a7..2ee1da9c 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ ], "contributes": { "configuration": { - "title": "ΞTHcode", + "title": "\u039eTHcode", "properties": { "ethcode.networks": { "scope": "application", @@ -220,7 +220,8 @@ "compile": "tsc -p ./", "watch": "tsc -watch -p ./", "pretest": "npm run compile && npm run lint", - "lint": "eslint src --ext ts" + "lint": "eslint src --ext ts", + "test:unit": "node --test test/*.test.cjs" }, "author": "0mkara <0mkar@protonmail.com>", "license": "MIT" diff --git a/src/api/api.ts b/src/api/api.ts index bb0c1246..1cb0cb89 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,7 +1,7 @@ import { type ExtensionContext } from 'vscode' import { type PublicClient } from 'viem' import { getProvider } from './provider' -import { type ContractABI, type CompiledJSONOutput, type NetworkConfig, type Fees } from '../types' +import { type ContractABI, type CompiledJSONOutput, type NetworkConfig, type Fees, type IAccountCreated } from '../types' import { createConstructorInput, createDeployed, createFunctionInput, getConstructorInputFullPath, getDeployedFullPath, getFunctionInputFullPath } from '../utils/functions' import { logger } from '../lib' import * as vscode from 'vscode' @@ -10,7 +10,8 @@ export const event = { network: new vscode.EventEmitter(), account: new vscode.EventEmitter(), contracts: new vscode.EventEmitter(), - updateAccountList: new vscode.EventEmitter() + updateAccountList: new vscode.EventEmitter(), + accountCreated: new vscode.EventEmitter() } export async function getNetwork(context: ExtensionContext) { diff --git a/src/api/events.ts b/src/api/events.ts index 4d02de25..53a1affc 100644 --- a/src/api/events.ts +++ b/src/api/events.ts @@ -1,5 +1,6 @@ import type * as vscode from 'vscode' import { event } from './api' +import { type IAccountCreated } from '../types' /** * Represents an interface for event emitters of network and account changes. @@ -36,6 +37,14 @@ export interface EventsInterface { * @type {vscode.EventEmitter} */ updateAccountList: vscode.EventEmitter + + /** + * An event emitter for account creation. + * + * @event + * @type {vscode.EventEmitter} + */ + accountCreated: vscode.EventEmitter } /** @@ -49,11 +58,13 @@ export function events (): EventsInterface { const account = event.account const contracts = event.contracts const updateAccountList = event.updateAccountList + const accountCreated = event.accountCreated return { network, account, contracts, - updateAccountList + updateAccountList, + accountCreated } } diff --git a/src/extension.ts b/src/extension.ts index 330dc5e9..8cce91eb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -230,6 +230,13 @@ export async function activate (context: ExtensionContext): Promise { + if (info.success) logger.success(info.successMsg) + else { + logger.error(info.error) + } + })) + const path_ = workspace.workspaceFolders if (path_ === undefined) { await window.showErrorMessage('No folder selected please open one.') diff --git a/src/types/types.ts b/src/types/types.ts index ba31bba2..b5ef7cc2 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -123,3 +123,11 @@ export function isConstructorInputValue ( ): obj is ConstructorInputValue { return obj.value !== undefined } + +export type IAccountCreated = { + successMsg: string + success: true +} | { + error: unknown + success: false +} diff --git a/src/utils/wallet.ts b/src/utils/wallet.ts index bf17edfa..41d82461 100644 --- a/src/utils/wallet.ts +++ b/src/utils/wallet.ts @@ -60,48 +60,52 @@ const listAddresses: any = async ( // Create keypair (using viem mnemonic/account) const createKeyPair: any = (context: vscode.ExtensionContext, keyPath: string, pswd: string) => { - // For now, keep using keythereum for keystore compatibility - const privateKey = generatePrivateKey() - - // Generate salt and IV using crypto library - const salt = randomBytes(32) - const iv = randomBytes(16) - - // Options for keythereum - const options = { - kdf: 'scrypt', - cipher: 'aes-128-ctr', - kdfparams: { - n: 8192, - r: 8, - p: 1, - dklen: 32 + try { + // For now, keep using keythereum for keystore compatibility + const privateKey = generatePrivateKey() + + // Generate salt and IV using crypto library + const salt = randomBytes(32) + const iv = randomBytes(16) + + // Options for keythereum + const options = { + kdf: 'scrypt', + cipher: 'aes-128-ctr', + kdfparams: { + n: 8192, + r: 8, + p: 1, + dklen: 32 + } } + + const keyObject = dump( + Buffer.from(pswd, 'utf-8'), + Buffer.from(privateKey.slice(2), 'hex'), + salt, + iv, + options + ) + const pubAddr = `0x${keyObject.address}` + const account: Account = { + pubAddr, + checksumAddr: checksumAddress(pubAddr as `0x${string}`) + } + logger.log(JSON.stringify(account)) + const keyStorePath = path.join(context.extensionPath, 'keystore') + if (!fs.existsSync(keyStorePath)) { + fs.mkdirSync(keyStorePath) + } + exportToFile(keyObject, keyStorePath) + event.accountCreated.fire({ success: true, successMsg: `New account created: ${pubAddr}` }) + listAddresses(context, keyPath).then((addresses: string[]) => { + event.updateAccountList.fire(addresses) + }).catch((error: any) => logger.error(error)) + return pubAddr + } catch (error) { + event.accountCreated.fire({ error, success: false }) } - - const keyObject = dump( - Buffer.from(pswd, 'utf-8'), - Buffer.from(privateKey.slice(2), 'hex'), - salt, - iv, - options - ) - const pubAddr = `0x${keyObject.address}` - const account: Account = { - pubAddr, - checksumAddr: checksumAddress(pubAddr as `0x${string}`) - } - logger.success('Account created!') - logger.log(JSON.stringify(account)) - const keyStorePath = path.join(context.extensionPath, 'keystore') - if (!fs.existsSync(keyStorePath)) { - fs.mkdirSync(keyStorePath) - } - exportToFile(keyObject, keyStorePath) - listAddresses(context, keyPath).then((addresses: string[]) => { - event.updateAccountList.fire(addresses) - }).catch((error: any) => logger.error(error)) - return pubAddr } // Delete privateKey against address diff --git a/test/account-created.test.cjs b/test/account-created.test.cjs new file mode 100644 index 00000000..8250ad52 --- /dev/null +++ b/test/account-created.test.cjs @@ -0,0 +1,51 @@ +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); +const vm = require('node:vm'); +const ts = require('typescript'); + +for (const failure of [null, 'mkdir', 'export']) { + test(`account creation emits only ${failure ? 'failure after ' + failure : 'success after persistence'}`, async () => { + const events = []; + let persisted = false; + const failureError = new Error('storage failure'); + const mocks = { + 'viem': { checksumAddress: (address) => address }, + 'viem/accounts': { generatePrivateKey: () => '0x' + '1'.repeat(64) }, + 'fs': { + existsSync: () => false, + mkdirSync: () => { if (failure === 'mkdir') throw failureError; }, + readdirSync: () => [], + }, + 'path': path, + 'crypto': { randomBytes: (length) => Buffer.alloc(length) }, + 'vscode': { window: {} }, + '../api/api': { event: { + accountCreated: { fire: (event) => events.push({ ...event, persisted }) }, + updateAccountList: { fire: () => {} }, + } }, + '../lib': { logger: { log: () => {}, error: () => {} } }, + './networks': { isTestingNetwork: () => false }, + './keythereum': { + dump: () => ({ address: '2'.repeat(40) }), + exportToFile: () => { if (failure === 'export') throw failureError; persisted = true; }, + }, + }; + const source = ts.transpileModule(readFileSync(path.resolve('src/utils/wallet.ts'), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true }, + }).outputText; + const module = { exports: {} }; + vm.runInNewContext(source, { + module, exports: module.exports, Buffer, + require: (name) => { if (!(name in mocks)) throw new Error(`Unexpected import: ${name}`); return mocks[name]; }, + }); + const address = module.exports.createKeyPair({ extensionPath: '/test-only' }, '/test-only', 'test-only-password'); + await Promise.resolve(); + assert.equal(events.length, 1); + assert.equal(events[0].success, !failure); + assert.equal(events[0].persisted, !failure); + if (failure) { assert.equal(events[0].error, failureError); assert.equal(address, undefined); } + else { assert.equal(address, '0x' + '2'.repeat(40)); assert.match(events[0].successMsg, /New account created/); } + }); +}